feat(infra): migrate afterhours to HCP Terraform (PLAT-74) (#252)
Some checks are pending
Deploy / Deploy to prod (push) Waiting to run

* fix(cutover): write Slack secrets into empty Terraform shells

DescribeSecret succeeds on HCP-created shells with no version, so skip-if-exists left roster and Slack tokens unset.

* feat(infra): migrate afterhours to HCP Terraform (PLAT-74)

Replace the mgmt SAM stack with a prod-only HCP workspace, in-repo hcptf IAM, stub Lambdas, and zip CD on push to main.

* fix(cutover): retry DDB unprocessed items and skip past at() holidays

Unprocessed BatchWriteItem rows and leftover past at() schedules would drop roster data or abort holiday recreation during prod cutover.
This commit is contained in:
Adam Moussa 2026-09-15 23:31:59 +00:00 • committed by GitHub
parent dbef3bda52
commit 13350b72d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3403 additions and 1272 deletions

View file

@ -1,4 +1,5 @@
name: CI
on:
pull_request:
branches: [main]
@ -8,8 +9,84 @@ permissions:
contents: read
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
pytest:
name: Pytest
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/roster-api src/ring-scheduler src/shared/shared tests"
run-tests: true
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install test dependencies
run: |
set -euo pipefail
python -m pip install --upgrade pip
pip install -r tests/requirements.txt
pip install -r src/slack-bot/requirements.txt
pip install -r src/weekly-post/requirements.txt
pip install -r src/shared/requirements.txt
- name: Pytest
run: pytest
terraform:
name: Terraform
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.16.0"
terraform_wrapper: false
- name: Terraform fmt
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate
ci:
name: ci / ci
needs: [pytest, terraform]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check jobs
env:
PYTEST_RESULT: ${{ needs.pytest.result }}
TERRAFORM_RESULT: ${{ needs.terraform.result }}
run: |
set -euo pipefail
fail=0
check() {
local name="$1"
local result="$2"
case "${result}" in
success)
echo "${name}: ${result}"
;;
*)
echo "${name}: ${result}" >&2
fail=1
;;
esac
}
check pytest "${PYTEST_RESULT}"
check terraform "${TERRAFORM_RESULT}"
exit "${fail}"

View file

@ -1,120 +1,150 @@
name: Deploy
# Terraform owns Lambda skeletons. This workflow ships zips to prod and calls
# update-function-code. It never creates an HCP run. No GitHub Releases and no
# tagging in this workflow.
on:
push:
branches: [main]
paths-ignore:
- "terraform/**"
- "docs/**"
- "README.md"
- "SETUP.md"
- "AGENTS.md"
workflow_dispatch:
inputs:
ref:
description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref."
required: false
type: string
default: ""
permissions:
id-token: write
contents: read
concurrency:
group: deploy
cancel-in-progress: false
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
with:
stack-name: afterhours-shift-manager
cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
parameter-overrides: ${{ secrets.SAM_PARAMETER_OVERRIDES }}
# Tag + announce a release once the deploy succeeds. This lives in the deploy
# workflow (gated on `needs: deploy`) rather than a separate workflow_run-
# triggered job on purpose: a push-to-main run is a trusted context, so
# checking out and running repo code with write/OIDC is safe here — unlike
# workflow_run, which CodeQL (rightly) flags for untrusted checkout + cache
# poisoning. Gating on `needs: deploy` still guarantees we never announce a
# version that isn't live, and the `deploy` concurrency group serializes
# releases. When the top CHANGELOG version already has a Release, this no-ops.
release:
needs: deploy
name: Deploy to prod
runs-on: ubuntu-latest
timeout-minutes: 30
environment: prod
concurrency:
group: deploy-afterhours-prod
cancel-in-progress: false
permissions:
contents: write # create the tag + GitHub Release
id-token: write # OIDC to assume the notifier-invoke role
contents: read
id-token: write
env:
AWS_REGION: us-east-1
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
fetch-tags: true
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
persist-credentials: false
- name: Resolve commit
id: commit
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
echo "Building ${sha}"
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Determine release
id: rel
- name: Build function zips
env:
GH_TOKEN: ${{ github.token }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
run: |
TOP=$(python scripts/changelog_cli.py top-version CHANGELOG.md)
if [ -z "$TOP" ]; then
echo "No version entry in CHANGELOG.md — nothing to release."
echo "release=false" >> "$GITHUB_OUTPUT"; exit 0
fi
PREV=$(git tag -l 'v*' --sort=-v:refname | head -1)
PREV="${PREV:-v0.0.0}"
KIND=$(python scripts/changelog_cli.py bump-kind CHANGELOG.md "$PREV")
set -euo pipefail
python scripts/package_lambdas.py --git-sha "${GIT_SHA}" --out-dir build/packages
python - <<'PY'
import os, zipfile
from pathlib import Path
sha = os.environ["GIT_SHA"]
names = [
"slack_bot",
"weekly_post",
"roster_sync",
"roster_api",
"ring_scheduler",
"holiday_router",
"release_notifier",
]
for name in names:
path = Path("build/packages") / f"{name}.zip"
if not path.is_file():
raise SystemExit(f"missing {path}")
with zipfile.ZipFile(path) as zf:
info = zf.read("shared/build_info.py").decode()
if sha not in info:
raise SystemExit(f"{path} missing GIT_SHA {sha}")
if "shared/sentry_init.py" not in zf.namelist():
raise SystemExit(f"{path} missing bundled shared package")
print("zips ok")
PY
RELEASE_EXISTS=false
gh release view "v$TOP" >/dev/null 2>&1 && RELEASE_EXISTS=true
echo "version=$TOP" >> "$GITHUB_OUTPUT"
echo "kind=$KIND" >> "$GITHUB_OUTPUT"
# Act only on a clean SemVer bump whose Release isn't published yet.
if [ "$KIND" != "none" ] && [ "$RELEASE_EXISTS" = "false" ]; then
echo "release=true" >> "$GITHUB_OUTPUT"
else
echo "release=false" >> "$GITHUB_OUTPUT"
echo "v$TOP: kind=$KIND release_exists=$RELEASE_EXISTS — no action."
fi
- name: Build release notes
if: ${{ steps.rel.outputs.release == 'true' }}
run: |
python scripts/changelog_cli.py payload CHANGELOG.md "${{ steps.rel.outputs.version }}" > payload.json
python -c "import json; print(json.load(open('payload.json'))['notes'])" > notes.md
# Announce BEFORE publishing the Release: the Release is the durable "done"
# marker (the step above skips once it exists), so announcing first keeps
# this retryable. Minor/major only, and only once the invoke-role variable
# has been bootstrapped (see README).
- name: Configure AWS credentials
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
with:
role-to-assume: ${{ vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN }}
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Announce in Slack
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
- name: Get deploy parameters
id: deploy
run: |
aws lambda invoke \
--function-name afterhours-release-notifier \
--cli-binary-format raw-in-base64-out \
--payload file://payload.json \
--output json response.json > invoke-meta.json
# aws lambda invoke only emits a FunctionError key when the handler errored.
if grep -q '"FunctionError"' invoke-meta.json; then
echo "::error::release-notifier returned an error"; cat response.json; exit 1
fi
echo "Announced v${{ steps.rel.outputs.version }}."
set -euo pipefail
prefix=/afterhours-shift-manager/deploy
ARTIFACTS_BUCKET=$(aws ssm get-parameter --name "${prefix}/artifacts-bucket" --query Parameter.Value --output text)
{
echo "artifacts_bucket=${ARTIFACTS_BUCKET}"
echo "slack_bot=$(aws ssm get-parameter --name "${prefix}/slack_bot-function-name" --query Parameter.Value --output text)"
echo "weekly_post=$(aws ssm get-parameter --name "${prefix}/weekly_post-function-name" --query Parameter.Value --output text)"
echo "roster_sync=$(aws ssm get-parameter --name "${prefix}/roster_sync-function-name" --query Parameter.Value --output text)"
echo "roster_api=$(aws ssm get-parameter --name "${prefix}/roster_api-function-name" --query Parameter.Value --output text)"
echo "ring_scheduler=$(aws ssm get-parameter --name "${prefix}/ring_scheduler-function-name" --query Parameter.Value --output text)"
echo "holiday_router=$(aws ssm get-parameter --name "${prefix}/holiday_router-function-name" --query Parameter.Value --output text)"
echo "release_notifier=$(aws ssm get-parameter --name "${prefix}/release_notifier-function-name" --query Parameter.Value --output text)"
} >> "${GITHUB_OUTPUT}"
- name: Warn if announcement skipped (not bootstrapped)
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN == '' }}
run: echo "::warning::RELEASE_NOTIFY_INVOKE_ROLE_ARN is unset — tagging + releasing but not announcing. Set the repo variable from the stack output."
- name: Publish GitHub Release
if: ${{ steps.rel.outputs.release == 'true' }}
- name: Upload zips and update function code
env:
GH_TOKEN: ${{ github.token }}
ARTIFACTS_BUCKET: ${{ steps.deploy.outputs.artifacts_bucket }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
SLACK_BOT: ${{ steps.deploy.outputs.slack_bot }}
WEEKLY_POST: ${{ steps.deploy.outputs.weekly_post }}
ROSTER_SYNC: ${{ steps.deploy.outputs.roster_sync }}
ROSTER_API: ${{ steps.deploy.outputs.roster_api }}
RING_SCHEDULER: ${{ steps.deploy.outputs.ring_scheduler }}
HOLIDAY_ROUTER: ${{ steps.deploy.outputs.holiday_router }}
RELEASE_NOTIFIER: ${{ steps.deploy.outputs.release_notifier }}
run: |
# gh creates the tag at the deployed commit and the Release together.
gh release create "v${{ steps.rel.outputs.version }}" \
--repo "${{ github.repository }}" \
--title "v${{ steps.rel.outputs.version }}" \
--notes-file notes.md \
--target "${{ github.sha }}"
set -euo pipefail
keys=(
slack_bot:"${SLACK_BOT}"
weekly_post:"${WEEKLY_POST}"
roster_sync:"${ROSTER_SYNC}"
roster_api:"${ROSTER_API}"
ring_scheduler:"${RING_SCHEDULER}"
holiday_router:"${HOLIDAY_ROUTER}"
release_notifier:"${RELEASE_NOTIFIER}"
)
for pair in "${keys[@]}"; do
name="${pair%%:*}"
fn="${pair#*:}"
key="functions/${name}/${GIT_SHA}.zip"
aws s3 cp "build/packages/${name}.zip" "s3://${ARTIFACTS_BUCKET}/${key}"
aws lambda update-function-code \
--function-name "${fn}" \
--s3-bucket "${ARTIFACTS_BUCKET}" \
--s3-key "${key}" \
--query '{Function:FunctionName,Sha256:CodeSha256,Updated:LastModified}' \
--output table
aws lambda wait function-updated-v2 --function-name "${fn}"
done

3
.gitignore vendored
View file

@ -8,3 +8,6 @@ venv/
samconfig.toml
output.json
.idea/
build/
terraform/.terraform/
terraform/build/

View file

@ -1,7 +1,7 @@
# After-Hours Shift Manager
![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white)
![AWS SAM](https://img.shields.io/badge/AWS-SAM-FF9900?logo=amazonaws&logoColor=white)
![Terraform](https://img.shields.io/badge/Terraform-844FBA?logo=terraform&logoColor=white)
![Slack](https://img.shields.io/badge/Slack-integration-4A154B?logo=slack&logoColor=white)
![CI](https://github.com/Sea-Haven-Industries/afterhours-shift-manager/actions/workflows/ci.yaml/badge.svg)
@ -56,9 +56,9 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
## Architecture
- **Runtime**: Python 3.12 on AWS Lambda (arm64)
- **Runtime**: Python 3.12 on AWS Lambda (arm64), seahaven-prod `011934824531`
- **Data**: DynamoDB single-table (`afterhours-shifts`)
- **IaC**: AWS SAM (`template.yaml`) with shared Lambda Layer
- **IaC**: HCP Terraform workspace `afterhours-shift-manager-prod` (containers) plus GitHub Actions `deploy.yaml` (zips). `src/shared` is bundled into each function zip. Terraform does not package `src/`.
- **Slack**: Slack Bolt framework with `/oncall` slash command
- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI
- **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`)
@ -73,7 +73,7 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
| `afterhours-roster-api` | API Gateway (PUT /roster, DELETE /roster/{extension}) | Bearer-authenticated roster upsert/delete for the identity processor |
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift |
| `afterhours-holiday-router` | EventBridge Scheduler (per-holiday one-off: 8am activate / 5pm deactivate ET) | Repoints the IVR to the holiday queue and sets queue agents for a holiday day shift; reverts at 5pm (see [Holidays](#holidays)) |
| `afterhours-release-notifier` | Invoked by the Deploy workflow's release job on minor/major releases | Posts a "What's New" announcement to the shift channel |
| `afterhours-release-notifier` | Skeleton only until tagging exists | Posts a "What's New" announcement to the shift channel |
### Project Layout
@ -86,7 +86,8 @@ src/
ring-scheduler/ 3CX queue routing updates
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
release-notifier/ Posts release announcements to Slack
shared/ Lambda Layer (schedule, blocks, changelog, 3CX client, secrets)
shared/ Bundled into each function zip (schedule, blocks, changelog, 3CX client, secrets)
terraform/ HCP Terraform (function skeletons, API, DDB, IAM, schedules)
scripts/ changelog CLI + CI guard + in-package copy sync
tests/ pytest suite (mirrors src/, one dir per Lambda + shared)
```
@ -133,7 +134,7 @@ Map updates on the record (see above) so the slot count can't be oversubscribed.
creates two **one-off EventBridge Scheduler** schedules for that date —
`holiday-activate-<YYYYMMDD>` at 08:00 ET and `holiday-deactivate-<YYYYMMDD>` at
17:00 ET — whose names are stored on the record's `schedule_names`. Scheduler
assumes `HolidaySchedulerExecutionRole` to invoke `afterhours-holiday-router`:
assumes `afterhours-shift-manager-holiday-scheduler` to invoke `afterhours-holiday-router`:
- **Activate (08:00):** capture both IVR `ivr_number` (800) routes — key-0 **and**
no-input/timeout — into `CONFIG.captured_ivr_routes` (skipped if they already
@ -183,7 +184,7 @@ A slot claimed after the shift has started always needs an admin to approve it.
### Roster HTTP API
Identity hire/offboard in `paychex-integrations` calls this API. It is a separate Lambda on the same implicit HTTP API as Slack (`POST /slack/events` is unchanged).
Identity hire/offboard in `paychex-integrations` calls this API. It is a separate Lambda on the same HTTP API as Slack (`POST /slack/events` is unchanged).
| Method | Path | Body | Success |
|---|---|---|---|
@ -192,17 +193,11 @@ Identity hire/offboard in `paychex-integrations` calls this API. It is a separat
Header: `Authorization: Bearer {token}`. Missing or wrong token is 401. Invalid JSON or fields is 400. A secret-read failure is 503.
Set processor `AFTERHOURS_BASE_URL` to the stack output `AfterhoursApiBaseUrl`:
```
https://${ServerlessHttpApi}.execute-api.us-east-1.amazonaws.com
```
That value is the API origin only. Do not append `/mgmt` or `/roster`.
Set processor `AFTERHOURS_BASE_URL` to the Terraform output `api_origin` (HCP variable `afterhours_base_url` on `paychex-integrations-prod`). That value is the API origin only. Do not append `/roster`. Flip it at cutover after DynamoDB is copied, not before.
Daily `afterhours-roster-sync` still owns the 3CX `DEFAULT` group at 6am ET: rows absent from that group are deleted. Hire is safe because 3CX create (into `DEFAULT`) happens before the roster PUT. An HTTP-only row that is not in that group will be removed on the next sync. Sync preserves `slack_user_id` on existing rows and does not overwrite a just-created API row's Slack id.
Token rotation is a maintenance-window action. The Lambda caches the token per execution environment. Update the mgmt secret and the prod copy together, then recycle `afterhours-roster-api`. Updating only one copy, or recycling environments out of order, causes 401s until both sides match.
Token rotation is a maintenance-window action. The Lambda caches the token per execution environment. Update `afterhours-shift-manager/roster-api-token` and `paychex-integrations/afterhours-roster-token` together, then recycle `afterhours-roster-api`. Updating only one copy, or recycling environments out of order, causes 401s until both sides match.
## Documentation
@ -212,20 +207,15 @@ The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This pr
## Deployment
Merges to `main` are automatically deployed via **GitHub Actions** using reusable SAM workflows from the Sea Haven org.
Infrastructure is applied by HCP Terraform workspace `afterhours-shift-manager-prod` (VCS on `main`, working directory `terraform/`, file trigger `terraform/**` only). Function code is shipped by `.github/workflows/deploy.yaml` on push to `main` (`environment: prod`). A terraform-only merge does not run the zip deploy. A mixed app+terraform merge may race the apply; re-run the deploy job if the functions are still stubs.
For manual deploys:
```bash
sam build
sam deploy
```
Manual zip redeploy: Actions → Deploy → Run workflow (`workflow_dispatch`, always prod). Do not `terraform apply` locally to prod.
## Monitoring & Alarms
All CloudWatch alarms are defined in `template.yaml` and notify the shared
All CloudWatch alarms are defined in `terraform/alarms.tf` and notify the shared
`site-alerts` SNS topic (→ AWS Chatbot → Slack). None set `OKActions` — recovery
is not paged. Alarm names follow the in-template convention `Lambda-<Metric>-<fn>`
is not paged. Alarm names follow `Lambda-<Metric>-<fn>`
(e.g. `Lambda-Errors-afterhours-ring-scheduler`).
**Lambda alarms** (all seven functions: `afterhours-shift-manager`,
@ -252,8 +242,7 @@ transition normally. `ThrottledRequests` and `SystemErrors` are intentionally
**not** alarmed: AWS emits them only at `TableName`+`Operation` granularity, so a
`TableName`-only alarm would sit permanently in `INSUFFICIENT_DATA`.
**API Gateway alarms** (implicit HTTP API `ServerlessHttpApi`, `AWS/ApiGateway`
v2 metrics, `ApiId` dimension):
**API Gateway alarms** (HTTP API, `AWS/ApiGateway` v2 metrics, `ApiId` dimension):
| Alarm | Metric | Condition |
|---|---|---|
@ -266,40 +255,13 @@ v2 metrics, `ApiId` dimension):
## Releases & Versioning
The bot is versioned with SemVer, driven entirely by **`CHANGELOG.md`** — it is
the single source of truth for both the version number and the human-readable
notes. There is no separate tagging tool.
The bot is versioned with SemVer, driven entirely by **`CHANGELOG.md`**. The
**App Home** tab reads the copy that ships in the slack-bot zip. Run
`python scripts/sync_changelog.py` after editing the root file. Changelog Guard
enforces that the in-package copy matches.
**To cut a release**, in your feature PR add a new `## vX.Y.Z — Month D, YYYY`
section at the top of `CHANGELOG.md` (plain language, written for on-call staff),
bumping per SemVer, then run `python scripts/sync_changelog.py` to update the
in-package copy. The `Changelog Guard` PR check enforces that the bump is a clean
single SemVer step above the latest tag and that the two copies match.
On the **deploy-then-merge** path, once the merge's Deploy succeeds, the Deploy
workflow's `release` job (`needs: deploy`) tags the new version, publishes a
GitHub Release with the notes, and — for **minor and major** bumps only (patches
stay silent) — invokes `afterhours-release-notifier` to post a "What's New"
message in the shift channel. The **App Home** tab ("About" page on the bot)
always shows the current version's notes, read from the CHANGELOG that ships in
the slack-bot package.
> The release job lives inside the Deploy workflow (gated on `needs: deploy`)
> rather than a separate `workflow_run`-triggered workflow. A push-to-main run is
> a trusted context, so checking out and running repo code with write/OIDC is safe
> — whereas `workflow_run` is flagged by CodeQL for untrusted checkout. Gating on
> `needs: deploy` still guarantees we never announce a version that isn't live.
**One-time setup (per environment):** after the first deploy creates the
`ReleaseNotifyInvokeRole`, copy its ARN from the `ReleaseNotifyInvokeRoleArn` stack
output into the repo **variable** `RELEASE_NOTIFY_INVOKE_ROLE_ARN` (Settings →
Secrets and variables → Actions → Variables). Until it's set, releases still tag
and publish but skip the Slack announcement (with a warning).
> **Convention note (deliberate deviation).** The Sea Haven handbook says internal
> SAM stacks generally need no versioning and that tags are applied manually. This
> bot is versioned by owner choice (it has staff-facing release notes) and tagged
> automatically by the Deploy workflow's release job. This is intentional — not drift.
GitHub Releases and git tags are not cut by `deploy.yaml`. `afterhours-release-notifier`
exists as a function skeleton; CD does not invoke it until tagging exists.
## Testing
@ -318,6 +280,6 @@ pytest
Each Lambda has its own `app.py`, so the per-package `conftest.py` loads each one
under a unique module name (importlib mode) to avoid collisions. CI runs the same
suite on every PR via the org `ci-python-sam` workflow (`run-tests: true`).
suite on every PR via pytest plus `terraform fmt` / `init -backend=false` / `validate`.
See [SETUP.md](SETUP.md) for full deployment and Slack app creation instructions.

101
SETUP.md
View file

@ -58,42 +58,66 @@ aws secretsmanager create-secret \
Create `afterhours-shift-manager/roster-api-token` **before** the first deploy that
includes `afterhours-roster-api`, or live PUT/DELETE calls return 503.
Rotation is coordinated: write the new value to both the mgmt secret and the
prod copy, then recycle `afterhours-roster-api` so cached execution environments
pick it up. Updating only one copy causes 401s. The identity processor
`AFTERHOURS_BASE_URL` is the stack output `AfterhoursApiBaseUrl` (origin only,
no `/mgmt` or `/roster` suffix). Leave that URL empty until the API is live
and smoke-tested.
Rotation is coordinated: write the new value to both
`afterhours-shift-manager/roster-api-token` and
`paychex-integrations/afterhours-roster-token`, then recycle
`afterhours-roster-api` so cached execution environments pick it up. Updating
only one copy causes 401s. The identity processor `AFTERHOURS_BASE_URL` is the
Terraform output `api_origin` (origin only, no `/roster` suffix). Flip that HCP
variable on `paychex-integrations-prod` at cutover after DynamoDB is copied.
Daily roster-sync still removes DynamoDB rows that are not in the 3CX `DEFAULT`
group. Hire stays safe because 3CX create lands the extension in that group
before the identity processor PUTs `/roster`.
> The Slack **channel ID** is not a secret — it's passed as the `ShiftChannel`
> deploy parameter in step 3, not stored in Secrets Manager or SSM.
> The Slack **channel ID** is not a secret. It is Terraform variable
> `shift_channel` (default `C0APATP612N`), not stored in Secrets Manager.
## 3. Deploy the Stack
## 3. HCP Terraform and GitHub Environment
```bash
# Build and deploy. ShiftChannel is the Slack channel ID for schedule posts
# (right-click the channel in Slack → Copy link → the ID is the last segment).
sam build
sam deploy --guided \
--stack-name afterhours-shift-manager \
--region us-east-1 \
--parameter-overrides ShiftChannel=C0XXXXXXX QueueNumber=801
Prod only. Workspace `afterhours-shift-manager-prod` in project `seahaven-prod`
(account `011934824531`). No seahaven-dev workspace.
# Note SlackBotApiUrl (Slack Request URL) and AfterhoursApiBaseUrl
# (paychex AFTERHOURS_BASE_URL origin).
```
First apply uses the hcptf-bootstrap window (exact `StringEquals` trust, never
`StringLike`):
1. Create the HCP workspace. Auto-apply off. No project-level variable set.
Working directory `terraform`. File trigger prefix `terraform/**` only.
Speculative plans on. VCS on `main`.
2. From `seahaven-org-baseline`:
`scripts/create-hcptf-bootstrap-roles.sh --account prod --allow-workspace afterhours-shift-manager-prod`
3. Point workspace `TFC_AWS_APPLY_ROLE_ARN` / `TFC_AWS_PLAN_ROLE_ARN` at
`hcptf-bootstrap` / `hcptf-bootstrap-plan`. Set `TFC_AWS_PROVIDER_AUTH=true`.
4. One manual apply with `schedules_enabled=false`. This creates the scoped
`hcptf-*` roles, the Lambda boundary, and the rest of the stack. If 3CX
secrets already exist from seahaven-door-unlock-api, import those three
names instead of creating them:
`terraform import 'aws_secretsmanager_secret.this["afterhours-shift-manager/3cx-domain"]' afterhours-shift-manager/3cx-domain`
(and the client-id / client-secret names). Do not overwrite 3CX values.
5. Retarget `TFC_AWS_*` to `hcptf-afterhours-shift-manager` /
`hcptf-afterhours-shift-manager-plan`. Re-run the create script with no
`--allow-workspace`.
6. Second manual apply as the scoped role. Then seal auto-apply on.
GitHub Environment `prod`: reviewers, branch policy `main` only, Environment
variable `DEPLOY_ROLE_ARN` = Terraform output `github_deploy_role_arn`.
Function zips: Actions → Deploy on push to `main`, or `workflow_dispatch`.
Keep `schedules_enabled=false` until Slack and Paychex point at this stack.
HCP outputs to copy: `slack_request_url`, `api_origin`,
`holiday_scheduler_role_arn`, `github_deploy_role_arn`.
## 4. Set the Slack Request URL
After deploy, copy the `SlackBotApiUrl` from the SAM output. Go back to your Slack app settings:
Reuse the existing Slack app. After the zip deploy, copy `slack_request_url`
from HCP outputs:
- **Slash Commands** → edit `/oncall` → set **Request URL** to the output URL
- **Slash Commands** → edit `/oncall` → set **Request URL** to that URL
- **Interactivity & Shortcuts** → set **Request URL** to the same URL
Do this in the cutover window, not before DynamoDB is copied.
## 5. Seed the Schedule
```bash
@ -117,6 +141,39 @@ To have the 3CX scheduler read overrides from DynamoDB (so Slack-driven changes
Without this step, the Slack bot still works — it invokes the 3CX scheduler Lambda directly for same-day changes. Future-date overrides would only take effect if the scheduler reads DynamoDB.
## 8. Prod cutover (PLAT-74)
Avoid Monday 06:00-08:00 ET and any holiday 08:00/17:00 ET window. Dry-run the
scripts first (`--execute` is required for writes).
1. Merge this repo's PR (SAM CD is gone). First HCP apply is the bootstrap
window above with `schedules_enabled=false`.
2. Copy DynamoDB `afterhours-shifts` mgmt → prod. Verify item counts:
`python scripts/cutover/copy_dynamodb.py --src-profile mgmt --dst-profile prod`
then `--execute`.
3. Confirm secrets in prod. `copy_secrets.py` writes Slack bot token, Slack
signing secret, and roster-api-token into empty Terraform shells and skips
dest names that already have a value. It never writes 3CX secrets:
`python scripts/cutover/copy_secrets.py --src-profile mgmt --dst-profile prod`
then `--execute`. Strip trailing newlines is built in.
4. GHA `workflow_dispatch` (or the merge deploy; re-run if it raced apply) to
overwrite stubs.
5. Recreate outstanding future `holiday-activate-*` / `holiday-deactivate-*`
in prod against the new router ARN and scheduler role:
`python scripts/cutover/recreate_holiday_schedules.py --src-profile mgmt --dst-profile prod`
6. Instant cut: Slack Request URL → prod `/slack/events`; Paychex HCP variable
`afterhours_base_url` on `paychex-integrations-prod` → prod `api_origin`;
`schedules_enabled=true` via a terraform-only merge; disable mgmt EventBridge.
Smoke: Slack `/oncall`, roster PUT/DELETE, weekly-post SendMessage (or
simulate-principal-policy plus one smoke message), ring-scheduler invoke,
holiday GetSchedule.
7. Seal auto-apply on. Delete the mgmt SAM stack. Remove the mgmt SQS principal
from `paychex-checkcomponents`. Update Confluence AWS Architecture Map and
check PLAT-71 item 4.
Do not dual-run 3CX writers. Do not flip `afterhours_base_url` before DynamoDB
is copied.
## Commands Reference
| Command | Description |

View file

@ -1,9 +0,0 @@
version = 0.1
[default.deploy.parameters]
stack_name = "afterhours-shift-manager"
resolve_s3 = true
s3_prefix = "afterhours-shift-manager"
region = "us-east-1"
capabilities = "CAPABILITY_IAM"
confirm_changeset = true

View file

@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Copy afterhours-shifts from mgmt to prod. Dry-run unless --execute."""
from __future__ import annotations
import argparse
import sys
import time
import boto3
TABLE = "afterhours-shifts"
SRC_ACCOUNT = "328440206208"
DST_ACCOUNT = "011934824531"
def _client(profile: str, region: str):
session = boto3.Session(profile_name=profile, region_name=region)
return session.client("dynamodb")
def _scan_all(client, *, consistent: bool = False):
items = []
kwargs = {"TableName": TABLE, "ConsistentRead": consistent}
while True:
resp = client.scan(**kwargs)
items.extend(resp.get("Items", []))
start = resp.get("LastEvaluatedKey")
if not start:
return items
kwargs["ExclusiveStartKey"] = start
def _batch_write_all(client, items, *, sleep=time.sleep, max_attempts: int = 8) -> int:
"""Put every item. Retry UnprocessedItems with backoff. Raise if they remain."""
pending = [{"PutRequest": {"Item": item}} for item in items]
written = 0
while pending:
chunk, pending = pending[:25], pending[25:]
to_send = chunk
attempts = 0
while to_send:
attempts += 1
if attempts > max_attempts:
raise RuntimeError(
f"batch_write_item left {len(to_send)} unprocessed after {max_attempts} attempts"
)
resp = client.batch_write_item(RequestItems={TABLE: to_send})
unprocessed = resp.get("UnprocessedItems", {}).get(TABLE, [])
written += len(to_send) - len(unprocessed)
to_send = unprocessed
if to_send:
sleep(min(0.1 * (2 ** (attempts - 1)), 5.0))
return written
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--src-profile", required=True)
parser.add_argument("--dst-profile", required=True)
parser.add_argument("--region", default="us-east-1")
parser.add_argument("--execute", action="store_true")
args = parser.parse_args()
src = _client(args.src_profile, args.region)
dst = _client(args.dst_profile, args.region)
src_id = boto3.Session(profile_name=args.src_profile).client("sts").get_caller_identity()["Account"]
dst_id = boto3.Session(profile_name=args.dst_profile).client("sts").get_caller_identity()["Account"]
if src_id != SRC_ACCOUNT:
print(f"src account {src_id} is not mgmt {SRC_ACCOUNT}", file=sys.stderr)
return 2
if dst_id != DST_ACCOUNT:
print(f"dst account {dst_id} is not prod {DST_ACCOUNT}", file=sys.stderr)
return 2
items = _scan_all(src, consistent=True)
dst_count = dst.describe_table(TableName=TABLE)["Table"]["ItemCount"]
print(f"src items={len(items)} dst describe ItemCount={dst_count}")
if not args.execute:
print("dry-run; pass --execute to BatchWriteItem")
return 0
written = _batch_write_all(dst, items)
after = _scan_all(dst, consistent=True)
print(f"wrote={written} dst_scan={len(after)}")
if len(after) != len(items):
print("item counts differ after copy", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Copy afterhours secrets mgmt → prod. Dry-run unless --execute.
Terraform creates empty secret shells. Slack, signing, and roster tokens are
written into those shells when the dest has no current string value. Populated
dest values are left alone. 3CX secrets are verified only and never written.
Strips trailing newlines. Never prints secret values.
"""
from __future__ import annotations
import argparse
import sys
import boto3
from botocore.exceptions import ClientError
SRC_ACCOUNT = "328440206208"
DST_ACCOUNT = "011934824531"
COPY = [
"afterhours-shift-manager/slack-bot-token",
"afterhours-shift-manager/slack-signing-secret",
"afterhours-shift-manager/roster-api-token",
]
VERIFY_ONLY = [
"afterhours-shift-manager/3cx-domain",
"afterhours-shift-manager/3cx-client-id",
"afterhours-shift-manager/3cx-client-secret",
]
# Describe succeeds on a Terraform shell; GetSecretValue fails until a version exists.
_NO_VALUE_CODES = frozenset({"ResourceNotFoundException", "InvalidRequestException"})
def _client(profile: str, region: str):
return boto3.Session(profile_name=profile, region_name=region).client("secretsmanager")
def _account(profile: str) -> str:
return boto3.Session(profile_name=profile).client("sts").get_caller_identity()["Account"]
def secret_string(client, name: str) -> str | None:
"""Return the current SecretString, or None if the secret does not exist.
An empty string means the secret exists (Terraform shell) but has no usable
current version.
"""
try:
client.describe_secret(SecretId=name)
except ClientError as exc:
if exc.response["Error"]["Code"] == "ResourceNotFoundException":
return None
raise
try:
payload = client.get_secret_value(SecretId=name)
except ClientError as exc:
if exc.response["Error"]["Code"] in _NO_VALUE_CODES:
return ""
raise
value = payload.get("SecretString")
if value is None:
return ""
return value
def copy_secrets(src, dst, *, execute: bool) -> int:
rc = 0
for name in VERIFY_ONLY:
value = secret_string(dst, name)
if value is None:
print(f"missing prod secret {name} (expected from PLAT-76)", file=sys.stderr)
rc = 1
elif not value.strip():
print(f"empty prod 3cx secret {name} (do not overwrite from mgmt)", file=sys.stderr)
rc = 1
else:
print(f"keep existing prod secret {name}")
for name in COPY:
src_value = secret_string(src, name)
if src_value is None or not src_value.strip():
print(f"missing mgmt secret {name}", file=sys.stderr)
rc = 1
continue
dest_value = secret_string(dst, name)
if dest_value is None:
print(f"missing prod secret shell {name}", file=sys.stderr)
rc = 1
continue
if dest_value.strip():
print(f"skip populated prod secret {name}")
continue
print(f"would copy {name}")
if not execute:
continue
value = src_value.rstrip("\n")
dst.put_secret_value(SecretId=name, SecretString=value)
print(f"wrote {name} ({len(value)} chars)")
if not execute:
print("dry-run; pass --execute to PutSecretValue")
return rc
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--src-profile", required=True)
parser.add_argument("--dst-profile", required=True)
parser.add_argument("--region", default="us-east-1")
parser.add_argument("--execute", action="store_true")
args = parser.parse_args()
if _account(args.src_profile) != SRC_ACCOUNT:
print("src profile is not mgmt", file=sys.stderr)
return 2
if _account(args.dst_profile) != DST_ACCOUNT:
print("dst profile is not prod", file=sys.stderr)
return 2
src = _client(args.src_profile, args.region)
dst = _client(args.dst_profile, args.region)
return copy_secrets(src, dst, execute=args.execute)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Recreate future holiday-* EventBridge Scheduler schedules in prod.
Reads outstanding holiday-activate-* / holiday-deactivate-* from mgmt and
creates the same names in prod targeting the prod router ARN and scheduler
role. Dry-run unless --execute.
"""
from __future__ import annotations
import argparse
import re
import sys
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import boto3
from botocore.exceptions import ClientError
SRC_ACCOUNT = "328440206208"
DST_ACCOUNT = "011934824531"
PROD_ROUTER_ARN = "arn:aws:lambda:us-east-1:011934824531:function:afterhours-holiday-router"
PROD_ROLE_ARN = "arn:aws:iam::011934824531:role/tf-managed/afterhours-shift-manager-holiday-scheduler"
PREFIXES = ("holiday-activate-", "holiday-deactivate-")
_AT = re.compile(r"^at\((\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\)$")
def _client(profile: str, region: str):
return boto3.Session(profile_name=profile, region_name=region).client("scheduler")
def _account(profile: str) -> str:
return boto3.Session(profile_name=profile).client("sts").get_caller_identity()["Account"]
def schedule_when(detail: dict) -> datetime | None:
"""UTC instant the one-off schedule fires, or None if it cannot be parsed."""
expr = (detail.get("ScheduleExpression") or "").strip()
tzname = detail.get("ScheduleExpressionTimezone") or "America/New_York"
match = _AT.match(expr)
if match:
naive = datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S")
return naive.replace(tzinfo=ZoneInfo(tzname)).astimezone(timezone.utc)
at = detail.get("EndDate") or detail.get("StartDate")
if at is None:
return None
if at.tzinfo is None:
return at.replace(tzinfo=timezone.utc)
return at.astimezone(timezone.utc)
def _list_holiday(client):
names = []
token = None
while True:
kwargs = {"GroupName": "default"}
if token:
kwargs["NextToken"] = token
resp = client.list_schedules(**kwargs)
for item in resp.get("Schedules", []):
name = item.get("Name", "")
if name.startswith(PREFIXES):
names.append(name)
token = resp.get("NextToken")
if not token:
return names
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--src-profile", required=True)
parser.add_argument("--dst-profile", required=True)
parser.add_argument("--region", default="us-east-1")
parser.add_argument("--execute", action="store_true")
args = parser.parse_args()
if _account(args.src_profile) != SRC_ACCOUNT:
print("src profile is not mgmt", file=sys.stderr)
return 2
if _account(args.dst_profile) != DST_ACCOUNT:
print("dst profile is not prod", file=sys.stderr)
return 2
src = _client(args.src_profile, args.region)
dst = _client(args.dst_profile, args.region)
now = datetime.now(timezone.utc)
created = 0
skipped = 0
failed = 0
for name in _list_holiday(src):
detail = src.get_schedule(Name=name, GroupName="default")
expr = detail.get("ScheduleExpression", "")
tzname = detail.get("ScheduleExpressionTimezone", "America/New_York")
when = schedule_when(detail)
if when is not None and when < now:
print(f"skip past {name} expr={expr}")
skipped += 1
continue
payload = {
"Name": name,
"GroupName": "default",
"ScheduleExpression": expr,
"ScheduleExpressionTimezone": tzname,
"FlexibleTimeWindow": {"Mode": "OFF"},
"Target": {
"Arn": PROD_ROUTER_ARN,
"RoleArn": PROD_ROLE_ARN,
"Input": detail.get("Target", {}).get("Input", ""),
},
"ActionAfterCompletion": detail.get("ActionAfterCompletion", "DELETE"),
}
if detail.get("EndDate"):
payload["EndDate"] = detail["EndDate"]
print(f"would create {name} expr={expr} tz={tzname}")
if not args.execute:
continue
try:
dst.create_schedule(**payload)
created += 1
except ClientError as exc:
code = exc.response["Error"]["Code"]
if code == "ConflictException":
print(f"exists {name}")
elif code == "ValidationException":
print(f"skip invalid {name}: {exc.response['Error'].get('Message', code)}")
skipped += 1
else:
print(f"failed {name}: {code}", file=sys.stderr)
failed += 1
print(f"created={created} skipped_past={skipped} failed={failed} execute={args.execute}")
if not args.execute:
print("dry-run; pass --execute to CreateSchedule")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())

140
scripts/package_lambdas.py Normal file
View file

@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Build Lambda zips with src/shared bundled in. Used by deploy.yaml.
Each zip is functions/<name>/<sha>.zip on S3. GIT_SHA is written to
shared/build_info.py inside the zip so Sentry release is the commit, not a
runtime env var Terraform would own.
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
# Keys match terraform/locals.tf local.functions.
FUNCTIONS = {
"slack_bot": ROOT / "src" / "slack-bot",
"weekly_post": ROOT / "src" / "weekly-post",
"roster_sync": ROOT / "src" / "roster-sync",
"roster_api": ROOT / "src" / "roster-api",
"ring_scheduler": ROOT / "src" / "ring-scheduler",
"holiday_router": ROOT / "src" / "holiday-router",
"release_notifier": ROOT / "src" / "release-notifier",
}
SKIP_INSTALL_PREFIXES = ("boto3", "botocore")
SKIP_COPY_NAMES = {"requirements.txt", "__pycache__"}
def _req_lines(path: Path) -> list[str]:
lines: list[str] = []
if not path.is_file():
return lines
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
lower = line.lower()
if any(lower.startswith(prefix) for prefix in SKIP_INSTALL_PREFIXES):
continue
lines.append(line)
return lines
def _copy_tree(src: Path, dest: Path) -> None:
dest.mkdir(parents=True, exist_ok=True)
for item in src.iterdir():
if item.name in SKIP_COPY_NAMES or item.name.endswith(".pyc"):
continue
target = dest / item.name
if item.is_dir():
if item.name == "__pycache__":
continue
shutil.copytree(item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
else:
shutil.copy2(item, target)
def build_function(name: str, src: Path, git_sha: str, out_dir: Path) -> Path:
with tempfile.TemporaryDirectory(prefix=f"afterhours-{name}-") as tmp:
dest = Path(tmp)
_copy_tree(src, dest)
shared_src = ROOT / "src" / "shared" / "shared"
_copy_tree(shared_src, dest / "shared")
(dest / "shared" / "build_info.py").write_text(
f'"""Pinned at zip time by scripts/package_lambdas.py."""\n\nGIT_SHA = "{git_sha}"\n',
encoding="utf-8",
)
reqs = _req_lines(src / "requirements.txt") + _req_lines(
ROOT / "src" / "shared" / "requirements.txt"
)
# Preserve order, drop duplicates.
seen: set[str] = set()
unique: list[str] = []
for line in reqs:
if line not in seen:
seen.add(line)
unique.append(line)
if unique:
cmd = [
sys.executable,
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-compile",
"--python-version",
"3.12",
"--platform",
"manylinux2014_aarch64",
"--only-binary=:all:",
"--target",
str(dest),
*unique,
]
subprocess.run(cmd, check=True)
out_dir.mkdir(parents=True, exist_ok=True)
zip_path = out_dir / f"{name}.zip"
if zip_path.exists():
zip_path.unlink()
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for dirpath, dirnames, filenames in os.walk(dest):
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
for filename in filenames:
if filename.endswith(".pyc"):
continue
full = Path(dirpath) / filename
rel = full.relative_to(dest)
zf.write(full, rel.as_posix())
return zip_path
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--git-sha", required=True)
parser.add_argument("--out-dir", type=Path, default=ROOT / "build" / "packages")
parser.add_argument("--only", nargs="*", default=())
args = parser.parse_args()
selected = args.only or list(FUNCTIONS)
missing = [name for name in selected if name not in FUNCTIONS]
if missing:
print(f"unknown function keys: {missing}", file=sys.stderr)
return 2
for name in selected:
path = build_function(name, FUNCTIONS[name], args.git_sha, args.out_dir)
print(path)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -120,19 +120,31 @@ def _before_send(event, _hint):
return event
def _git_sha():
try:
from shared.build_info import GIT_SHA
except ImportError:
return os.environ.get("GIT_SHA", "").strip()
return str(GIT_SHA or "").strip()
def init_sentry():
dsn = os.environ.get("SENTRY_DSN")
if not dsn:
return
sentry_sdk.init(
dsn=dsn,
integrations=[AwsLambdaIntegration(timeout_warning=True)],
send_default_pii=False,
include_local_variables=False,
enable_logs=False,
traces_sample_rate=0.0,
before_send=_before_send,
)
kwargs = {
"dsn": dsn,
"integrations": [AwsLambdaIntegration(timeout_warning=True)],
"send_default_pii": False,
"include_local_variables": False,
"enable_logs": False,
"traces_sample_rate": 0.0,
"before_send": _before_send,
}
sha = _git_sha()
if sha:
kwargs["release"] = sha
sentry_sdk.init(**kwargs)
init_sentry()

File diff suppressed because it is too large Load diff

47
terraform/.terraform.lock.hcl generated Normal file
View file

@ -0,0 +1,47 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/archive" {
version = "2.8.1"
constraints = "~> 2.8"
hashes = [
"h1:aLNmq6dc3cDcqZc8s/8eKtn0I+UQXyJMGrmo4rRFtNw=",
"zh:03de290604114a89fcd45c2e5bc7787d5a1ebfc5f964fb5989306bea7a4c79ec",
"zh:0a7d69dc9fbbc48960bc2f04588c8fb1bd92c78a8f306566b7fb17fc4a4f2058",
"zh:4df1f3981379c35f1757da957470f7f7724496b57219da3485177cf1647bdf59",
"zh:50f0e72ba53bfe6e11b03b7fc899e1f72536354381d302a743a274ae2a3f45f4",
"zh:5c4e15a04c98e2a8cafb1cd9632b48ad318051e8462d105b1153006277985c35",
"zh:66069e604bcf5c4af0278e15997d9e6bd755c54fb3801d78885838b889729c5f",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:979765db3f42601870ab377104ba70befb029547b278337ec4ada980e3582de6",
"zh:b165254da774f49945a73fbccc3ef1b63d70ea00a98fa9e14716665ba80ecaad",
"zh:c0bb2697b525da9fec4511f569ed2bd2b42f25d5c1f9fa00f8f3645b50cfc2e9",
"zh:c48f6695d12df0d0fa5231f7c1b8d518a4feae91a733fdd35a5804af3a303831",
"zh:d589954c93f075180c9f4e2ce91780d5edcb56dfd0d3cda8ba08e13c10b52249",
"zh:f5792ed06da65d0daf7ca3711f5399ff78c7cb4400afe53fbce9a926fbde4477",
]
}
provider "registry.terraform.io/hashicorp/aws" {
version = "6.64.0"
constraints = "~> 6.64"
hashes = [
"h1:wXARLY+IeQ7ufYxCLTPCwToWGMRvOpiOTfJS97iwUzI=",
"zh:07172315d67bc9781240272759cdfc7bd32b7e72384a56862c2c1da3cca99a81",
"zh:154ce7d2659de9a59ddfe96d7cab41a9ddc2cb267a7d4bcdf4e737ff2ffdec06",
"zh:17324d4335a7a7ac01cc23eded530775606680ff53b47cb74a3cb95d1121f836",
"zh:307ab92324ec5a61b124881ab8cac1d9e316f4527dfd0e1b59794c229407eb4e",
"zh:31e25f1903661332e36a95283042dd3ec50b47c186db00663fbd976a11e6a6b2",
"zh:3311d9f3bd12a24886027dbe73859dcd1e67bd0e3046227a338cf2c7ca04d18e",
"zh:37916156a3aac3b29be3acebd15d53145ea4ab5d4aaa825eaebe75481fa00500",
"zh:4158cb8c38b3ac6aa98eb15935ec6bd7c30838d85d2b00acc9812df8382ae908",
"zh:5bfb9499c66d9db5b34dc5c60f426a1ab1baa5457ce2aefebca826a9c3f92fb0",
"zh:6eb29ead5a4aca3b1f35812e7e8c75419180e1928e479b458f206861277736db",
"zh:7a82b6dd0c0cdef8045a4adfbddd36acb86b6b23fcbed8e189c2d71f7dc4a502",
"zh:9556bd792032c3f7e73ea4dd08cec88dc1327f5a4a57d79c30ba844ae2b9a3c0",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:c5234180464cb800c83a41f57462742b802c150ad7d4417626fcd9cb511c01d2",
"zh:cd776b83b1f7b36635957350afe7ce28ba4e4ea3a5e2deb00d13dbd3b35d9d40",
"zh:fb583a7b791c6f915b86573d04f05ddbf7f1a5e4120c5d8a7450a3086c1225c4",
]
}

155
terraform/alarms.tf Normal file
View file

@ -0,0 +1,155 @@
locals {
lambda_alarm_matrix = {
errors = {
metric_name = "Errors"
statistic = "Sum"
evaluation_periods = 1
datapoints_to_alarm = 1
threshold = 1
comparison = "GreaterThanOrEqualToThreshold"
period = 300
}
throttles = {
metric_name = "Throttles"
statistic = "Sum"
evaluation_periods = 1
datapoints_to_alarm = 1
threshold = 1
comparison = "GreaterThanOrEqualToThreshold"
period = 300
}
}
lambda_alarms = {
for pair in flatten([
for fn_key, fn in local.functions : [
for metric_key, metric in local.lambda_alarm_matrix : {
key = "${fn_key}-${metric_key}"
fn_key = fn_key
function = fn.function_name
metric_key = metric_key
metric_name = metric.metric_name
statistic = metric.statistic
evaluation = metric.evaluation_periods
datapoints = metric.datapoints_to_alarm
threshold = metric.threshold
comparison = metric.comparison
period = metric.period
description = metric_key == "errors" ? "${fn.function_name} reported one or more errors" : "${fn.function_name} was throttled (concurrency limit hit)"
}
]
]) : pair.key => pair
}
}
resource "aws_cloudwatch_metric_alarm" "lambda_errors_throttles" {
for_each = local.lambda_alarms
alarm_name = "Lambda-${title(each.value.metric_key)}-${each.value.function}"
alarm_description = each.value.description
namespace = "AWS/Lambda"
metric_name = each.value.metric_name
dimensions = { FunctionName = each.value.function }
statistic = each.value.statistic
period = each.value.period
evaluation_periods = each.value.evaluation
datapoints_to_alarm = each.value.datapoints
threshold = each.value.threshold
comparison_operator = each.value.comparison
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "lambda_duration" {
for_each = local.functions
alarm_name = "Lambda-Duration-${each.value.function_name}"
alarm_description = "${each.value.function_name} duration approaching its ${each.value.timeout}s timeout (>=${each.value.duration_ms}ms)"
namespace = "AWS/Lambda"
metric_name = "Duration"
dimensions = { FunctionName = each.value.function_name }
statistic = "Maximum"
period = 300
evaluation_periods = 3
datapoints_to_alarm = 2
threshold = each.value.duration_ms
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "ddb_read_throttle" {
alarm_name = "DDB-ReadThrottle-${local.table_name}"
alarm_description = "afterhours-shifts table had one or more read throttle events"
namespace = "AWS/DynamoDB"
metric_name = "ReadThrottleEvents"
dimensions = { TableName = aws_dynamodb_table.shifts.name }
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "ddb_write_throttle" {
alarm_name = "DDB-WriteThrottle-${local.table_name}"
alarm_description = "afterhours-shifts table had one or more write throttle events"
namespace = "AWS/DynamoDB"
metric_name = "WriteThrottleEvents"
dimensions = { TableName = aws_dynamodb_table.shifts.name }
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "api_4xx" {
alarm_name = "ApiGateway-4xx-${aws_apigatewayv2_api.http.id}"
alarm_description = "Elevated 4xx responses on the afterhours HTTP API"
namespace = "AWS/ApiGateway"
metric_name = "4xx"
dimensions = { ApiId = aws_apigatewayv2_api.http.id }
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 5
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "api_5xx" {
alarm_name = "ApiGateway-5xx-${aws_apigatewayv2_api.http.id}"
alarm_description = "5xx responses on the afterhours HTTP API"
namespace = "AWS/ApiGateway"
metric_name = "5xx"
dimensions = { ApiId = aws_apigatewayv2_api.http.id }
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 1
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "api_latency" {
alarm_name = "ApiGateway-Latency-${aws_apigatewayv2_api.http.id}"
alarm_description = "p99 latency on the afterhours HTTP API exceeded 3s"
namespace = "AWS/ApiGateway"
metric_name = "Latency"
dimensions = { ApiId = aws_apigatewayv2_api.http.id }
extended_statistic = "p99"
period = 300
evaluation_periods = 3
datapoints_to_alarm = 2
threshold = 3000
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}

82
terraform/apigateway.tf Normal file
View file

@ -0,0 +1,82 @@
# HTTP API: Slack events plus the Paychex roster contract.
resource "aws_apigatewayv2_api" "http" {
name = local.project
protocol_type = "HTTP"
description = "afterhours-shift-manager Slack and roster API"
}
resource "aws_apigatewayv2_integration" "slack_bot" {
api_id = aws_apigatewayv2_api.http.id
integration_type = "AWS_PROXY"
integration_method = "POST"
integration_uri = aws_lambda_function.this["slack_bot"].invoke_arn
payload_format_version = "2.0"
timeout_milliseconds = 30000
}
resource "aws_apigatewayv2_integration" "roster_api" {
api_id = aws_apigatewayv2_api.http.id
integration_type = "AWS_PROXY"
integration_method = "POST"
integration_uri = aws_lambda_function.this["roster_api"].invoke_arn
payload_format_version = "2.0"
timeout_milliseconds = 30000
}
resource "aws_apigatewayv2_route" "slack_events" {
api_id = aws_apigatewayv2_api.http.id
route_key = "POST /slack/events"
target = "integrations/${aws_apigatewayv2_integration.slack_bot.id}"
}
resource "aws_apigatewayv2_route" "put_roster" {
api_id = aws_apigatewayv2_api.http.id
route_key = "PUT /roster"
target = "integrations/${aws_apigatewayv2_integration.roster_api.id}"
}
resource "aws_apigatewayv2_route" "delete_roster" {
api_id = aws_apigatewayv2_api.http.id
route_key = "DELETE /roster/{extension}"
target = "integrations/${aws_apigatewayv2_integration.roster_api.id}"
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.http.id
name = "$default"
auto_deploy = true
access_log_settings {
destination_arn = aws_cloudwatch_log_group.api_access.arn
format = "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"requestTime\":\"$context.requestTime\",\"method\":\"$context.httpMethod\",\"routeKey\":\"$context.routeKey\",\"status\":\"$context.status\",\"protocol\":\"$context.protocol\",\"responseLength\":\"$context.responseLength\",\"integrationError\":\"$context.integrationErrorMessage\"}"
}
default_route_settings {
throttling_burst_limit = 50
throttling_rate_limit = 100
}
depends_on = [
aws_apigatewayv2_route.slack_events,
aws_apigatewayv2_route.put_roster,
aws_apigatewayv2_route.delete_roster,
aws_iam_role_policy.hcptf_apply_services,
]
}
resource "aws_lambda_permission" "api_slack_bot" {
statement_id = "AllowApiGatewayInvokeSlackBot"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.this["slack_bot"].function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*"
}
resource "aws_lambda_permission" "api_roster_api" {
statement_id = "AllowApiGatewayInvokeRosterApi"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.this["roster_api"].function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*"
}

118
terraform/artifacts.tf Normal file
View file

@ -0,0 +1,118 @@
# Lambda artifacts bucket. Terraform ships only the bootstrap stub.
# .github/workflows/deploy.yaml uploads functions/<name>/<sha>.zip and calls
# update-function-code. Functions ignore code attributes afterwards.
resource "aws_s3_bucket" "artifacts" {
bucket = local.artifacts_bucket_name
tags = {
Purpose = "Lambda deployment packages for afterhours-shift-manager"
}
}
resource "aws_s3_bucket_public_access_block" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_versioning" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
id = "expire-noncurrent-packages"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = 180
}
}
rule {
id = "abort-incomplete-multipart"
status = "Enabled"
filter {}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
depends_on = [aws_s3_bucket_versioning.artifacts]
}
data "aws_iam_policy_document" "artifacts" {
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
principals {
type = "*"
identifiers = ["*"]
}
actions = ["s3:*"]
resources = [
aws_s3_bucket.artifacts.arn,
"${aws_s3_bucket.artifacts.arn}/*",
]
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}
resource "aws_s3_bucket_policy" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
policy = data.aws_iam_policy_document.artifacts.json
depends_on = [aws_s3_bucket_public_access_block.artifacts]
}
data "archive_file" "bootstrap_stub" {
type = "zip"
source_dir = "${path.module}/bootstrap/stub"
output_path = "${path.module}/build/packages/bootstrap-stub.zip"
}
resource "aws_s3_object" "bootstrap_stub" {
bucket = aws_s3_bucket.artifacts.id
key = "functions/bootstrap-stub.zip"
content_base64 = filebase64(data.archive_file.bootstrap_stub.output_path)
source_hash = data.archive_file.bootstrap_stub.output_base64sha256
}

View file

@ -0,0 +1,9 @@
"""Bootstrap stub. GitHub Actions replaces this zip via update-function-code."""
def handler(event, context):
return {
"statusCode": 503,
"headers": {"content-type": "application/json"},
"body": '{"error":{"code":"NOT_DEPLOYED","message":"Function code has not been deployed yet."}}',
}

21
terraform/dynamodb.tf Normal file
View file

@ -0,0 +1,21 @@
resource "aws_dynamodb_table" "shifts" {
name = local.table_name
billing_mode = "PAY_PER_REQUEST"
hash_key = "PK"
range_key = "SK"
attribute {
name = "PK"
type = "S"
}
attribute {
name = "SK"
type = "S"
}
ttl {
attribute_name = "expires_at"
enabled = true
}
}

76
terraform/events.tf Normal file
View file

@ -0,0 +1,76 @@
# EventBridge schedules. Every schedule is an EST/EDT pair firing the same
# function one hour apart in UTC: EventBridge cron has no timezone. Both fire
# year-round and the handlers are idempotent. Keep schedules_enabled=false
# until Slack and Paychex point at this stack.
locals {
schedules = {
weekly-post-est = {
description = "Post weekly schedule Monday 7am EST"
schedule = "cron(0 12 ? * MON *)"
function_key = "weekly_post"
}
weekly-post-edt = {
description = "Post weekly schedule Monday 7am EDT"
schedule = "cron(0 11 ? * MON *)"
function_key = "weekly_post"
}
roster-sync-est = {
description = "Sync roster from 3CX at 6am EST"
schedule = "cron(0 11 ? * * *)"
function_key = "roster_sync"
}
roster-sync-edt = {
description = "Sync roster from 3CX at 6am EDT"
schedule = "cron(0 10 ? * * *)"
function_key = "roster_sync"
}
ring-scheduler-daily-est = {
description = "Update 3CX queue at 8am EST"
schedule = "cron(0 13 ? * * *)"
function_key = "ring_scheduler"
}
ring-scheduler-daily-edt = {
description = "Update 3CX queue at 8am EDT"
schedule = "cron(0 12 ? * * *)"
function_key = "ring_scheduler"
}
ring-scheduler-weekend-est = {
description = "Update 3CX queue at 5pm EST weekends"
schedule = "cron(0 22 ? * SAT,SUN *)"
function_key = "ring_scheduler"
}
ring-scheduler-weekend-edt = {
description = "Update 3CX queue at 5pm EDT weekends"
schedule = "cron(0 21 ? * SAT,SUN *)"
function_key = "ring_scheduler"
}
}
}
resource "aws_cloudwatch_event_rule" "schedule" {
for_each = local.schedules
name = "${local.project}-${each.key}"
description = each.value.description
schedule_expression = each.value.schedule
state = var.schedules_enabled ? "ENABLED" : "DISABLED"
}
resource "aws_cloudwatch_event_target" "schedule" {
for_each = local.schedules
rule = aws_cloudwatch_event_rule.schedule[each.key].name
target_id = "${local.project}-${each.key}"
arn = aws_lambda_function.this[each.value.function_key].arn
}
resource "aws_lambda_permission" "schedule" {
for_each = local.schedules
statement_id = "AllowEventBridgeInvoke-${each.key}"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.this[each.value.function_key].function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.schedule[each.key].arn
}

843
terraform/hcp_iam.tf Normal file
View file

@ -0,0 +1,843 @@
# HCP plan/apply roles for afterhours-shift-manager-prod (PLAT-74 / PLAT-144).
# Copy of seahaven-org-baseline/examples/hcptf-workspace-iam/hcp_iam.tf.example
# with the afterhours service set. Create, do not import.
#
# Live seahaven-hcptf-iam-management DenySelfMutation blocks DetachRolePolicy
# and PutRolePolicy on hcptf-* (including this role). First-apply sequence:
# 1. seahaven-org-baseline scripts/create-hcptf-bootstrap-roles.sh
# --account prod --allow-workspace afterhours-shift-manager-prod
# 2. Point this workspace's TFC_AWS_* at hcptf-bootstrap /
# hcptf-bootstrap-plan (workspace vars, never a project set).
# 3. One Manual apply (create roles + scoped inline + boundary + stack,
# schedules_enabled=false).
# 4. Point TFC_AWS_* back at hcptf-afterhours-shift-manager /
# hcptf-afterhours-shift-manager-plan.
# 5. Re-run the script without --allow-workspace to pin trust back to
# iam-bootstrap-prod only.
# Later apply-role IAM edits use the same window. Do not add StringLike
# on bootstrap trust. CreatePolicy stays on hcptf-bootstrap only; boundary
# document changes after seal also need that window.
data "aws_iam_policy_document" "hcptf_apply_trust" {
statement {
sid = "HcpApply"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:apply",
]
}
}
}
data "aws_iam_policy_document" "hcptf_plan_trust" {
statement {
sid = "HcpPlan"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:plan",
]
}
}
}
data "aws_iam_policy_document" "hcptf_scoped_iam" {
statement {
sid = "DenyCreatePolicy"
effect = "Deny"
actions = [
"iam:CreatePolicy",
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
]
resources = ["*"]
}
statement {
sid = "CreateExecRoleWithBoundary"
effect = "Allow"
actions = ["iam:CreateRole"]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*",
]
condition {
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-${local.stack_name}",
]
}
}
statement {
sid = "MutateExecRoleWithBoundary"
effect = "Allow"
actions = [
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*",
]
condition {
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-${local.stack_name}",
]
}
}
statement {
sid = "WriteExecRoles"
effect = "Allow"
actions = [
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*",
]
}
statement {
sid = "PassExecRolesToLambda"
effect = "Allow"
actions = ["iam:PassRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["lambda.amazonaws.com"]
}
}
statement {
sid = "PassHolidaySchedulerRole"
effect = "Allow"
actions = ["iam:PassRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/afterhours-shift-manager-holiday-scheduler"]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["scheduler.amazonaws.com"]
}
}
# githubdeploy-afterhours-shift-manager lives at /tf-managed/ so
# DenySelfMutation (role/githubdeploy-*) does not match. Create without a
# permissions boundary; this is not a Lambda execution role.
statement {
sid = "CreateDeployRole"
effect = "Allow"
actions = ["iam:CreateRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.deploy_role}"]
condition {
test = "Null"
variable = "iam:PermissionsBoundary"
values = ["true"]
}
}
statement {
sid = "WriteDeployRoles"
effect = "Allow"
actions = [
"iam:AttachRolePolicy",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.deploy_role}"]
}
statement {
sid = "IamReadOnly"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfilesForRole",
"iam:ListPolicies",
"iam:ListPolicyVersions",
"iam:ListRolePolicies",
"iam:ListRoleTags",
"iam:ListRoles",
]
resources = ["*"]
}
statement {
sid = "DenySelfMutation"
effect = "Deny"
actions = [
"iam:AttachRolePolicy",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DeleteRolePermissionsBoundary",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = [
"arn:aws:iam::${local.account_id}:role/hcptf-*",
"arn:aws:iam::${local.account_id}:role/github-cfn-execution-role",
"arn:aws:iam::${local.account_id}:role/githubdeploy-*",
"arn:aws:iam::${local.account_id}:role/cdk-hnb659fds-*",
"arn:aws:iam::${local.account_id}:role/OrganizationAccountAccessRole",
"arn:aws:iam::${local.account_id}:role/seahaven-*",
]
}
statement {
sid = "DenyBoundaryTampering"
effect = "Deny"
actions = [
"iam:DeleteRolePermissionsBoundary",
"iam:DeleteUserPermissionsBoundary",
]
resources = [
"arn:aws:iam::${local.account_id}:role/*",
"arn:aws:iam::${local.account_id}:user/*",
]
}
statement {
sid = "DenyBoundaryPolicyEdit"
effect = "Deny"
actions = [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
]
resources = ["arn:aws:iam::${local.account_id}:policy/seahaven-*"]
}
}
data "aws_iam_policy_document" "hcptf_apply_services" {
statement {
sid = "LambdaAll"
effect = "Allow"
actions = [
"lambda:*",
]
resources = [
"arn:aws:lambda:${var.aws_region}:${local.account_id}:function:afterhours-*",
]
}
statement {
sid = "LambdaList"
effect = "Allow"
actions = [
"lambda:ListFunctions",
"lambda:ListLayers",
"lambda:GetAccountSettings",
]
resources = ["*"]
}
statement {
sid = "EventBridgeRules"
effect = "Allow"
actions = [
"events:*",
]
resources = [
"arn:aws:events:${var.aws_region}:${local.account_id}:rule/afterhours-shift-manager-*",
]
}
statement {
sid = "EventBridgeList"
effect = "Allow"
actions = ["events:ListRules", "events:ListRuleNamesByTarget"]
resources = ["*"]
}
statement {
sid = "CloudWatchLogs"
effect = "Allow"
actions = [
"logs:CreateLogGroup",
"logs:DeleteLogGroup",
"logs:PutRetentionPolicy",
"logs:DeleteRetentionPolicy",
"logs:TagResource",
"logs:UntagResource",
"logs:ListTagsForResource",
"logs:PutMetricFilter",
"logs:DeleteMetricFilter",
"logs:DescribeMetricFilters",
]
resources = [
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/lambda/afterhours-*",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/afterhours-shift-manager",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/afterhours-shift-manager:*",
]
}
statement {
sid = "CloudWatchLogsDescribe"
effect = "Allow"
actions = ["logs:DescribeLogGroups"]
resources = ["*"]
}
# CreateStage access_log_settings uses log-delivery APIs. Resource "*" is
# required; these actions do not accept a log-group ARN.
statement {
sid = "ApiGwAccessLogDelivery"
effect = "Allow"
actions = [
"logs:CreateLogDelivery",
"logs:GetLogDelivery",
"logs:UpdateLogDelivery",
"logs:DeleteLogDelivery",
"logs:ListLogDeliveries",
"logs:PutResourcePolicy",
"logs:DescribeResourcePolicies",
]
resources = ["*"]
}
statement {
sid = "StackBuckets"
effect = "Allow"
actions = [
"s3:*",
]
resources = [
"arn:aws:s3:::${local.artifacts_bucket_name}",
"arn:aws:s3:::${local.artifacts_bucket_name}/*",
]
}
statement {
sid = "DynamoDBTable"
effect = "Allow"
actions = [
"dynamodb:*",
]
resources = [
"arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}",
"arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}/*",
]
}
statement {
sid = "DynamoDBList"
effect = "Allow"
actions = ["dynamodb:ListTables"]
resources = ["*"]
}
statement {
sid = "HttpApiManage"
effect = "Allow"
actions = [
"apigateway:*",
]
resources = [
"arn:aws:apigateway:${var.aws_region}::/apis",
"arn:aws:apigateway:${var.aws_region}::/apis/*",
"arn:aws:apigateway:${var.aws_region}::/tags/*",
"arn:aws:apigateway:${var.aws_region}::/vpclinks",
"arn:aws:apigateway:${var.aws_region}::/vpclinks/*",
]
}
statement {
sid = "AfterhoursSsm"
effect = "Allow"
actions = [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:PutParameter",
"ssm:DeleteParameter",
"ssm:AddTagsToResource",
"ssm:RemoveTagsFromResource",
"ssm:ListTagsForResource",
]
resources = [
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*",
]
}
statement {
sid = "SsmDescribeParameters"
effect = "Allow"
actions = ["ssm:DescribeParameters"]
resources = ["*"]
}
statement {
sid = "SecretsManagerReadAndManage"
effect = "Allow"
actions = [
"secretsmanager:CreateSecret",
"secretsmanager:DeleteSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:GetResourcePolicy",
"secretsmanager:PutResourcePolicy",
"secretsmanager:DeleteResourcePolicy",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"secretsmanager:UpdateSecret",
"secretsmanager:ListSecretVersionIds",
]
resources = [
"arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*",
]
}
statement {
sid = "SecretsManagerCreateByName"
effect = "Allow"
actions = [
"secretsmanager:CreateSecret",
]
resources = ["*"]
condition {
test = "StringLike"
variable = "secretsmanager:Name"
values = ["afterhours-shift-manager/*"]
}
}
statement {
sid = "SecretsManagerList"
effect = "Allow"
actions = ["secretsmanager:ListSecrets"]
resources = ["*"]
}
statement {
sid = "CloudWatchAlarms"
effect = "Allow"
actions = [
"cloudwatch:PutMetricAlarm",
"cloudwatch:DeleteAlarms",
"cloudwatch:DescribeAlarms",
"cloudwatch:TagResource",
"cloudwatch:UntagResource",
"cloudwatch:ListTagsForResource",
]
resources = [
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:Lambda-*-afterhours-*",
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:DDB-*-afterhours-shifts",
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:ApiGateway-*",
]
}
statement {
sid = "CloudWatchDescribeAlarms"
effect = "Allow"
actions = ["cloudwatch:DescribeAlarms"]
resources = ["*"]
}
statement {
sid = "SnsPublishSiteAlerts"
effect = "Allow"
actions = [
"sns:Publish",
"sns:GetTopicAttributes",
"sns:ListTagsForResource",
]
resources = [local.site_alerts_arn]
}
statement {
sid = "ManageTfManagedBoundary"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:ListPolicyVersions",
"iam:ListPolicyTags",
"iam:TagPolicy",
"iam:UntagPolicy",
]
resources = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
]
}
statement {
sid = "EventBridgeScheduler"
effect = "Allow"
actions = [
"scheduler:CreateSchedule",
"scheduler:DeleteSchedule",
"scheduler:GetSchedule",
"scheduler:UpdateSchedule",
"scheduler:ListTagsForResource",
"scheduler:TagResource",
"scheduler:UntagResource",
]
resources = [
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*",
]
}
statement {
sid = "EventBridgeSchedulerList"
effect = "Allow"
actions = [
"scheduler:ListSchedules",
"scheduler:ListScheduleGroups",
"scheduler:GetScheduleGroup",
]
resources = ["*"]
}
}
data "aws_iam_policy_document" "hcptf_plan_refresh" {
statement {
sid = "RefreshIamRoles"
effect = "Allow"
actions = [
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies",
"iam:ListRoleTags",
]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.deploy_role}",
"arn:aws:iam::${local.account_id}:role/${local.apply_role}",
"arn:aws:iam::${local.account_id}:role/${local.plan_role}",
]
}
statement {
sid = "RefreshManagedPolicies"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
]
resources = ["*"]
}
statement {
sid = "RefreshLambda"
effect = "Allow"
actions = [
"lambda:GetFunction",
"lambda:GetFunctionConfiguration",
"lambda:GetPolicy",
"lambda:GetFunctionCodeSigningConfig",
"lambda:GetFunctionConcurrency",
"lambda:GetFunctionEventInvokeConfig",
"lambda:GetFunctionUrlConfig",
"lambda:GetRuntimeManagementConfig",
"lambda:GetFunctionRecursionConfig",
"lambda:ListTags",
"lambda:ListVersionsByFunction",
"lambda:ListAliases",
]
resources = [
"arn:aws:lambda:${var.aws_region}:${local.account_id}:function:afterhours-*",
]
}
statement {
sid = "RefreshLambdaList"
effect = "Allow"
actions = [
"lambda:ListFunctions",
"lambda:ListLayers",
"lambda:GetAccountSettings",
]
resources = ["*"]
}
statement {
sid = "RefreshBuckets"
effect = "Allow"
actions = [
"s3:GetBucketLocation",
"s3:GetBucketAcl",
"s3:GetBucketPolicy",
"s3:GetBucketPolicyStatus",
"s3:GetBucketPublicAccessBlock",
"s3:GetBucketVersioning",
"s3:GetBucketLifecycleConfiguration",
"s3:GetBucketTagging",
"s3:GetBucketOwnershipControls",
"s3:GetEncryptionConfiguration",
"s3:GetBucketCORS",
"s3:GetBucketLogging",
"s3:GetBucketNotification",
"s3:GetBucketObjectLockConfiguration",
"s3:GetBucketRequestPayment",
"s3:GetBucketWebsite",
"s3:GetAccelerateConfiguration",
"s3:GetObject",
"s3:GetObjectVersion",
"s3:GetObjectTagging",
"s3:ListBucket",
]
resources = [
"arn:aws:s3:::${local.artifacts_bucket_name}",
"arn:aws:s3:::${local.artifacts_bucket_name}/*",
]
}
statement {
sid = "RefreshDynamoDB"
effect = "Allow"
actions = [
"dynamodb:DescribeTable",
"dynamodb:DescribeTimeToLive",
"dynamodb:DescribeContinuousBackups",
"dynamodb:DescribeKinesisStreamingDestination",
"dynamodb:ListTagsOfResource",
]
resources = [
"arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}",
]
}
statement {
sid = "RefreshEventBridge"
effect = "Allow"
actions = [
"events:DescribeRule",
"events:ListTargetsByRule",
"events:ListTagsForResource",
]
resources = [
"arn:aws:events:${var.aws_region}:${local.account_id}:rule/afterhours-shift-manager-*",
]
}
statement {
sid = "RefreshLogs"
effect = "Allow"
actions = [
"logs:DescribeLogGroups",
"logs:ListTagsForResource",
]
resources = ["*"]
}
statement {
sid = "RefreshHttpApi"
effect = "Allow"
actions = [
"apigateway:GET",
]
resources = [
"arn:aws:apigateway:${var.aws_region}::/apis",
"arn:aws:apigateway:${var.aws_region}::/apis/*",
"arn:aws:apigateway:${var.aws_region}::/tags/*",
]
}
statement {
sid = "RefreshScheduler"
effect = "Allow"
actions = [
"scheduler:GetSchedule",
"scheduler:ListTagsForResource",
]
resources = [
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*",
]
}
statement {
sid = "RefreshSchedulerList"
effect = "Allow"
actions = [
"scheduler:ListSchedules",
"scheduler:ListScheduleGroups",
"scheduler:GetScheduleGroup",
]
resources = ["*"]
}
statement {
sid = "RefreshSsm"
effect = "Allow"
actions = [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:ListTagsForResource",
]
resources = [
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*",
]
}
statement {
sid = "RefreshSsmDescribeParameters"
effect = "Allow"
actions = ["ssm:DescribeParameters"]
resources = ["*"]
}
statement {
sid = "RefreshSecrets"
effect = "Allow"
actions = [
"secretsmanager:DescribeSecret",
"secretsmanager:GetResourcePolicy",
"secretsmanager:ListSecretVersionIds",
]
resources = [
"arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*",
]
}
statement {
sid = "RefreshSecretsList"
effect = "Allow"
actions = ["secretsmanager:ListSecrets"]
resources = ["*"]
}
statement {
sid = "RefreshAlarms"
effect = "Allow"
actions = [
"cloudwatch:DescribeAlarms",
"cloudwatch:ListTagsForResource",
]
resources = ["*"]
}
statement {
sid = "RefreshSns"
effect = "Allow"
actions = [
"sns:GetTopicAttributes",
"sns:ListTagsForResource",
]
resources = [local.site_alerts_arn]
}
}
resource "aws_iam_role" "hcptf_apply" {
name = local.apply_role
assume_role_policy = data.aws_iam_policy_document.hcptf_apply_trust.json
max_session_duration = 3600
tags = {
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
}
resource "aws_iam_role" "hcptf_plan" {
name = local.plan_role
assume_role_policy = data.aws_iam_policy_document.hcptf_plan_trust.json
max_session_duration = 3600
tags = {
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
}
resource "aws_iam_role_policy" "hcptf_scoped_iam" {
name = "scoped-iam-management"
role = aws_iam_role.hcptf_apply.id
policy = data.aws_iam_policy_document.hcptf_scoped_iam.json
}
resource "aws_iam_role_policy" "hcptf_apply_services" {
name = "afterhours-shift-manager-services"
role = aws_iam_role.hcptf_apply.id
policy = data.aws_iam_policy_document.hcptf_apply_services.json
}
resource "aws_iam_role_policy" "hcptf_plan_refresh" {
name = "afterhours-shift-manager-plan-refresh"
role = aws_iam_role.hcptf_plan.id
policy = data.aws_iam_policy_document.hcptf_plan_refresh.json
}
resource "aws_iam_role_policy_attachment" "hcptf_plan_view_only" {
role = aws_iam_role.hcptf_plan.name
policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"
}
resource "aws_iam_role_policy_attachments_exclusive" "hcptf_apply" {
role_name = aws_iam_role.hcptf_apply.name
policy_arns = []
}
resource "aws_iam_role_policy_attachments_exclusive" "hcptf_plan" {
role_name = aws_iam_role.hcptf_plan.name
policy_arns = [
"arn:aws:iam::aws:policy/job-function/ViewOnlyAccess",
]
}

View file

@ -0,0 +1,101 @@
# GitHub Actions OIDC role for .github/workflows/deploy.yaml.
#
# Trust is pinned three ways: aud, sub to Environment prod (immutable subject
# form), and job_workflow_ref to deploy.yaml at refs/heads/main only. No v*
# tags until a later release ticket. A job with environment: does not present
# ref:refs/heads/main.
#
# Not a Lambda execution role: no permissions_boundary. Path /tf-managed/ so
# seahaven-hcptf-iam-management DenySelfMutation (role/githubdeploy-*) does not
# match.
data "aws_iam_policy_document" "github_deploy_assume" {
statement {
sid = "GithubDeployOidc"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.github_oidc_provider_arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = [local.github_oidc_sub]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = [
"${var.github_repo}/.github/workflows/deploy.yaml@refs/heads/${var.github_deploy_branch}",
]
}
}
}
resource "aws_iam_role" "github_deploy" {
name = local.deploy_role
path = "/tf-managed/"
description = "GitHub Actions Lambda deploy role for ${var.github_repo} Environment prod"
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
max_session_duration = 3600
}
data "aws_iam_policy_document" "github_deploy" {
statement {
sid = "ListArtifactsBucket"
effect = "Allow"
actions = [
"s3:GetBucketLocation",
"s3:ListBucket",
]
resources = [aws_s3_bucket.artifacts.arn]
}
statement {
sid = "UploadFunctionArtifacts"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:PutObject",
]
resources = ["${aws_s3_bucket.artifacts.arn}/functions/*"]
}
statement {
sid = "UpdateFunctionCode"
effect = "Allow"
actions = [
"lambda:GetFunction",
"lambda:GetFunctionConfiguration",
"lambda:UpdateFunctionCode",
]
resources = [for fn in local.functions : "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:${fn.function_name}"]
}
statement {
sid = "DeployParams"
effect = "Allow"
actions = [
"ssm:GetParameter",
]
resources = [
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/*",
]
}
}
resource "aws_iam_role_policy" "github_deploy" {
name = "afterhours-shift-manager-deploy"
role = aws_iam_role.github_deploy.id
policy = data.aws_iam_policy_document.github_deploy.json
}

281
terraform/lambda.tf Normal file
View file

@ -0,0 +1,281 @@
# Terraform owns the function skeletons (role, runtime, memory, environment).
# Code is owned by .github/workflows/deploy.yaml, which uploads
# functions/<name>/<sha>.zip and calls update-function-code. The lifecycle
# block is the seam: an app deploy is not drift, and a Terraform apply never
# rolls the code back to the bootstrap stub. GIT_SHA is written into
# shared/build_info.py at zip time, not set here.
data "aws_iam_policy_document" "lambda_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
locals {
table_arn = aws_dynamodb_table.shifts.arn
lambda_identity = {
slack_bot = [
{
sid = "DdbCrud"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
resources = [local.table_arn, "${local.table_arn}/*"]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*"]
condition = null
},
{
sid = "HolidaySchedules"
actions = ["scheduler:CreateSchedule", "scheduler:DeleteSchedule", "scheduler:GetSchedule"]
resources = ["arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*"]
condition = null
},
{
sid = "PassHolidayScheduler"
actions = ["iam:PassRole"]
resources = [local.holiday_scheduler_role_arn]
condition = {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["scheduler.amazonaws.com"]
}
},
{
sid = "InvokeHolidayRouter"
actions = ["lambda:InvokeFunction"]
resources = [local.holiday_router_arn]
condition = null
},
]
weekly_post = [
{
sid = "DdbCrud"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
resources = [local.table_arn, "${local.table_arn}/*"]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/slack-bot-token-*"]
condition = null
},
{
sid = "CheckcomponentsSend"
actions = ["sqs:SendMessage"]
resources = [var.checkcomponents_queue_arn]
condition = null
},
]
roster_sync = [
{
sid = "DdbCrud"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
resources = [local.table_arn, "${local.table_arn}/*"]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/3cx-*"]
condition = null
},
]
roster_api = [
{
sid = "DdbWrite"
actions = ["dynamodb:UpdateItem", "dynamodb:DeleteItem"]
resources = [local.table_arn]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/roster-api-token-*"]
condition = null
},
]
ring_scheduler = [
{
sid = "DdbRead"
actions = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:DescribeTable"]
resources = [local.table_arn, "${local.table_arn}/*"]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/3cx-*"]
condition = null
},
]
holiday_router = [
{
sid = "DdbCrud"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
resources = [local.table_arn, "${local.table_arn}/*"]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/3cx-*"]
condition = null
},
]
release_notifier = [
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/slack-bot-token-*"]
condition = null
},
]
}
lambda_env = {
slack_bot = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
SLACK_SIGNING_SECRET = "afterhours-shift-manager/slack-signing-secret"
SHIFT_CHANNEL = var.shift_channel
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
QUEUE_NUMBER = var.queue_number
TZ = var.timezone
HOLIDAY_ROUTER_ARN = local.holiday_router_arn
HOLIDAY_SCHEDULER_ROLE_ARN = local.holiday_scheduler_role_arn
SENTRY_DSN = var.sentry_dsn
}
weekly_post = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
SHIFT_CHANNEL = var.shift_channel
PAY_REPORT_USER = var.pay_report_user
TZ = var.timezone
CHECKCOMPONENTS_QUEUE_URL = var.checkcomponents_queue_url
SENTRY_DSN = var.sentry_dsn
}
roster_sync = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
SYNC_GROUP = "DEFAULT"
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
roster_api = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
ROSTER_API_TOKEN_SECRET = "afterhours-shift-manager/roster-api-token"
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
ring_scheduler = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
QUEUE_NUMBER = var.queue_number
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
holiday_router = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
release_notifier = {
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
SHIFT_CHANNEL = var.shift_channel
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
}
}
resource "aws_iam_role" "lambda" {
for_each = local.functions
name = each.value.role_name
path = "/tf-managed/"
description = "Lambda execution role for ${each.value.function_name}"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = aws_iam_policy.lambda_boundary.arn
}
data "aws_iam_policy_document" "lambda" {
for_each = local.functions
dynamic "statement" {
for_each = local.lambda_identity[each.key]
content {
sid = statement.value.sid
effect = "Allow"
actions = statement.value.actions
resources = statement.value.resources
dynamic "condition" {
for_each = try(statement.value.condition, null) == null ? [] : [statement.value.condition]
content {
test = condition.value.test
variable = condition.value.variable
values = condition.value.values
}
}
}
}
}
resource "aws_iam_role_policy" "lambda" {
for_each = local.functions
name = each.key
role = aws_iam_role.lambda[each.key].id
policy = data.aws_iam_policy_document.lambda[each.key].json
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
for_each = local.functions
role = aws_iam_role.lambda[each.key].name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_lambda_function" "this" {
for_each = local.functions
function_name = each.value.function_name
role = aws_iam_role.lambda[each.key].arn
handler = each.value.handler
runtime = "python3.12"
architectures = ["arm64"]
memory_size = 1024
timeout = each.value.timeout
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.bootstrap_stub.key
source_code_hash = data.archive_file.bootstrap_stub.output_base64sha256
environment {
variables = local.lambda_env[each.key]
}
lifecycle {
ignore_changes = [filename, s3_bucket, s3_key, s3_object_version, source_code_hash]
}
depends_on = [
aws_cloudwatch_log_group.lambda,
aws_iam_role_policy.lambda,
aws_iam_role_policy_attachment.lambda_basic,
]
}

View file

@ -0,0 +1,141 @@
# Per-workload Lambda permissions boundary. Created on the first (bootstrap)
# apply. The scoped apply role denies iam:CreatePolicy / CreatePolicyVersion,
# so later edits to this document need the hcptf-bootstrap window.
data "aws_iam_policy_document" "lambda_boundary" {
statement {
sid = "CloudWatchLogsWrite"
effect = "Allow"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams",
]
resources = [
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/lambda*",
]
}
statement {
sid = "CloudWatchLogsDescribe"
effect = "Allow"
actions = ["logs:DescribeLogGroups"]
resources = ["*"]
}
statement {
sid = "XRay"
effect = "Allow"
actions = [
"xray:PutTraceSegments",
"xray:PutTelemetryRecords",
]
resources = ["*"]
}
statement {
sid = "Ec2Eni"
effect = "Allow"
actions = [
"ec2:CreateNetworkInterface",
"ec2:DescribeNetworkInterfaces",
"ec2:DeleteNetworkInterface",
"ec2:DescribeSubnets",
"ec2:DescribeSecurityGroups",
"ec2:DescribeVpcs",
]
resources = ["*"]
}
statement {
sid = "AfterhoursSecrets"
effect = "Allow"
actions = [
"secretsmanager:GetSecretValue",
]
resources = [
"arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*",
]
}
statement {
sid = "AfterhoursDynamoDB"
effect = "Allow"
actions = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query",
"dynamodb:Scan",
"dynamodb:BatchGetItem",
"dynamodb:BatchWriteItem",
"dynamodb:DescribeTable",
"dynamodb:ConditionCheckItem",
]
resources = [
"arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}",
"arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}/*",
]
}
statement {
sid = "AfterhoursScheduler"
effect = "Allow"
actions = [
"scheduler:CreateSchedule",
"scheduler:DeleteSchedule",
"scheduler:GetSchedule",
]
resources = [
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*",
]
}
statement {
sid = "AfterhoursPassRoleScheduler"
effect = "Allow"
actions = [
"iam:PassRole",
]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/afterhours-shift-manager-holiday-scheduler",
]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["scheduler.amazonaws.com"]
}
}
statement {
sid = "AfterhoursInvokeHolidayRouter"
effect = "Allow"
actions = [
"lambda:InvokeFunction",
]
resources = [
"arn:aws:lambda:${var.aws_region}:${local.account_id}:function:afterhours-holiday-router",
]
}
statement {
sid = "AfterhoursCheckcomponentsSend"
effect = "Allow"
actions = [
"sqs:SendMessage",
]
resources = [
var.checkcomponents_queue_arn,
]
}
}
resource "aws_iam_policy" "lambda_boundary" {
name = "afterhours-shift-manager-lambda-boundary"
path = "/tf-managed/"
description = "Per-workload Lambda permissions boundary for afterhours-shift-manager (PLAT-74)."
policy = data.aws_iam_policy_document.lambda_boundary.json
}

87
terraform/locals.tf Normal file
View file

@ -0,0 +1,87 @@
locals {
project = "afterhours-shift-manager"
account_id = "011934824531"
environment = "prod"
hcp_project = "seahaven-prod"
hcp_workspace = "afterhours-shift-manager-prod"
apply_role = "hcptf-afterhours-shift-manager"
plan_role = "hcptf-afterhours-shift-manager-plan"
deploy_role = "githubdeploy-afterhours-shift-manager"
stack_name = local.project
stack_prefix = "afterhours-shift-manager-"
artifacts_bucket_name = "afterhours-shift-manager-artifacts-${local.account_id}"
ssm_prefix = "/afterhours-shift-manager"
table_name = "afterhours-shifts"
site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts"
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
# Org has Actions OIDC use_immutable_subject=true.
github_oidc_sub = "repo:Sea-Haven-Industries@183236204/afterhours-shift-manager@1200846448:environment:prod"
secret_names = [
"afterhours-shift-manager/slack-bot-token",
"afterhours-shift-manager/slack-signing-secret",
"afterhours-shift-manager/3cx-domain",
"afterhours-shift-manager/3cx-client-id",
"afterhours-shift-manager/3cx-client-secret",
"afterhours-shift-manager/roster-api-token",
]
holiday_router_arn = "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:afterhours-holiday-router"
holiday_scheduler_role_name = "afterhours-shift-manager-holiday-scheduler"
holiday_scheduler_role_arn = "arn:aws:iam::${local.account_id}:role/tf-managed/${local.holiday_scheduler_role_name}"
functions = {
slack_bot = {
function_name = "afterhours-shift-manager"
role_name = "afterhours-shift-manager-slack-bot"
handler = "handler.handler"
timeout = 30
duration_ms = 24000
}
weekly_post = {
function_name = "afterhours-weekly-post"
role_name = "afterhours-shift-manager-weekly-post"
handler = "app.handler"
timeout = 30
duration_ms = 24000
}
roster_sync = {
function_name = "afterhours-roster-sync"
role_name = "afterhours-shift-manager-roster-sync"
handler = "app.handler"
timeout = 60
duration_ms = 48000
}
roster_api = {
function_name = "afterhours-roster-api"
role_name = "afterhours-shift-manager-roster-api"
handler = "app.handler"
timeout = 30
duration_ms = 24000
}
ring_scheduler = {
function_name = "afterhours-ring-scheduler"
role_name = "afterhours-shift-manager-ring-scheduler"
handler = "app.handler"
timeout = 60
duration_ms = 48000
}
holiday_router = {
function_name = "afterhours-holiday-router"
role_name = "afterhours-shift-manager-holiday-router"
handler = "app.handler"
timeout = 60
duration_ms = 48000
}
release_notifier = {
function_name = "afterhours-release-notifier"
role_name = "afterhours-shift-manager-release-notifier"
handler = "app.handler"
timeout = 30
duration_ms = 24000
}
}
}

11
terraform/logs.tf Normal file
View file

@ -0,0 +1,11 @@
resource "aws_cloudwatch_log_group" "lambda" {
for_each = local.functions
name = "/aws/lambda/${each.value.function_name}"
retention_in_days = 60
}
resource "aws_cloudwatch_log_group" "api_access" {
name = "/aws/apigateway/${local.project}"
retention_in_days = 90
}

39
terraform/outputs.tf Normal file
View file

@ -0,0 +1,39 @@
output "slack_request_url" {
description = "Slack app Request URL (slash command and interactivity)."
value = "${aws_apigatewayv2_api.http.api_endpoint}/slack/events"
}
output "api_origin" {
description = "HTTP API origin for Paychex AFTERHOURS_BASE_URL. No /roster suffix."
value = aws_apigatewayv2_api.http.api_endpoint
}
output "shift_table_name" {
description = "DynamoDB table name."
value = aws_dynamodb_table.shifts.name
}
output "holiday_scheduler_role_arn" {
description = "Role EventBridge Scheduler assumes to invoke the holiday router."
value = aws_iam_role.holiday_scheduler.arn
}
output "github_deploy_role_arn" {
description = "OIDC role ARN for .github/workflows/deploy.yaml (GitHub Environment prod variable DEPLOY_ROLE_ARN)."
value = aws_iam_role.github_deploy.arn
}
output "artifacts_bucket_name" {
description = "Lambda artifacts bucket. deploy.yaml uploads functions/<name>/<sha>.zip."
value = aws_s3_bucket.artifacts.id
}
output "hcptf_apply_role_arn" {
description = "HCP apply role ARN. Set TFC_AWS_APPLY_ROLE_ARN after the bootstrap window."
value = aws_iam_role.hcptf_apply.arn
}
output "hcptf_plan_role_arn" {
description = "HCP plan role ARN. Set TFC_AWS_PLAN_ROLE_ARN after the bootstrap window."
value = aws_iam_role.hcptf_plan.arn
}

12
terraform/providers.tf Normal file
View file

@ -0,0 +1,12 @@
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = local.project
Environment = "prod"
ManagedBy = "terraform"
Workspace = local.hcp_workspace
}
}
}

50
terraform/scheduler.tf Normal file
View file

@ -0,0 +1,50 @@
# EventBridge Scheduler execution role. slack-bot creates one-off holiday-*
# schedules at runtime; Terraform does not create those schedules.
data "aws_iam_policy_document" "holiday_scheduler_assume" {
statement {
sid = "SchedulerAssume"
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["scheduler.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [local.account_id]
}
condition {
test = "ArnLike"
variable = "aws:SourceArn"
values = ["arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*"]
}
}
}
resource "aws_iam_role" "holiday_scheduler" {
name = local.holiday_scheduler_role_name
path = "/tf-managed/"
description = "EventBridge Scheduler assumes this role to invoke afterhours-holiday-router"
assume_role_policy = data.aws_iam_policy_document.holiday_scheduler_assume.json
permissions_boundary = aws_iam_policy.lambda_boundary.arn
}
data "aws_iam_policy_document" "holiday_scheduler" {
statement {
sid = "InvokeHolidayRouter"
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [local.holiday_router_arn]
}
}
resource "aws_iam_role_policy" "holiday_scheduler" {
name = "invoke-holiday-router"
role = aws_iam_role.holiday_scheduler.id
policy = data.aws_iam_policy_document.holiday_scheduler.json
}

15
terraform/secrets.tf Normal file
View file

@ -0,0 +1,15 @@
# Secret shells only. Values are set outside Terraform. 3CX secrets may already
# exist in prod from seahaven-door-unlock-api (PLAT-76); import those names
# rather than recreating:
# terraform import 'aws_secretsmanager_secret.this["afterhours-shift-manager/3cx-domain"]' afterhours-shift-manager/3cx-domain
resource "aws_secretsmanager_secret" "this" {
for_each = toset(local.secret_names)
name = each.value
recovery_window_in_days = 30
tags = {
Purpose = "afterhours-shift-manager secret shell"
}
}

15
terraform/ssm.tf Normal file
View file

@ -0,0 +1,15 @@
resource "aws_ssm_parameter" "deploy_artifacts_bucket" {
name = "${local.ssm_prefix}/deploy/artifacts-bucket"
type = "String"
value = aws_s3_bucket.artifacts.id
description = "Lambda artifacts bucket; deploy.yaml uploads functions/<name>/<sha>.zip"
}
resource "aws_ssm_parameter" "deploy_function_name" {
for_each = local.functions
name = "${local.ssm_prefix}/deploy/${each.key}-function-name"
type = "String"
value = each.value.function_name
description = "Lambda function name for ${each.key}; deploy.yaml calls update-function-code"
}

66
terraform/variables.tf Normal file
View file

@ -0,0 +1,66 @@
variable "aws_region" {
description = "Region every resource in this configuration is created in."
type = string
default = "us-east-1"
}
variable "shift_channel" {
description = "Slack channel ID for schedule posts and shift notifications. Not a secret."
type = string
default = "C0APATP612N"
}
variable "queue_number" {
description = "3CX queue extension number the ring scheduler updates."
type = string
default = "801"
}
variable "timezone" {
description = "IANA timezone for schedule math and EventBridge cron comments."
type = string
default = "America/New_York"
}
variable "pay_report_user" {
description = "Slack user ID that receives the weekly pay DM."
type = string
default = "U0A3SC48T47"
}
variable "sentry_dsn" {
description = "Sentry DSN. Empty disables the SDK. Set in HCP, never in git."
type = string
sensitive = true
default = ""
}
variable "schedules_enabled" {
description = "When false, EventBridge rules exist but do not fire. Keep false until Slack and Paychex point at this stack."
type = bool
default = false
}
variable "github_repo" {
description = "GitHub owner/name for the deploy OIDC trust."
type = string
default = "Sea-Haven-Industries/afterhours-shift-manager"
}
variable "github_deploy_branch" {
description = "Git branch pinned in job_workflow_ref for the deploy role."
type = string
default = "main"
}
variable "checkcomponents_queue_url" {
description = "paychex-checkcomponents SQS URL. Empty skips the weekly SendMessage."
type = string
default = "https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents"
}
variable "checkcomponents_queue_arn" {
description = "paychex-checkcomponents SQS ARN for WeeklyPost SendMessage."
type = string
default = "arn:aws:sqs:us-east-1:011934824531:paychex-checkcomponents"
}

22
terraform/versions.tf Normal file
View file

@ -0,0 +1,22 @@
terraform {
required_version = ">= 1.14.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.64"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.8"
}
}
cloud {
organization = "seahaven"
workspaces {
name = "afterhours-shift-manager-prod"
}
}
}

View file

@ -0,0 +1,92 @@
"""Contracts for the HCP Terraform seam (PLAT-74)."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TERRAFORM = ROOT / "terraform"
LAMBDA_TF = (TERRAFORM / "lambda.tf").read_text()
HCP_IAM = (TERRAFORM / "hcp_iam.tf").read_text()
DEPLOY = (ROOT / ".github" / "workflows" / "deploy.yaml").read_text()
CI = (ROOT / ".github" / "workflows" / "ci.yaml").read_text()
LOCALS = (TERRAFORM / "locals.tf").read_text()
def test_sam_template_removed():
assert not (ROOT / "template.yaml").exists()
assert not (ROOT / "samconfig.toml.example").exists()
def test_lambda_ignore_changes_includes_code_attributes():
for attr in (
"filename",
"s3_bucket",
"s3_key",
"s3_object_version",
"source_code_hash",
):
assert attr in LAMBDA_TF
assert "lifecycle" in LAMBDA_TF
assert "ignore_changes" in LAMBDA_TF
def test_schedules_disabled_by_default():
chunk = (TERRAFORM / "variables.tf").read_text().split('variable "schedules_enabled"')[1]
chunk = chunk.split("variable ")[0]
assert "default = false" in chunk or "default = false" in chunk
def test_prod_only_workspace():
versions = (TERRAFORM / "versions.tf").read_text()
assert "afterhours-shift-manager-prod" in versions
assert "afterhours-shift-manager-dev" not in versions
assert 'environment = "prod"' in LOCALS
assert "seahaven-dev" not in LOCALS
def test_in_repo_hcptf_roles():
assert 'apply_role = "hcptf-afterhours-shift-manager"' in LOCALS
assert 'plan_role = "hcptf-afterhours-shift-manager-plan"' in LOCALS
assert "hcptf_apply" in HCP_IAM
assert "DenyCreatePolicy" in HCP_IAM
def test_deploy_workflow_is_prod_zip_cd():
assert "release: published" not in DEPLOY
assert "cd-sam" not in DEPLOY
assert "environment: prod" in DEPLOY
assert "deploy-afterhours-prod" in DEPLOY
assert "gh release create" not in DEPLOY
assert "package_lambdas.py" in DEPLOY
assert "update-function-code" in DEPLOY
def test_ci_runs_pytest_and_terraform_validate():
assert "ci-python-sam" not in CI
assert "pytest" in CI
assert "terraform fmt -check" in CI
assert "terraform init -backend=false" in CI
assert "terraform validate" in CI
def test_seven_functions_named():
for name in (
"afterhours-shift-manager",
"afterhours-weekly-post",
"afterhours-roster-sync",
"afterhours-roster-api",
"afterhours-ring-scheduler",
"afterhours-holiday-router",
"afterhours-release-notifier",
):
assert name in LOCALS
def test_weekly_post_role_is_tf_managed_name():
assert 'role_name = "afterhours-shift-manager-weekly-post"' in LOCALS
def test_github_deploy_trust_is_main_only():
iam = (TERRAFORM / "iam_github_deploy.tf").read_text()
assert "refs/heads/${var.github_deploy_branch}" in iam
assert "refs/tags/v*" not in iam
assert "environment:prod" in iam or "environment:prod" in LOCALS

View file

@ -1,6 +1,5 @@
# Test-only dependencies. The CI reusable workflow (ci-python-sam.yaml) installs
# every requirements.txt it finds when run-tests is true, so this file is picked
# up automatically alongside each Lambda's runtime requirements.
# Test-only dependencies. CI's pytest job installs this file plus the runtime
# requirements.txt files the imports need.
pytest>=9.1.1
moto[dynamodb,ses,secretsmanager]>=5.2.2
responses>=0.26.2

View file

@ -0,0 +1,64 @@
"""copy_dynamodb retries UnprocessedItems instead of counting them as written."""
import importlib.util
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _load():
spec = importlib.util.spec_from_file_location(
"copy_dynamodb", ROOT / "scripts" / "cutover" / "copy_dynamodb.py"
)
mod = importlib.util.module_from_spec(spec)
sys.modules["copy_dynamodb"] = mod
spec.loader.exec_module(mod)
return mod
mod = _load()
class FakeDdb:
def __init__(self, unprocessed_first=None):
self.calls = []
self.unprocessed_first = list(unprocessed_first or [])
self._first = True
def batch_write_item(self, RequestItems):
batch = RequestItems[mod.TABLE]
self.calls.append(batch)
if self._first and self.unprocessed_first:
self._first = False
leftover = [req for req in batch if req in self.unprocessed_first]
return {"UnprocessedItems": {mod.TABLE: leftover} if leftover else {}}
return {"UnprocessedItems": {}}
def test_batch_write_retries_unprocessed_items():
items = [{"PK": {"S": "a"}}, {"PK": {"S": "b"}}]
first = [{"PutRequest": {"Item": items[1]}}]
client = FakeDdb(unprocessed_first=first)
written = mod._batch_write_all(client, items, sleep=lambda _s: None)
assert written == 2
assert len(client.calls) == 2
assert client.calls[1] == first
def test_batch_write_raises_if_unprocessed_remain():
items = [{"PK": {"S": "a"}}]
stuck = [{"PutRequest": {"Item": items[0]}}]
client = FakeDdb(unprocessed_first=stuck)
client._always = True
def always_unprocessed(RequestItems):
client.calls.append(RequestItems[mod.TABLE])
return {"UnprocessedItems": {mod.TABLE: stuck}}
client.batch_write_item = always_unprocessed
try:
mod._batch_write_all(client, items, sleep=lambda _s: None, max_attempts=3)
raise AssertionError("expected RuntimeError")
except RuntimeError as exc:
assert "unprocessed" in str(exc)

View file

@ -0,0 +1,99 @@
"""copy_secrets.py writes Slack tokens into empty Terraform shells."""
import importlib.util
import sys
from pathlib import Path
from botocore.exceptions import ClientError
ROOT = Path(__file__).resolve().parents[2]
def _load():
spec = importlib.util.spec_from_file_location(
"copy_secrets", ROOT / "scripts" / "cutover" / "copy_secrets.py"
)
mod = importlib.util.module_from_spec(spec)
sys.modules["copy_secrets"] = mod
spec.loader.exec_module(mod)
return mod
mod = _load()
def _client_error(code: str) -> ClientError:
return ClientError({"Error": {"Code": code, "Message": code}}, "GetSecretValue")
class FakeSecrets:
def __init__(self, described, strings=None, get_errors=None):
self.described = set(described)
self.strings = dict(strings or {})
self.get_errors = dict(get_errors or {})
self.puts = []
def describe_secret(self, SecretId):
if SecretId not in self.described:
raise _client_error("ResourceNotFoundException")
return {"Name": SecretId}
def get_secret_value(self, SecretId):
if SecretId in self.get_errors:
raise _client_error(self.get_errors[SecretId])
if SecretId not in self.strings:
raise _client_error("ResourceNotFoundException")
return {"SecretString": self.strings[SecretId]}
def put_secret_value(self, SecretId, SecretString):
self.puts.append((SecretId, SecretString))
self.strings[SecretId] = SecretString
return {}
def test_execute_puts_into_empty_terraform_shells():
src = FakeSecrets(
described=mod.COPY,
strings={name: f"{name}-value\n" for name in mod.COPY},
)
dst = FakeSecrets(
described=mod.COPY + mod.VERIFY_ONLY,
strings={name: "already-copied" for name in mod.VERIFY_ONLY},
get_errors={name: "InvalidRequestException" for name in mod.COPY},
)
rc = mod.copy_secrets(src, dst, execute=True)
assert rc == 0
assert [name for name, _ in dst.puts] == list(mod.COPY)
assert all(value.endswith("-value") and not value.endswith("\n") for _, value in dst.puts)
def test_skip_populated_copy_targets_and_never_write_3cx():
src = FakeSecrets(
described=mod.COPY,
strings={name: "from-mgmt" for name in mod.COPY},
)
dst = FakeSecrets(
described=mod.COPY + mod.VERIFY_ONLY,
strings={
**{name: "prod-already" for name in mod.COPY},
**{name: "3cx-prod" for name in mod.VERIFY_ONLY},
},
)
rc = mod.copy_secrets(src, dst, execute=True)
assert rc == 0
assert dst.puts == []
def test_dry_run_does_not_put():
src = FakeSecrets(
described=mod.COPY,
strings={name: "from-mgmt" for name in mod.COPY},
)
dst = FakeSecrets(
described=mod.COPY + mod.VERIFY_ONLY,
strings={name: "3cx-prod" for name in mod.VERIFY_ONLY},
get_errors={name: "InvalidRequestException" for name in mod.COPY},
)
rc = mod.copy_secrets(src, dst, execute=False)
assert rc == 0
assert dst.puts == []

View file

@ -0,0 +1,54 @@
"""package_lambdas.py zip layout without a full pip install."""
import importlib.util
import sys
import zipfile
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
def _load():
spec = importlib.util.spec_from_file_location(
"package_lambdas", ROOT / "scripts" / "package_lambdas.py"
)
mod = importlib.util.module_from_spec(spec)
sys.modules["package_lambdas"] = mod
spec.loader.exec_module(mod)
return mod
pkg = _load()
def test_function_keys_match_terraform_locals():
locals_tf = (ROOT / "terraform" / "locals.tf").read_text()
for key in pkg.FUNCTIONS:
assert f" {key} =" in locals_tf
for src in pkg.FUNCTIONS.values():
assert src.is_dir()
assert (src / "requirements.txt").is_file()
def test_build_function_bundles_shared_and_git_sha(tmp_path, monkeypatch):
monkeypatch.setattr(pkg, "_req_lines", lambda path: [])
def fake_run(cmd, check):
raise AssertionError(f"pip should not run when reqs are empty: {cmd}")
with patch.object(pkg.subprocess, "run", fake_run):
zip_path = pkg.build_function(
"weekly_post",
pkg.FUNCTIONS["weekly_post"],
"deadbeef",
tmp_path,
)
assert zip_path.is_file()
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
assert "app.py" in names
assert "shared/sentry_init.py" in names
assert "shared/build_info.py" in names
assert 'GIT_SHA = "deadbeef"' in zf.read("shared/build_info.py").decode()
assert "requirements.txt" not in names

View file

@ -0,0 +1,57 @@
"""recreate_holiday_schedules skips past at() expressions, not only EndDate."""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import importlib.util
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _load():
spec = importlib.util.spec_from_file_location(
"recreate_holiday_schedules",
ROOT / "scripts" / "cutover" / "recreate_holiday_schedules.py",
)
mod = importlib.util.module_from_spec(spec)
sys.modules["recreate_holiday_schedules"] = mod
spec.loader.exec_module(mod)
return mod
mod = _load()
def test_schedule_when_parses_at_expression_in_eastern():
detail = {
"ScheduleExpression": "at(2026-07-04T08:00:00)",
"ScheduleExpressionTimezone": "America/New_York",
}
when = mod.schedule_when(detail)
expected = datetime(2026, 7, 4, 8, 0, 0, tzinfo=ZoneInfo("America/New_York")).astimezone(
timezone.utc
)
assert when == expected
def test_past_at_expression_is_before_now_without_end_date():
detail = {
"ScheduleExpression": "at(2020-01-01T08:00:00)",
"ScheduleExpressionTimezone": "America/New_York",
}
when = mod.schedule_when(detail)
assert when is not None
assert when < datetime.now(timezone.utc)
assert "EndDate" not in detail
assert "StartDate" not in detail
def test_future_at_expression_is_kept():
detail = {
"ScheduleExpression": "at(2099-12-25T17:00:00)",
"ScheduleExpressionTimezone": "America/New_York",
}
when = mod.schedule_when(detail)
assert when is not None
assert when > datetime.now(timezone.utc)

View file

@ -1,6 +1,8 @@
"""sentry_init: DSN no-op, init options, and before_send scrub."""
import importlib
import sys
from types import ModuleType
from unittest.mock import patch
from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration
@ -42,6 +44,18 @@ def test_set_dsn_inits_lambda_integration(monkeypatch):
assert len(integrations) == 1
assert isinstance(integrations[0], AwsLambdaIntegration)
assert integrations[0].timeout_warning is True
assert "release" not in kwargs
def test_build_info_sha_sets_sentry_release(monkeypatch):
monkeypatch.setenv("SENTRY_DSN", "https://key@o1.ingest.sentry.io/1")
fake = ModuleType("shared.build_info")
fake.GIT_SHA = "abc123def"
monkeypatch.setitem(sys.modules, "shared.build_info", fake)
with patch("sentry_sdk.init") as mocked:
importlib.reload(sentry_mod)
kwargs = mocked.call_args.kwargs
assert kwargs["release"] == "abc123def"
def test_before_send_strips_auth_and_sigv4_headers():

View file

@ -236,9 +236,9 @@ def test_payload_skips_fallback_and_zero(weeklypost_app):
def test_weekly_post_has_no_payroll_email_path():
root = Path(__file__).resolve().parents[2]
template = (root / "template.yaml").read_text()
tf_text = "".join(p.read_text() for p in (root / "terraform").glob("*.tf"))
for token in ("PAYROLL_RECIPIENTS", "SES_SENDER", "ses:", "PayrollEmailFailure"):
assert token not in template, token
assert token not in tf_text, token
source = (root / "src" / "weekly-post" / "app.py").read_text()
for token in ("_send_pay_email", "_build_pay_email_html", 'boto3.client("ses")'):
assert token not in source, token