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.
This commit is contained in:
Adam Moussa 2026-09-15 19:19:25 -04:00
parent 8f0ab60974
commit af5e2183e0
No known key found for this signature in database
36 changed files with 3015 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:
pytest:
name: Pytest
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
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:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
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
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

5
.gitignore vendored
View file

@ -7,4 +7,7 @@ venv/
.env
samconfig.toml
output.json
.idea/
.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,80 @@
#!/usr/bin/env python3
"""Copy afterhours-shifts from mgmt to prod. Dry-run unless --execute."""
from __future__ import annotations
import argparse
import sys
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):
items = []
kwargs = {"TableName": TABLE}
while True:
resp = client.scan(**kwargs)
items.extend(resp.get("Items", []))
start = resp.get("LastEvaluatedKey")
if not start:
return items
kwargs["ExclusiveStartKey"] = start
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)
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 = 0
batch = []
for item in items:
batch.append({"PutRequest": {"Item": item}})
if len(batch) == 25:
dst.batch_write_item(RequestItems={TABLE: batch})
written += len(batch)
batch = []
if batch:
dst.batch_write_item(RequestItems={TABLE: batch})
written += len(batch)
after = _scan_all(dst)
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,114 @@
#!/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 sys
from datetime import datetime, timezone
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-")
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 _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
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")
at = detail.get("EndDate") or detail.get("StartDate")
if at is not None and at < now:
print(f"skip past {name}")
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:
if exc.response["Error"]["Code"] == "ConflictException":
print(f"exists {name}")
else:
raise
print(f"created={created} skipped_past={skipped} execute={args.execute}")
if not args.execute:
print("dry-run; pass --execute to CreateSchedule")
return 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,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

@ -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