diff --git a/.gitattributes b/.gitattributes index 1c9a7f9..85d4698 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,14 +3,19 @@ * text=auto eol=lf /.gitattributes linguist-generated /.github/dependabot.yml linguist-generated -/.github/workflows/auto-approve.yml linguist-generated -/.github/workflows/cdk-deploy-dev.yml linguist-generated +/.github/workflows/build.yml linguist-generated +/.github/workflows/cdk-deploy-production.yml linguist-generated +/.github/workflows/cdk-deploy-test-branch.yml linguist-generated /.github/workflows/cdk-deploy-test.yml linguist-generated +/.github/workflows/cdk-destroy-test-branch.yml linguist-generated +/.github/workflows/cdk-diff-pr-comment.yml linguist-generated /.github/workflows/pull-request-lint.yml linguist-generated +/.github/workflows/release.yml linguist-generated /.gitignore linguist-generated /.projen/** linguist-generated /.projen/deps.json linguist-generated /.projen/files.json linguist-generated /.projen/tasks.json linguist-generated +/.vscode/extensions.json linguist-generated /cdk.json linguist-generated /pyproject.toml linguist-generated \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 947c0bf..e43fc6c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,15 +8,17 @@ updates: interval: weekly ignore: - dependency-name: aws-cdk-lib - - dependency-name: aws-cdk + - dependency-name: aws-cdk-cli - dependency-name: projen + - dependency-name: pytest labels: - dependencies - - auto-approve groups: default: patterns: - "*" exclude-patterns: - - aws-cdk* + - aws-cdk-lib + - aws-cdk-cli - projen + - pytest diff --git a/.github/workflows/auto-approve.yml b/.github/workflows/auto-approve.yml deleted file mode 100644 index 82f1639..0000000 --- a/.github/workflows/auto-approve.yml +++ /dev/null @@ -1,30 +0,0 @@ -# ~~ Generated by projen. To modify, edit .projenrc.py and run "uv run projen". - -name: auto-approve -on: - pull_request_target: - types: - - labeled - - opened - - synchronize - - reopened - - ready_for_review -jobs: - approve: - runs-on: ubuntu-latest - permissions: - pull-requests: write - contents: write - if: contains(github.event.pull_request.labels.*.name, 'auto-approve') && (github.event.pull_request.user.login == 'dependabot' || github.event.pull_request.user.login == 'dependabot[bot]') - steps: - - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: gh pr review --approve "$PR_NUMBER" --repo "$GH_REPO" - - name: Checkout - uses: actions/checkout@v5 - - name: Enable Pull Request Automerge - run: gh pr merge --merge --auto "${{ github.event.pull_request.number }}" - env: - GH_TOKEN: ${{ secrets.PROJEN_GITHUB_TOKEN }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..27a0a21 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,84 @@ +# ~~ Generated by projen. To modify, edit .projenrc.py and run "uv run projen". + +name: build +on: + pull_request: {} + workflow_dispatch: {} +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + self_mutation_happened: ${{ steps.self_mutation.outputs.self_mutation_happened }} + env: + CI: "true" + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + - name: Setup python environment + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Setup uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: uv.lock + - name: Install dependencies + run: uv sync + - name: build + run: uv run projen build + - name: Find mutations + id: self_mutation + run: |- + git add . + git diff --staged --patch --exit-code > repo.patch || echo "self_mutation_happened=true" >> $GITHUB_OUTPUT + shell: bash + - name: Upload patch + if: steps.self_mutation.outputs.self_mutation_happened + uses: actions/upload-artifact@v7 + with: + name: repo.patch + path: repo.patch + overwrite: true + - name: Fail build on mutation + if: steps.self_mutation.outputs.self_mutation_happened + run: |- + echo "::error::Files were changed during build (see build log). If this was triggered from a fork, you will need to update your branch." + cat repo.patch + exit 1 + self-mutation: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + if: always() && needs.build.outputs.self_mutation_happened && !(github.event.pull_request.head.repo.full_name != github.repository) + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + token: ${{ secrets.PROJEN_GITHUB_TOKEN }} + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + - name: Download patch + uses: actions/download-artifact@v8 + with: + name: repo.patch + path: ${{ runner.temp }} + - name: Apply patch + run: '[ -s ${{ runner.temp }}/repo.patch ] && git apply ${{ runner.temp }}/repo.patch || echo "Empty patch. Skipping."' + - name: Set git identity + run: |- + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Push changes + env: + PULL_REQUEST_REF: ${{ github.event.pull_request.head.ref }} + run: |- + git add . + git commit -s -m "chore: self mutation" + git push origin "HEAD:$PULL_REQUEST_REF" diff --git a/.github/workflows/cdk-deploy-dev.yml b/.github/workflows/cdk-deploy-dev.yml deleted file mode 100644 index 0cf4e40..0000000 --- a/.github/workflows/cdk-deploy-dev.yml +++ /dev/null @@ -1,38 +0,0 @@ -# ~~ Generated by projen. To modify, edit .projenrc.py and run "uv run projen". - -name: cdk-deploy-dev -on: - push: - branches: - - main - workflow_dispatch: {} -jobs: - deploy: - name: Deploy CDK stacks to dev AWS account - runs-on: ubuntu-latest - permissions: - actions: write - contents: read - id-token: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Setup python environment - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Setup uv - uses: astral-sh/setup-uv@v5 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::987654321012:role/GitHubDeployRole - aws-region: us-east-1 - - name: Install dependencies - run: uv sync - - name: Install cdk cli - run: npm install -g aws-cdk - - name: Run CDK synth for the DEV environment - run: uv run projen dev:synth - - name: Deploy CDK to the DEV environment on AWS account 987654321012 - run: uv run projen dev:deploy diff --git a/.github/workflows/cdk-deploy-production.yml b/.github/workflows/cdk-deploy-production.yml new file mode 100644 index 0000000..8626ae9 --- /dev/null +++ b/.github/workflows/cdk-deploy-production.yml @@ -0,0 +1,45 @@ +# ~~ Generated by projen. To modify, edit .projenrc.py and run "uv run projen". + +name: cdk-deploy-production +on: + workflow_dispatch: {} + workflow_run: + types: + - completed + workflows: + - cdk-deploy-test +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: false +jobs: + deploy: + name: Deploy CDK stacks to production AWS account + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + environment: production + if: github.event.workflow_run.conclusion == 'success' + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Setup python environment + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Setup uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: uv.lock + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsServiceRole + aws-region: us-east-1 + - name: Install dependencies + run: uv sync --frozen + - name: Run CDK synth for the PRODUCTION environment + run: uv run projen production:synth + - name: Deploy CDK to the PRODUCTION environment on AWS account 123456789012 + run: uv run projen production:deploy:all diff --git a/.github/workflows/cdk-deploy-test-branch.yml b/.github/workflows/cdk-deploy-test-branch.yml new file mode 100644 index 0000000..d36521d --- /dev/null +++ b/.github/workflows/cdk-deploy-test-branch.yml @@ -0,0 +1,46 @@ +# ~~ Generated by projen. To modify, edit .projenrc.py and run "uv run projen". + +name: cdk-deploy-test-branch +on: + push: + branches: + - "**" + - "!main" + - "!hotfix/*" + - "!github-actions/*" + - "!dependabot/**" + workflow_dispatch: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: false +jobs: + deploy: + name: Deploy CDK stacks to test AWS account (Branch) + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + environment: test + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Setup python environment + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Setup uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: uv.lock + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: arn:aws:iam::987654321012:role/GitHubActionsServiceRole + aws-region: us-east-1 + - name: Install dependencies + run: uv sync --frozen + - name: Run CDK synth for the TEST environment + run: uv run projen test:branch:synth + - name: Deploy CDK to the TEST environment on AWS account 987654321012 + run: uv run projen test:branch:deploy:all diff --git a/.github/workflows/cdk-deploy-test.yml b/.github/workflows/cdk-deploy-test.yml index 8b1b1c8..7b60138 100644 --- a/.github/workflows/cdk-deploy-test.yml +++ b/.github/workflows/cdk-deploy-test.yml @@ -6,33 +6,37 @@ on: branches: - main workflow_dispatch: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: false jobs: deploy: name: Deploy CDK stacks to test AWS account runs-on: ubuntu-latest permissions: - actions: write contents: read id-token: write + environment: test steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup python environment - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: uv.lock - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v6 with: - role-to-assume: arn:aws:iam::123456789012:role/GitHubDeployRole + role-to-assume: arn:aws:iam::987654321012:role/GitHubActionsServiceRole aws-region: us-east-1 - name: Install dependencies - run: uv sync - - name: Install cdk cli - run: npm install -g aws-cdk + run: uv sync --frozen - name: Run CDK synth for the TEST environment run: uv run projen test:synth - - name: Deploy CDK to the TEST environment on AWS account 123456789012 - run: uv run projen test:deploy + - name: Deploy CDK to the TEST environment on AWS account 987654321012 + run: uv run projen test:deploy:all diff --git a/.github/workflows/cdk-destroy-test-branch.yml b/.github/workflows/cdk-destroy-test-branch.yml new file mode 100644 index 0000000..d20a337 --- /dev/null +++ b/.github/workflows/cdk-destroy-test-branch.yml @@ -0,0 +1,48 @@ +# ~~ Generated by projen. To modify, edit .projenrc.py and run "uv run projen". + +name: cdk-destroy-test-branch +on: + delete: {} + workflow_dispatch: {} +jobs: + destroy: + name: Remove deployment of feature branch + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + environment: test + if: github.event_name == 'workflow_dispatch' || (github.event.ref_type == 'branch' && github.event_name == 'delete' && github.event.ref != 'main' && !startsWith(github.event.ref, 'hotfix/') && !startsWith(github.event.ref, 'github-actions/') && !startsWith(github.event.ref, 'dependabot/')) + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Setup python environment + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Setup uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: uv.lock + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: arn:aws:iam::987654321012:role/GitHubActionsServiceRole + aws-region: us-east-1 + - name: Install dependencies + run: uv sync --frozen + - name: Fetch Deleted Branch Name + id: destroy-branch + if: github.event.ref_type == 'branch' && github.event_name == 'delete' + run: BRANCH=$(cat ${{ github.event_path }} | jq --raw-output '.ref'); echo "${{ github.repository }} has ${BRANCH} branch"; echo "DESTROY_BRANCH_NAME=$BRANCH" >> $GITHUB_OUTPUT + - name: Destroy Branch Stack (Workflow Dispatch) + if: github.event_name == 'workflow_dispatch' + env: + GIT_BRANCH_REF: ${{ github.ref_name }} + run: uv run projen test:branch:destroy:all + - name: Destroy Branch Stack (Branch Deletion) + if: github.event.ref_type == 'branch' && github.event_name == 'delete' + env: + GIT_BRANCH_REF: ${{ steps.destroy-branch.outputs.DESTROY_BRANCH_NAME }} + run: uv run projen test:branch:destroy:all diff --git a/.github/workflows/cdk-diff-pr-comment.yml b/.github/workflows/cdk-diff-pr-comment.yml new file mode 100644 index 0000000..47ab8bd --- /dev/null +++ b/.github/workflows/cdk-diff-pr-comment.yml @@ -0,0 +1,46 @@ +# ~~ Generated by projen. To modify, edit .projenrc.py and run "uv run projen". + +name: cdk-diff-pr-comment +on: + pull_request_target: + branches: + - main +jobs: + diff: + name: CDK diff PR branch with production environment (via main) + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + pull-requests: write + env: + AWS_REGION: us-east-1 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Setup python environment + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Setup uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: uv.lock + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsServiceRole + aws-region: us-east-1 + - name: Install dependencies + run: uv sync --frozen + - name: CDK diff and notify PR + run: uv run projen production:diff:all > cdk-diff-output.txt 2>&1 || true + - name: Post CDK Diff Comment in PR + uses: towardsthecloud/aws-cdk-diff-pr-commenter@v1 + with: + diff-file: cdk-diff-output.txt + aws-region: us-east-1 + header: CDK Diff for production in us-east-1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fe2a203 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +# ~~ Generated by projen. To modify, edit .projenrc.py and run "uv run projen". + +name: release +on: + push: + branches: + - main + workflow_dispatch: {} +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false +jobs: + release: + name: Tag and publish a GitHub release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Setup python environment + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Setup uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: uv.lock + - name: Install dependencies + run: uv sync --frozen + - name: Read project version + id: version + run: |- + VERSION=$(uv run python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + echo "tag=v$VERSION" >> $GITHUB_OUTPUT + - name: Check if version has already been tagged + id: check_tag_exists + run: git ls-remote -q --exit-code --tags origin ${{ steps.version.outputs.tag }} > /dev/null && echo "exists=true" >> $GITHUB_OUTPUT || echo "exists=false" >> $GITHUB_OUTPUT + - name: Set git identity + run: |- + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Release + if: steps.check_tag_exists.outputs.exists != 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create ${{ steps.version.outputs.tag }} -R $GITHUB_REPOSITORY -t ${{ steps.version.outputs.tag }} --target $GITHUB_SHA --generate-notes diff --git a/.gitignore b/.gitignore index 9a3a41f..a6b091a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__ .DS_Store .mypy_cache .pytest_cache +.ruff_cache .venv/ *.pyc node_modules/ @@ -12,7 +13,6 @@ node_modules/ !/.projen/deps.json !/.projen/files.json !/.github/workflows/pull-request-lint.yml -!/.github/workflows/auto-approve.yml !/pyproject.toml __pycache__/ *.py[cod] @@ -92,7 +92,13 @@ cython_debug/ !/cdk.json /cdk.out/ .cdk.staging/ +!/.vscode/extensions.json !/.github/dependabot.yml -!/.github/workflows/cdk-deploy-dev.yml +!/.github/workflows/build.yml +!/.github/workflows/release.yml !/.github/workflows/cdk-deploy-test.yml +!/.github/workflows/cdk-deploy-test-branch.yml +!/.github/workflows/cdk-destroy-test-branch.yml +!/.github/workflows/cdk-deploy-production.yml +!/.github/workflows/cdk-diff-pr-comment.yml !/.projenrc.py diff --git a/.projen/deps.json b/.projen/deps.json index 786e207..a5c9e32 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -1,8 +1,13 @@ { "dependencies": [ + { + "name": "aws-cdk-cli", + "version": "2.1130.0", + "type": "devenv" + }, { "name": "projen", - "version": "0.99.62", + "version": "0.101.20", "type": "devenv" }, { @@ -18,6 +23,10 @@ "version": "2.254.0", "type": "runtime" }, + { + "name": "cloudstructs", + "type": "runtime" + }, { "name": "constructs", "version": "^10.5.1", @@ -25,7 +34,7 @@ }, { "name": "pytest", - "version": "9.0.3", + "version": "9.1.1", "type": "test" } ], diff --git a/.projen/files.json b/.projen/files.json index 526fca0..39a3f31 100644 --- a/.projen/files.json +++ b/.projen/files.json @@ -2,14 +2,19 @@ "files": [ ".gitattributes", ".github/dependabot.yml", - ".github/workflows/auto-approve.yml", - ".github/workflows/cdk-deploy-dev.yml", + ".github/workflows/build.yml", + ".github/workflows/cdk-deploy-production.yml", + ".github/workflows/cdk-deploy-test-branch.yml", ".github/workflows/cdk-deploy-test.yml", + ".github/workflows/cdk-destroy-test-branch.yml", + ".github/workflows/cdk-diff-pr-comment.yml", ".github/workflows/pull-request-lint.yml", + ".github/workflows/release.yml", ".gitignore", ".projen/deps.json", ".projen/files.json", ".projen/tasks.json", + ".vscode/extensions.json", "cdk.json", "pyproject.toml" ], diff --git a/.projen/tasks.json b/.projen/tasks.json index 4c5438b..2ada5b0 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -1,4 +1,10 @@ { + "manifestVersion": 3, + "env": { + "VIRTUAL_ENV": ".venv", + "PATH": "$(echo .venv/bin:$PATH)", + "CDK_DEFAULT_REGION": "us-east-1" + }, "tasks": { "build": { "name": "build", @@ -65,7 +71,10 @@ "description": "Synthesize project files", "steps": [ { - "exec": "python .projenrc.py" + "execArgs": [ + "python", + ".projenrc.py" + ] } ] }, @@ -74,7 +83,10 @@ "description": "Deploys your CDK app to the AWS cloud", "steps": [ { - "exec": "cdk deploy", + "execArgs": [ + "cdk", + "deploy" + ], "receiveArgs": true } ] @@ -84,69 +96,23 @@ "description": "Destroys your cdk app in the AWS cloud", "steps": [ { - "exec": "cdk destroy", + "execArgs": [ + "cdk", + "destroy" + ], "receiveArgs": true } ] }, - "dev:deploy": { - "name": "dev:deploy", - "description": "Deploy the stacks on the DEV account", - "env": { - "CDK_DEFAULT_ACCOUNT": "987654321012", - "ENVIRONMENT": "dev" - }, - "steps": [ - { - "exec": "cdk deploy --require-approval never *Stack-dev" - } - ] - }, - "dev:destroy": { - "name": "dev:destroy", - "description": "Destroy the stacks on the DEV account", - "env": { - "CDK_DEFAULT_ACCOUNT": "987654321012", - "ENVIRONMENT": "dev" - }, - "steps": [ - { - "exec": "cdk destroy --force *Stack-dev" - } - ] - }, - "dev:diff": { - "name": "dev:diff", - "description": "Diff the stacks on the DEV account", - "env": { - "CDK_DEFAULT_ACCOUNT": "987654321012", - "ENVIRONMENT": "dev" - }, - "steps": [ - { - "exec": "cdk diff --require-approval never *Stack-dev" - } - ] - }, - "dev:synth": { - "name": "dev:synth", - "description": "Synth the stacks on the DEV account", - "env": { - "CDK_DEFAULT_ACCOUNT": "987654321012", - "ENVIRONMENT": "dev" - }, - "steps": [ - { - "exec": "cdk synth" - } - ] - }, "diff": { "name": "diff", "description": "Diffs the currently deployed app against your code", "steps": [ { - "exec": "cdk diff" + "execArgs": [ + "cdk", + "diff" + ] } ] }, @@ -176,7 +142,22 @@ "description": "Install dependencies with frozen lockfile", "steps": [ { - "exec": "uv sync" + "execArgs": [ + "uv", + "sync" + ] + } + ] + }, + "lint": { + "name": "lint", + "description": "Lint and auto-fix the codebase using Ruff", + "steps": [ + { + "exec": "ruff check --fix" + }, + { + "exec": "ruff format" } ] }, @@ -185,7 +166,10 @@ "description": "Creates the distribution package", "steps": [ { - "exec": "uv build" + "execArgs": [ + "uv", + "build" + ] } ] }, @@ -202,12 +186,138 @@ "name": "pre-compile", "description": "Prepare the project for compilation" }, + "production:deploy:all": { + "name": "production:deploy:all", + "description": "Deploy all stacks on the PRODUCTION account", + "env": { + "CDK_DEFAULT_ACCOUNT": "123456789012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "production", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk deploy --require-approval never --all" + } + ] + }, + "production:deploy:stack": { + "name": "production:deploy:stack", + "description": "Deploy specific stack(s) on the PRODUCTION account", + "env": { + "CDK_DEFAULT_ACCOUNT": "123456789012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "production", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk deploy --require-approval never", + "receiveArgs": true + } + ] + }, + "production:destroy:all": { + "name": "production:destroy:all", + "description": "Destroy all stacks on the PRODUCTION account", + "env": { + "CDK_DEFAULT_ACCOUNT": "123456789012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "production", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk destroy --force --all" + } + ] + }, + "production:destroy:stack": { + "name": "production:destroy:stack", + "description": "Destroy specific stack(s) on the PRODUCTION account", + "env": { + "CDK_DEFAULT_ACCOUNT": "123456789012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "production", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk destroy --force", + "receiveArgs": true + } + ] + }, + "production:diff:all": { + "name": "production:diff:all", + "description": "Diff all stacks on the PRODUCTION account", + "env": { + "CDK_DEFAULT_ACCOUNT": "123456789012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "production", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk diff --all" + } + ] + }, + "production:diff:stack": { + "name": "production:diff:stack", + "description": "Diff specific stack(s) on the PRODUCTION account", + "env": { + "CDK_DEFAULT_ACCOUNT": "123456789012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "production", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk diff", + "receiveArgs": true + } + ] + }, + "production:ls": { + "name": "production:ls", + "description": "Ls the stacks on the PRODUCTION account", + "env": { + "CDK_DEFAULT_ACCOUNT": "123456789012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "production", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk ls" + } + ] + }, + "production:synth": { + "name": "production:synth", + "description": "Synth the stacks on the PRODUCTION account", + "env": { + "CDK_DEFAULT_ACCOUNT": "123456789012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "production", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk synth" + } + ] + }, "publish": { "name": "publish", "description": "Uploads the package to PyPI.", "steps": [ { - "exec": "uv publish" + "execArgs": [ + "uv", + "publish" + ] } ] }, @@ -216,7 +326,12 @@ "description": "Uploads the package against a test PyPI endpoint.", "steps": [ { - "exec": "uv publish --index testpypi" + "execArgs": [ + "uv", + "publish", + "--index", + "testpypi" + ] } ] }, @@ -225,7 +340,10 @@ "description": "Synthesizes your cdk app into cdk.out", "steps": [ { - "exec": "cdk synth" + "execArgs": [ + "cdk", + "synth" + ] } ] }, @@ -234,7 +352,11 @@ "description": "Synthesizes your cdk app into cdk.out and suppresses the template in stdout (part of \"yarn build\")", "steps": [ { - "exec": "cdk synth -q" + "execArgs": [ + "cdk", + "synth", + "-q" + ] } ] }, @@ -244,45 +366,284 @@ "steps": [ { "exec": "pytest" + }, + { + "spawn": "lint" + }, + { + "spawn": "typecheck" } ] }, - "test:deploy": { - "name": "test:deploy", - "description": "Deploy the stacks on the TEST account", + "test:branch:deploy:all": { + "name": "test:branch:deploy:all", + "description": "Deploy all stacks on the TEST account", "env": { - "CDK_DEFAULT_ACCOUNT": "123456789012", - "ENVIRONMENT": "test" + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole", + "GIT_BRANCH_REF": "$(echo ${GIT_BRANCH_REF:-$(git rev-parse --abbrev-ref HEAD)})" }, "steps": [ { - "exec": "cdk deploy --require-approval never *Stack-test" + "exec": "cdk deploy --express --require-approval never --all" } ] }, - "test:destroy": { - "name": "test:destroy", - "description": "Destroy the stacks on the TEST account", + "test:branch:deploy:hotswap:all": { + "name": "test:branch:deploy:hotswap:all", + "description": "Hotswap deploy all stacks on the TEST account", "env": { - "CDK_DEFAULT_ACCOUNT": "123456789012", - "ENVIRONMENT": "test" + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole", + "GIT_BRANCH_REF": "$(echo ${GIT_BRANCH_REF:-$(git rev-parse --abbrev-ref HEAD)})" }, "steps": [ { - "exec": "cdk destroy --force *Stack-test" + "exec": "cdk deploy --hotswap --require-approval never --all" } ] }, - "test:diff": { - "name": "test:diff", - "description": "Diff the stacks on the TEST account", + "test:branch:deploy:hotswap:stack": { + "name": "test:branch:deploy:hotswap:stack", + "description": "Hotswap deploy specific stack(s) on the TEST account", "env": { - "CDK_DEFAULT_ACCOUNT": "123456789012", - "ENVIRONMENT": "test" + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole", + "GIT_BRANCH_REF": "$(echo ${GIT_BRANCH_REF:-$(git rev-parse --abbrev-ref HEAD)})" }, "steps": [ { - "exec": "cdk diff --require-approval never *Stack-test" + "exec": "cdk deploy --hotswap --require-approval never", + "receiveArgs": true + } + ] + }, + "test:branch:deploy:stack": { + "name": "test:branch:deploy:stack", + "description": "Deploy specific stack(s) on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole", + "GIT_BRANCH_REF": "$(echo ${GIT_BRANCH_REF:-$(git rev-parse --abbrev-ref HEAD)})" + }, + "steps": [ + { + "exec": "cdk deploy --express --require-approval never", + "receiveArgs": true + } + ] + }, + "test:branch:destroy:all": { + "name": "test:branch:destroy:all", + "description": "Destroy all stacks on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole", + "GIT_BRANCH_REF": "$(echo ${GIT_BRANCH_REF:-$(git rev-parse --abbrev-ref HEAD)})" + }, + "steps": [ + { + "exec": "cdk destroy --express --force --all" + } + ] + }, + "test:branch:destroy:stack": { + "name": "test:branch:destroy:stack", + "description": "Destroy specific stack(s) on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole", + "GIT_BRANCH_REF": "$(echo ${GIT_BRANCH_REF:-$(git rev-parse --abbrev-ref HEAD)})" + }, + "steps": [ + { + "exec": "cdk destroy --express --force", + "receiveArgs": true + } + ] + }, + "test:branch:diff:all": { + "name": "test:branch:diff:all", + "description": "Diff all stacks on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole", + "GIT_BRANCH_REF": "$(echo ${GIT_BRANCH_REF:-$(git rev-parse --abbrev-ref HEAD)})" + }, + "steps": [ + { + "exec": "cdk diff --all" + } + ] + }, + "test:branch:diff:stack": { + "name": "test:branch:diff:stack", + "description": "Diff specific stack(s) on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole", + "GIT_BRANCH_REF": "$(echo ${GIT_BRANCH_REF:-$(git rev-parse --abbrev-ref HEAD)})" + }, + "steps": [ + { + "exec": "cdk diff", + "receiveArgs": true + } + ] + }, + "test:branch:ls": { + "name": "test:branch:ls", + "description": "Ls the stacks on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole", + "GIT_BRANCH_REF": "$(echo ${GIT_BRANCH_REF:-$(git rev-parse --abbrev-ref HEAD)})" + }, + "steps": [ + { + "exec": "cdk ls" + } + ] + }, + "test:branch:synth": { + "name": "test:branch:synth", + "description": "Synth the stacks on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole", + "GIT_BRANCH_REF": "$(echo ${GIT_BRANCH_REF:-$(git rev-parse --abbrev-ref HEAD)})" + }, + "steps": [ + { + "exec": "cdk synth" + } + ] + }, + "test:deploy:all": { + "name": "test:deploy:all", + "description": "Deploy all stacks on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk deploy --require-approval never --all" + } + ] + }, + "test:deploy:stack": { + "name": "test:deploy:stack", + "description": "Deploy specific stack(s) on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk deploy --require-approval never", + "receiveArgs": true + } + ] + }, + "test:destroy:all": { + "name": "test:destroy:all", + "description": "Destroy all stacks on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk destroy --force --all" + } + ] + }, + "test:destroy:stack": { + "name": "test:destroy:stack", + "description": "Destroy specific stack(s) on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk destroy --force", + "receiveArgs": true + } + ] + }, + "test:diff:all": { + "name": "test:diff:all", + "description": "Diff all stacks on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk diff --all" + } + ] + }, + "test:diff:stack": { + "name": "test:diff:stack", + "description": "Diff specific stack(s) on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk diff", + "receiveArgs": true + } + ] + }, + "test:ls": { + "name": "test:ls", + "description": "Ls the stacks on the TEST account", + "env": { + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" + }, + "steps": [ + { + "exec": "cdk ls" } ] }, @@ -290,8 +651,10 @@ "name": "test:synth", "description": "Synth the stacks on the TEST account", "env": { - "CDK_DEFAULT_ACCOUNT": "123456789012", - "ENVIRONMENT": "test" + "CDK_DEFAULT_ACCOUNT": "987654321012", + "CDK_DEFAULT_REGION": "us-east-1", + "ENVIRONMENT": "test", + "GITHUB_DEPLOY_ROLE": "GitHubActionsServiceRole" }, "steps": [ { @@ -299,23 +662,34 @@ } ] }, + "typecheck": { + "name": "typecheck", + "description": "Type check the codebase using ty", + "steps": [ + { + "exec": "ty check src tests" + } + ] + }, "watch": { "name": "watch", "description": "Watches changes in your source code and rebuilds and deploys to the current account", "steps": [ { - "exec": "cdk deploy --hotswap" + "execArgs": [ + "cdk", + "deploy", + "--hotswap" + ] }, { - "exec": "cdk watch" + "execArgs": [ + "cdk", + "watch" + ] } ] } }, - "env": { - "VIRTUAL_ENV": ".venv", - "PATH": "$(echo .venv/bin:$PATH)", - "CDK_DEFAULT_REGION": "us-east-1" - }, "//": "~~ Generated by projen. To modify, edit .projenrc.py and run \"uv run projen\"." } diff --git a/.projenrc.py b/.projenrc.py index 12b61b4..7c36aa9 100644 --- a/.projenrc.py +++ b/.projenrc.py @@ -5,11 +5,20 @@ if os.path.isdir(".venv"): os.environ["PROJEN_DISABLE_POST"] = "true" -from projen import YamlFile +from projen import JsonFile, YamlFile from projen.awscdk import AwsCdkPythonApp -from src.bin.cicd_helper import github_cicd -from src.bin.env_helper import cdk_action_task +from src.bin.cicd_helper import ( + create_build_workflow, + create_cdk_deployment_workflows, + create_cdk_diff_pr_workflow, + create_release_workflow, +) +from src.bin.env_helper import ( + DEFAULT_GITHUB_DEPLOY_ROLE_NAME, + EnvironmentConfig, + add_cdk_action_task, +) # Define the python module name and set the python version project_name = "aws-cdk-python-starter-kit" @@ -18,30 +27,52 @@ python_major, python_minor = map(int, python_version.split(".")) python_requires = f">={python_version},<{python_major}.{python_minor + 1}" +# Pin the CDK CLI so local runs, CI and the lockfile agree on one version. The PyPI package +# ships the same CLI as the npm one, so no workflow needs Node.js installed separately. +cdk_cli_version = "2.1130.0" # Find the latest CDK CLI version here: https://pypi.org/project/aws-cdk-cli/ + +# Packages whose versions this file pins, so Dependabot ignores them: the next synth would +# revert its bump. Keep these pinned to releases older than MINIMUM_RELEASE_AGE_DAYS, or +# `uv lock` fails outright rather than merely declining the upgrade. +PINNED_PACKAGES = ["aws-cdk-lib", "aws-cdk-cli", "projen", "pytest"] + +# Ignore releases younger than this when resolving. Matches the TypeScript kit's pnpm setting. +MINIMUM_RELEASE_AGE_DAYS = 7 + # Define the AWS region for the CDK app and github workflows # Default to us-east-1 if AWS_REGION is not set in your environment variables aws_region = os.getenv("AWS_REGION", "us-east-1") +# Name of the GitHub deploy role created by the FoundationStack. Set as an environment variable +# for the projen tasks so the CDK app and the workflows agree on one name. Sourced from +# env_helper rather than retyped, because the construct falls back to the same constant. +github_role = DEFAULT_GITHUB_DEPLOY_ROLE_NAME + project = AwsCdkPythonApp( author_email="danny@towardsthecloud.com", author_name="Danny Steenman", cdk_version_pinning=True, cdk_version="2.254.0", # Find the latest CDK version here: https://pypi.org/project/aws-cdk-lib - cdk_cli_version="2.1117.0", # Find the latest CDK CLI version https://pypi.org/project/aws-cdk-cli/ + cdk_cli_version=cdk_cli_version, module_name=python_module_name, name=project_name, projen_command="uv run projen", description="Create and deploy an AWS CDK app on your AWS account in less than 5 minutes using GitHub actions!", version="2.101.0", app_entrypoint=f"{python_module_name}/app.py", + deps=["cloudstructs"], # Runtime dependencies of this module dev_deps=[ - "projen@0.99.62", + "projen@0.101.20", "ruff", "ty", + f"aws-cdk-cli@{cdk_cli_version}", ], # Find the latest projen version here: https://pypi.org/project/projen/ pytest_options={ - "version": "9.0.3" + "version": "9.1.1" }, # Find the latest pytest version here: https://pypi.org/project/pytest/ + context={ + "cli-telemetry": False, # Disable AWS CDK CLI telemetry, see: https://github.com/aws/aws-cdk/issues/34892 + }, uv=True, uv_options={ "python_exec": f"python{python_version}", @@ -51,6 +82,7 @@ }, }, github_options={ + "mergify": False, "pull_request_lint_options": { "semantic_title_options": { "types": [ @@ -68,18 +100,17 @@ }, }, }, - auto_approve_options={ - "allowed_usernames": ["dependabot", "dependabot[bot]"], - }, git_ignore_options={ "ignore_patterns": [ "__pycache__", "__pycache__/", ".python-version", ".DS_Store", + ".env", ".mypy_cache", ".pytest_cache", ".Python", + ".ruff_cache", ".venv/", "*.pyc", "venv/", @@ -91,20 +122,45 @@ # so the CDK CLI knows which region to use project.tasks.add_environment("CDK_DEFAULT_REGION", aws_region) +# Add a lint task and wire both linters into `test`, so CI fails on a lint or type error +# instead of only on a failing assertion. +lint_task = project.add_task( + "lint", + description="Lint and auto-fix the codebase using Ruff", + receive_args=True, +) +lint_task.exec("ruff check --fix") +lint_task.exec("ruff format") + +typecheck_task = project.add_task( + "typecheck", + description="Type check the codebase using ty", + exec="ty check src tests", +) + +project.test_task.spawn(lint_task) +project.test_task.spawn(typecheck_task) + # The CDK app entrypoint lives in `src`, so Python puts that directory on sys.path and the # modules import each other as `stacks.x` and `bin.y`. pytest collects from the repository # root instead, so give it the same import root or every test module fails to collect. pyproject = project.try_find_object_file("pyproject.toml") if pyproject: pyproject.add_override("tool.pytest.ini_options.pythonpath", [python_module_name]) + # Ignore anything published in the last 7 days when resolving, so a compromised or broken + # release has time to be caught and yanked before it can reach this project. The uv + # equivalent of the TypeScript kit's pnpm minimumReleaseAge. It takes a rolling duration, + # so it needs no maintenance. Resolution only happens on `uv lock`; `uv sync --frozen` + # installs exactly what the lockfile pins and is unaffected. + pyproject.add_override("tool.uv.exclude-newer", f"{MINIMUM_RELEASE_AGE_DAYS} days") -# Define the target AWS accounts for the different environments -target_accounts = { - "dev": "987654321012", - "test": "123456789012", - "staging": None, - "production": None, -} +# Add VSCode extensions recommendation +JsonFile( + project, + ".vscode/extensions.json", + obj={"recommendations": ["dannysteenman.aws-cdk-extension-pack"]}, + marker=False, +) gh = project.github @@ -119,16 +175,14 @@ "package-ecosystem": "uv", "directory": "/", "schedule": {"interval": "weekly"}, - "ignore": [ - {"dependency-name": "aws-cdk-lib"}, - {"dependency-name": "aws-cdk"}, - {"dependency-name": "projen"}, - ], - "labels": ["dependencies", "auto-approve"], + # projen pins these in this file, so a Dependabot bump would be + # reverted by the next synth. + "ignore": [{"dependency-name": name} for name in PINNED_PACKAGES], + "labels": ["dependencies"], "groups": { "default": { "patterns": ["*"], - "exclude-patterns": ["aws-cdk*", "projen"], + "exclude-patterns": PINNED_PACKAGES, } }, } @@ -136,39 +190,67 @@ }, ) -# Add auto-merge step to the auto-approve workflow -auto_approve_workflow = project.try_find_object_file( - ".github/workflows/auto-approve.yml" -) -if auto_approve_workflow: - auto_approve_workflow.add_override("jobs.approve.permissions.contents", "write") - # Add checkout step before the merge step - auto_approve_workflow.add_override( - "jobs.approve.steps.1", - {"name": "Checkout", "uses": "actions/checkout@v5"}, - ) - auto_approve_workflow.add_override( - "jobs.approve.steps.2", - { - "name": "Enable Pull Request Automerge", - "run": 'gh pr merge --merge --auto "${{ github.event.pull_request.number }}"', - "env": {"GH_TOKEN": "${{ secrets.PROJEN_GITHUB_TOKEN }}"}, - }, - ) +# Defines the environment configurations for the CDK application. +# The order of this list is the deployment order in the pipeline: each environment's workflow +# is chained onto the completion of the previous one, so `production` only runs after `test` +# succeeded. Enable branch deployments on the lower environments only. +environment_configs = [ + EnvironmentConfig( + name="test", account_id="987654321012", enable_branch_deploy=True + ), + EnvironmentConfig( + name="production", account_id="123456789012", enable_branch_deploy=False + ), +] -# Loop through each environment in target_accounts -for env, account in target_accounts.items(): - if account: # Check if account is not None - # Adds customized projen tasks for executing cdk actions for each environment - cdk_action_task( - project, - { - "CDK_DEFAULT_ACCOUNT": account, - "ENVIRONMENT": env, - }, - ) +if gh: + ordered_environments = [config.name for config in environment_configs] + + # Lint, type check, test and synth on every pull request + create_build_workflow(gh, python_version) + + # Tag and publish a GitHub release when the project version changes on main + create_release_workflow(gh, python_version) + + for config in environment_configs: + task_environment = { + "CDK_DEFAULT_ACCOUNT": config.account_id, + "CDK_DEFAULT_REGION": aws_region, + "ENVIRONMENT": config.name, + "GITHUB_DEPLOY_ROLE": github_role, + } + + # Adds `uv run projen` commands for executing cdk synth, diff, deploy, destroy and ls + add_cdk_action_task(project, task_environment) + + # If branch deployment is enabled for this environment, add the GIT_BRANCH_REF tasks + if config.enable_branch_deploy: + add_cdk_action_task( + project, + { + **task_environment, + "GIT_BRANCH_REF": "$(echo ${GIT_BRANCH_REF:-$(git rev-parse --abbrev-ref HEAD)})", + }, + ) # Adds GitHub action workflows for deploying the CDK stacks to the target AWS account - github_cicd(gh, account, env, python_version, aws_region) + create_cdk_deployment_workflows( + gh, + config, + aws_region, + github_role, + python_version, + ordered_environments, + ) + + # Create the CDK diff PR workflow once, against the environment deployed last + create_cdk_diff_pr_workflow( + gh, + environment_configs[-1].account_id, + aws_region, + github_role, + python_version, + ordered_environments, + ) project.synth() diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..a9f3e58 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "dannysteenman.aws-cdk-extension-pack" + ] +} diff --git a/README.md b/README.md index c3dec13..b9159db 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,20 @@ ## AWS CDK Python Starter Kit -Production-ready AWS CDK Python starter kit template with secure OIDC authentication and automated CI/CD. Deploy infrastructure to AWS in minutes with projen-powered configuration. +Deploy an AWS CDK app written in Python through GitHub Actions, without storing a single AWS credential. One projen config file generates the workflows, the tasks and the packaging. ### πŸš€ Features -- **⚑ Rapid Setup**: Jumpstart your project within minutes by tweaking a [single configuration file (projen)](./.projenrc.py) - - Preconfigured Python with uv dependency management via [pyproject.toml](./pyproject.toml) - - Pre-configured linting & formatting with [Ruff](https://github.com/astral-sh/ruff) for code quality - - Clean [project structure](#project-structure) for easy management of constructs and stacks -- **πŸ›‘οΈ Seamless Security**: OIDC authentication for keyless AWS deployments - no stored credentials or long-lived secrets required -- **πŸ€– Automated CI/CD**: Out-of-the-box GitHub Actions workflows with multi-account support for enterprise-ready deployments -- **πŸš€ Enhanced Pull Requests**: Built-in pull request template for structured and informative code reviews +- **⚑ One file to configure**: Set your accounts and region in [.projenrc.py](./.projenrc.py), run `uv run projen`, and the workflows, tasks and [pyproject.toml](./pyproject.toml) regenerate to match + - The CDK CLI is pinned in the lockfile alongside everything else, so your machine and CI run the same version + - [Ruff](https://github.com/astral-sh/ruff) for linting and formatting, [ty](https://github.com/astral-sh/ty) for type checking, both wired into the build + - A [project structure](#project-structure) that separates constructs, stacks and aspects instead of piling them into one file +- **πŸ›‘οΈ Keyless deploys**: GitHub Actions assumes an IAM role through OIDC, so there's nothing in your repository secrets to leak. The trust policy pins GitHub's immutable subject claim, so renaming or recreating the repo can't hand its access to a different one +- **πŸ€– Chained multi-account pipelines**: Push to `main` and `test` deploys. `production` runs only after `test` succeeds +- **πŸ’¬ CDK diff on every PR**: The [diff commenter](https://github.com/marketplace/actions/aws-cdk-diff-pr-commenter) posts what your change does to production before anyone approves it +- **πŸ’» Per-branch environments**: Push a feature branch and it gets its own stacks in the shared test account. Delete the branch and they go with it. Branch deploys use CDK express mode, [up to 4x faster](https://aws.amazon.com/about-aws/whats-new/2026/06/aws-cloudformation-cdk/) than a normal deploy +- **🧹 Aspects you can switch on**: Four [aspects](./src/aspects/README.md) that catch an IAM role with no permission boundary, an unencrypted bucket, a bucket open to the public, and a VPC on public address space +- **πŸ“¦ Dependency updates that wait**: Dependabot opens grouped weekly PRs and nothing merges itself. uv skips any release younger than 7 days, so a bad publish has a week to get caught before it can reach your lockfile > [!TIP] @@ -60,7 +63,7 @@ Production-ready AWS CDK Python starter kit template with secure OIDC authentica ### Setup Guide -All the config that is needed to personalise the CDK App to your environment is defined in the [.projenrc.py file](./.projenrc.py). +Everything you need to change lives in [.projenrc.py](./.projenrc.py). **To get started, follow these steps:** @@ -68,11 +71,9 @@ All the config that is needed to personalise the CDK App to your environment is 2. Add a Personal Access Token to the repository settings on GitHub, follow these [instructions for setting up a fine-grained personal access token](https://projen.io/docs/integrations/github/#fine-grained-personal-access-token-beta). -3. Install the AWS CDK CLI: `npm install -g aws-cdk` +3. Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then run `uv sync`. That pulls in the pinned CDK CLI too, so you don't need a global `npm install -g aws-cdk`. -4. Install uv (if needed) and sync dependencies: `uv sync` - -5. Customize the AWS Region and Account IDs in the [.projenrc.py](./.projenrc.py) file to match your AWS setup: +4. Customize the AWS Region and Account IDs in the [.projenrc.py](./.projenrc.py) file to match your AWS setup: ```python # Define the AWS region for the CDK app and github workflows @@ -83,48 +84,43 @@ aws_region = os.getenv("AWS_REGION", "us-east-1") # so the CDK CLI knows which region to use project.tasks.add_environment("CDK_DEFAULT_REGION", aws_region) -# Define the target AWS accounts for the different environments -target_accounts = { - "dev": "987654321012", - "test": "123456789012", - "staging": None, - "production": None, -} +# Defines the environment configurations for the CDK application. +# The order of this list is the deployment order in the pipeline: each environment's workflow +# is chained onto the completion of the previous one, so `production` only runs after `test` +# succeeded. Enable branch deployments on the lower environments only. +environment_configs = [ + EnvironmentConfig(name="test", account_id="987654321012", enable_branch_deploy=True), + EnvironmentConfig(name="production", account_id="123456789012", enable_branch_deploy=False), +] ``` -6. Run `uv run projen` to generate the github actions workflow files. - -7. AWS CLI Authentication: Ensure you're logged into an AWS Account (one of the ones you configured in step 4) via the AWS CLI. If you haven't set up the AWS CLI, [then follow this guide](https://towardsthecloud.com/set-up-aws-cli-aws-sso)) +5. Run `uv run projen` to generate the GitHub Actions workflow files. -8. Deploy the CDK toolkit stack to your AWS environment with `cdk bootstrap` if it's not already set up. +6. Log into one of the accounts you set in step 4 with the AWS CLI. If you haven't set that up yet, [follow this guide](https://towardsthecloud.com/set-up-aws-cli-aws-sso). -9. Deploy the GitHub OIDC Stack to enable GitHub Actions workflow permissions for AWS deployments. For instance, if you set up a `dev` environment, execute `uv run projen dev:deploy`. +7. Run `uv run cdk bootstrap` if the account doesn't have the CDK toolkit stack yet. - Local synth resolves your repository's numeric GitHub IDs through `gh api`, so run `gh auth login` first. +8. Deploy the FoundationStack to create the GitHub Actions deploy role. For the `test` environment that's `uv run projen test:deploy:all`. -10. Opt the repository into immutable OIDC subject claims: + Synthesizing locally reads your repository's numeric GitHub IDs through `gh api`, so run `gh auth login` first. - ```bash - gh api -X PUT repos/OWNER/REPOSITORY/actions/oidc/customization/sub -F use_default=true -F use_immutable_subject=true - ``` +9. Opt the repository into immutable OIDC subject claims: - The deploy role only trusts the immutable claim, so do this after step 9. See [`src/stacks/README.md`](./src/stacks/README.md#github-immutable-oidc-subjects). + ```bash + gh api -X PUT repos/OWNER/REPOSITORY/actions/oidc/customization/sub -F use_default=true -F use_immutable_subject=true + ``` -11. Commit and push your changes to the `main` branch to trigger the CDK deploy pipeline in GitHub. + The deploy role trusts only the immutable claim and there's no fallback to the old one, so do this after step 8, never before. [`src/stacks/README.md`](./src/stacks/README.md#github-immutable-oidc-subjects) explains why. -Congratulations πŸŽ‰! You've successfully set up your project. +10. Push to `main`. The pipeline takes it from there. ### Project Structure -When working on smaller projects using infrastructure as code, where you deploy single applications that don’t demand extensive maintenance or collaboration from multiple teams, it’s recommended to structure your AWS CDK project in a way that enables you to deploy both the application and infrastructure using a single stack. - -However, as projects evolve to encompass multiple microservices and a variety of stateful resources (e.g., databases), the complexity inherently increases. - -In such cases, adopting a more sophisticated AWS CDK project organization becomes critical. This ensures not only the ease of extensibility but also the smooth deployment of each component, thereby supporting a more robust development lifecycle and facilitating greater operational efficiency. +One stack is fine while your app is a single service owned by one team. Put it all in `StarterStack` and move on. -To cater to these advanced needs, your AWS CDK project should adopt a modular structure. This is where the **AWS CDK Python Starter Kit** shines ✨. +That stops working once you have several services and stateful resources on different lifecycles, because every deploy then touches everything, including the database you'd rather nobody touched. -Here’s a closer look at how this structure enhances maintainability and scalability: +So this kit splits the app along the seams that change independently: ```bash . @@ -142,6 +138,12 @@ Here’s a closer look at how this structure enhances maintainability and scalab β”‚ β”‚ └── lambda β”‚ β”‚ └── hello-world β”‚ β”‚ └── lambda_function.py +β”‚ β”œβ”€β”€ aspects +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ permission_boundary_aspect.py +β”‚ β”‚ β”œβ”€β”€ s3_aspect.py +β”‚ β”‚ β”œβ”€β”€ vpc_aspect.py +β”‚ β”‚ └── README.md β”‚ β”œβ”€β”€ bin β”‚ β”‚ β”œβ”€β”€ cicd_helper.py β”‚ β”‚ β”œβ”€β”€ env_helper.py @@ -149,38 +151,61 @@ Here’s a closer look at how this structure enhances maintainability and scalab β”‚ β”œβ”€β”€ custom_constructs β”‚ β”‚ β”œβ”€β”€ __init__.py β”‚ β”‚ β”œβ”€β”€ base_construct.py +β”‚ β”‚ β”œβ”€β”€ github_actions_oidc_construct.py β”‚ β”‚ β”œβ”€β”€ network_construct.py β”‚ β”‚ └── README.md β”‚ └── stacks β”‚ β”œβ”€β”€ __init__.py -β”‚ β”œβ”€β”€ base_stack.py -β”‚ β”œβ”€β”€ github_oidc_stack.py +β”‚ β”œβ”€β”€ foundation_stack.py +β”‚ β”œβ”€β”€ starter_stack.py β”‚ └── README.md └── tests β”œβ”€β”€ __init__.py - β”œβ”€β”€ test_example.py - β”œβ”€β”€ test_git_helper.py - └── test_github_oidc_stack.py + β”œβ”€β”€ test_aspects.py + β”œβ”€β”€ test_env_helper.py + β”œβ”€β”€ test_foundation_stack.py + └── test_git_helper.py ``` -As you can see in the above tree diagram, the way this project is setup it tries to segment it into logical units, such as **constructs** for reusable infrastructure patterns, **stacks** for deploying groups of resources and **assets** for managing source code of containers and lambda functions. +Each directory has one job: + +- `src/aspects`: Rules applied to every construct in a scope, like attaching a permission boundary to each IAM role. The [aspects README](./src/aspects/README.md) covers what each one catches and how to write your own. +- `src/assets`: Lambda handler code and container Dockerfiles, sitting next to the infrastructure that ships them. +- `src/bin`: Helpers shared by the CDK app and `.projenrc.py`: resource naming, workflow generation, and resolving this repository's GitHub identity. +- `src/custom_constructs`: Reusable building blocks you compose into stacks. The [constructs README](./src/custom_constructs/README.md) covers the environment-aware pattern. It's called `custom_constructs` because a directory named `constructs` would shadow the `constructs` package every CDK module imports. +- `src/stacks`: `FoundationStack` holds the account-level pieces (the deploy role and the toolkit cleaner), and `StarterStack` is where your own resources go. See the [stacks README](./src/stacks/README.md). +- `src/app.py`: Where the app gets built and the stacks get their names. +- `tests`: pytest tests. `uv run projen test` runs them together with Ruff and ty. -Here is a brief explanation of what each section does: +### Branch-based Deployments -- `src/assets`: Organizes the assets for your Lambda functions and ECS services, ensuring that the application code is neatly encapsulated with the infrastructure code. -- `src/bin`: Contains utility scripts (e.g., `cicd_helper.py`, `env_helper.py`, `git_helper.py`) that streamline environment setup and integration with CI/CD pipelines. -- `src/custom_constructs`: Houses the core building blocks of your infrastructure. These constructs can be composed into higher-level abstractions, promoting reusability across different parts of your infrastructure. Check out the [README in the constructs folder](./src/custom_constructs/README.md) to read how you can utilize environment-aware configurations. -- `src/stacks`: Dedicated to defining stacks that represent collections of AWS resources (constructs). This allows for logical grouping of related resources, making it simpler to manage deployments and resource dependencies. Check out the [README in the stacks folder](./src/stacks/README.md) to read how you can instantiate new stacks. -- `src/lib/main.ts`: This is where the CDK app is instantiated. -- `test`: Is the location to store your unit or integration tests (powered by jest) +Push a feature branch and the `cdk-deploy-test-branch` workflow deploys its own copy of the stacks, named after the branch: `StarterStack-add-api` rather than `StarterStack-test`. Several developers can work against the same AWS account without stepping on each other. + +`FoundationStack` is skipped during branch deployments. A feature branch has no business recreating the role its own pipeline assumes. + +Deploys and destroys in the `test` environment use CDK express mode, which is meaningfully faster and safe for stacks that only exist for the life of a branch. + +Deleting the branch triggers `cdk-destroy-test-branch`, which tears the stacks down. Turn on **"Automatically delete head branches"** in your repository settings so merging a pull request cleans up after itself. `main` and the automation branches are excluded. + +The generated projen tasks mirror this: + +```bash +uv run projen test:synth # synthesize every stack +uv run projen test:deploy:all # deploy every stack +uv run projen test:deploy:stack -- StarterStack-test +uv run projen test:branch:deploy:all # deploy this branch's stacks +uv run projen test:branch:deploy:hotswap # fast inner loop, branch only +uv run projen test:branch:destroy:all +uv run projen test:ls +``` ### AWS CDK Starter Kit for TypeScript Users -> **Looking for the TypeScript version of this AWS CDK Starter Kit?** Check out the [AWS CDK Starter Kit](https://github.com/towardsthecloud/aws-cdk-starter-kit) for a tailored experience that leverages the full power of AWS CDK with TypeScript. +> Prefer TypeScript? The [AWS CDK Starter Kit](https://github.com/towardsthecloud/aws-cdk-starter-kit) is the same setup, written in TypeScript. ### Acknowledgements -A heartfelt thank you to the creators of [projen](https://github.com/projen/projen). This starter kit stands on the shoulders of giants, made possible by their pioneering work in simplifying cloud infrastructure projects! +Thanks to the [projen](https://github.com/projen/projen) team. Every workflow, task and config file in this repo is generated, and none of it would exist without their work. ### Author diff --git a/cdk.json b/cdk.json index 867c37f..20e2bce 100644 --- a/cdk.json +++ b/cdk.json @@ -1,5 +1,8 @@ { "app": "python src/app.py", + "context": { + "cli-telemetry": false + }, "output": "cdk.out", "watch": { "include": [ diff --git a/pyproject.toml b/pyproject.toml index 479fe33..60744fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,14 +6,27 @@ version = "2.101.0" description = "Create and deploy an AWS CDK app on your AWS account in less than 5 minutes using GitHub actions!" readme = "README.md" requires-python = ">=3.13,<3.14" -dependencies = [ "aws-cdk-lib==2.254.0", "constructs>=10.5.1,<11.0.0" ] +dependencies = [ + "aws-cdk-lib==2.254.0", + "cloudstructs", + "constructs>=10.5.1,<11.0.0" +] [[project.authors]] name = "Danny Steenman" email = "danny@towardsthecloud.com" [dependency-groups] -dev = [ "projen==0.99.62", "ruff", "ty", "pytest==9.0.3" ] +dev = [ + "aws-cdk-cli==2.1130.0", + "projen==0.101.20", + "ruff", + "ty", + "pytest==9.1.1" +] [tool.pytest.ini_options] pythonpath = [ "src" ] + +[tool.uv] +exclude-newer = "7 days" diff --git a/src/app.py b/src/app.py index 80ed022..dc31600 100644 --- a/src/app.py +++ b/src/app.py @@ -1,24 +1,50 @@ import os import aws_cdk as cdk -from stacks.base_stack import BaseStack -from stacks.github_oidc_stack import GitHubOIDCStack -# Inherit environment variables from npm run commands (displayed in .projen/tasks.json) -environment = os.environ.get("ENVIRONMENT", "dev") -aws_environment = cdk.Environment(account=os.getenv("CDK_DEFAULT_ACCOUNT"), region=os.getenv("CDK_DEFAULT_REGION")) +from bin.env_helper import ( + DEFAULT_ENVIRONMENT, + create_env_resource_name, + extract_cleaned_branch_name, +) +from stacks.foundation_stack import FoundationStack +from stacks.starter_stack import StarterStack + +# Inherit environment variables from the `uv run projen` commands (see .projen/tasks.json) +environment = os.environ.get("ENVIRONMENT", DEFAULT_ENVIRONMENT) +aws_environment = cdk.Environment( + account=os.getenv("CDK_DEFAULT_ACCOUNT"), region=os.getenv("CDK_DEFAULT_REGION") +) # Instantiate the CDK app app = cdk.App() -# Add GitHub OpenID Connect support and create an IAM role for GitHub -GitHubOIDCStack(app, f"GitHubOIDCStack-{environment}", env=aws_environment) +# The FoundationStack holds account-level infrastructure: the GitHub Actions deploy role and +# the CDK toolkit cleaner. Deploy it once per account, and never from a feature branch, which +# would otherwise recreate the very role its own pipeline assumes. +if not os.environ.get("GIT_BRANCH_REF"): + FoundationStack( + app, + create_env_resource_name("FoundationStack"), + environment=environment, + env=aws_environment, + ) # Create a new stack with your resources -BaseStack(app, f"BaseStack-{environment}", env=aws_environment) +StarterStack( + app, + create_env_resource_name("StarterStack"), + environment=environment, + env=aws_environment, +) # Tag all resources in CloudFormation with the environment name -cdk.Tags.of(app).add("Environment", environment) +cdk.Tags.of(app).add("environment", environment) + +# Tag branch based deploys with the branch name to easily identify the branch in the AWS console +branch_name = extract_cleaned_branch_name(os.environ.get("GIT_BRANCH_REF")) +if branch_name: + cdk.Tags.of(app).add("branch", branch_name) # Synthesize the CDK app app.synth() diff --git a/src/aspects/README.md b/src/aspects/README.md new file mode 100644 index 0000000..68c026a --- /dev/null +++ b/src/aspects/README.md @@ -0,0 +1,57 @@ +# AWS CDK Aspects + +An [aspect](https://docs.aws.amazon.com/cdk/v2/guide/aspects.html) visits every construct in a scope during the prepare phase. That makes it the right place for a rule you want applied everywhere, without every stack having to remember it. + +Each aspect here either **corrects** a property or **reports** a problem. The split is deliberate: correct when there's one right answer, report when the author has a real decision to make. + +## Available aspects + +| Aspect | Behaviour | What it catches | +| -------------------------- | --------------- | --------------------------------------------------------------------------------- | +| `PermissionBoundaryAspect` | Corrects | IAM roles with no permissions boundary, or a weaker one than the account requires | +| `BucketPublicAccessAspect` | Corrects, warns | S3 buckets that don't block public access | +| `BucketEncryptionAspect` | Errors | S3 buckets with no server-side encryption | +| `VpcCidrAspect` | Errors | VPCs on address space outside the RFC 1918 private ranges | + +## Usage + +Apply an aspect to the whole app, a single stack, or any construct in between. Scope it as widely as the rule applies: + +```python +import aws_cdk as cdk +from aspects import BucketEncryptionAspect, BucketPublicAccessAspect, PermissionBoundaryAspect, VpcCidrAspect + +app = cdk.App() + +cdk.Aspects.of(app).add(BucketEncryptionAspect()) +cdk.Aspects.of(app).add(BucketPublicAccessAspect()) +cdk.Aspects.of(app).add(VpcCidrAspect()) +cdk.Aspects.of(app).add(PermissionBoundaryAspect(f"arn:aws:iam::{account}:policy/base-permission-boundary")) +``` + +None of them are applied in [`app.py`](../app.py) by default. Pick the ones your account actually needs and add them there. + +An aspect that calls `Annotations.add_error` fails `cdk synth`, so it blocks a deployment rather than reporting after the fact. + +## Writing your own + +Implement `visit`, and decorate the class with `@jsii.implements(IAspect)` so the CDK recognises it: + +```python +import jsii +from aws_cdk import Annotations, IAspect +from aws_cdk import aws_sqs as sqs +from constructs import IConstruct + + +@jsii.implements(IAspect) +class QueueEncryptionAspect: + def visit(self, node: IConstruct) -> None: + if isinstance(node, sqs.CfnQueue) and not node.kms_master_key_id: + Annotations.of(node).add_error("SQS queue is not encrypted with a KMS key.") +``` + +Two things to know before you read an L1 property in an aspect: + +- **Some reads raise.** jsii cannot deserialize a struct that was set from the TypeScript side, so reading it raises `ValueError: Unknown interface`. `BucketPublicAccessAspect` hits this on any bucket built through the `Bucket` L2 construct and catches it. Check your property against both an L1 and an L2 construct before trusting the read. +- **Some values are tokens.** A property resolved at deploy time is not readable at synthesis time. `Tokenization.is_resolvable` tells you, and the right move is usually to leave it alone rather than override a value you can't see. diff --git a/src/aspects/__init__.py b/src/aspects/__init__.py new file mode 100644 index 0000000..5bcc1be --- /dev/null +++ b/src/aspects/__init__.py @@ -0,0 +1,10 @@ +from .permission_boundary_aspect import PermissionBoundaryAspect +from .s3_aspect import BucketEncryptionAspect, BucketPublicAccessAspect +from .vpc_aspect import VpcCidrAspect + +__all__ = [ + "BucketEncryptionAspect", + "BucketPublicAccessAspect", + "PermissionBoundaryAspect", + "VpcCidrAspect", +] diff --git a/src/aspects/permission_boundary_aspect.py b/src/aspects/permission_boundary_aspect.py new file mode 100644 index 0000000..0e43317 --- /dev/null +++ b/src/aspects/permission_boundary_aspect.py @@ -0,0 +1,35 @@ +import jsii +from aws_cdk import CfnResource, IAspect +from constructs import IConstruct + + +@jsii.implements(IAspect) +class PermissionBoundaryAspect: + """Attach a permissions boundary to every IAM role in the scope. + + A boundary caps what a role can do no matter what its policies grant, so applying one + across the app closes the gap where a single over-permissive policy slips through review. + Roles that already carry a boundary are overridden, which is deliberate: the account-wide + boundary wins. + + Example: + ```python + Aspects.of(app).add(PermissionBoundaryAspect(f"arn:aws:iam::{account}:policy/base-permission-boundary")) + ``` + + See: + https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-role.html#cfn-iam-role-permissionsboundary + """ + + def __init__(self, permissions_boundary_arn: str) -> None: + """ + Args: + permissions_boundary_arn: ARN of the managed policy to attach as the boundary. + """ + self.permissions_boundary_arn = permissions_boundary_arn + + def visit(self, node: IConstruct) -> None: + if isinstance(node, CfnResource) and node.cfn_resource_type == "AWS::IAM::Role": + node.add_property_override( + "PermissionsBoundary", self.permissions_boundary_arn + ) diff --git a/src/aspects/s3_aspect.py b/src/aspects/s3_aspect.py new file mode 100644 index 0000000..4b6566e --- /dev/null +++ b/src/aspects/s3_aspect.py @@ -0,0 +1,85 @@ +import jsii +from aws_cdk import Annotations, IAspect, Tokenization +from aws_cdk import aws_s3 as s3 +from constructs import IConstruct + + +@jsii.implements(IAspect) +class BucketEncryptionAspect: + """Fail synthesis when a bucket has no server-side encryption configured. + + This one reports rather than corrects: the encryption choice decides who can read the + objects (SSE-S3 versus SSE-KMS with a specific key), so silently picking one would hide a + decision the author should make. + + Example: + ```python + Aspects.of(app).add(BucketEncryptionAspect()) + ``` + """ + + def visit(self, node: IConstruct) -> None: + if isinstance(node, s3.CfnBucket) and not node.bucket_encryption: + Annotations.of(node).add_error("S3 bucket encryption is not enabled.") + + +@jsii.implements(IAspect) +class BucketPublicAccessAspect: + """Block public access on every bucket that does not already block it. + + Unlike encryption there is one right answer here, so this aspect corrects the property and + warns instead of failing. + + Two cases are deliberately left alone, because in both the property is set to something + this aspect cannot read and overriding it would discard a deliberate configuration: + + - An unresolved token, whose value is not known at synthesis time. + - A configuration set through the `Bucket` L2 construct. jsii cannot deserialize that + struct back into Python and raises `ValueError: Unknown interface` on the read, so a + bucket built with `block_public_access=BlockPublicAccess.BLOCK_ALL` is skipped. The + case this aspect exists to catch, a bucket with no configuration at all, reads cleanly + as `None` and is still corrected. + + Example: + ```python + Aspects.of(app).add(BucketPublicAccessAspect()) + ``` + + See: + https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3-bucket-publicaccessblockconfiguration.html + """ + + def visit(self, node: IConstruct) -> None: + if not isinstance(node, s3.CfnBucket): + return + + try: + configuration = node.public_access_block_configuration + except ValueError: + return + + # Only a bucket with no configuration at all falls through to the correction below. + if configuration is not None: + if Tokenization.is_resolvable(configuration): + return + if not isinstance( + configuration, s3.CfnBucket.PublicAccessBlockConfigurationProperty + ): + return + if configuration.block_public_acls is True: + return + + Annotations.of(node).add_warning( + f"S3 bucket: {node.bucket_name} has public access! " + "This is not recommended. " + "Therefore correcting the publicAccessBlockConfiguration property using Aspects" + ) + node.add_property_override( + "PublicAccessBlockConfiguration", + { + "BlockPublicAcls": True, + "BlockPublicPolicy": True, + "IgnorePublicAcls": True, + "RestrictPublicBuckets": True, + }, + ) diff --git a/src/aspects/vpc_aspect.py b/src/aspects/vpc_aspect.py new file mode 100644 index 0000000..bb5822f --- /dev/null +++ b/src/aspects/vpc_aspect.py @@ -0,0 +1,67 @@ +import ipaddress + +import jsii +from aws_cdk import Annotations, IAspect +from aws_cdk import aws_ec2 as ec2 +from constructs import IConstruct + +# The three private address blocks from RFC 1918. +PRIVATE_BLOCKS = ( + ipaddress.ip_network("10.0.0.0/8"), + ipaddress.ip_network("172.16.0.0/12"), + ipaddress.ip_network("192.168.0.0/16"), +) + +RFC1918_RANGES = """ + 10.0.0.0 - 10.255.255.255 (10/8 prefix) + 172.16.0.0 - 172.31.255.255 (172.16/12 prefix) + 192.168.0.0 - 192.168.255.255 (192.168/16 prefix)""" + + +@jsii.implements(IAspect) +class VpcCidrAspect: + """Fail synthesis when a VPC CIDR falls outside the RFC 1918 private ranges. + + A VPC on public address space routes badly against peered networks and on-premises + ranges, and the mistake is cheap to make and expensive to undo: a VPC CIDR cannot be + changed after creation. + + A VPC whose CIDR is still an unresolved token is skipped, since its value is not known + at synthesis time. + + Example: + ```python + Aspects.of(app).add(VpcCidrAspect()) + ``` + + See: + http://www.faqs.org/rfcs/rfc1918.html + """ + + def visit(self, node: IConstruct) -> None: + if not isinstance(node, ec2.CfnVPC): + return + + cidr_block = node.cidr_block + if cidr_block is None: + # A VPC built from an IPAM pool has no literal CIDR to check. + return + + try: + cidr = ipaddress.ip_network(cidr_block, strict=False) + except ValueError: + # Either an unresolved token or a malformed value; CloudFormation rejects the + # latter with a clearer message than this aspect could give. + return + + if any( + cidr.subnet_of(block) + for block in PRIVATE_BLOCKS + if cidr.version == block.version + ): + return + + Annotations.of(node).add_error( + f"\nYour current VPC Cidr range {node.cidr_block} does not comply with " + f"the standard CIDR range in block: \n{RFC1918_RANGES}" + ) diff --git a/src/bin/cicd_helper.py b/src/bin/cicd_helper.py index b008a28..bb09ad6 100644 --- a/src/bin/cicd_helper.py +++ b/src/bin/cicd_helper.py @@ -1,62 +1,629 @@ +from collections.abc import Sequence +from typing import Any + from projen import github +from .env_helper import EnvironmentConfig, get_task_name + +COMMON_RUNS_ON = ["ubuntu-latest"] + +# Branches that must never get their own ephemeral stacks: `main` owns the shared environment +# stacks, and the rest are automation branches whose pushes would deploy on every bot commit. +BRANCH_EXCLUSIONS = ["main", "hotfix/*", "github-actions/*", "dependabot/**"] + +COMMON_WORKFLOW_PERMISSIONS = { + "contents": github.workflows.JobPermission.READ, + "idToken": github.workflows.JobPermission.WRITE, +} +"""Least privilege for a deployment job: read the code, mint an OIDC token, nothing else.""" + + +GIT_IDENTITY_STEP = { + "name": "Set git identity", + "run": ( + 'git config user.name "github-actions[bot]"\n' + 'git config user.email "41898282+github-actions[bot]@users.noreply.github.com"' + ), +} + + +def create_build_workflow( + gh: github.GitHub, python_version: str +) -> github.GithubWorkflow: + """Create the workflow that lints, type checks, tests and synthesizes on every pull request. + + Also carries projen's self-mutation pattern: if `projen build` regenerates a file, the diff + is pushed back onto the pull request branch rather than silently ignored, so a stale + generated file can never merge. -def github_cicd(gh, account, env, python_version, aws_region): - # Add a GitHub workflow for deploying the CDK stacks to the AWS account - cdk_deployment_workflow = github.GithubWorkflow(gh, f"cdk-deploy-{env}") - cdk_deployment_workflow.on(push={"branches": ["main"]} if env != "production" else None, workflow_dispatch={}) + Args: + gh: The projen GitHub component to attach the workflow to. + python_version: The Python version to set up. - cdk_deployment_workflow.add_jobs( + Returns: + The created workflow. + """ + workflow = github.GithubWorkflow(gh, "build") + workflow.on(pull_request={}, workflow_dispatch={}) + + # A fork PR runs contributor-controlled test and build code, so this job holds nothing that + # can write to the repository. The self-mutation job below does the pushing, and it skips + # forks entirely. + build_steps = [ + _checkout_step( + ref="${{ github.event.pull_request.head.ref }}", + repository="${{ github.event.pull_request.head.repo.full_name }}", + ), + _setup_python_step(python_version), + _setup_uv_step(), + # Not --frozen: projen may legitimately update the lockfile, and the self-mutation + # step below is what surfaces that as a pushed change. + {"name": "Install dependencies", "run": "uv sync"}, + {"name": "build", "run": "uv run projen build"}, { - "deploy": { - "name": f"Deploy CDK stacks to {env} AWS account", - "runsOn": ["ubuntu-latest"], - "permissions": { - "actions": github.workflows.JobPermission.WRITE, - "contents": github.workflows.JobPermission.READ, - "idToken": github.workflows.JobPermission.WRITE, + "name": "Find mutations", + "id": "self_mutation", + "run": ( + "git add .\n" + "git diff --staged --patch --exit-code > repo.patch " + '|| echo "self_mutation_happened=true" >> $GITHUB_OUTPUT' + ), + "shell": "bash", + }, + { + "name": "Upload patch", + "if": "steps.self_mutation.outputs.self_mutation_happened", + "uses": "actions/upload-artifact@v7", + "with": {"name": "repo.patch", "path": "repo.patch", "overwrite": True}, + }, + { + "name": "Fail build on mutation", + "if": "steps.self_mutation.outputs.self_mutation_happened", + "run": ( + 'echo "::error::Files were changed during build (see build log). ' + 'If this was triggered from a fork, you will need to update your branch."\n' + "cat repo.patch\n" + "exit 1" + ), + }, + ] + + self_mutation_steps = [ + _checkout_step( + token="${{ secrets.PROJEN_GITHUB_TOKEN }}", + ref="${{ github.event.pull_request.head.ref }}", + repository="${{ github.event.pull_request.head.repo.full_name }}", + ), + { + "name": "Download patch", + "uses": "actions/download-artifact@v8", + "with": {"name": "repo.patch", "path": "${{ runner.temp }}"}, + }, + { + "name": "Apply patch", + "run": ( + "[ -s ${{ runner.temp }}/repo.patch ] && git apply ${{ runner.temp }}/repo.patch " + '|| echo "Empty patch. Skipping."' + ), + }, + GIT_IDENTITY_STEP, + { + "name": "Push changes", + "env": {"PULL_REQUEST_REF": "${{ github.event.pull_request.head.ref }}"}, + "run": ( + 'git add .\ngit commit -s -m "chore: self mutation"\ngit push origin "HEAD:$PULL_REQUEST_REF"' + ), + }, + ] + + workflow.add_jobs( + { + "build": { + "runsOn": COMMON_RUNS_ON, + "permissions": {"contents": github.workflows.JobPermission.READ}, + "outputs": { + "self_mutation_happened": { + "stepId": "self_mutation", + "outputName": "self_mutation_happened", + } }, + "env": {"CI": "true"}, + "steps": build_steps, + }, + "self-mutation": { + "needs": ["build"], + "runsOn": COMMON_RUNS_ON, + "permissions": {"contents": github.workflows.JobPermission.WRITE}, + # A fork cannot be pushed to, and the token must never reach fork-controlled code. + "if": ( + "always() && needs.build.outputs.self_mutation_happened && " + "!(github.event.pull_request.head.repo.full_name != github.repository)" + ), + "steps": self_mutation_steps, + }, + } + ) + + return workflow + + +def create_release_workflow( + gh: github.GitHub, python_version: str +) -> github.GithubWorkflow: + """Create the workflow that tags a release when the project version changes. + + This differs from the TypeScript starter kit, which uses projen's release component to + derive the next version from conventional commits. projen has no equivalent for Python + projects, so the version in `.projenrc.py` is the trigger: bump it, merge to main, and + this workflow tags the commit and publishes a GitHub release with generated notes. + + Args: + gh: The projen GitHub component to attach the workflow to. + python_version: The Python version to set up. + + Returns: + The created workflow. + """ + workflow = github.GithubWorkflow( + gh, + "release", + limit_concurrency=True, + concurrency_options={ + "group": "${{ github.workflow }}", + "cancel_in_progress": False, + }, + ) + workflow.on(push={"branches": ["main"]}, workflow_dispatch={}) + + workflow.add_jobs( + { + "release": { + "name": "Tag and publish a GitHub release", + "runsOn": COMMON_RUNS_ON, + "permissions": {"contents": github.workflows.JobPermission.WRITE}, "steps": [ + # Full history so the generated release notes can reach the previous tag. + _checkout_step(fetch_depth=0), + _setup_python_step(python_version), + _setup_uv_step(), + {"name": "Install dependencies", "run": "uv sync --frozen"}, { - "name": "Checkout repository", - "uses": "actions/checkout@v4", - }, - { - "name": "Setup python environment", - "uses": "actions/setup-python@v5", - "with": { - "python-version": python_version, - }, + "name": "Read project version", + "id": "version", + "run": ( + 'VERSION=$(uv run python -c "' + "import tomllib, pathlib; " + "print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])" + '")\n' + 'echo "tag=v$VERSION" >> $GITHUB_OUTPUT' + ), }, { - "name": "Setup uv", - "uses": "astral-sh/setup-uv@v5", + "name": "Check if version has already been tagged", + "id": "check_tag_exists", + "run": ( + "git ls-remote -q --exit-code --tags origin " + "${{ steps.version.outputs.tag }} > /dev/null " + '&& echo "exists=true" >> $GITHUB_OUTPUT ' + '|| echo "exists=false" >> $GITHUB_OUTPUT' + ), }, + GIT_IDENTITY_STEP, { - "name": "Configure AWS credentials", - "uses": "aws-actions/configure-aws-credentials@v4", - "with": { - "role-to-assume": f"arn:aws:iam::{account}:role/GitHubDeployRole", - "aws-region": aws_region, - }, - }, - { - "name": "Install dependencies", - "run": "uv sync", - }, - { - "name": "Install cdk cli", - "run": "npm install -g aws-cdk", - }, - { - "name": f"Run CDK synth for the {env.upper()} environment", - "run": f"uv run projen {env}:synth", - }, - { - "name": f"Deploy CDK to the {env.upper()} environment on AWS account {account}", - "run": f"uv run projen {env}:deploy", + "name": "Release", + "if": "steps.check_tag_exists.outputs.exists != 'true'", + "env": {"GITHUB_TOKEN": "${{ secrets.GITHUB_TOKEN }}"}, + "run": ( + "gh release create ${{ steps.version.outputs.tag }} " + "-R $GITHUB_REPOSITORY -t ${{ steps.version.outputs.tag }} " + "--target $GITHUB_SHA --generate-notes" + ), }, ], + } + } + ) + + return workflow + + +def _branch_filter() -> list[str]: + """Build the push branch filter that excludes the long-lived and automation branches.""" + return ["**", *[f"!{branch}" for branch in BRANCH_EXCLUSIONS]] + + +def _excluded_branch_expression(ref: str) -> str: + """Build a GitHub expression that is true when `ref` is not an excluded branch. + + Triggers that support a `branches` filter get `_branch_filter()`. The `delete` event does + not support one, so it needs the same exclusions expressed as a job condition. + + Args: + ref: The GitHub expression holding the branch name, for example `github.event.ref`. + + Returns: + The condition, without the surrounding `${{ }}`. + """ + clauses = [] + + for branch in BRANCH_EXCLUSIONS: + if "*" in branch: + prefix = branch.split("*", 1)[0] + clauses.append(f"!startsWith({ref}, '{prefix}')") + else: + clauses.append(f"{ref} != '{branch}'") + + return " && ".join(clauses) + + +def _checkout_step(**with_options: Any) -> dict[str, Any]: + """Build the repository checkout step. + + Args: + **with_options: Entries for the step's `with` block, such as `ref`, `repository`, + `token`, or `fetch_depth`. Underscores become hyphens, so `fetch_depth=0` renders + as `fetch-depth: 0`. Falsy values other than `0` are dropped, which lets callers + pass an optional ref without branching. + + Returns: + The checkout step. + """ + step: dict[str, Any] = { + "name": "Checkout repository", + "uses": "actions/checkout@v6", + } + options = { + key.replace("_", "-"): value + for key, value in with_options.items() + if value or value == 0 + } + + if options: + step["with"] = options + + return step + + +def _setup_python_step(python_version: str) -> dict[str, Any]: + """Build the Python setup step.""" + return { + "name": "Setup python environment", + "uses": "actions/setup-python@v6", + "with": {"python-version": python_version}, + } + + +def _setup_uv_step() -> dict[str, Any]: + """Build the uv setup step, with caching keyed on the lockfile.""" + return { + "name": "Setup uv", + "uses": "astral-sh/setup-uv@v7", + "with": {"enable-cache": True, "cache-dependency-glob": "uv.lock"}, + } + + +def _aws_credentials_step(account: str, region: str, role_name: str) -> dict[str, Any]: + """Build the OIDC credential step for the deploy role in one account.""" + return { + "name": "Configure AWS credentials", + "uses": "aws-actions/configure-aws-credentials@v6", + "with": { + "role-to-assume": f"arn:aws:iam::{account}:role/{role_name}", + "aws-region": region, + }, + } + + +def _install_deps_step() -> dict[str, Any]: + """Build the dependency install step. + + `--frozen` fails rather than silently resolving a different dependency set than the + lockfile pins, which is what makes a CI run reproducible. + """ + return {"name": "Install dependencies", "run": "uv sync --frozen"} + + +def common_workflow_steps( + python_version: str, + account: str | None = None, + region: str | None = None, + github_deploy_role: str | None = None, + checkout_ref: str | None = None, +) -> list[dict[str, Any]]: + """Build the steps every workflow starts with. + + AWS credentials are only requested when an account, region, and role are all supplied, so + the same helper serves workflows that never touch AWS. + + Args: + python_version: The Python version to set up. + account: The AWS account to assume the deploy role in. + region: The AWS region to operate in. + github_deploy_role: The name of the deploy role to assume. + checkout_ref: A specific git ref to check out, for example a pull request head SHA. + + Returns: + The ordered list of workflow steps. + """ + steps = [ + _checkout_step(ref=checkout_ref), + _setup_python_step(python_version), + _setup_uv_step(), + ] + + if github_deploy_role and region and account: + steps.append(_aws_credentials_step(account, region, github_deploy_role)) + + steps.append(_install_deps_step()) + + return steps + + +def create_cdk_diff_pr_workflow( + gh: github.GitHub, + account: str, + region: str, + github_deploy_role: str, + python_version: str, + ordered_environments: Sequence[str], +) -> github.GithubWorkflow: + """Create the workflow that posts a CDK diff as a pull request comment. + + The diff runs against the last environment in the deployment order, which is the one a + reviewer most needs to see the impact on. + + Args: + gh: The projen GitHub component to attach the workflow to. + account: The AWS account to diff against. + region: The AWS region to diff in. + github_deploy_role: The name of the deploy role to assume. + python_version: The Python version to set up. + ordered_environments: Environment names in deployment order. + + Returns: + The created workflow. + """ + workflow = github.GithubWorkflow(gh, "cdk-diff-pr-comment") + highest_env = ordered_environments[-1] if ordered_environments else "production" + + # pull_request_target runs the workflow definition from the base branch, so a fork PR cannot + # rewrite it to steal the deploy credential. The checkout below is pinned to the PR head SHA, + # so the diff still reflects the proposed change. + workflow.on(pull_request_target={"branches": ["main"]}) + + steps = common_workflow_steps( + python_version, + account, + region, + github_deploy_role, + "${{ github.event.pull_request.head.sha }}", + ) + + steps += [ + { + "name": "CDK diff and notify PR", + # A non-zero exit here means the diff itself failed; the commenter still posts the + # captured output, which is more useful to a reviewer than a red X with no detail. + "run": ( + f"uv run projen {get_task_name(highest_env, 'diff', task_type='all')} " + "> cdk-diff-output.txt 2>&1 || true" + ), + }, + { + "name": "Post CDK Diff Comment in PR", + "uses": "towardsthecloud/aws-cdk-diff-pr-commenter@v1", + "with": { + "diff-file": "cdk-diff-output.txt", + "aws-region": region, + "header": f"CDK Diff for {highest_env} in {region}", + }, + }, + ] + + workflow.add_jobs( + { + "diff": { + "name": f"CDK diff PR branch with {highest_env} environment (via main)", + "runsOn": COMMON_RUNS_ON, + "permissions": { + **COMMON_WORKFLOW_PERMISSIONS, + "pullRequests": github.workflows.JobPermission.WRITE, + }, + "env": {"AWS_REGION": region}, + "steps": steps, + } + } + ) + + return workflow + + +def create_cdk_deployment_workflows( + gh: github.GitHub, + config: EnvironmentConfig, + region: str, + github_deploy_role: str, + python_version: str, + ordered_environments: Sequence[str], +) -> None: + """Create every deployment workflow for one environment. + + Always creates the shared environment workflow. When the environment enables branch + deployments, also creates the branch deploy and branch destroy workflows. + + Args: + gh: The projen GitHub component to attach the workflows to. + config: The environment to generate workflows for. + region: The AWS region to deploy to. + github_deploy_role: The name of the deploy role to assume. + python_version: The Python version to set up. + ordered_environments: Environment names in deployment order. + """ + _create_cdk_deployment_workflow( + gh, + config, + region, + github_deploy_role, + python_version, + False, + ordered_environments, + ) + + if config.enable_branch_deploy: + _create_cdk_deployment_workflow( + gh, config, region, github_deploy_role, python_version, True, [] + ) + _create_cdk_destroy_workflow( + gh, config, region, github_deploy_role, python_version + ) + + +def _create_cdk_deployment_workflow( + gh: github.GitHub, + config: EnvironmentConfig, + region: str, + github_deploy_role: str, + python_version: str, + deploy_for_branch: bool, + ordered_environments: Sequence[str], +) -> github.GithubWorkflow: + """Create a single deployment workflow, for either the shared environment or a branch.""" + env = config.name + workflow_name = ( + f"cdk-deploy-{env}-branch" if deploy_for_branch else f"cdk-deploy-{env}" + ) + + workflow = github.GithubWorkflow( + gh, + workflow_name, + limit_concurrency=True, + concurrency_options={ + "group": "${{ github.workflow }}-${{ github.ref_name }}", + # Cancelling a deploy mid-flight leaves the stack in UPDATE_IN_PROGRESS, so queue instead. + "cancel_in_progress": False, + }, + ) + + triggers: dict[str, Any] = {"workflow_dispatch": {}} + + if deploy_for_branch: + triggers["push"] = {"branches": _branch_filter()} + else: + current_index = ordered_environments.index(env) + if current_index == 0: + triggers["push"] = {"branches": ["main"]} + elif current_index > 0: + # Chain onto the previous environment so production only runs once test has passed. + previous_env = ordered_environments[current_index - 1] + triggers["workflow_run"] = { + "workflows": [f"cdk-deploy-{previous_env}"], + "types": ["completed"], + } + + workflow.on(**triggers) + + steps = common_workflow_steps( + python_version, config.account_id, region, github_deploy_role + ) + steps += [ + { + "name": f"Run CDK synth for the {env.upper()} environment", + "run": f"uv run projen {get_task_name(env, 'synth', is_branch=deploy_for_branch)}", + }, + { + "name": f"Deploy CDK to the {env.upper()} environment on AWS account {config.account_id}", + "run": f"uv run projen {get_task_name(env, 'deploy', is_branch=deploy_for_branch, task_type='all')}", + }, + ] + + job: dict[str, Any] = { + "name": f"Deploy CDK stacks to {env} AWS account{' (Branch)' if deploy_for_branch else ''}", + "runsOn": COMMON_RUNS_ON, + "environment": env, + "permissions": COMMON_WORKFLOW_PERMISSIONS, + "steps": steps, + } + + if "workflow_run" in triggers: + # workflow_run fires on completion regardless of outcome, so a failed test deploy would + # otherwise promote straight to production. + job["if"] = "github.event.workflow_run.conclusion == 'success'" + + workflow.add_jobs({"deploy": job}) + + return workflow + + +def _create_cdk_destroy_workflow( + gh: github.GitHub, + config: EnvironmentConfig, + region: str, + github_deploy_role: str, + python_version: str, +) -> github.GithubWorkflow: + """Create the workflow that tears down a branch's ephemeral stacks. + + Runs on branch deletion, which covers the merged pull request case when the repository has + "Automatically delete head branches" enabled, and on manual dispatch. + """ + env = config.name + workflow = github.GithubWorkflow(gh, f"cdk-destroy-{env}-branch") + + # GitHub does not support a `branches` filter on `delete` events, so the automation and + # long-lived branches are excluded by the job condition below instead of by the trigger. + workflow.on(workflow_dispatch={}, delete={}) + + steps = common_workflow_steps( + python_version, config.account_id, region, github_deploy_role + ) + destroy_task = get_task_name(env, "destroy", is_branch=True, task_type="all") + deleted_branch_condition = ( + "github.event.ref_type == 'branch' && github.event_name == 'delete'" + ) + job_condition = ( + "github.event_name == 'workflow_dispatch' || " + f"({deleted_branch_condition} && {_excluded_branch_expression('github.event.ref')})" + ) + + steps += [ + { + "name": "Fetch Deleted Branch Name", + "id": "destroy-branch", + "if": deleted_branch_condition, + # The deleted branch no longer exists to check out, so read its name from the event payload. + "run": ( + "BRANCH=$(cat ${{ github.event_path }} | jq --raw-output '.ref'); " + 'echo "${{ github.repository }} has ${BRANCH} branch"; ' + 'echo "DESTROY_BRANCH_NAME=$BRANCH" >> $GITHUB_OUTPUT' + ), + }, + { + "name": "Destroy Branch Stack (Workflow Dispatch)", + "if": "github.event_name == 'workflow_dispatch'", + "run": f"uv run projen {destroy_task}", + "env": {"GIT_BRANCH_REF": "${{ github.ref_name }}"}, + }, + { + "name": "Destroy Branch Stack (Branch Deletion)", + "if": deleted_branch_condition, + "run": f"uv run projen {destroy_task}", + "env": { + "GIT_BRANCH_REF": "${{ steps.destroy-branch.outputs.DESTROY_BRANCH_NAME }}" }, + }, + ] + + workflow.add_jobs( + { + "destroy": { + "name": "Remove deployment of feature branch", + "if": job_condition, + "runsOn": COMMON_RUNS_ON, + "environment": env, + "permissions": COMMON_WORKFLOW_PERMISSIONS, + "steps": steps, + } } ) + + return workflow diff --git a/src/bin/env_helper.py b/src/bin/env_helper.py index 19e9c07..1cf1954 100644 --- a/src/bin/env_helper.py +++ b/src/bin/env_helper.py @@ -1,25 +1,225 @@ -from typing import Dict +from __future__ import annotations -from projen.awscdk import AwsCdkPythonApp +import os +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal +if TYPE_CHECKING: + # Only needed to annotate `add_cdk_action_task`. Importing projen at runtime would drag the + # build tool into the CDK app, which imports this module for `create_env_resource_name`. + from projen.awscdk import AwsCdkPythonApp -def cdk_action_task(project: AwsCdkPythonApp, target_account: Dict[str, str]): - task_actions = ["synth", "diff", "deploy", "destroy"] - stack_name_pattern = f"*Stack-{target_account['ENVIRONMENT']}" +Environment = Literal["sandbox", "development", "test", "staging", "production"] +"""The deployment environments this project supports.""" - for action in task_actions: - task_name = f"{target_account['ENVIRONMENT']}:{action}" - task_description = f"{action.capitalize()} the stacks on the {target_account['ENVIRONMENT'].upper()} account" +SUPPORTED_CDK_ACTIONS = ("synth", "diff", "deploy", "deploy:hotswap", "destroy", "ls") +"""CDK actions that get a generated projen task.""" - exec_command = f"cdk {action} --require-approval never {stack_name_pattern}" - if action == "destroy": - exec_command = f"cdk destroy --force {stack_name_pattern}" - if action == "synth": - exec_command = "cdk synth" +DEFAULT_ENVIRONMENT = "dev" +"""Environment assumed when `ENVIRONMENT` is unset, as it is on a bare `cdk synth`.""" + +DEFAULT_GITHUB_DEPLOY_ROLE_NAME = "GitHubActionsServiceRole" +"""Name of the role GitHub Actions assumes. + +Owned here because two sides must agree on it: `FoundationStack` creates the role, and the +generated workflows put its ARN in `role-to-assume`. A rename in only one of them leaves CI +requesting a role that does not exist. +""" + +# A resource name longer than this breaks the strictest AWS naming constraints. +MAX_RESOURCE_NAME_LENGTH = 64 +# Longer branch names push the resource name over the limit once a base name is prepended. +MAX_BRANCH_NAME_LENGTH = 25 + +GIT_TAG_PATTERN = re.compile(r"v\d+\.\d+\.\d+$") +NON_RESOURCE_NAME_CHARACTERS = re.compile(r"[^a-zA-Z0-9-]") +TRAILING_SEPARATORS = re.compile(r"[^a-zA-Z0-9]+$") + +# Deploying a branch stack from one of these would collide with the shared environment stacks. +LONG_LIVED_BRANCHES = frozenset({"main", "develop", "development"}) + + +@dataclass(frozen=True) +class EnvironmentConfig: + """Configuration for a single deployment environment. + + Attributes: + name: The environment name, which also names the projen tasks and the GitHub environment. + account_id: The AWS account this environment deploys to. + enable_branch_deploy: Whether feature branches get their own ephemeral stacks in this + environment. Keep this off for production. + """ + + name: Environment + account_id: str + enable_branch_deploy: bool = False + + +def get_task_name( + environment: str, action: str, is_branch: bool = False, task_type: str | None = None +) -> str: + """Build a projen task name following the project's naming convention. + + Args: + environment: The environment name, for example `test` or `production`. + action: The CDK action, for example `synth`, `deploy`, or `destroy`. + is_branch: Whether the task targets a branch deployment. + task_type: Either `all` or `stack`. Ignored for `synth` and `ls`, which always + operate on every stack. + + Returns: + The task name. + + Examples: + >>> get_task_name("test", "synth") + 'test:synth' + >>> get_task_name("test", "deploy", is_branch=True, task_type="all") + 'test:branch:deploy:all' + """ + task_name = ( + f"{environment}:branch:{action}" if is_branch else f"{environment}:{action}" + ) + + if task_type and action not in ("synth", "ls"): + task_name += f":{task_type}" + + return task_name + + +def add_cdk_action_task( + project: AwsCdkPythonApp, target_account: dict[str, str] +) -> None: + """Add `uv run projen` tasks for every CDK action in one environment. + + Creates a single task for `synth` and `ls`, which always cover every stack, and `:all` plus + `:stack` variants for the actions where targeting one stack is useful. `deploy:hotswap` is + only generated for branch deployments, where a fast inner loop matters and drift does not. + + Branch deployments to the `test` environment use CDK express mode, which is considerably + faster and safe for ephemeral stacks. + + Args: + project: The projen project to add the tasks to. + target_account: Environment variables for the tasks. Must include `ENVIRONMENT`, and + carries `GIT_BRANCH_REF` for branch deployments. + """ + is_branch = bool(target_account.get("GIT_BRANCH_REF")) + use_express_mode = target_account["ENVIRONMENT"] == "test" and is_branch + express_mode_arg = " --express" if use_express_mode else "" + + command_map = { + "synth": "cdk synth", + "destroy": f"cdk destroy{express_mode_arg} --force", + "deploy": f"cdk deploy{express_mode_arg} --require-approval never", + "deploy:hotswap": "cdk deploy --hotswap --require-approval never", + "diff": "cdk diff", + "ls": "cdk ls", + } + + def describe(action: str, target: str) -> str: + return f"{action.capitalize()} {target} on the {target_account['ENVIRONMENT'].upper()} account" + + for action in SUPPORTED_CDK_ACTIONS: + # Hotswap only makes sense for the ephemeral stacks of a branch deployment. + if action == "deploy:hotswap" and not is_branch: + continue + + exec_command = command_map[action] + + if action in ("synth", "ls"): + project.add_task( + get_task_name( + target_account["ENVIRONMENT"], action, is_branch=is_branch + ), + description=describe(action, "the stacks"), + env=target_account, + exec=exec_command, + ) + continue + + action_label = "hotswap deploy" if action == "deploy:hotswap" else action + + project.add_task( + get_task_name( + target_account["ENVIRONMENT"], + action, + is_branch=is_branch, + task_type="all", + ), + description=describe(action_label, "all stacks"), + env=target_account, + exec=f"{exec_command} --all", + ) project.add_task( - task_name, - description=task_description, + get_task_name( + target_account["ENVIRONMENT"], + action, + is_branch=is_branch, + task_type="stack", + ), + description=describe(action_label, "specific stack(s)"), env=target_account, exec=exec_command, + receive_args=True, + ) + + +def extract_cleaned_branch_name(git_branch_ref: str | None) -> str | None: + """Reduce a Git ref to the suffix used in branch-deployed resource names. + + Args: + git_branch_ref: The Git ref, for example `feature/add-api` or `refs/heads/fix-1`. + + Returns: + The cleaned branch name, or `None` when the ref should not get its own stacks: a + version tag, or one of the long-lived branches that own the shared environment stacks. + """ + if not git_branch_ref: + return None + + if GIT_TAG_PATTERN.search(git_branch_ref): + return None + + last_part = git_branch_ref.lower().split("/")[-1] + + if last_part in LONG_LIVED_BRANCHES: + return None + + cleaned = TRAILING_SEPARATORS.sub( + "", NON_RESOURCE_NAME_CHARACTERS.sub("", last_part) + ) + return cleaned[:MAX_BRANCH_NAME_LENGTH] + + +def create_env_resource_name(base_name: str) -> str: + """Suffix a resource name with the branch or environment it belongs to. + + Args: + base_name: The base resource name, for example `StarterStack`. + + Returns: + The suffixed name, truncated to 64 characters to satisfy AWS naming constraints. + + Raises: + ValueError: If `GIT_BRANCH_REF` is `main`. A branch deployment from `main` would + collide with the shared environment stacks. + """ + branch_name = os.environ.get("GIT_BRANCH_REF") + environment = os.environ.get("ENVIRONMENT", DEFAULT_ENVIRONMENT) + + if branch_name and branch_name.lower() == "main": + raise ValueError( + 'Invalid branch-based deployment: GIT_BRANCH_REF cannot be "main"' ) + + cleaned_branch_name = extract_cleaned_branch_name(branch_name) + suffix = cleaned_branch_name or environment + resource_name = f"{base_name}-{suffix}" + + if len(resource_name) <= MAX_RESOURCE_NAME_LENGTH: + return resource_name + + # Truncating can leave a trailing hyphen, which most AWS resource names reject. + return TRAILING_SEPARATORS.sub("", resource_name[:MAX_RESOURCE_NAME_LENGTH]) diff --git a/src/custom_constructs/README.md b/src/custom_constructs/README.md index f21f029..588ff0b 100644 --- a/src/custom_constructs/README.md +++ b/src/custom_constructs/README.md @@ -1,4 +1,4 @@ -# AWS CDK Constructs: BaseConstruct and NetworkConstruct +# AWS CDK Constructs: BaseConstruct, GitHubActionsOidcConstruct and NetworkConstruct This README provides a comprehensive guide to understanding and utilizing the `BaseConstruct` and `NetworkConstruct` within your AWS CDK project, emphasizing the importance of environment-aware configurations. @@ -59,3 +59,17 @@ class MyStack(cdk.Stack): NetworkConstruct(self, "NetworkConstruct") ``` + +## GitHubActionsOidcConstruct + +Creates the IAM role that GitHub Actions workflows assume to deploy, trusting GitHub's immutable repository subject claim. `FoundationStack` instantiates it, so you rarely need it directly; reach for it when you want the deploy role in a stack of your own. + +The account's OIDC provider for `token.actions.githubusercontent.com` is imported rather than created, because AWS allows one provider per issuer URL per account. + +See the [stacks README](../stacks/README.md#github-immutable-oidc-subjects) for the subject format, how the repository identity is resolved, and the cutover steps. + +```python +from custom_constructs.github_actions_oidc_construct import GitHubActionsOidcConstruct + +GitHubActionsOidcConstruct(self, "GitHubActionsOidc", environment="production") +``` diff --git a/src/custom_constructs/__init__.py b/src/custom_constructs/__init__.py index 7c98cae..1c35bcb 100644 --- a/src/custom_constructs/__init__.py +++ b/src/custom_constructs/__init__.py @@ -1,4 +1,5 @@ from .base_construct import BaseConstruct +from .github_actions_oidc_construct import GitHubActionsOidcConstruct from .network_construct import NetworkConstruct -__all__ = ["BaseConstruct", "NetworkConstruct"] +__all__ = ["BaseConstruct", "GitHubActionsOidcConstruct", "NetworkConstruct"] diff --git a/src/custom_constructs/github_actions_oidc_construct.py b/src/custom_constructs/github_actions_oidc_construct.py new file mode 100644 index 0000000..50f6226 --- /dev/null +++ b/src/custom_constructs/github_actions_oidc_construct.py @@ -0,0 +1,108 @@ +import os +from typing import Any + +from aws_cdk import Duration, Stack +from aws_cdk.aws_iam import ( + ManagedPolicy, + OpenIdConnectProvider, + Role, + WebIdentityPrincipal, +) +from constructs import Construct + +from bin.env_helper import DEFAULT_GITHUB_DEPLOY_ROLE_NAME +from bin.git_helper import ( + GitHubRepositoryReference, + build_github_actions_oidc_subject, + get_git_repository_identity, + resolve_repository_reference, +) + +GITHUB_DOMAIN = "token.actions.githubusercontent.com" + + +class GitHubActionsOidcConstruct(Construct): + """Create the IAM role that GitHub Actions workflows assume to deploy. + + The role trusts GitHub's immutable subject claim, + `repo:OWNER@OWNER-ID/REPOSITORY@REPOSITORY-ID:CONTEXT`, so renaming, recreating, or + transferring a repository cannot hand its trust to a different one. A repository still + emitting legacy subjects must be opted into immutable subjects after this construct is + deployed; there is no fallback to the legacy subject form. + + The account's OIDC provider for `token.actions.githubusercontent.com` is imported rather + than created, because AWS allows one provider per issuer URL per account. + + Attributes: + provider: The imported GitHub Actions OIDC identity provider. + role: The IAM role GitHub Actions workflows assume. + """ + + def __init__( + self, + scope: Construct, + id: str, + environment: str, + additional_repositories: list[GitHubRepositoryReference] | None = None, + max_session_duration: Duration | None = None, + role_name: str | None = None, + ) -> None: + """ + Args: + scope: The scope in which to define this construct. + id: The scoped construct ID. + environment: The GitHub environment allowed to assume the role. The trust policy + is scoped to `environment:`, so a workflow job must declare the + matching `environment:` to get a credential. + additional_repositories: Other repositories under the same owner allowed to assume + the role. Each needs its numeric GitHub ID, read with + `gh api repos/OWNER/NAME --jq .id`. The ID is checked in rather than resolved + during synthesis: a lookup would force every synthesizing CI job to hold a + token able to read the other repository. + max_session_duration: How long an assumed session lasts. Defaults to 2 hours. + role_name: Name of the IAM role. Defaults to `GITHUB_DEPLOY_ROLE`, then + `GitHubActionsServiceRole`. + """ + super().__init__(scope, id) + + repository = get_git_repository_identity() + + stack = Stack.of(self) + self.provider = OpenIdConnectProvider.from_open_id_connect_provider_arn( + self, + "GithubProvider", + f"arn:{stack.partition}:iam::{stack.account}:oidc-provider/{GITHUB_DOMAIN}", + ) + + context = f"environment:{environment}" + subjects = [build_github_actions_oidc_subject(repository, context)] + [ + build_github_actions_oidc_subject( + resolve_repository_reference(repository, reference), context + ) + for reference in additional_repositories or [] + ] + + conditions: dict[str, Any] = { + "StringLike": {f"{GITHUB_DOMAIN}:sub": subjects}, + "StringEquals": {f"{GITHUB_DOMAIN}:aud": "sts.amazonaws.com"}, + } + + self.role = Role( + self, + "GitHubActionsServiceRole", + # ty reads IPrincipal structurally and every jsii-generated PrincipalBase subclass + # names its parameter `_statement` where the protocol says `statement`, so no CDK + # principal satisfies the check. The runtime type is correct. + assumed_by=WebIdentityPrincipal( # ty: ignore[invalid-argument-type] + self.provider.open_id_connect_provider_arn, conditions + ), + description=( + "This role is used via GitHub Actions to deploy with AWS CDK on the target AWS account" + ), + managed_policies=[ + ManagedPolicy.from_aws_managed_policy_name("AdministratorAccess") + ], + max_session_duration=max_session_duration or Duration.hours(2), + role_name=role_name + or os.environ.get("GITHUB_DEPLOY_ROLE", DEFAULT_GITHUB_DEPLOY_ROLE_NAME), + ) diff --git a/src/stacks/README.md b/src/stacks/README.md index 4f5cfc9..233aea4 100644 --- a/src/stacks/README.md +++ b/src/stacks/README.md @@ -1,53 +1,63 @@ -# AWS CDK Stacks: BaseStack and GitHubOIDCStack +# AWS CDK Stacks: StarterStack and FoundationStack -This documentation details the structure and functionality of two pivotal stacks within our AWS CDK TypeScript project: `BaseStack` and `GitHubOIDCStack`. These stacks lay the groundwork for deploying AWS resources with specific configurations and capabilities, tailored to different deployment stages and integration with GitHub Actions for CI/CD processes. +Two stacks ship with this kit. `FoundationStack` holds the account-level plumbing your pipeline needs, and `StarterStack` is where your own infrastructure goes. -## BaseStack +Both are instantiated in [`app.py`](../app.py) with a name from `create_env_resource_name`, so a `test` deployment produces `StarterStack-test` and a branch deployment produces `StarterStack-add-api`. -The `BaseStack` serves as a foundational stack that you can use to start instantiation your custom and cdk-lib constructs. +## StarterStack + +The starting point for your own resources. Add constructs in the constructor, and add another stack next to this one once a group of resources grows its own lifecycle. ### Properties -- `environment`: Optional. Specifies the deployment stage (e.g., `dev`, `test`, `staging`, `production`). It's crucial for tailoring the stack configuration to the target environment. +- `environment`: Optional. The deployment stage (`test`, `production`, and so on). ### Example Usage ```python -import os - -import aws_cdk as cdk -from stacks.base_stack import BaseStack +from aws_cdk import aws_s3 as s3 + +# In the constructor: +s3.Bucket( + self, + "MyBucket", + versioned=True, + encryption=s3.BucketEncryption.S3_MANAGED, +) +``` -# Inherit environment variables from npm run commands (displayed in .projen/tasks.json) -environment = os.environ.get("ENVIRONMENT", "dev") -aws_environment = cdk.Environment(account=os.getenv("CDK_DEFAULT_ACCOUNT"), region=os.getenv("CDK_DEFAULT_REGION")) +## FoundationStack -# Instantiate the CDK app -app = cdk.App() +Account-level infrastructure that everything else depends on. Deploy it once per AWS account. -BaseStack(app, f"BaseStack-{environment}", env=aws_environment) -``` +`app.py` skips it during branch deployments: a feature branch has no business recreating the role its own pipeline assumes. -## GitHubOIDCStack +### Features -The GitHubOIDCStack is designed to facilitate secure CI/CD workflows by integrating AWS resources with GitHub Actions via OpenID Connect (OIDC). This allows for a more secure and streamlined deployment process directly from GitHub Actions. +- **GitHub Actions OIDC provider**: imports the account's provider for `token.actions.githubusercontent.com`, the trust anchor between GitHub and AWS. AWS allows one provider per issuer URL per account, so the stack references it rather than creating it. +- **GitHub deploy role**: an IAM role with the AdministratorAccess managed policy, assumable from GitHub Actions through OIDC. +- **CDK toolkit cleaner**: deletes CDK asset objects and images no deployed stack references any more. Without it the staging bucket and ECR repository grow with every deployment. -### Features +### Properties -- GitHub Actions OIDC Provider: Imports the account's OIDC provider for `token.actions.githubusercontent.com`, the trust anchor between GitHub and AWS. AWS allows one provider per issuer URL per account, so the stack references it instead of creating it. -- GitHub Deploy Role: Creates an IAM role with AdministratorAccess managed policy. This role is assumable by GitHub Actions workflows, granting them the permissions needed to deploy resources. +- `environment`: Required. The deployment stage, which also names the GitHub environment the deploy role trusts. +- `additional_repositories`: Optional. Other repositories under the same owner allowed to assume the role. Each needs its name and numeric GitHub ID, read with `gh api repos/OWNER/NAME --jq .id`. The ID is checked in rather than looked up during synth, because a lookup would hand every synthesizing CI job a token able to read the other repository. +- `max_session_duration`: Optional. How long an assumed session lasts. Defaults to 2 hours. +- `role_name`: Optional. Defaults to `GITHUB_DEPLOY_ROLE`, then `GitHubActionsServiceRole`. ### GitHub immutable OIDC subjects The deploy role trusts GitHub's immutable subject claim: ```text -repo:OWNER@OWNER-ID/REPOSITORY@REPOSITORY-ID:* +repo:OWNER@OWNER-ID/REPOSITORY@REPOSITORY-ID:environment:ENVIRONMENT ``` The numeric IDs pin the trust to one repository. Rename it, delete and recreate it, or transfer it to another owner, and the old trust no longer matches. -Your repository has to emit that claim. Opt in through the API, then check it took: +The `environment:` segment means a workflow job must declare a matching `environment:` to get a credential. The generated deploy workflows already do. + +Your repository has to emit the immutable claim. Opt in through the API, then check it took: ```bash gh api -X PUT repos/OWNER/REPOSITORY/actions/oidc/customization/sub -F use_default=true -F use_immutable_subject=true @@ -58,38 +68,26 @@ Deploy the stack first, then flip the setting. There's no fallback to the legacy Synthesis resolves your repository's IDs on its own. GitHub Actions passes them in `GITHUB_REPOSITORY_ID` and `GITHUB_REPOSITORY_OWNER_ID`, so no workflow needs a token. Local synth reads the `origin` remote and calls `gh api`, so run `gh auth login` once. -### Configuration - -Both arguments are optional: - -- `additional_repositories`: other repositories under the same owner allowed to assume the role. Each needs its name and numeric GitHub ID, read with `gh api repos/OWNER/NAME --jq .id`. The ID is checked in rather than looked up during synth, because a lookup would hand every synthesizing CI job a token able to read the other repository. -- `subject_context`: the part of the subject after the repository. Defaults to `*`, which trusts every ref. Narrow it to `environment:production` once the deploy workflows declare a GitHub environment. - -```python -from bin.git_helper import GitHubRepositoryReference - -GitHubOIDCStack( - app, - f"GitHubOIDCStack-{environment}", - env=aws_environment, - additional_repositories=[GitHubRepositoryReference(name="my-cdk-app", id="123456789")], -) -``` - ### Example Usage ```python import os import aws_cdk as cdk -from stacks.github_oidc_stack import GitHubOIDCStack +from bin.env_helper import create_env_resource_name +from bin.git_helper import GitHubRepositoryReference +from stacks.foundation_stack import FoundationStack -# Inherit environment variables from npm run commands (displayed in .projen/tasks.json) environment = os.environ.get("ENVIRONMENT", "dev") aws_environment = cdk.Environment(account=os.getenv("CDK_DEFAULT_ACCOUNT"), region=os.getenv("CDK_DEFAULT_REGION")) -# Instantiate the CDK app app = cdk.App() -GitHubOIDCStack(app, f"GitHubOIDCStack-{environment}", env=aws_environment) +FoundationStack( + app, + create_env_resource_name("FoundationStack"), + environment=environment, + env=aws_environment, + additional_repositories=[GitHubRepositoryReference(name="my-cdk-app", id="123456789")], +) ``` diff --git a/src/stacks/__init__.py b/src/stacks/__init__.py index 5f17680..6d7cecc 100644 --- a/src/stacks/__init__.py +++ b/src/stacks/__init__.py @@ -1,4 +1,4 @@ -from .base_stack import BaseStack -from .github_oidc_stack import GitHubOIDCStack +from .foundation_stack import FoundationStack +from .starter_stack import StarterStack -__all__ = ["BaseStack", "GitHubOIDCStack"] +__all__ = ["FoundationStack", "StarterStack"] diff --git a/src/stacks/base_stack.py b/src/stacks/base_stack.py deleted file mode 100644 index d83aee1..0000000 --- a/src/stacks/base_stack.py +++ /dev/null @@ -1,12 +0,0 @@ -import aws_cdk as cdk -from constructs import Construct - -# from custom_constructs.network_construct import NetworkConstruct - - -class BaseStack(cdk.Stack): - def __init__(self, scope: Construct, id: str, **kwargs) -> None: - super().__init__(scope, id, **kwargs) - - # ↓↓ instantiate your constructs here ↓↓ - # NetworkConstruct(self, "NetworkConstruct") # sample construct that creates a VPC diff --git a/src/stacks/foundation_stack.py b/src/stacks/foundation_stack.py new file mode 100644 index 0000000..98b732d --- /dev/null +++ b/src/stacks/foundation_stack.py @@ -0,0 +1,56 @@ +from aws_cdk import CfnOutput, Duration, Stack +from cloudstructs import ToolkitCleaner +from constructs import Construct + +from bin.git_helper import GitHubRepositoryReference +from custom_constructs.github_actions_oidc_construct import GitHubActionsOidcConstruct + + +class FoundationStack(Stack): + """Account-level infrastructure that every other stack depends on. + + Holds the GitHub Actions deploy role and the CDK toolkit cleaner. Deploy it once per AWS + account; it is excluded from branch deployments in `app.py`, because a feature branch has + no business recreating the role its own pipeline assumes. + + Args: + scope: The scope in which to define this stack. + id: The scoped construct ID. + environment: The deployment stage, which also names the GitHub environment the deploy + role trusts. + additional_repositories: Other repositories under the same owner allowed to assume the + deploy role. Each needs its numeric GitHub ID, read with + `gh api repos/OWNER/NAME --jq .id`. + max_session_duration: How long an assumed deploy session lasts. Defaults to 2 hours. + role_name: Name of the deploy role. Defaults to `GITHUB_DEPLOY_ROLE`, then + `GitHubActionsServiceRole`. + """ + + def __init__( + self, + scope: Construct, + id: str, + environment: str, + additional_repositories: list[GitHubRepositoryReference] | None = None, + max_session_duration: Duration | None = None, + role_name: str | None = None, + **kwargs, + ) -> None: + super().__init__(scope, id, **kwargs) + + # ↓↓ Setup GitHub OIDC support ↓↓ + github_actions_oidc = GitHubActionsOidcConstruct( + self, + "GitHubActionsOidc", + environment=environment, + additional_repositories=additional_repositories, + max_session_duration=max_session_duration, + role_name=role_name, + ) + + # ↓↓ Setup CDK Toolkit Cleaner ↓↓ + # Removes CDK asset objects and images that no deployed stack references any more. + # Without it the staging bucket and ECR repository grow with every deployment. + ToolkitCleaner(self, "ToolkitCleaner") + + CfnOutput(self, "DeployRole", value=github_actions_oidc.role.role_arn) diff --git a/src/stacks/github_oidc_stack.py b/src/stacks/github_oidc_stack.py deleted file mode 100644 index df895a9..0000000 --- a/src/stacks/github_oidc_stack.py +++ /dev/null @@ -1,94 +0,0 @@ -import os -from typing import Any - -from aws_cdk import CfnOutput, Duration, Stack -from aws_cdk.aws_iam import ( - ManagedPolicy, - OpenIdConnectProvider, - Role, - WebIdentityPrincipal, -) -from constructs import Construct - -from bin.git_helper import ( - GitHubRepositoryReference, - build_github_actions_oidc_subject, - get_git_repository_identity, - resolve_repository_reference, -) - -GITHUB_DOMAIN = "token.actions.githubusercontent.com" -DEFAULT_ROLE_NAME = "GitHubDeployRole" - - -class GitHubOIDCStack(Stack): - """Create the IAM role that GitHub Actions workflows assume to deploy. - - The role trusts GitHub's immutable subject claim, - `repo:OWNER@OWNER-ID/REPOSITORY@REPOSITORY-ID:CONTEXT`, so renaming, recreating, or - transferring a repository cannot hand its trust to a different one. A repository still - emitting legacy subjects must be opted into immutable subjects after this stack is - deployed; there is no fallback to the legacy subject form. - - Args: - scope: The scope in which to define this stack. - id: The scoped construct ID. - additional_repositories: Other repositories under the same owner allowed to assume - the role. Each needs its numeric GitHub ID, read with - `gh api repos/OWNER/NAME --jq .id`. - subject_context: Subject context after the repository segment. Defaults to `*`, which - trusts every ref of the trusted repositories. Narrow it to - `environment:production` once the deploy workflows declare a GitHub environment. - """ - - def __init__( - self, - scope: Construct, - id: str, - additional_repositories: list[GitHubRepositoryReference] | None = None, - subject_context: str = "*", - **kwargs, - ) -> None: - super().__init__(scope, id, **kwargs) - - repository = get_git_repository_identity() - - # Import the account's existing GitHub OIDC provider rather than creating one. AWS allows - # a single provider per issuer URL per account, and this is what the stack did before it - # dropped the aws-cdk-github-oidc dependency, so redeploying does not fight an existing one. - provider = OpenIdConnectProvider.from_open_id_connect_provider_arn( - self, - "GithubProvider", - f"arn:{self.partition}:iam::{self.account}:oidc-provider/{GITHUB_DOMAIN}", - ) - - subjects = [build_github_actions_oidc_subject(repository, subject_context)] + [ - build_github_actions_oidc_subject( - resolve_repository_reference(repository, reference), subject_context - ) - for reference in additional_repositories or [] - ] - - conditions: dict[str, Any] = { - "StringLike": {f"{GITHUB_DOMAIN}:sub": subjects}, - "StringEquals": {f"{GITHUB_DOMAIN}:aud": "sts.amazonaws.com"}, - } - - deploy_role = Role( - self, - "GitHubDeployRole", - # ty reads IPrincipal structurally and every jsii-generated PrincipalBase subclass - # names its parameter `_statement` where the protocol says `statement`, so no CDK - # principal satisfies the check. The runtime type is correct. - assumed_by=WebIdentityPrincipal( # ty: ignore[invalid-argument-type] - provider.open_id_connect_provider_arn, conditions - ), - description="This role is used via GitHub Actions to deploy your AWS CDK stacks on your AWS account", - managed_policies=[ - ManagedPolicy.from_aws_managed_policy_name("AdministratorAccess") - ], - max_session_duration=Duration.hours(2), - role_name=os.environ.get("GITHUB_DEPLOY_ROLE", DEFAULT_ROLE_NAME), - ) - - CfnOutput(self, "DeployRole", value=deploy_role.role_arn) diff --git a/src/stacks/starter_stack.py b/src/stacks/starter_stack.py new file mode 100644 index 0000000..5d3575b --- /dev/null +++ b/src/stacks/starter_stack.py @@ -0,0 +1,45 @@ +from aws_cdk import Stack +from constructs import Construct + +# from custom_constructs.network_construct import NetworkConstruct + + +class StarterStack(Stack): + """The starting point for your own infrastructure. + + Add your constructs and resources in the constructor. For anything that grows past a + handful of related resources, add another stack next to this one rather than letting this + one sprawl. + + Args: + scope: The scope in which to define this stack. + id: The scoped construct ID. + environment: The deployment stage, for example `test` or `production`. + + Example: + Adding an S3 bucket: + + ```python + from aws_cdk import aws_s3 as s3 + + # In the constructor: + s3.Bucket( + self, + "MyBucket", + versioned=True, + encryption=s3.BucketEncryption.S3_MANAGED, + ) + ``` + """ + + def __init__( + self, scope: Construct, id: str, environment: str | None = None, **kwargs + ) -> None: + super().__init__(scope, id, **kwargs) + + self.environment_name = environment + + # ↓↓ Add your constructs and resources below ↓↓ + + # Sample construct that creates a secure VPC + # NetworkConstruct(self, "NetworkConstruct") diff --git a/tests/test_aspects.py b/tests/test_aspects.py new file mode 100644 index 0000000..0158d67 --- /dev/null +++ b/tests/test_aspects.py @@ -0,0 +1,169 @@ +import aws_cdk as cdk +import pytest +from aws_cdk import aws_ec2 as ec2 +from aws_cdk import aws_iam as iam +from aws_cdk import aws_s3 as s3 +from aws_cdk.assertions import Annotations, Match, Template + +from aspects import ( + BucketEncryptionAspect, + BucketPublicAccessAspect, + PermissionBoundaryAspect, + VpcCidrAspect, +) + +BOUNDARY_ARN = "arn:aws:iam::123456789012:policy/base-permission-boundary" + + +def new_stack() -> cdk.Stack: + return cdk.Stack( + cdk.App(), + "aspect-test", + env=cdk.Environment(account="123456789012", region="us-east-1"), + ) + + +def lambda_role(stack: cdk.Stack, id: str, **kwargs) -> iam.Role: + """Build a role for the boundary tests. + + ty reads IPrincipal structurally and every jsii-generated PrincipalBase subclass names its + parameter `_statement` where the protocol says `statement`, so no CDK principal satisfies + the check. The runtime type is correct, and keeping the suppression here means the tests + below do not each carry one. + """ + principal = iam.ServicePrincipal("lambda.amazonaws.com") + return iam.Role(stack, id, assumed_by=principal, **kwargs) # ty: ignore[invalid-argument-type] + + +def test_permission_boundary_is_attached_to_every_role(): + stack = new_stack() + lambda_role(stack, "Role") + cdk.Aspects.of(stack).add(PermissionBoundaryAspect(BOUNDARY_ARN)) + + Template.from_stack(stack).has_resource_properties( + "AWS::IAM::Role", {"PermissionsBoundary": BOUNDARY_ARN} + ) + + +def test_permission_boundary_overrides_an_existing_one(): + """The account-wide boundary wins, so a role-level one cannot widen what the role may do.""" + stack = new_stack() + lambda_role( + stack, + "Role", + permissions_boundary=iam.ManagedPolicy.from_managed_policy_arn( + stack, "Weaker", "arn:aws:iam::123456789012:policy/weaker" + ), + ) + cdk.Aspects.of(stack).add(PermissionBoundaryAspect(BOUNDARY_ARN)) + + Template.from_stack(stack).has_resource_properties( + "AWS::IAM::Role", {"PermissionsBoundary": BOUNDARY_ARN} + ) + + +def test_bucket_encryption_aspect_errors_on_an_unencrypted_bucket(): + stack = new_stack() + s3.CfnBucket(stack, "Bucket") + cdk.Aspects.of(stack).add(BucketEncryptionAspect()) + + Annotations.from_stack(stack).has_error( + "*", Match.string_like_regexp("encryption is not enabled") + ) + + +def test_bucket_encryption_aspect_accepts_an_encrypted_bucket(): + stack = new_stack() + s3.Bucket(stack, "Bucket", encryption=s3.BucketEncryption.S3_MANAGED) + cdk.Aspects.of(stack).add(BucketEncryptionAspect()) + + Annotations.from_stack(stack).has_no_error( + "*", Match.string_like_regexp("encryption is not enabled") + ) + + +def test_public_access_aspect_blocks_a_bucket_with_no_configuration(): + stack = new_stack() + s3.CfnBucket(stack, "Bucket") + cdk.Aspects.of(stack).add(BucketPublicAccessAspect()) + + Template.from_stack(stack).has_resource_properties( + "AWS::S3::Bucket", + { + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": True, + "BlockPublicPolicy": True, + "IgnorePublicAcls": True, + "RestrictPublicBuckets": True, + } + }, + ) + + +def test_public_access_aspect_leaves_a_compliant_l1_bucket_alone(): + stack = new_stack() + s3.CfnBucket( + stack, + "Bucket", + public_access_block_configuration=s3.CfnBucket.PublicAccessBlockConfigurationProperty( + block_public_acls=True, block_public_policy=True + ), + ) + cdk.Aspects.of(stack).add(BucketPublicAccessAspect()) + + Annotations.from_stack(stack).has_no_warning( + "*", Match.string_like_regexp("has public access") + ) + + +def test_public_access_aspect_survives_a_bucket_built_through_the_l2_construct(): + """jsii cannot read back a struct set from TypeScript, and the aspect must not crash on it. + + `s3.Bucket(..., block_public_access=...)` sets the property from the TypeScript side, which + raises `ValueError: Unknown interface` on read. Synthesis has to succeed regardless. + """ + stack = new_stack() + s3.Bucket(stack, "Bucket", block_public_access=s3.BlockPublicAccess.BLOCK_ALL) + cdk.Aspects.of(stack).add(BucketPublicAccessAspect()) + + Template.from_stack(stack).has_resource_properties( + "AWS::S3::Bucket", + { + "PublicAccessBlockConfiguration": Match.object_like( + {"BlockPublicAcls": True} + ) + }, + ) + + +@pytest.mark.parametrize("cidr", ["10.0.0.0/16", "172.16.0.0/16", "192.168.0.0/24"]) +def test_vpc_cidr_aspect_accepts_rfc1918_ranges(cidr): + stack = new_stack() + ec2.CfnVPC(stack, "Vpc", cidr_block=cidr) + cdk.Aspects.of(stack).add(VpcCidrAspect()) + + Annotations.from_stack(stack).has_no_error( + "*", Match.string_like_regexp("does not comply") + ) + + +@pytest.mark.parametrize("cidr", ["11.0.0.0/16", "172.32.0.0/16", "8.8.8.0/24"]) +def test_vpc_cidr_aspect_errors_on_public_ranges(cidr): + stack = new_stack() + ec2.CfnVPC(stack, "Vpc", cidr_block=cidr) + cdk.Aspects.of(stack).add(VpcCidrAspect()) + + Annotations.from_stack(stack).has_error( + "*", Match.string_like_regexp("does not comply") + ) + + +def test_vpc_cidr_aspect_skips_a_vpc_without_a_literal_cidr(): + """A VPC from an IPAM pool has no CIDR to check at synthesis time.""" + stack = new_stack() + ec2.CfnVPC(stack, "Vpc", ipv4_ipam_pool_id="ipam-pool-1234", ipv4_netmask_length=16) + cdk.Aspects.of(stack).add(VpcCidrAspect()) + + Annotations.from_stack(stack).has_no_error( + "*", Match.string_like_regexp("does not comply") + ) diff --git a/tests/test_env_helper.py b/tests/test_env_helper.py new file mode 100644 index 0000000..e9fa803 --- /dev/null +++ b/tests/test_env_helper.py @@ -0,0 +1,92 @@ +import pytest + +from bin.env_helper import ( + create_env_resource_name, + extract_cleaned_branch_name, + get_task_name, +) + + +@pytest.fixture(autouse=True) +def clear_deployment_environment(monkeypatch): + for variable in ("GIT_BRANCH_REF", "ENVIRONMENT"): + monkeypatch.delenv(variable, raising=False) + + +@pytest.mark.parametrize( + ("kwargs", "expected"), + [ + ({}, "test:synth"), + ({"task_type": "all"}, "test:deploy:all"), + ({"is_branch": True}, "test:branch:deploy"), + ({"is_branch": True, "task_type": "stack"}, "test:branch:deploy:stack"), + ], +) +def test_get_task_name_builds_the_expected_variants(kwargs, expected): + action = "synth" if expected.endswith("synth") else "deploy" + assert get_task_name("test", action, **kwargs) == expected + + +def test_get_task_name_ignores_the_task_type_for_whole_app_actions(): + """`synth` and `ls` always cover every stack, so an :all/:stack split would be meaningless.""" + assert get_task_name("test", "synth", task_type="all") == "test:synth" + assert get_task_name("test", "ls", task_type="stack") == "test:ls" + + +@pytest.mark.parametrize( + ("ref", "expected"), + [ + ("feature/add-api", "add-api"), + ("refs/heads/FIX-Bug_42", "fix-bug42"), + ("trailing-hyphen---", "trailing-hyphen"), + ("a" * 40, "a" * 25), + ], +) +def test_extract_cleaned_branch_name_normalises_a_ref(ref, expected): + assert extract_cleaned_branch_name(ref) == expected + + +# These refs own the shared environment stacks or are releases, so they must not get their own. +@pytest.mark.parametrize( + "ref", [None, "", "main", "develop", "development", "v1.2.3", "release/v10.0.1"] +) +def test_extract_cleaned_branch_name_rejects_refs_without_their_own_stacks(ref): + assert extract_cleaned_branch_name(ref) is None + + +def test_create_env_resource_name_falls_back_to_the_environment(monkeypatch): + monkeypatch.setenv("ENVIRONMENT", "production") + + assert create_env_resource_name("StarterStack") == "StarterStack-production" + + +def test_create_env_resource_name_uses_the_branch_when_deploying_one(monkeypatch): + monkeypatch.setenv("ENVIRONMENT", "test") + monkeypatch.setenv("GIT_BRANCH_REF", "feature/add-api") + + assert create_env_resource_name("StarterStack") == "StarterStack-add-api" + + +def test_create_env_resource_name_falls_back_when_the_branch_owns_no_stacks( + monkeypatch, +): + monkeypatch.setenv("ENVIRONMENT", "test") + monkeypatch.setenv("GIT_BRANCH_REF", "release/v1.2.3") + + assert create_env_resource_name("StarterStack") == "StarterStack-test" + + +# A branch deployment from main would overwrite the shared environment stacks. +def test_create_env_resource_name_rejects_a_branch_deployment_from_main(monkeypatch): + monkeypatch.setenv("GIT_BRANCH_REF", "main") + + with pytest.raises(ValueError, match='GIT_BRANCH_REF cannot be "main"'): + create_env_resource_name("StarterStack") + + +def test_create_env_resource_name_truncates_without_a_trailing_separator(monkeypatch): + monkeypatch.setenv("ENVIRONMENT", "production") + name = create_env_resource_name("A" * 62) + + assert len(name) <= 64 + assert name[-1].isalnum() diff --git a/tests/test_example.py b/tests/test_example.py deleted file mode 100644 index c75f5f3..0000000 --- a/tests/test_example.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest -from aws_cdk import App -from aws_cdk.assertions import Template - -from stacks.base_stack import BaseStack - - -@pytest.fixture(scope="module") -def template(): - app = App() - stack = BaseStack(app, "my-stack-test") - template = Template.from_stack(stack) - yield template - - -def test_no_buckets_found(template): - template.resource_count_is("AWS::S3::Bucket", 0) diff --git a/tests/test_github_oidc_stack.py b/tests/test_foundation_stack.py similarity index 53% rename from tests/test_github_oidc_stack.py rename to tests/test_foundation_stack.py index 7dfdd26..a716afa 100644 --- a/tests/test_github_oidc_stack.py +++ b/tests/test_foundation_stack.py @@ -3,15 +3,17 @@ from aws_cdk.assertions import Match, Template from bin.git_helper import GitHubRepositoryReference -from stacks.github_oidc_stack import GitHubOIDCStack +from stacks.foundation_stack import FoundationStack + +GITHUB_SUB = "token.actions.githubusercontent.com:sub" @pytest.fixture(autouse=True) def pinned_repository(monkeypatch): """Pin the repository identity so the trust policy is asserted against known values. - Without this the assertions would depend on whichever checkout the tests run in, and - every test would shell out to git and the GitHub CLI. + Without this the assertions would depend on whichever checkout the tests run in, and every + test would shell out to git and the GitHub CLI. """ monkeypatch.setenv("GITHUB_REPOSITORY", "octo-org/octo-repo") monkeypatch.setenv("GITHUB_REPOSITORY_ID", "456789") @@ -19,15 +21,30 @@ def pinned_repository(monkeypatch): monkeypatch.delenv("GITHUB_DEPLOY_ROLE", raising=False) +def assume_role_condition(template: Template) -> dict: + """Read the trust policy condition off the single deploy role in the template.""" + roles = [ + role + for role in template.find_resources("AWS::IAM::Role").values() + if role["Properties"].get("RoleName") == "GitHubActionsServiceRole" + ] + assert len(roles) == 1 + return roles[0]["Properties"]["AssumeRolePolicyDocument"]["Statement"][0][ + "Condition" + ] + + def test_stack_trusts_the_immutable_subject_of_its_own_repository(): - template = Template.from_stack(GitHubOIDCStack(App(), "oidc-test")) + template = Template.from_stack( + FoundationStack(App(), "foundation-test", environment="production") + ) # The account's GitHub OIDC provider is imported, not managed by this stack. template.resource_count_is("Custom::AWSCDKOpenIdConnectProvider", 0) template.has_resource_properties( "AWS::IAM::Role", { - "RoleName": "GitHubDeployRole", + "RoleName": "GitHubActionsServiceRole", "MaxSessionDuration": 7200, "AssumeRolePolicyDocument": { "Statement": [ @@ -39,8 +56,8 @@ def test_stack_trusts_the_immutable_subject_of_its_own_repository(): "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" }, "StringLike": { - "token.actions.githubusercontent.com:sub": [ - "repo:octo-org@123456/octo-repo@456789:*" + GITHUB_SUB: [ + "repo:octo-org@123456/octo-repo@456789:environment:production" ] }, }, @@ -52,74 +69,53 @@ def test_stack_trusts_the_immutable_subject_of_its_own_repository(): ) +def test_stack_scopes_the_trust_to_the_given_github_environment(): + template = Template.from_stack( + FoundationStack(App(), "foundation-test", environment="test") + ) + + assert assume_role_condition(template)["StringLike"][GITHUB_SUB] == [ + "repo:octo-org@123456/octo-repo@456789:environment:test" + ] + + def test_stack_trusts_additional_repositories_by_name_and_numeric_id(): - stack = GitHubOIDCStack( + stack = FoundationStack( App(), - "oidc-test", + "foundation-test", + environment="production", additional_repositories=[ GitHubRepositoryReference(name="octo-site", id="111111"), GitHubRepositoryReference(name="octo-app", id="222222"), ], ) - Template.from_stack(stack).has_resource_properties( - "AWS::IAM::Role", - { - "AssumeRolePolicyDocument": { - "Statement": [ - Match.object_like( - { - "Condition": { - "StringLike": { - "token.actions.githubusercontent.com:sub": [ - "repo:octo-org@123456/octo-repo@456789:*", - "repo:octo-org@123456/octo-site@111111:*", - "repo:octo-org@123456/octo-app@222222:*", - ] - } - } - } - ) - ] - } - }, - ) - - -def test_stack_can_narrow_the_subject_to_a_github_environment(): - stack = GitHubOIDCStack( - App(), "oidc-test", subject_context="environment:production" - ) - - Template.from_stack(stack).has_resource_properties( - "AWS::IAM::Role", - { - "AssumeRolePolicyDocument": { - "Statement": [ - Match.object_like( - { - "Condition": { - "StringLike": { - "token.actions.githubusercontent.com:sub": [ - "repo:octo-org@123456/octo-repo@456789:environment:production" - ] - } - } - } - ) - ] - } - }, - ) + assert assume_role_condition(Template.from_stack(stack))["StringLike"][ + GITHUB_SUB + ] == [ + "repo:octo-org@123456/octo-repo@456789:environment:production", + "repo:octo-org@123456/octo-site@111111:environment:production", + "repo:octo-org@123456/octo-app@222222:environment:production", + ] # A malformed ID would otherwise reach an IAM StringLike condition and widen the trust policy. def test_stack_rejects_an_additional_repository_without_a_numeric_id(): with pytest.raises(ValueError, match="requires a decimal id"): - GitHubOIDCStack( + FoundationStack( App(), - "oidc-test", + "foundation-test", + environment="production", additional_repositories=[ GitHubRepositoryReference(name="octo-site", id="*") ], ) + + +def test_stack_includes_the_toolkit_cleaner(): + """The cleaner is what stops the CDK staging bucket growing with every deployment.""" + template = Template.from_stack( + FoundationStack(App(), "foundation-test", environment="production") + ) + + template.resource_count_is("AWS::Scheduler::Schedule", 1) diff --git a/uv.lock b/uv.lock index 7ba6f71..8b29af5 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,10 @@ version = 1 revision = 3 requires-python = "==3.13.*" +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + [[package]] name = "attrs" version = "25.4.0" @@ -39,18 +43,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/06/29837eb8ae4dc82ed2e593e4b24449394393b9a8a247b0ce2cee811e5cbb/aws_cdk_asset_node_proxy_agent_v6-2.1.2-py3-none-any.whl", hash = "sha256:1028bff16fdb87b8c82404e9b48f32ed383429dece84067f47379c4049da5da4", size = 1533233, upload-time = "2026-05-15T08:14:17.622Z" }, ] +[[package]] +name = "aws-cdk-cli" +version = "2.1130.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/b5/09e6c4dd64deb204678d55ff846b5d8a321caeba475aff278bcd7dc0f7f2/aws_cdk_cli-2.1130.0.tar.gz", hash = "sha256:918d01f0468624869859affefdf9b2e4d21c5d6fe364f0aa9167879019d09ca6", size = 6633545, upload-time = "2026-07-10T09:41:34.471Z" } + [[package]] name = "aws-cdk-cloud-assembly-schema" -version = "53.24.0" +version = "53.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsii" }, { name = "publication" }, { name = "typeguard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/67/a386fee11c1d51ad57d2c045f228f6a57b0fc846eb8b441fc98fbdf1a4b1/aws_cdk_cloud_assembly_schema-53.24.0.tar.gz", hash = "sha256:f999f4c777deaca6631c61993bf5583022ff57c3a94a65930e2fc6b68cb7c407", size = 212924, upload-time = "2026-05-14T17:07:18.047Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/b9/3380c3d9e5127e395446f679b186d04162d56441c48ec57ae847ed2d41f9/aws_cdk_cloud_assembly_schema-53.28.0.tar.gz", hash = "sha256:12e28dc44026e5f062041ba1450a510b1acddcfbe8358f73cfda8df993712d42", size = 223218, upload-time = "2026-05-27T15:15:57.197Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/34/b180fe10ae8c314d5f8c2e095a57669d54bcb036dc63b001eace16c88052/aws_cdk_cloud_assembly_schema-53.24.0-py3-none-any.whl", hash = "sha256:360c4804f3073601ac320d1773432bc45b34201d7c8fb85aff7ed536801efb0c", size = 212811, upload-time = "2026-05-14T17:07:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/d8/98/4a36cb52d90502a51ae8aab1092c95fd8050cd6a313285782073d3343a95/aws_cdk_cloud_assembly_schema-53.28.0-py3-none-any.whl", hash = "sha256:c5de34f4a0d61eb48574a0264a9567175f601c27b4d053fb24ad1157c4ea0d99", size = 222782, upload-time = "2026-05-27T15:15:55.105Z" }, ] [[package]] @@ -77,11 +87,13 @@ version = "2.101.0" source = { virtual = "." } dependencies = [ { name = "aws-cdk-lib" }, + { name = "cloudstructs" }, { name = "constructs" }, ] [package.dev-dependencies] dev = [ + { name = "aws-cdk-cli" }, { name = "projen" }, { name = "pytest" }, { name = "ruff" }, @@ -91,13 +103,15 @@ dev = [ [package.metadata] requires-dist = [ { name = "aws-cdk-lib", specifier = "==2.254.0" }, + { name = "cloudstructs" }, { name = "constructs", specifier = ">=10.5.1,<11.0.0" }, ] [package.metadata.requires-dev] dev = [ - { name = "projen", specifier = "==0.99.62" }, - { name = "pytest", specifier = "==9.0.3" }, + { name = "aws-cdk-cli", specifier = "==2.1130.0" }, + { name = "projen", specifier = "==0.101.20" }, + { name = "pytest", specifier = "==9.1.1" }, { name = "ruff" }, { name = "ty" }, ] @@ -115,6 +129,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/2b/a40e1488fdfa02d3f9a653a61a5935ea08b3c2225ee818db6a76c7ba9695/cattrs-25.3.0-py3-none-any.whl", hash = "sha256:9896e84e0a5bf723bc7b4b68f4481785367ce07a8a02e7e9ee6eb2819bc306ff", size = 70738, upload-time = "2025-10-07T12:26:06.603Z" }, ] +[[package]] +name = "cloudstructs" +version = "0.13.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aws-cdk-lib" }, + { name = "constructs" }, + { name = "jsii" }, + { name = "publication" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/40/f5cf6daa46fdd5dec56b09c51d6e5eb8d7f9bffcc89f5a87b56674060088/cloudstructs-0.13.8.tar.gz", hash = "sha256:114887eb6576fff7692b22669e474985cf63b04699180ffa189aabb174c9d58a", size = 3732321, upload-time = "2026-05-18T09:02:52.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/20/03013f2cd3cc7149195a97fcf6185be9193129ac0451d72ce1153c5a8b02/cloudstructs-0.13.8-py3-none-any.whl", hash = "sha256:0b3fec01f279ad62a57e75d0bce8dc640f5110b41b3d1c03ed4c455166708e90", size = 3730826, upload-time = "2026-05-18T09:02:50.169Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -138,15 +168,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/38/6de46d122a2fcd275a5f1fa4ad1d8d2058afd6b72d8db5f0c125515cbb5c/constructs-10.6.0-py3-none-any.whl", hash = "sha256:ad4ffabdb53c17cde00fb94e441a1ba9fddac57c92ad49d263f8dbd416cec513", size = 66969, upload-time = "2026-03-23T09:00:53.041Z" }, ] -[[package]] -name = "importlib-resources" -version = "6.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -158,20 +179,19 @@ wheels = [ [[package]] name = "jsii" -version = "1.130.0" +version = "1.139.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "cattrs" }, - { name = "importlib-resources" }, { name = "publication" }, { name = "python-dateutil" }, { name = "typeguard" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/36/d84bb41b72b5a830dc87f1daea521ff4da024a08c1bd0b9b34989fe84bcf/jsii-1.130.0.tar.gz", hash = "sha256:7436ae382e2de27970b34a4ccfef953a45980c5070241c1bd610bf3af68a2d6b", size = 445207, upload-time = "2026-05-14T18:35:31.792Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/71/7c612c16291a6dc654bac00f56bef24c45f86e9a7c9668dc7d36f187e808/jsii-1.139.0.tar.gz", hash = "sha256:163c5d3ec00fd4ec89a33b9097f20a6f27626dd69d6875a8f7cc7577b8021ad0", size = 531387, upload-time = "2026-07-17T02:34:33.293Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/34/2dc16666ee53c1158fde17c2b713a85dc90eab029fb832b9564751f40eeb/jsii-1.130.0-py3-none-any.whl", hash = "sha256:ce50e11ea588fe6b2d0766d90edaf4c78b9e97e2e1f075fbd8bc29349c6503c8", size = 418405, upload-time = "2026-05-14T18:35:30.283Z" }, + { url = "https://files.pythonhosted.org/packages/33/3e/fce01768f79b2d5d0cf6258b55045f4b083ee86fd5de24c1af38168312ab/jsii-1.139.0-py3-none-any.whl", hash = "sha256:11468f767c6da698ed9888a04352850af33ccccfec7656475626caf36fca8bbb", size = 501693, upload-time = "2026-07-17T02:34:31.747Z" }, ] [[package]] @@ -194,17 +214,16 @@ wheels = [ [[package]] name = "projen" -version = "0.99.62" +version = "0.101.20" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "constructs" }, { name = "jsii" }, { name = "publication" }, - { name = "typeguard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/c6/89d291618a10347fee0e6d6de98220c8f7a1ffdf2a82dc52be51fbe89b45/projen-0.99.62.tar.gz", hash = "sha256:b00a56e4b46ad655a3682fa14a875e47d0809f5e7add9aa75712dc0b065af743", size = 4416957, upload-time = "2026-05-18T10:40:26.121Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ba/196637c0f146021c76c255682d6b824c9fad8750442285321eeed81da5eb/projen-0.101.20.tar.gz", hash = "sha256:2147fbff70973e70406e4154e4d0c2309dc4b5a0b4702079a593e6c2168a8fe4", size = 5230329, upload-time = "2026-07-24T15:27:32.704Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/95/10a1f64078026c330a23fa596526f2ec102069222d9093994bc16d5eef71/projen-0.99.62-py3-none-any.whl", hash = "sha256:e566c6d73039cc7a140b4a7622f144092019281169a49a69f1d7fe84a0f144ca", size = 4426000, upload-time = "2026-05-18T10:40:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/f5/fe/70347765c43b294946144db0be350f6a9eb2a3eb8fe9ba6079dcaeac5285/projen-0.101.20-py3-none-any.whl", hash = "sha256:50f9acb2b6736ba958b3ab4ea6bf9f0d028636fd362b78bd432d812418f3e4a0", size = 5236641, upload-time = "2026-07-24T15:27:30.148Z" }, ] [[package]] @@ -227,7 +246,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -236,9 +255,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]]