mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 04:33:10 +00:00
feat(api): collapse Slack, portal, and jobs onto Fargate (PLAT-216) (#259)
* feat(api): collapse Slack, portal, and jobs onto Fargate (PLAT-216) Move HTTP and scheduled work onto one always-on Flask task so after-hours loses Lambda cold start without changing the Cognito or roster contracts. * fix(portal-api): keep CORS headers on unexpected 500s Portal SPA error handling needs Access-Control-Allow-Origin even when DynamoDB or other internals fail, otherwise the browser hides the 500. * fix(api): retarget holidays per account and ship App Home changelog (PLAT-216) * fix(iam): list ECS tasks and fail closed on non-prod Paychex (PLAT-216) * fix(portal-api): serve portal JSON with an explicit JSON content type
This commit is contained in:
parent
969ebf90de
commit
26adb8e6c0
55 changed files with 3036 additions and 668 deletions
12
.dockerignore
Normal file
12
.dockerignore
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.venv
|
||||||
|
.cursor
|
||||||
|
terraform
|
||||||
|
tests
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
!src/slack-bot/CHANGELOG.md
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
1
.github/workflows/ci.yaml
vendored
1
.github/workflows/ci.yaml
vendored
|
|
@ -31,6 +31,7 @@ jobs:
|
||||||
pip install -r src/weekly-post/requirements.txt
|
pip install -r src/weekly-post/requirements.txt
|
||||||
pip install -r src/shared/requirements.txt
|
pip install -r src/shared/requirements.txt
|
||||||
pip install -r src/portal-api/requirements.txt
|
pip install -r src/portal-api/requirements.txt
|
||||||
|
pip install -r requirements-api.txt
|
||||||
|
|
||||||
- name: Pytest
|
- name: Pytest
|
||||||
run: pytest
|
run: pytest
|
||||||
|
|
|
||||||
247
.github/workflows/deploy-api.yaml
vendored
Normal file
247
.github/workflows/deploy-api.yaml
vendored
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
name: Deploy API
|
||||||
|
|
||||||
|
# Fargate image CD (PLAT-216). GitHub Actions builds the Flask image, pushes
|
||||||
|
# to ECR, and registers a new task definition. Terraform owns the cluster,
|
||||||
|
# service, ALB, and ignores container_definitions / task_definition.
|
||||||
|
#
|
||||||
|
# push to main -> dev, at github.sha
|
||||||
|
# release: published -> prod, at the release tag
|
||||||
|
# workflow_dispatch -> chosen environment at a chosen ref
|
||||||
|
#
|
||||||
|
# Releases are cut by a human with `gh release create vX.Y.Z --target main`.
|
||||||
|
# Nothing here creates an HCP run. Zip CD stays in deploy.yaml until cutover.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "terraform/**"
|
||||||
|
- "docs/**"
|
||||||
|
- "*.md"
|
||||||
|
- ".github/workflows/deploy.yaml"
|
||||||
|
- ".github/workflows/ci.yaml"
|
||||||
|
- ".github/workflows/changelog-guard.yml"
|
||||||
|
- ".github/workflows/labeler.yml"
|
||||||
|
- ".github/workflows/dependency-review.yml"
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Target Environment"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options: [dev, prod]
|
||||||
|
ref:
|
||||||
|
description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref."
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
target:
|
||||||
|
name: Resolve target
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
environment: ${{ steps.resolve.outputs.environment }}
|
||||||
|
ref: ${{ steps.resolve.outputs.ref }}
|
||||||
|
steps:
|
||||||
|
- id: resolve
|
||||||
|
env:
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
GITHUB_REF_NAME_IN: ${{ github.ref }}
|
||||||
|
GITHUB_SHA_IN: ${{ github.sha }}
|
||||||
|
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
INPUT_ENVIRONMENT: ${{ inputs.environment }}
|
||||||
|
INPUT_REF: ${{ inputs.ref }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
case "${EVENT_NAME}" in
|
||||||
|
push)
|
||||||
|
if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
|
||||||
|
echo "push deploys only run from main" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
environment=dev
|
||||||
|
ref="${GITHUB_SHA_IN}"
|
||||||
|
;;
|
||||||
|
release)
|
||||||
|
environment=prod
|
||||||
|
ref="${RELEASE_TAG}"
|
||||||
|
status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)"
|
||||||
|
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
|
||||||
|
echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
workflow_dispatch)
|
||||||
|
environment="${INPUT_ENVIRONMENT}"
|
||||||
|
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unsupported event ${EVENT_NAME}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
{
|
||||||
|
echo "environment=${environment}"
|
||||||
|
echo "ref=${ref}"
|
||||||
|
} >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "Deploying ${ref} to ${environment}"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy API to ${{ needs.target.outputs.environment }}
|
||||||
|
needs: target
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
environment: ${{ needs.target.outputs.environment }}
|
||||||
|
concurrency:
|
||||||
|
group: deploy-api-${{ needs.target.outputs.environment }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
permissions:
|
||||||
|
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:
|
||||||
|
ref: ${{ needs.target.outputs.ref }}
|
||||||
|
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}"
|
||||||
|
|
||||||
|
- name: Configure AWS credentials using OIDC
|
||||||
|
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
|
||||||
|
with:
|
||||||
|
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
||||||
|
aws-region: us-east-1
|
||||||
|
audience: sts.amazonaws.com
|
||||||
|
|
||||||
|
- name: Get deploy parameters
|
||||||
|
id: deploy
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
get_param() {
|
||||||
|
aws ssm get-parameter --name "$1" --query Parameter.Value --output text
|
||||||
|
}
|
||||||
|
CLUSTER=$(get_param /afterhours-shift-manager/deploy/cluster)
|
||||||
|
SERVICE=$(get_param /afterhours-shift-manager/deploy/service)
|
||||||
|
FAMILY=$(get_param /afterhours-shift-manager/deploy/task-family)
|
||||||
|
ECR=$(get_param /afterhours-shift-manager/deploy/ecr-repository)
|
||||||
|
CONTAINER=$(get_param /afterhours-shift-manager/deploy/container-name)
|
||||||
|
API_URL=$(get_param /afterhours-shift-manager/deploy/api-url)
|
||||||
|
{
|
||||||
|
echo "cluster=${CLUSTER}"
|
||||||
|
echo "service=${SERVICE}"
|
||||||
|
echo "family=${FAMILY}"
|
||||||
|
echo "ecr=${ECR}"
|
||||||
|
echo "container=${CONTAINER}"
|
||||||
|
echo "api_url=${API_URL}"
|
||||||
|
} >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||||
|
with:
|
||||||
|
platforms: arm64
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
|
||||||
|
- name: Login to Amazon ECR
|
||||||
|
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
env:
|
||||||
|
ECR: ${{ steps.deploy.outputs.ecr }}
|
||||||
|
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||||
|
ENVIRONMENT: ${{ needs.target.outputs.environment }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--build-arg "GIT_SHA=${GIT_SHA}" \
|
||||||
|
-t "${ECR}:${GIT_SHA}" \
|
||||||
|
-t "${ECR}:${ENVIRONMENT}" \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Register task definition and update service
|
||||||
|
env:
|
||||||
|
CLUSTER: ${{ steps.deploy.outputs.cluster }}
|
||||||
|
SERVICE: ${{ steps.deploy.outputs.service }}
|
||||||
|
FAMILY: ${{ steps.deploy.outputs.family }}
|
||||||
|
CONTAINER: ${{ steps.deploy.outputs.container }}
|
||||||
|
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
|
||||||
|
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
aws ecs describe-task-definition \
|
||||||
|
--task-definition "${FAMILY}" \
|
||||||
|
--query taskDefinition \
|
||||||
|
--output json \
|
||||||
|
| python3 -c '
|
||||||
|
import json, os, sys
|
||||||
|
td = json.load(sys.stdin)
|
||||||
|
for key in (
|
||||||
|
"taskDefinitionArn",
|
||||||
|
"revision",
|
||||||
|
"status",
|
||||||
|
"requiresAttributes",
|
||||||
|
"compatibilities",
|
||||||
|
"registeredAt",
|
||||||
|
"registeredBy",
|
||||||
|
"deregisteredAt",
|
||||||
|
):
|
||||||
|
td.pop(key, None)
|
||||||
|
image = os.environ["IMAGE"]
|
||||||
|
sha = os.environ["GIT_SHA"]
|
||||||
|
name = os.environ["CONTAINER"]
|
||||||
|
for container in td["containerDefinitions"]:
|
||||||
|
if container["name"] != name:
|
||||||
|
continue
|
||||||
|
container["image"] = image
|
||||||
|
env = {item["name"]: item["value"] for item in container.get("environment", [])}
|
||||||
|
env["GIT_SHA"] = sha
|
||||||
|
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
|
||||||
|
container.pop("command", None)
|
||||||
|
json.dump(td, sys.stdout)
|
||||||
|
' > /tmp/task-def.json
|
||||||
|
REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)"
|
||||||
|
aws ecs update-service \
|
||||||
|
--cluster "${CLUSTER}" \
|
||||||
|
--service "${SERVICE}" \
|
||||||
|
--task-definition "${FAMILY}:${REV}" \
|
||||||
|
--force-new-deployment \
|
||||||
|
>/dev/null
|
||||||
|
aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}"
|
||||||
|
|
||||||
|
- name: Verify health SHA
|
||||||
|
env:
|
||||||
|
API_URL: ${{ steps.deploy.outputs.api_url }}
|
||||||
|
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for _ in 1 2 3 4 5 6; do
|
||||||
|
BODY="$(curl -fsS "${API_URL}/api/health" || true)"
|
||||||
|
echo "${BODY}"
|
||||||
|
if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "health SHA did not match ${EXPECTED_SHA}" >&2
|
||||||
|
exit 1
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY src/shared/requirements.txt /tmp/shared-requirements.txt
|
||||||
|
COPY src/slack-bot/requirements.txt /tmp/slack-bot-requirements.txt
|
||||||
|
COPY src/weekly-post/requirements.txt /tmp/weekly-post-requirements.txt
|
||||||
|
COPY src/portal-api/requirements.txt /tmp/portal-api-requirements.txt
|
||||||
|
COPY requirements-api.txt /tmp/requirements-api.txt
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
-r /tmp/shared-requirements.txt \
|
||||||
|
-r /tmp/slack-bot-requirements.txt \
|
||||||
|
-r /tmp/weekly-post-requirements.txt \
|
||||||
|
-r /tmp/portal-api-requirements.txt \
|
||||||
|
-r /tmp/requirements-api.txt
|
||||||
|
|
||||||
|
COPY src /app/src
|
||||||
|
|
||||||
|
ARG GIT_SHA=dev
|
||||||
|
ENV PYTHONPATH=/app/src:/app/src/shared:/app/src/slack-bot \
|
||||||
|
GIT_SHA=${GIT_SHA} \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app/src
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["python", "-m", "server.entrypoint"]
|
||||||
32
README.md
32
README.md
|
|
@ -58,10 +58,10 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Runtime**: Python 3.12 on AWS Lambda (arm64), seahaven-prod `011934824531`
|
- **Runtime**: Python 3.12 on ECS Fargate (arm64) plus dual-run Lambdas until cutover. seahaven-prod `011934824531`, seahaven-dev `710827005802`
|
||||||
- **Data**: DynamoDB single-table (`afterhours-shifts`)
|
- **Data**: DynamoDB single-table (`afterhours-shifts`)
|
||||||
- **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/`.
|
- **IaC**: HCP Terraform workspaces tagged `app:afterhours-shift-manager` (`afterhours-shift-manager-dev` / `-prod`) plus GitHub Actions `deploy.yaml` (zips) and `deploy-api.yaml` (image). Terraform does not package `src/`.
|
||||||
- **Slack**: Slack Bolt framework with `/oncall` slash command
|
- **Slack**: Slack Bolt on `POST /slack/events`
|
||||||
- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI
|
- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI
|
||||||
- **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`)
|
- **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`)
|
||||||
|
|
||||||
|
|
@ -81,7 +81,8 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
slack-bot/ Slack Bolt Lambda (handler + app); ships CHANGELOG.md for App Home
|
server/ Flask + gunicorn + SQS worker (Fargate)
|
||||||
|
slack-bot/ Slack Bolt app; Lambda handler until cutover; ships CHANGELOG.md for App Home
|
||||||
weekly-post/ Monday schedule + pay post
|
weekly-post/ Monday schedule + pay post
|
||||||
roster-sync/ Daily 3CX roster sync
|
roster-sync/ Daily 3CX roster sync
|
||||||
roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard
|
roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard
|
||||||
|
|
@ -89,10 +90,10 @@ src/
|
||||||
ring-scheduler/ 3CX queue routing updates
|
ring-scheduler/ 3CX queue routing updates
|
||||||
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
|
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
|
||||||
release-notifier/ Posts release announcements to Slack
|
release-notifier/ Posts release announcements to Slack
|
||||||
shared/ Bundled into each function zip (schedule, blocks, changelog, 3CX client, secrets)
|
shared/ Bundled into each function zip and the Fargate image
|
||||||
terraform/ HCP Terraform (function skeletons, API, DDB, IAM, schedules)
|
terraform/ HCP Terraform (Lambda skeletons, ECS/ALB, API, DDB, IAM, schedules)
|
||||||
scripts/ changelog CLI + CI guard + in-package copy sync
|
scripts/ changelog CLI + CI guard + in-package copy sync + cutover
|
||||||
tests/ pytest suite (mirrors src/, one dir per Lambda + shared)
|
tests/ pytest suite (mirrors src/, one dir per Lambda + shared + server)
|
||||||
```
|
```
|
||||||
|
|
||||||
### DynamoDB Schema
|
### DynamoDB Schema
|
||||||
|
|
@ -299,7 +300,9 @@ python -m venv .venv && source .venv/bin/activate
|
||||||
pip install -r tests/requirements.txt # test-only deps
|
pip install -r tests/requirements.txt # test-only deps
|
||||||
pip install -r src/slack-bot/requirements.txt \
|
pip install -r src/slack-bot/requirements.txt \
|
||||||
-r src/weekly-post/requirements.txt \
|
-r src/weekly-post/requirements.txt \
|
||||||
-r src/shared/requirements.txt # runtime deps the imports need
|
-r src/shared/requirements.txt \
|
||||||
|
-r src/portal-api/requirements.txt \
|
||||||
|
-r requirements-api.txt
|
||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -307,4 +310,15 @@ Each Lambda has its own `app.py`, so the per-package `conftest.py` loads each on
|
||||||
under a unique module name (importlib mode) to avoid collisions. CI runs the same
|
under a unique module name (importlib mode) to avoid collisions. CI runs the same
|
||||||
suite on every PR via pytest plus `terraform fmt` / `init -backend=false` / `validate`.
|
suite on every PR via pytest plus `terraform fmt` / `init -backend=false` / `validate`.
|
||||||
|
|
||||||
|
Local Fargate process (needs the same DynamoDB table and Secrets Manager names
|
||||||
|
the Lambdas use, plus `JOBS_QUEUE_URL` to consume jobs):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PYTHONPATH=src:src/shared:src/slack-bot
|
||||||
|
export STAGE=local GIT_SHA=dev
|
||||||
|
python -m server.entrypoint
|
||||||
|
```
|
||||||
|
|
||||||
|
Health is `GET /api/health` on port 8080.
|
||||||
|
|
||||||
See [SETUP.md](SETUP.md) for full deployment and Slack app creation instructions.
|
See [SETUP.md](SETUP.md) for full deployment and Slack app creation instructions.
|
||||||
|
|
|
||||||
46
SETUP.md
46
SETUP.md
|
|
@ -75,11 +75,15 @@ before the identity processor PUTs `/roster`.
|
||||||
|
|
||||||
## 3. HCP Terraform and GitHub Environment
|
## 3. HCP Terraform and GitHub Environment
|
||||||
|
|
||||||
Prod only. Workspace `afterhours-shift-manager-prod` in project `seahaven-prod`
|
Workspaces tagged `app:afterhours-shift-manager`:
|
||||||
(account `011934824531`). No seahaven-dev workspace.
|
`afterhours-shift-manager-prod` in `seahaven-prod` (account `011934824531`) and
|
||||||
|
`afterhours-shift-manager-dev` in `seahaven-dev` (account `710827005802`).
|
||||||
|
Create the dev workspace before any Slack URL flip. HCP variable `environment`
|
||||||
|
is `prod` or `dev`.
|
||||||
|
|
||||||
First apply uses the hcptf-bootstrap window (exact `StringEquals` trust, never
|
First apply uses the hcptf-bootstrap window (exact `StringEquals` trust, never
|
||||||
`StringLike`):
|
`StringLike`). Creating `afterhours-shift-manager-ecs-task-boundary` is
|
||||||
|
`iam:CreatePolicy` and needs that window.
|
||||||
|
|
||||||
1. Create the HCP workspace. Auto-apply off. No project-level variable set.
|
1. Create the HCP workspace. Auto-apply off. No project-level variable set.
|
||||||
Working directory `terraform`. File trigger prefix `terraform/**` only.
|
Working directory `terraform`. File trigger prefix `terraform/**` only.
|
||||||
|
|
@ -99,14 +103,21 @@ First apply uses the hcptf-bootstrap window (exact `StringEquals` trust, never
|
||||||
`--allow-workspace`.
|
`--allow-workspace`.
|
||||||
6. Second manual apply as the scoped role. Then seal auto-apply on.
|
6. Second manual apply as the scoped role. Then seal auto-apply on.
|
||||||
|
|
||||||
GitHub Environment `prod`: reviewers, branch policy `main` only, Environment
|
GitHub Environment `prod`: reviewers, branch policy `main` and `v*`, Environment
|
||||||
variable `DEPLOY_ROLE_ARN` = Terraform output `github_deploy_role_arn`.
|
variable `DEPLOY_ROLE_ARN` = Terraform output `github_deploy_role_arn`.
|
||||||
|
GitHub Environment `dev`: no reviewers, `DEPLOY_ROLE_ARN` from the seahaven-dev
|
||||||
|
apply of the same output. Set `checkcomponents_queue_url` and
|
||||||
|
`checkcomponents_queue_arn` empty on the dev workspace.
|
||||||
|
|
||||||
Function zips: Actions → Deploy on push to `main`, or `workflow_dispatch`.
|
Function zips: Actions → Deploy on push to `main`, or `workflow_dispatch`.
|
||||||
Keep `schedules_enabled=false` until Slack and Paychex point at this stack.
|
Image CD: Actions → Deploy API (`deploy-api.yaml`). Keep `schedules_enabled=false`
|
||||||
|
and `ecs_schedules_enabled=false` until the Fargate cutover below.
|
||||||
|
|
||||||
HCP outputs to copy: `slack_request_url`, `api_origin`,
|
HCP outputs to copy: `slack_request_url`, `api_origin`, `fargate_origin`,
|
||||||
`holiday_scheduler_role_arn`, `github_deploy_role_arn`.
|
`holiday_scheduler_role_arn`, `github_deploy_role_arn`, `jobs_queue_arn`,
|
||||||
|
`ecs_task_role_arn`. Add `ecs_task_role_arn` to paychex-checkcomponents
|
||||||
|
(alongside `afterhours-shift-manager-weekly-post`) before Fargate weekly_post
|
||||||
|
runs. Dual-run keeps the Lambda principal until zip CD is retired.
|
||||||
|
|
||||||
## 4. Set the Slack Request URL
|
## 4. Set the Slack Request URL
|
||||||
|
|
||||||
|
|
@ -174,6 +185,27 @@ scripts first (`--execute` is required for writes).
|
||||||
Do not dual-run 3CX writers. Do not flip `afterhours_base_url` before DynamoDB
|
Do not dual-run 3CX writers. Do not flip `afterhours_base_url` before DynamoDB
|
||||||
is copied.
|
is copied.
|
||||||
|
|
||||||
|
## 9. Fargate cutover (PLAT-216)
|
||||||
|
|
||||||
|
Dual-run ECS beside API Gateway. Do not dual-write 3CX. Do not flip Slack
|
||||||
|
without the `afterhours-shift-manager-dev` workspace already serving
|
||||||
|
`/api/health`.
|
||||||
|
|
||||||
|
1. Image deploy via `deploy-api.yaml`. `GET /api/health` reports the real sha.
|
||||||
|
2. Recreate outstanding `holiday-activate-*` / `holiday-deactivate-*` onto
|
||||||
|
jobs SQS (same class of work as `recreate_holiday_schedules.py`):
|
||||||
|
`python scripts/cutover/retarget_holiday_schedules_to_sqs.py --profile prod --queue-arn <jobs_queue_arn>`
|
||||||
|
then `--execute`.
|
||||||
|
3. Instant cut: Slack Request URL, Paychex `AFTERHOURS_BASE_URL`, portal
|
||||||
|
`VITE_SHIFTS_API_BASE` → `https://afterhours.seahaven.com` (dev hostname in
|
||||||
|
portal-dev). Smoke `/oncall`, roster PUT/DELETE, `GET /api/shifts`, one
|
||||||
|
holiday GetSchedule, ring job.
|
||||||
|
4. Set `ecs_schedules_enabled=true` and keep `schedules_enabled=false`. Confirm
|
||||||
|
no 3CX writers remain on Lambda.
|
||||||
|
5. After smoke, remove API Gateway, the eight Lambdas, zip packaging, and
|
||||||
|
Lambda alarms in a follow-up apply. ALB 5xx/latency and unhealthy-host
|
||||||
|
alarms stay.
|
||||||
|
|
||||||
## Commands Reference
|
## Commands Reference
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@
|
||||||
# `src/shared` on the path makes the `shared` layer package importable as it is at
|
# `src/shared` on the path makes the `shared` layer package importable as it is at
|
||||||
# runtime. Each Lambda's own `app.py` is loaded under a unique name by the
|
# runtime. Each Lambda's own `app.py` is loaded under a unique name by the
|
||||||
# per-package conftest (importlib mode) to avoid the four-`app.py` collision.
|
# per-package conftest (importlib mode) to avoid the four-`app.py` collision.
|
||||||
pythonpath = ["src/shared"]
|
pythonpath = ["src", "src/shared"]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "--import-mode=importlib"
|
addopts = "--import-mode=importlib"
|
||||||
|
|
|
||||||
2
requirements-api.txt
Normal file
2
requirements-api.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
flask==3.1.3
|
||||||
|
gunicorn==23.0.0
|
||||||
181
scripts/cutover/retarget_holiday_schedules_to_sqs.py
Normal file
181
scripts/cutover/retarget_holiday_schedules_to_sqs.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Retarget outstanding holiday-* Scheduler one-offs from Lambda to jobs SQS.
|
||||||
|
|
||||||
|
Lists holiday-activate-* / holiday-deactivate-* in the destination account and
|
||||||
|
updates Target to the jobs queue with Input
|
||||||
|
``{"event":"holiday","action":"activate|deactivate","date":"YYYY-MM-DD"}``.
|
||||||
|
Dry-run unless --execute. Does not create schedules that are already past.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
PROD_ACCOUNT = "011934824531"
|
||||||
|
DEV_ACCOUNT = "710827005802"
|
||||||
|
HOLIDAY_SCHEDULER_ROLE = "afterhours-shift-manager-holiday-scheduler"
|
||||||
|
PREFIXES = ("holiday-activate-", "holiday-deactivate-")
|
||||||
|
_AT = re.compile(r"^at\((\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\)$")
|
||||||
|
_DATE = re.compile(r"(\d{4}-\d{2}-\d{2})")
|
||||||
|
|
||||||
|
|
||||||
|
def _client(profile: str, region: str):
|
||||||
|
return boto3.Session(profile_name=profile, region_name=region).client("scheduler")
|
||||||
|
|
||||||
|
|
||||||
|
def _account(profile: str) -> str:
|
||||||
|
return (
|
||||||
|
boto3.Session(profile_name=profile)
|
||||||
|
.client("sts")
|
||||||
|
.get_caller_identity()["Account"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_when(detail: dict) -> datetime | None:
|
||||||
|
expr = (detail.get("ScheduleExpression") or "").strip()
|
||||||
|
tzname = detail.get("ScheduleExpressionTimezone") or "America/New_York"
|
||||||
|
match = _AT.match(expr)
|
||||||
|
if match:
|
||||||
|
naive = datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S")
|
||||||
|
return naive.replace(tzinfo=ZoneInfo(tzname)).astimezone(timezone.utc)
|
||||||
|
at = detail.get("EndDate") or detail.get("StartDate")
|
||||||
|
if at is None:
|
||||||
|
return None
|
||||||
|
if at.tzinfo is None:
|
||||||
|
return at.replace(tzinfo=timezone.utc)
|
||||||
|
return at.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def action_and_date(name: str, existing_input: str) -> tuple[str, str]:
|
||||||
|
action = "deactivate" if name.startswith("holiday-deactivate-") else "activate"
|
||||||
|
date = ""
|
||||||
|
if existing_input:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(existing_input)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed = {}
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
date = str(parsed.get("date") or "")
|
||||||
|
if parsed.get("action") in {"activate", "deactivate"}:
|
||||||
|
action = parsed["action"]
|
||||||
|
if not date:
|
||||||
|
compact = name.split("-")[-1]
|
||||||
|
if len(compact) == 8 and compact.isdigit():
|
||||||
|
date = f"{compact[0:4]}-{compact[4:6]}-{compact[6:8]}"
|
||||||
|
if not date:
|
||||||
|
match = _DATE.search(existing_input or "")
|
||||||
|
if match:
|
||||||
|
date = match.group(1)
|
||||||
|
if not date:
|
||||||
|
raise ValueError(f"cannot derive date from {name}")
|
||||||
|
return action, date
|
||||||
|
|
||||||
|
|
||||||
|
def holiday_scheduler_role_arn(account: str) -> str:
|
||||||
|
return f"arn:aws:iam::{account}:role/tf-managed/{HOLIDAY_SCHEDULER_ROLE}"
|
||||||
|
|
||||||
|
|
||||||
|
def holiday_sqs_input(action: str, date: str) -> str:
|
||||||
|
return json.dumps({"event": "holiday", "action": action, "date": date})
|
||||||
|
|
||||||
|
|
||||||
|
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("--profile", required=True)
|
||||||
|
parser.add_argument("--region", default="us-east-1")
|
||||||
|
parser.add_argument("--queue-arn", required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--role-arn",
|
||||||
|
default="",
|
||||||
|
help="Scheduler execution role. Empty uses the tf-managed holiday role in the caller account.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--execute", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
account = _account(args.profile)
|
||||||
|
if account not in {PROD_ACCOUNT, DEV_ACCOUNT}:
|
||||||
|
print("profile is not seahaven-prod or seahaven-dev", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
role_arn = args.role_arn.strip() or holiday_scheduler_role_arn(account)
|
||||||
|
|
||||||
|
client = _client(args.profile, args.region)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for name in _list_holiday(client):
|
||||||
|
detail = client.get_schedule(Name=name, GroupName="default")
|
||||||
|
expr = detail.get("ScheduleExpression", "")
|
||||||
|
when = schedule_when(detail)
|
||||||
|
if when is not None and when < now:
|
||||||
|
print(f"skip past {name} expr={expr}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
action, date = action_and_date(
|
||||||
|
name, detail.get("Target", {}).get("Input", "")
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"skip {exc}", file=sys.stderr)
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
payload = holiday_sqs_input(action, date)
|
||||||
|
print(f"would retarget {name} action={action} date={date}")
|
||||||
|
if not args.execute:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
client.update_schedule(
|
||||||
|
Name=name,
|
||||||
|
GroupName="default",
|
||||||
|
ScheduleExpression=expr,
|
||||||
|
ScheduleExpressionTimezone=detail.get(
|
||||||
|
"ScheduleExpressionTimezone", "America/New_York"
|
||||||
|
),
|
||||||
|
FlexibleTimeWindow={"Mode": "OFF"},
|
||||||
|
ActionAfterCompletion=detail.get("ActionAfterCompletion", "DELETE"),
|
||||||
|
Target={
|
||||||
|
"Arn": args.queue_arn,
|
||||||
|
"RoleArn": role_arn,
|
||||||
|
"Input": payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
except ClientError as exc:
|
||||||
|
code = exc.response["Error"]["Code"]
|
||||||
|
print(f"failed {name}: {code}", file=sys.stderr)
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print(f"updated={updated} skipped={skipped} failed={failed} execute={args.execute}")
|
||||||
|
if not args.execute:
|
||||||
|
print("dry-run; pass --execute to UpdateSchedule")
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -3,24 +3,6 @@
|
||||||
A holiday is a day-only shift (08:00-17:00 ET), one HOLIDAY record per date.
|
A holiday is a day-only shift (08:00-17:00 ET), one HOLIDAY record per date.
|
||||||
At 08:00 this Lambda is invoked with ``{"action": "activate", "date": ...}`` and
|
At 08:00 this Lambda is invoked with ``{"action": "activate", "date": ...}`` and
|
||||||
at 17:00 with ``{"action": "deactivate", ...}``.
|
at 17:00 with ``{"action": "deactivate", ...}``.
|
||||||
|
|
||||||
Activate:
|
|
||||||
* Capture both IVR 800 routes (key-0 and no-input/timeout) into
|
|
||||||
``CONFIG.captured_ivr_routes`` — but only if they are NOT already pointed at
|
|
||||||
the holiday queue (so a re-run never overwrites the real originals).
|
|
||||||
* Set queue 802's agents to the holiday's assignees, or ``[FALLBACK_EXTENSION]``
|
|
||||||
("100") when no slots are filled. Membership is set ONCE here.
|
|
||||||
* Repoint BOTH IVR 800 routes to the holiday queue (802).
|
|
||||||
* Mark the holiday ``activated = True``.
|
|
||||||
Idempotent: no-op if the record is gone or already activated.
|
|
||||||
|
|
||||||
Deactivate:
|
|
||||||
* Restore both IVR routes from ``CONFIG.captured_ivr_routes`` — only the routes
|
|
||||||
that currently point at the queue are reverted (defensive against manual
|
|
||||||
changes); clear the captured routes afterwards.
|
|
||||||
* Clear queue 802's agents.
|
|
||||||
* Mark the holiday ``activated = False``.
|
|
||||||
Idempotent: no-op if the record is gone or not activated.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -28,7 +10,8 @@ import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import shared.sentry_init # noqa: F401
|
import shared.sentry_init # noqa: F401
|
||||||
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
from shared.holiday_flow import activate, deactivate
|
||||||
|
from shared.schedule import ShiftSchedule
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
from shared.three_cx_client import ThreeCXClient
|
from shared.three_cx_client import ThreeCXClient
|
||||||
|
|
||||||
|
|
@ -46,121 +29,12 @@ def _make_client() -> ThreeCXClient:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _holiday_extensions(holiday: dict) -> list[str]:
|
|
||||||
"""Assignee extensions for the holiday, or the fallback when none claimed."""
|
|
||||||
assignees = holiday.get("assignees", {}) or {}
|
|
||||||
extensions = list(assignees.keys())
|
|
||||||
return extensions or [FALLBACK_EXTENSION]
|
|
||||||
|
|
||||||
|
|
||||||
def _activate(schedule: ShiftSchedule, date: str) -> dict:
|
def _activate(schedule: ShiftSchedule, date: str) -> dict:
|
||||||
holiday = schedule.get_holiday(date)
|
return activate(schedule, date, client_factory=_make_client)
|
||||||
if holiday is None:
|
|
||||||
logger.info("No holiday record for %s — nothing to activate", date)
|
|
||||||
return {"action": "activate", "date": date, "skipped": "no_record"}
|
|
||||||
if holiday.get("activated"):
|
|
||||||
logger.info("Holiday %s already activated — no-op", date)
|
|
||||||
return {"action": "activate", "date": date, "skipped": "already_active"}
|
|
||||||
|
|
||||||
queue_number = schedule.get_holiday_queue()
|
|
||||||
ivr_number = schedule.get_ivr_number()
|
|
||||||
extensions = _holiday_extensions(holiday)
|
|
||||||
|
|
||||||
client = _make_client()
|
|
||||||
|
|
||||||
# Capture the live IVR routes BEFORE repointing — but guard against storing
|
|
||||||
# holiday-state routes: if both routes already target the queue, a prior
|
|
||||||
# activation is in effect, so keep whatever originals we already captured.
|
|
||||||
ivr = client.get_ivr(ivr_number)
|
|
||||||
ivr_id = ivr["Id"]
|
|
||||||
current = client.extract_ivr_routes(ivr)
|
|
||||||
already_queue = str(current.get("key0")) == str(queue_number) and str(
|
|
||||||
current.get("timeout")
|
|
||||||
) == str(queue_number)
|
|
||||||
if already_queue:
|
|
||||||
logger.info(
|
|
||||||
"IVR %s already points at queue %s — not re-capturing routes",
|
|
||||||
ivr_number,
|
|
||||||
queue_number,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
schedule.set_captured_ivr_routes(current)
|
|
||||||
|
|
||||||
# Set queue membership ONCE, then repoint both IVR routes to the queue.
|
|
||||||
queue = client.get_queue(queue_number)
|
|
||||||
client.set_queue_agents(queue["Id"], extensions)
|
|
||||||
|
|
||||||
client.set_ivr_routes(ivr_id, key0_dn=queue_number, timeout_dn=queue_number)
|
|
||||||
|
|
||||||
schedule.set_holiday_activated(date, True)
|
|
||||||
logger.info(
|
|
||||||
"Activated holiday %s: queue %s agents=%s, IVR %s -> queue",
|
|
||||||
date,
|
|
||||||
queue_number,
|
|
||||||
extensions,
|
|
||||||
ivr_number,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"action": "activate",
|
|
||||||
"date": date,
|
|
||||||
"queue": str(queue_number),
|
|
||||||
"ivr": str(ivr_number),
|
|
||||||
"agents": extensions,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _deactivate(schedule: ShiftSchedule, date: str) -> dict:
|
def _deactivate(schedule: ShiftSchedule, date: str) -> dict:
|
||||||
holiday = schedule.get_holiday(date)
|
return deactivate(schedule, date, client_factory=_make_client)
|
||||||
if holiday is None:
|
|
||||||
logger.info("No holiday record for %s — nothing to deactivate", date)
|
|
||||||
return {"action": "deactivate", "date": date, "skipped": "no_record"}
|
|
||||||
if not holiday.get("activated"):
|
|
||||||
logger.info("Holiday %s not activated — no-op", date)
|
|
||||||
return {"action": "deactivate", "date": date, "skipped": "not_active"}
|
|
||||||
|
|
||||||
queue_number = schedule.get_holiday_queue()
|
|
||||||
ivr_number = schedule.get_ivr_number()
|
|
||||||
captured = schedule.get_captured_ivr_routes() or {}
|
|
||||||
|
|
||||||
client = _make_client()
|
|
||||||
|
|
||||||
ivr = client.get_ivr(ivr_number)
|
|
||||||
ivr_id = ivr["Id"]
|
|
||||||
live = client.extract_ivr_routes(ivr)
|
|
||||||
|
|
||||||
# Only restore a route if it currently points at the queue; otherwise leave
|
|
||||||
# whatever destination it has now (it was changed outside this flow).
|
|
||||||
def _restore(which: str) -> str | None:
|
|
||||||
live_dn = live.get(which)
|
|
||||||
if str(live_dn) == str(queue_number):
|
|
||||||
# Restore the captured pre-holiday DN; if it was lost, keep the live
|
|
||||||
# value rather than blanking the IVR destination.
|
|
||||||
return captured.get(which) or live_dn
|
|
||||||
return None # not pointing at the holiday queue — leave it untouched
|
|
||||||
|
|
||||||
client.set_ivr_routes(
|
|
||||||
ivr_id,
|
|
||||||
key0_dn=_restore("key0"),
|
|
||||||
timeout_dn=_restore("timeout"),
|
|
||||||
)
|
|
||||||
|
|
||||||
queue = client.get_queue(queue_number)
|
|
||||||
client.set_queue_agents(queue["Id"], [])
|
|
||||||
|
|
||||||
schedule.set_captured_ivr_routes(None)
|
|
||||||
schedule.set_holiday_activated(date, False)
|
|
||||||
logger.info(
|
|
||||||
"Deactivated holiday %s: restored IVR %s, cleared queue %s",
|
|
||||||
date,
|
|
||||||
ivr_number,
|
|
||||||
queue_number,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"action": "deactivate",
|
|
||||||
"date": date,
|
|
||||||
"queue": str(queue_number),
|
|
||||||
"ivr": str(ivr_number),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
def handler(event, context):
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,12 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import base64
|
||||||
import logging
|
import logging
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
import shared.sentry_init # noqa: F401
|
import shared.sentry_init # noqa: F401
|
||||||
from shared.cognito import CognitoVerificationUnavailable, verify_cognito_id_token
|
from shared.portal_http import cors_headers, encode_body, handle
|
||||||
from shared.portal_ops import ActionError, snapshot
|
from shared.portal_ops import ActionError
|
||||||
from shared.schedule import ShiftSchedule
|
|
||||||
from shared import portal_ops as ops
|
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
|
|
@ -25,40 +20,6 @@ CORS_ORIGINS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class DecimalEncoder(json.JSONEncoder):
|
|
||||||
def default(self, o):
|
|
||||||
if isinstance(o, Decimal):
|
|
||||||
return float(o)
|
|
||||||
return super().default(o)
|
|
||||||
|
|
||||||
|
|
||||||
def _cors_headers(event: dict) -> dict[str, str]:
|
|
||||||
headers = event.get("headers") or {}
|
|
||||||
origin = ""
|
|
||||||
for key, value in headers.items():
|
|
||||||
if str(key).lower() == "origin":
|
|
||||||
origin = "" if value is None else str(value)
|
|
||||||
break
|
|
||||||
allowed = origin if origin in CORS_ORIGINS else ""
|
|
||||||
out = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Vary": "Origin",
|
|
||||||
}
|
|
||||||
if allowed:
|
|
||||||
out["Access-Control-Allow-Origin"] = allowed
|
|
||||||
out["Access-Control-Allow-Headers"] = "Authorization,Content-Type"
|
|
||||||
out["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _response(event: dict, status: int, body: dict[str, Any] | None = None) -> dict:
|
|
||||||
return {
|
|
||||||
"statusCode": status,
|
|
||||||
"headers": _cors_headers(event),
|
|
||||||
"body": json.dumps({} if body is None else body, cls=DecimalEncoder, separators=(",", ":")),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _header(event: dict, name: str) -> str:
|
def _header(event: dict, name: str) -> str:
|
||||||
headers = event.get("headers") or {}
|
headers = event.get("headers") or {}
|
||||||
if not isinstance(headers, dict):
|
if not isinstance(headers, dict):
|
||||||
|
|
@ -83,179 +44,50 @@ def _route(event: dict) -> tuple[str, str]:
|
||||||
return method, path.rstrip("/") or "/"
|
return method, path.rstrip("/") or "/"
|
||||||
|
|
||||||
|
|
||||||
def _json_body(event: dict) -> dict:
|
def _raw_body(event: dict) -> bytes | str | None:
|
||||||
raw = event.get("body")
|
body = event.get("body")
|
||||||
if raw in (None, ""):
|
if body is None:
|
||||||
return {}
|
return None
|
||||||
if event.get("isBase64Encoded"):
|
if event.get("isBase64Encoded"):
|
||||||
import base64
|
if isinstance(body, bytes):
|
||||||
|
body = body.decode("ascii")
|
||||||
raw = base64.b64decode(raw).decode("utf-8")
|
return base64.b64decode(body)
|
||||||
if isinstance(raw, bytes):
|
return body
|
||||||
raw = raw.decode("utf-8")
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ActionError(400, "INVALID_JSON", "Invalid JSON body.") from exc
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ActionError(400, "INVALID_JSON", "JSON body must be an object.")
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _identity(event: dict) -> dict:
|
|
||||||
presented = _header(event, "authorization")
|
|
||||||
parts = presented.split(None, 1)
|
|
||||||
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip():
|
|
||||||
raise ActionError(401, "UNAUTHORIZED", "Sign in to continue.")
|
|
||||||
try:
|
|
||||||
identity = verify_cognito_id_token(parts[1].strip())
|
|
||||||
except CognitoVerificationUnavailable as exc:
|
|
||||||
raise ActionError(503, "AUTH_UNAVAILABLE", "Sign-in verification is unavailable.") from exc
|
|
||||||
if not identity:
|
|
||||||
raise ActionError(401, "UNAUTHORIZED", "Sign in to continue.")
|
|
||||||
return identity
|
|
||||||
|
|
||||||
|
|
||||||
def _employee(schedule: ShiftSchedule, identity: dict) -> dict:
|
|
||||||
employee = schedule.get_employee_by_email(identity["email"])
|
|
||||||
if not employee:
|
|
||||||
raise ActionError(
|
|
||||||
404,
|
|
||||||
"UNLINKED",
|
|
||||||
"Your Google account is not on the after-hours roster yet.",
|
|
||||||
)
|
|
||||||
return employee
|
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
def handler(event, context):
|
||||||
try:
|
try:
|
||||||
method, path = _route(event)
|
method, path = _route(event)
|
||||||
if method == "OPTIONS":
|
status, headers, payload = handle(
|
||||||
return _response(event, 204, {})
|
method=method,
|
||||||
identity = _identity(event)
|
path=path,
|
||||||
schedule = ShiftSchedule()
|
origin=_header(event, "origin"),
|
||||||
if method == "GET" and path == "/api/shifts":
|
authorization=_header(event, "authorization"),
|
||||||
employee = schedule.get_employee_by_email(identity["email"])
|
query=event.get("queryStringParameters")
|
||||||
if not employee:
|
if isinstance(event.get("queryStringParameters"), dict)
|
||||||
return _response(
|
else {},
|
||||||
event,
|
body=_raw_body(event),
|
||||||
200,
|
)
|
||||||
{
|
if status == 204:
|
||||||
"linked": False,
|
return {"statusCode": 204, "headers": headers, "body": ""}
|
||||||
"email": identity["email"],
|
return {
|
||||||
"isAdmin": False,
|
"statusCode": status,
|
||||||
},
|
"headers": headers,
|
||||||
)
|
"body": encode_body(payload),
|
||||||
params = event.get("queryStringParameters") or {}
|
}
|
||||||
week = (params.get("week") if isinstance(params, dict) else None) or "this"
|
|
||||||
return _response(event, 200, snapshot(schedule, employee, week))
|
|
||||||
employee = _employee(schedule, identity)
|
|
||||||
body = _json_body(event) if method in {"POST", "PUT", "PATCH"} else {}
|
|
||||||
return _dispatch(event, method, path, schedule, employee, body)
|
|
||||||
except ActionError as exc:
|
except ActionError as exc:
|
||||||
return _response(event, exc.status, {"error": {"code": exc.code, "message": exc.message}})
|
logger.exception("portal api action error")
|
||||||
|
return {
|
||||||
|
"statusCode": exc.status,
|
||||||
|
"headers": cors_headers(_header(event, "origin")),
|
||||||
|
"body": encode_body({"error": {"code": exc.code, "message": exc.message}}),
|
||||||
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("portal api unexpected failure")
|
logger.exception("portal api unexpected failure")
|
||||||
return _response(event, 500, {"error": {"code": "INTERNAL", "message": "Internal error"}})
|
return {
|
||||||
|
"statusCode": 500,
|
||||||
|
"headers": cors_headers(_header(event, "origin")),
|
||||||
def _dispatch(event, method, path, schedule, employee, body):
|
"body": encode_body(
|
||||||
parts = [p for p in path.split("/") if p]
|
{"error": {"code": "INTERNAL", "message": "Internal error"}}
|
||||||
if method == "POST" and path == "/api/shifts/pick":
|
|
||||||
return _response(
|
|
||||||
event,
|
|
||||||
200,
|
|
||||||
ops.pick(schedule, employee, body.get("date", ""), body.get("shiftType")),
|
|
||||||
)
|
|
||||||
if method == "POST" and path == "/api/shifts/drop":
|
|
||||||
return _response(
|
|
||||||
event,
|
|
||||||
200,
|
|
||||||
ops.drop(schedule, employee, body.get("date", ""), body.get("shiftType")),
|
|
||||||
)
|
|
||||||
if method == "POST" and path == "/api/shifts/swap":
|
|
||||||
return _response(
|
|
||||||
event,
|
|
||||||
200,
|
|
||||||
ops.swap(
|
|
||||||
schedule,
|
|
||||||
employee,
|
|
||||||
body.get("date", ""),
|
|
||||||
body.get("targetExtension", ""),
|
|
||||||
body.get("shiftType"),
|
|
||||||
),
|
),
|
||||||
)
|
}
|
||||||
if method == "POST" and len(parts) == 6 and parts[:3] == ["api", "shifts", "swaps"] and parts[5] in ("accept", "decline"):
|
|
||||||
return _response(
|
|
||||||
event,
|
|
||||||
200,
|
|
||||||
ops.respond_swap(
|
|
||||||
schedule,
|
|
||||||
employee,
|
|
||||||
unquote(parts[3]),
|
|
||||||
unquote(parts[4]),
|
|
||||||
accept=parts[5] == "accept",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if method == "POST" and path == "/api/shifts/admin/override":
|
|
||||||
return _response(
|
|
||||||
event,
|
|
||||||
200,
|
|
||||||
ops.admin_override(
|
|
||||||
schedule,
|
|
||||||
employee,
|
|
||||||
body.get("date", ""),
|
|
||||||
body.get("extension", ""),
|
|
||||||
body.get("shiftType"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if method == "POST" and path == "/api/shifts/admin/open":
|
|
||||||
return _response(
|
|
||||||
event,
|
|
||||||
200,
|
|
||||||
ops.admin_open(schedule, employee, body.get("date", ""), body.get("shiftType")),
|
|
||||||
)
|
|
||||||
if method == "POST" and path == "/api/shifts/admin/clear":
|
|
||||||
return _response(
|
|
||||||
event,
|
|
||||||
200,
|
|
||||||
ops.admin_clear(schedule, employee, body.get("date", ""), body.get("shiftType")),
|
|
||||||
)
|
|
||||||
if method == "POST" and path == "/api/shifts/admin/holidays":
|
|
||||||
return _response(
|
|
||||||
event,
|
|
||||||
200,
|
|
||||||
ops.admin_holiday_add(
|
|
||||||
schedule,
|
|
||||||
employee,
|
|
||||||
body.get("date", ""),
|
|
||||||
body.get("slots"),
|
|
||||||
body.get("label", ""),
|
|
||||||
body.get("multiplier"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if method == "DELETE" and len(parts) == 5 and parts[:4] == ["api", "shifts", "admin", "holidays"]:
|
|
||||||
return _response(
|
|
||||||
event,
|
|
||||||
200,
|
|
||||||
ops.admin_holiday_remove(schedule, employee, unquote(parts[4])),
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
method == "POST"
|
|
||||||
and len(parts) == 8
|
|
||||||
and parts[:4] == ["api", "shifts", "admin", "pickups"]
|
|
||||||
and parts[7] in ("approve", "deny")
|
|
||||||
):
|
|
||||||
return _response(
|
|
||||||
event,
|
|
||||||
200,
|
|
||||||
ops.admin_pickup(
|
|
||||||
schedule,
|
|
||||||
employee,
|
|
||||||
unquote(parts[4]),
|
|
||||||
unquote(parts[5]),
|
|
||||||
unquote(parts[6]),
|
|
||||||
approve=parts[7] == "approve",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return _response(event, 405, {"error": {"code": "METHOD", "message": "Method not allowed"}})
|
|
||||||
|
|
|
||||||
|
|
@ -10,39 +10,23 @@ Authorization header, the token, or the request body.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hmac
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import unicodedata
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import shared.sentry_init # noqa: F401
|
import shared.sentry_init # noqa: F401
|
||||||
from shared.schedule import ShiftSchedule
|
from shared.roster_http import (
|
||||||
from shared.secrets import get_secret
|
AuthError,
|
||||||
|
SecretUnavailable,
|
||||||
|
authorize_bearer,
|
||||||
|
remove,
|
||||||
|
upsert,
|
||||||
|
validate_extension,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
PUT_FIELDS = ("name", "extension", "slack_user_id")
|
|
||||||
OPTIONAL_PUT_FIELDS = ("email",)
|
|
||||||
MAX_BODY_BYTES = 4096
|
|
||||||
MAX_NAME_LEN = 128
|
|
||||||
MAX_EXTENSION_LEN = 16
|
|
||||||
MAX_SLACK_ID_LEN = 64
|
|
||||||
MAX_EMAIL_LEN = 254
|
|
||||||
ALLOWED_EMAIL_DOMAINS = {"seahaven.com", "seahavenind.com"}
|
|
||||||
|
|
||||||
_cached_token: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AuthError(Exception):
|
|
||||||
"""Missing or wrong Bearer token."""
|
|
||||||
|
|
||||||
|
|
||||||
class SecretUnavailable(Exception):
|
|
||||||
"""Token secret could not be read."""
|
|
||||||
|
|
||||||
|
|
||||||
def _json_response(status: int, body: dict[str, Any] | None = None) -> dict:
|
def _json_response(status: int, body: dict[str, Any] | None = None) -> dict:
|
||||||
if status == 204:
|
if status == 204:
|
||||||
|
|
@ -95,116 +79,6 @@ def _raw_body(event: dict) -> bytes | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _has_disallowed_chars(value: str, *, allow_space: bool) -> bool:
|
|
||||||
for char in value:
|
|
||||||
if char == " " and allow_space:
|
|
||||||
continue
|
|
||||||
if char.isspace() or unicodedata.category(char).startswith("C"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _expected_token() -> str:
|
|
||||||
global _cached_token
|
|
||||||
if _cached_token:
|
|
||||||
return _cached_token
|
|
||||||
secret_id = os.environ["ROSTER_API_TOKEN_SECRET"]
|
|
||||||
try:
|
|
||||||
token = get_secret(secret_id)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("roster api token secret read failed")
|
|
||||||
raise SecretUnavailable from None
|
|
||||||
if not isinstance(token, str):
|
|
||||||
raise SecretUnavailable
|
|
||||||
token = token.strip()
|
|
||||||
if not token:
|
|
||||||
raise SecretUnavailable
|
|
||||||
_cached_token = token
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def _authorize(event: dict) -> None:
|
|
||||||
presented = _header(event, "authorization")
|
|
||||||
if not presented:
|
|
||||||
raise AuthError
|
|
||||||
parts = presented.split(None, 1)
|
|
||||||
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip():
|
|
||||||
raise AuthError
|
|
||||||
token = parts[1].strip()
|
|
||||||
expected = _expected_token()
|
|
||||||
try:
|
|
||||||
matched = hmac.compare_digest(token, expected)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise AuthError from None
|
|
||||||
if not matched:
|
|
||||||
raise AuthError
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_email(value: str) -> str:
|
|
||||||
if (
|
|
||||||
len(value) > MAX_EMAIL_LEN
|
|
||||||
or "@" not in value
|
|
||||||
or _has_disallowed_chars(value, allow_space=False)
|
|
||||||
):
|
|
||||||
raise ValueError("fields")
|
|
||||||
local, _, domain = value.partition("@")
|
|
||||||
if not local or domain.lower() not in ALLOWED_EMAIL_DOMAINS:
|
|
||||||
raise ValueError("fields")
|
|
||||||
return value.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_put(raw: bytes) -> tuple[str, str, str, str | None]:
|
|
||||||
if len(raw) > MAX_BODY_BYTES:
|
|
||||||
raise ValueError("oversized")
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw.decode("utf-8"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
raise ValueError("invalid json") from None
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise TypeError("invalid json")
|
|
||||||
allowed = set(PUT_FIELDS) | set(OPTIONAL_PUT_FIELDS)
|
|
||||||
if not set(PUT_FIELDS).issubset(parsed) or not set(parsed).issubset(allowed):
|
|
||||||
raise ValueError("fields")
|
|
||||||
values: dict[str, str] = {}
|
|
||||||
for field in PUT_FIELDS:
|
|
||||||
value = parsed[field]
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise TypeError("fields")
|
|
||||||
trimmed = value.strip()
|
|
||||||
if not trimmed:
|
|
||||||
raise ValueError("fields")
|
|
||||||
values[field] = trimmed
|
|
||||||
|
|
||||||
name = values["name"]
|
|
||||||
extension = values["extension"]
|
|
||||||
slack_user_id = values["slack_user_id"]
|
|
||||||
email = None
|
|
||||||
if "email" in parsed:
|
|
||||||
raw_email = parsed["email"]
|
|
||||||
if not isinstance(raw_email, str):
|
|
||||||
raise TypeError("fields")
|
|
||||||
trimmed_email = raw_email.strip()
|
|
||||||
if not trimmed_email:
|
|
||||||
raise ValueError("fields")
|
|
||||||
email = _validate_email(trimmed_email)
|
|
||||||
|
|
||||||
if len(name) > MAX_NAME_LEN or _has_disallowed_chars(name, allow_space=True):
|
|
||||||
raise ValueError("fields")
|
|
||||||
if (
|
|
||||||
len(extension) > MAX_EXTENSION_LEN
|
|
||||||
or not extension.isdigit()
|
|
||||||
or _has_disallowed_chars(extension, allow_space=False)
|
|
||||||
):
|
|
||||||
raise ValueError("fields")
|
|
||||||
if (
|
|
||||||
len(slack_user_id) > MAX_SLACK_ID_LEN
|
|
||||||
or not slack_user_id.isalnum()
|
|
||||||
or _has_disallowed_chars(slack_user_id, allow_space=False)
|
|
||||||
):
|
|
||||||
raise ValueError("fields")
|
|
||||||
return name, extension, slack_user_id, email
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_extension(event: dict) -> str:
|
def _delete_extension(event: dict) -> str:
|
||||||
params = event.get("pathParameters") or {}
|
params = event.get("pathParameters") or {}
|
||||||
if not isinstance(params, dict):
|
if not isinstance(params, dict):
|
||||||
|
|
@ -212,15 +86,7 @@ def _delete_extension(event: dict) -> str:
|
||||||
raw = params.get("extension")
|
raw = params.get("extension")
|
||||||
if not isinstance(raw, str):
|
if not isinstance(raw, str):
|
||||||
raise TypeError("extension")
|
raise TypeError("extension")
|
||||||
extension = raw.strip()
|
return validate_extension(raw)
|
||||||
if (
|
|
||||||
not extension
|
|
||||||
or len(extension) > MAX_EXTENSION_LEN
|
|
||||||
or not extension.isdigit()
|
|
||||||
or _has_disallowed_chars(extension, allow_space=False)
|
|
||||||
):
|
|
||||||
raise ValueError("extension")
|
|
||||||
return extension
|
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
def handler(event, context):
|
||||||
|
|
@ -228,7 +94,7 @@ def handler(event, context):
|
||||||
method, path = _route(event)
|
method, path = _route(event)
|
||||||
logger.info("roster api %s %s", method, path)
|
logger.info("roster api %s %s", method, path)
|
||||||
try:
|
try:
|
||||||
_authorize(event)
|
authorize_bearer(_header(event, "authorization"))
|
||||||
except AuthError:
|
except AuthError:
|
||||||
return _json_response(401, {"error": "unauthorized"})
|
return _json_response(401, {"error": "unauthorized"})
|
||||||
except SecretUnavailable:
|
except SecretUnavailable:
|
||||||
|
|
@ -239,13 +105,9 @@ def handler(event, context):
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return _json_response(400, {"error": "invalid request"})
|
return _json_response(400, {"error": "invalid request"})
|
||||||
try:
|
try:
|
||||||
name, extension, slack_user_id, email = _validate_put(raw)
|
upsert(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return _json_response(400, {"error": "invalid request"})
|
return _json_response(400, {"error": "invalid request"})
|
||||||
ShiftSchedule().upsert_roster_entry(
|
|
||||||
extension, name, slack_user_id, email=email
|
|
||||||
)
|
|
||||||
logger.info("roster upserted extension=%s", extension)
|
|
||||||
return _json_response(200, {"ok": True})
|
return _json_response(200, {"ok": True})
|
||||||
|
|
||||||
if method == "DELETE" and path.startswith("/roster/"):
|
if method == "DELETE" and path.startswith("/roster/"):
|
||||||
|
|
@ -253,8 +115,7 @@ def handler(event, context):
|
||||||
extension = _delete_extension(event)
|
extension = _delete_extension(event)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return _json_response(400, {"error": "invalid request"})
|
return _json_response(400, {"error": "invalid request"})
|
||||||
ShiftSchedule().remove_roster_entry(extension)
|
remove(extension)
|
||||||
logger.info("roster deleted extension=%s", extension)
|
|
||||||
return _json_response(204)
|
return _json_response(204)
|
||||||
|
|
||||||
return _json_response(405, {"error": "method not allowed"})
|
return _json_response(405, {"error": "method not allowed"})
|
||||||
|
|
|
||||||
1
src/server/__init__.py
Normal file
1
src/server/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Always-on Flask process for afterhours-shift-manager."""
|
||||||
132
src/server/app.py
Normal file
132
src/server/app.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"""Production Flask app for afterhours-shift-manager.
|
||||||
|
|
||||||
|
Local: PYTHONPATH=src:src/shared python3 -m server.app
|
||||||
|
Prod: gunicorn server.wsgi:app
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, Response, jsonify, request
|
||||||
|
|
||||||
|
import shared.sentry_init # noqa: F401
|
||||||
|
from shared.portal_http import encode_body, handle as portal_handle
|
||||||
|
from shared.roster_http import (
|
||||||
|
AuthError,
|
||||||
|
SecretUnavailable,
|
||||||
|
authorize_bearer,
|
||||||
|
remove,
|
||||||
|
upsert,
|
||||||
|
validate_extension,
|
||||||
|
)
|
||||||
|
from shared.secrets import get_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SLACK_BOT_DIR = Path(__file__).resolve().parents[1] / "slack-bot"
|
||||||
|
if str(_SLACK_BOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SLACK_BOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _slack_handler():
|
||||||
|
from slack_bolt.adapter.flask import SlackRequestHandler
|
||||||
|
|
||||||
|
from app import create_app as create_bolt_app
|
||||||
|
|
||||||
|
bot_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"])
|
||||||
|
signing_secret = get_secret(os.environ["SLACK_SIGNING_SECRET"])
|
||||||
|
schedule_channel = os.environ["SHIFT_CHANNEL"]
|
||||||
|
bolt_app = create_bolt_app(
|
||||||
|
bot_token, signing_secret, schedule_channel=schedule_channel
|
||||||
|
)
|
||||||
|
return SlackRequestHandler(bolt_app)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> Flask:
|
||||||
|
app = Flask(__name__)
|
||||||
|
slack_handler = None
|
||||||
|
|
||||||
|
def _get_slack_handler():
|
||||||
|
nonlocal slack_handler
|
||||||
|
if slack_handler is None:
|
||||||
|
slack_handler = _slack_handler()
|
||||||
|
return slack_handler
|
||||||
|
|
||||||
|
@app.route("/api/health")
|
||||||
|
def health():
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"stage": os.environ.get("STAGE", "local"),
|
||||||
|
"sha": os.environ.get("GIT_SHA", "dev"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/slack/events", methods=["POST"])
|
||||||
|
def slack_events():
|
||||||
|
return _get_slack_handler().handle(request)
|
||||||
|
|
||||||
|
@app.route("/api/shifts", methods=["GET", "POST", "DELETE", "OPTIONS"])
|
||||||
|
@app.route("/api/shifts/<path:rest>", methods=["GET", "POST", "DELETE", "OPTIONS"])
|
||||||
|
def portal(rest: str | None = None):
|
||||||
|
status, headers, payload = portal_handle(
|
||||||
|
method=request.method,
|
||||||
|
path=request.path,
|
||||||
|
origin=request.headers.get("Origin", ""),
|
||||||
|
authorization=request.headers.get("Authorization", ""),
|
||||||
|
query=request.args.to_dict(flat=True),
|
||||||
|
body=request.get_data(),
|
||||||
|
)
|
||||||
|
if status == 204:
|
||||||
|
return Response(b"", status=204, headers=headers)
|
||||||
|
return Response(
|
||||||
|
encode_body(payload),
|
||||||
|
status=status,
|
||||||
|
headers=headers,
|
||||||
|
mimetype="application/json",
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/roster", methods=["PUT"])
|
||||||
|
def roster_put():
|
||||||
|
try:
|
||||||
|
authorize_bearer(request.headers.get("Authorization", ""))
|
||||||
|
except AuthError:
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
except SecretUnavailable:
|
||||||
|
return jsonify({"error": "service unavailable"}), 503
|
||||||
|
try:
|
||||||
|
upsert(request.get_data())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "invalid request"}), 400
|
||||||
|
return jsonify({"ok": True}), 200
|
||||||
|
|
||||||
|
@app.route("/roster/<extension>", methods=["DELETE"])
|
||||||
|
def roster_delete(extension: str):
|
||||||
|
try:
|
||||||
|
authorize_bearer(request.headers.get("Authorization", ""))
|
||||||
|
except AuthError:
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
except SecretUnavailable:
|
||||||
|
return jsonify({"error": "service unavailable"}), 503
|
||||||
|
try:
|
||||||
|
remove(validate_extension(extension))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "invalid request"}), 400
|
||||||
|
return Response(b"", status=204)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
71
src/server/entrypoint.py
Normal file
71
src/server/entrypoint.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Gunicorn + SQS worker in one task. Stay a parent so SIGTERM reaches both."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
worker = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "server.worker"],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
gunicorn = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"gunicorn",
|
||||||
|
"--bind",
|
||||||
|
"0.0.0.0:8080",
|
||||||
|
"--workers",
|
||||||
|
os.environ.get("GUNICORN_WORKERS", "2"),
|
||||||
|
"--threads",
|
||||||
|
"2",
|
||||||
|
"--timeout",
|
||||||
|
"120",
|
||||||
|
"--graceful-timeout",
|
||||||
|
"30",
|
||||||
|
"--access-logfile",
|
||||||
|
"-",
|
||||||
|
"--error-logfile",
|
||||||
|
"-",
|
||||||
|
"server.wsgi:app",
|
||||||
|
],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
def shutdown(signum: int, _frame) -> None:
|
||||||
|
for proc in (gunicorn, worker):
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.send_signal(signum)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, shutdown)
|
||||||
|
signal.signal(signal.SIGINT, shutdown)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
g_code = gunicorn.poll()
|
||||||
|
w_code = worker.poll()
|
||||||
|
if g_code is not None:
|
||||||
|
if worker.poll() is None:
|
||||||
|
worker.terminate()
|
||||||
|
try:
|
||||||
|
worker.wait(timeout=30)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
worker.kill()
|
||||||
|
sys.exit(g_code)
|
||||||
|
if w_code is not None:
|
||||||
|
if gunicorn.poll() is None:
|
||||||
|
gunicorn.terminate()
|
||||||
|
try:
|
||||||
|
gunicorn.wait(timeout=30)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
gunicorn.kill()
|
||||||
|
sys.exit(w_code or 1)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
79
src/server/jobs.py
Normal file
79
src/server/jobs.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""In-process job dispatch. SQS when JOBS_QUEUE_URL is set; otherwise run inline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from shared.holiday_flow import activate, deactivate
|
||||||
|
from shared.schedule import ShiftSchedule
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SRC = Path(__file__).resolve().parents[1]
|
||||||
|
_sqs = None
|
||||||
|
_handlers: dict[str, object] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
global _sqs
|
||||||
|
if _sqs is None:
|
||||||
|
_sqs = boto3.client("sqs")
|
||||||
|
return _sqs
|
||||||
|
|
||||||
|
|
||||||
|
def _load_lambda_app(dirname: str):
|
||||||
|
if dirname in _handlers:
|
||||||
|
return _handlers[dirname]
|
||||||
|
path = _SRC / dirname / "app.py"
|
||||||
|
name = f"afterhours_{dirname.replace('-', '_')}"
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"cannot load {path}")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[name] = mod
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
_handlers[dirname] = mod
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_job(payload: dict) -> None:
|
||||||
|
queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip()
|
||||||
|
body = json.dumps(payload, default=str)
|
||||||
|
if not queue_url:
|
||||||
|
run_job(payload)
|
||||||
|
return
|
||||||
|
_client().send_message(QueueUrl=queue_url, MessageBody=body)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_holiday(payload: dict) -> dict:
|
||||||
|
action = payload.get("action")
|
||||||
|
date = payload.get("date")
|
||||||
|
if not date:
|
||||||
|
return {"error": True, "reason": "missing_date"}
|
||||||
|
schedule = ShiftSchedule()
|
||||||
|
if action == "activate":
|
||||||
|
return activate(schedule, date)
|
||||||
|
if action == "deactivate":
|
||||||
|
return deactivate(schedule, date)
|
||||||
|
return {"error": True, "reason": "unknown_action", "action": action}
|
||||||
|
|
||||||
|
|
||||||
|
def run_job(payload: dict) -> dict:
|
||||||
|
event_type = payload.get("event", "")
|
||||||
|
if event_type == "holiday":
|
||||||
|
return _run_holiday(payload)
|
||||||
|
forced = {**payload, "force": True}
|
||||||
|
if event_type == "weekly_post":
|
||||||
|
return _load_lambda_app("weekly-post").handler(forced, None)
|
||||||
|
if event_type == "roster_sync":
|
||||||
|
return _load_lambda_app("roster-sync").handler(forced, None)
|
||||||
|
if event_type in {"ring_scheduler_daily", "ring_scheduler_weekend"}:
|
||||||
|
return _load_lambda_app("ring-scheduler").handler(forced, None)
|
||||||
|
raise ValueError(f"unknown job event {event_type!r}")
|
||||||
58
src/server/worker.py
Normal file
58
src/server/worker.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""SQS long-poll consumer. One process per task, not per gunicorn worker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from server.jobs import run_job
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
|
||||||
|
|
||||||
|
_running = True
|
||||||
|
|
||||||
|
|
||||||
|
def _stop(_signum, _frame) -> None:
|
||||||
|
global _running
|
||||||
|
_running = False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
signal.signal(signal.SIGTERM, _stop)
|
||||||
|
signal.signal(signal.SIGINT, _stop)
|
||||||
|
|
||||||
|
queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip()
|
||||||
|
if not queue_url:
|
||||||
|
logger.info("JOBS_QUEUE_URL unset; worker idle")
|
||||||
|
while _running:
|
||||||
|
time.sleep(1)
|
||||||
|
return
|
||||||
|
sqs = boto3.client("sqs")
|
||||||
|
logger.info("Polling jobs queue")
|
||||||
|
while _running:
|
||||||
|
resp = sqs.receive_message(
|
||||||
|
QueueUrl=queue_url,
|
||||||
|
MaxNumberOfMessages=1,
|
||||||
|
WaitTimeSeconds=20,
|
||||||
|
VisibilityTimeout=180,
|
||||||
|
)
|
||||||
|
for msg in resp.get("Messages", []):
|
||||||
|
receipt = msg["ReceiptHandle"]
|
||||||
|
try:
|
||||||
|
payload = json.loads(msg["Body"])
|
||||||
|
result = run_job(payload)
|
||||||
|
logger.info("job result %s", result)
|
||||||
|
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("job failed; leaving message for retry")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
5
src/server/wsgi.py
Normal file
5
src/server/wsgi.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Gunicorn entrypoint."""
|
||||||
|
|
||||||
|
from server.app import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
123
src/shared/shared/holiday_flow.py
Normal file
123
src/shared/shared/holiday_flow.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Holiday activate/deactivate. Shared by the Lambda router and the Fargate worker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
||||||
|
from shared.secrets import get_secret
|
||||||
|
from shared.three_cx_client import ThreeCXClient, oauth_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def make_client() -> ThreeCXClient:
|
||||||
|
secret_prefix = os.environ["TCX_SECRET_PREFIX"]
|
||||||
|
return oauth_client(
|
||||||
|
domain=get_secret(f"{secret_prefix}domain"),
|
||||||
|
client_id=get_secret(f"{secret_prefix}client-id"),
|
||||||
|
client_secret=get_secret(f"{secret_prefix}client-secret"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def holiday_extensions(holiday: dict) -> list[str]:
|
||||||
|
assignees = holiday.get("assignees", {}) or {}
|
||||||
|
extensions = list(assignees.keys())
|
||||||
|
return extensions or [FALLBACK_EXTENSION]
|
||||||
|
|
||||||
|
|
||||||
|
def activate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict:
|
||||||
|
holiday = schedule.get_holiday(date)
|
||||||
|
if holiday is None:
|
||||||
|
logger.info("No holiday record for %s — nothing to activate", date)
|
||||||
|
return {"action": "activate", "date": date, "skipped": "no_record"}
|
||||||
|
if holiday.get("activated"):
|
||||||
|
logger.info("Holiday %s already activated — no-op", date)
|
||||||
|
return {"action": "activate", "date": date, "skipped": "already_active"}
|
||||||
|
|
||||||
|
queue_number = schedule.get_holiday_queue()
|
||||||
|
ivr_number = schedule.get_ivr_number()
|
||||||
|
extensions = holiday_extensions(holiday)
|
||||||
|
client = (client_factory or make_client)()
|
||||||
|
|
||||||
|
ivr = client.get_ivr(ivr_number)
|
||||||
|
ivr_id = ivr["Id"]
|
||||||
|
current = client.extract_ivr_routes(ivr)
|
||||||
|
already_queue = str(current.get("key0")) == str(queue_number) and str(
|
||||||
|
current.get("timeout")
|
||||||
|
) == str(queue_number)
|
||||||
|
if already_queue:
|
||||||
|
logger.info(
|
||||||
|
"IVR %s already points at queue %s — not re-capturing routes",
|
||||||
|
ivr_number,
|
||||||
|
queue_number,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
schedule.set_captured_ivr_routes(current)
|
||||||
|
|
||||||
|
queue = client.get_queue(queue_number)
|
||||||
|
client.set_queue_agents(queue["Id"], extensions)
|
||||||
|
client.set_ivr_routes(ivr_id, key0_dn=queue_number, timeout_dn=queue_number)
|
||||||
|
schedule.set_holiday_activated(date, True)
|
||||||
|
logger.info(
|
||||||
|
"Activated holiday %s: queue %s agents=%s, IVR %s -> queue",
|
||||||
|
date,
|
||||||
|
queue_number,
|
||||||
|
extensions,
|
||||||
|
ivr_number,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"action": "activate",
|
||||||
|
"date": date,
|
||||||
|
"queue": str(queue_number),
|
||||||
|
"ivr": str(ivr_number),
|
||||||
|
"agents": extensions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def deactivate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict:
|
||||||
|
holiday = schedule.get_holiday(date)
|
||||||
|
if holiday is None:
|
||||||
|
logger.info("No holiday record for %s — nothing to deactivate", date)
|
||||||
|
return {"action": "deactivate", "date": date, "skipped": "no_record"}
|
||||||
|
if not holiday.get("activated"):
|
||||||
|
logger.info("Holiday %s not activated — no-op", date)
|
||||||
|
return {"action": "deactivate", "date": date, "skipped": "not_active"}
|
||||||
|
|
||||||
|
queue_number = schedule.get_holiday_queue()
|
||||||
|
ivr_number = schedule.get_ivr_number()
|
||||||
|
captured = schedule.get_captured_ivr_routes() or {}
|
||||||
|
client = (client_factory or make_client)()
|
||||||
|
|
||||||
|
ivr = client.get_ivr(ivr_number)
|
||||||
|
ivr_id = ivr["Id"]
|
||||||
|
live = client.extract_ivr_routes(ivr)
|
||||||
|
|
||||||
|
def _restore(which: str) -> str | None:
|
||||||
|
live_dn = live.get(which)
|
||||||
|
if str(live_dn) == str(queue_number):
|
||||||
|
return captured.get(which) or live_dn
|
||||||
|
return None
|
||||||
|
|
||||||
|
client.set_ivr_routes(
|
||||||
|
ivr_id,
|
||||||
|
key0_dn=_restore("key0"),
|
||||||
|
timeout_dn=_restore("timeout"),
|
||||||
|
)
|
||||||
|
queue = client.get_queue(queue_number)
|
||||||
|
client.set_queue_agents(queue["Id"], [])
|
||||||
|
schedule.set_captured_ivr_routes(None)
|
||||||
|
schedule.set_holiday_activated(date, False)
|
||||||
|
logger.info(
|
||||||
|
"Deactivated holiday %s: restored IVR %s, cleared queue %s",
|
||||||
|
date,
|
||||||
|
ivr_number,
|
||||||
|
queue_number,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"action": "deactivate",
|
||||||
|
"date": date,
|
||||||
|
"queue": str(queue_number),
|
||||||
|
"ivr": str(ivr_number),
|
||||||
|
}
|
||||||
222
src/shared/shared/portal_http.py
Normal file
222
src/shared/shared/portal_http.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""Cognito portal HTTP dispatch shared by Lambda and Flask."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from shared.cognito import CognitoVerificationUnavailable, verify_cognito_id_token
|
||||||
|
from shared.portal_ops import ActionError, snapshot
|
||||||
|
from shared.schedule import ShiftSchedule
|
||||||
|
from shared import portal_ops as ops
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CORS_ORIGINS = {
|
||||||
|
"https://internal.seahaven.com",
|
||||||
|
"https://internal.dev.seahaven.com",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DecimalEncoder(json.JSONEncoder):
|
||||||
|
def default(self, o):
|
||||||
|
if isinstance(o, Decimal):
|
||||||
|
return float(o)
|
||||||
|
return super().default(o)
|
||||||
|
|
||||||
|
|
||||||
|
def cors_headers(origin: str) -> dict[str, str]:
|
||||||
|
out = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Vary": "Origin",
|
||||||
|
}
|
||||||
|
if origin in CORS_ORIGINS:
|
||||||
|
out["Access-Control-Allow-Origin"] = origin
|
||||||
|
out["Access-Control-Allow-Headers"] = "Authorization,Content-Type"
|
||||||
|
out["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def identity_from_authorization(presented: str) -> dict:
|
||||||
|
parts = presented.split(None, 1)
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip():
|
||||||
|
raise ActionError(401, "UNAUTHORIZED", "Sign in to continue.")
|
||||||
|
try:
|
||||||
|
identity = verify_cognito_id_token(parts[1].strip())
|
||||||
|
except CognitoVerificationUnavailable as exc:
|
||||||
|
raise ActionError(
|
||||||
|
503, "AUTH_UNAVAILABLE", "Sign-in verification is unavailable."
|
||||||
|
) from exc
|
||||||
|
if not identity:
|
||||||
|
raise ActionError(401, "UNAUTHORIZED", "Sign in to continue.")
|
||||||
|
return identity
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_object(raw: bytes | str | None) -> dict:
|
||||||
|
if raw in (None, "", b""):
|
||||||
|
return {}
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
raw = raw.decode("utf-8")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ActionError(400, "INVALID_JSON", "Invalid JSON body.") from exc
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ActionError(400, "INVALID_JSON", "JSON body must be an object.")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def encode_body(body: dict[str, Any] | None) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
{} if body is None else body, cls=DecimalEncoder, separators=(",", ":")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
schedule: ShiftSchedule,
|
||||||
|
employee: dict,
|
||||||
|
body: dict,
|
||||||
|
) -> tuple[int, dict]:
|
||||||
|
parts = [p for p in path.split("/") if p]
|
||||||
|
if method == "POST" and path == "/api/shifts/pick":
|
||||||
|
return 200, ops.pick(
|
||||||
|
schedule, employee, body.get("date", ""), body.get("shiftType")
|
||||||
|
)
|
||||||
|
if method == "POST" and path == "/api/shifts/drop":
|
||||||
|
return 200, ops.drop(
|
||||||
|
schedule, employee, body.get("date", ""), body.get("shiftType")
|
||||||
|
)
|
||||||
|
if method == "POST" and path == "/api/shifts/swap":
|
||||||
|
return 200, ops.swap(
|
||||||
|
schedule,
|
||||||
|
employee,
|
||||||
|
body.get("date", ""),
|
||||||
|
body.get("targetExtension", ""),
|
||||||
|
body.get("shiftType"),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
method == "POST"
|
||||||
|
and len(parts) == 6
|
||||||
|
and parts[:3] == ["api", "shifts", "swaps"]
|
||||||
|
and parts[5]
|
||||||
|
in (
|
||||||
|
"accept",
|
||||||
|
"decline",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return 200, ops.respond_swap(
|
||||||
|
schedule,
|
||||||
|
employee,
|
||||||
|
unquote(parts[3]),
|
||||||
|
unquote(parts[4]),
|
||||||
|
accept=parts[5] == "accept",
|
||||||
|
)
|
||||||
|
if method == "POST" and path == "/api/shifts/admin/override":
|
||||||
|
return 200, ops.admin_override(
|
||||||
|
schedule,
|
||||||
|
employee,
|
||||||
|
body.get("date", ""),
|
||||||
|
body.get("extension", ""),
|
||||||
|
body.get("shiftType"),
|
||||||
|
)
|
||||||
|
if method == "POST" and path == "/api/shifts/admin/open":
|
||||||
|
return 200, ops.admin_open(
|
||||||
|
schedule, employee, body.get("date", ""), body.get("shiftType")
|
||||||
|
)
|
||||||
|
if method == "POST" and path == "/api/shifts/admin/clear":
|
||||||
|
return 200, ops.admin_clear(
|
||||||
|
schedule, employee, body.get("date", ""), body.get("shiftType")
|
||||||
|
)
|
||||||
|
if method == "POST" and path == "/api/shifts/admin/holidays":
|
||||||
|
return 200, ops.admin_holiday_add(
|
||||||
|
schedule,
|
||||||
|
employee,
|
||||||
|
body.get("date", ""),
|
||||||
|
body.get("slots"),
|
||||||
|
body.get("label", ""),
|
||||||
|
body.get("multiplier"),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
method == "DELETE"
|
||||||
|
and len(parts) == 5
|
||||||
|
and parts[:4] == ["api", "shifts", "admin", "holidays"]
|
||||||
|
):
|
||||||
|
return 200, ops.admin_holiday_remove(schedule, employee, unquote(parts[4]))
|
||||||
|
if (
|
||||||
|
method == "POST"
|
||||||
|
and len(parts) == 8
|
||||||
|
and parts[:4] == ["api", "shifts", "admin", "pickups"]
|
||||||
|
and parts[7] in ("approve", "deny")
|
||||||
|
):
|
||||||
|
return 200, ops.admin_pickup(
|
||||||
|
schedule,
|
||||||
|
employee,
|
||||||
|
unquote(parts[4]),
|
||||||
|
unquote(parts[5]),
|
||||||
|
unquote(parts[6]),
|
||||||
|
approve=parts[7] == "approve",
|
||||||
|
)
|
||||||
|
return 405, {"error": {"code": "METHOD", "message": "Method not allowed"}}
|
||||||
|
|
||||||
|
|
||||||
|
def handle(
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
origin: str,
|
||||||
|
authorization: str,
|
||||||
|
query: dict | None,
|
||||||
|
body: bytes | str | None,
|
||||||
|
) -> tuple[int, dict[str, str], dict | None]:
|
||||||
|
headers = cors_headers(origin)
|
||||||
|
path = path.rstrip("/") or "/"
|
||||||
|
method = method.upper()
|
||||||
|
try:
|
||||||
|
if method == "OPTIONS":
|
||||||
|
return 204, headers, {}
|
||||||
|
identity = identity_from_authorization(authorization)
|
||||||
|
schedule = ShiftSchedule()
|
||||||
|
if method == "GET" and path == "/api/shifts":
|
||||||
|
employee = schedule.get_employee_by_email(identity["email"])
|
||||||
|
if not employee:
|
||||||
|
return (
|
||||||
|
200,
|
||||||
|
headers,
|
||||||
|
{
|
||||||
|
"linked": False,
|
||||||
|
"email": identity["email"],
|
||||||
|
"isAdmin": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
week = (query or {}).get("week") or "this"
|
||||||
|
return 200, headers, snapshot(schedule, employee, week)
|
||||||
|
employee = schedule.get_employee_by_email(identity["email"])
|
||||||
|
if not employee:
|
||||||
|
raise ActionError(
|
||||||
|
404,
|
||||||
|
"UNLINKED",
|
||||||
|
"Your Google account is not on the after-hours roster yet.",
|
||||||
|
)
|
||||||
|
parsed = parse_json_object(body) if method in {"POST", "PUT", "PATCH"} else {}
|
||||||
|
status, payload = dispatch(method, path, schedule, employee, parsed)
|
||||||
|
return status, headers, payload
|
||||||
|
except ActionError as exc:
|
||||||
|
return (
|
||||||
|
exc.status,
|
||||||
|
headers,
|
||||||
|
{"error": {"code": exc.code, "message": exc.message}},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("portal http unexpected failure")
|
||||||
|
return (
|
||||||
|
500,
|
||||||
|
headers,
|
||||||
|
{"error": {"code": "INTERNAL", "message": "Internal error"}},
|
||||||
|
)
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from shared.three_cx_client import ThreeCXClient
|
from shared.three_cx_client import oauth_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -15,12 +15,7 @@ def update_queue_routing(
|
||||||
client_secret: str,
|
client_secret: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update 3CX queue forwarding to route calls to the given extension."""
|
"""Update 3CX queue forwarding to route calls to the given extension."""
|
||||||
client = ThreeCXClient(
|
client = oauth_client(domain, client_id, client_secret)
|
||||||
domain=domain,
|
|
||||||
auth_mode="oauth",
|
|
||||||
client_id=client_id,
|
|
||||||
client_secret=client_secret,
|
|
||||||
)
|
|
||||||
queue = client.get_queue(queue_number)
|
queue = client.get_queue(queue_number)
|
||||||
client.update_queue_forwarding(
|
client.update_queue_forwarding(
|
||||||
queue_id=queue["Id"],
|
queue_id=queue["Id"],
|
||||||
|
|
|
||||||
165
src/shared/shared/roster_http.py
Normal file
165
src/shared/shared/roster_http.py
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
"""Roster PUT/DELETE helpers shared by Lambda and Flask."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from shared.schedule import ShiftSchedule
|
||||||
|
from shared.secrets import get_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PUT_FIELDS = ("name", "extension", "slack_user_id")
|
||||||
|
OPTIONAL_PUT_FIELDS = ("email",)
|
||||||
|
MAX_BODY_BYTES = 4096
|
||||||
|
MAX_NAME_LEN = 128
|
||||||
|
MAX_EXTENSION_LEN = 16
|
||||||
|
MAX_SLACK_ID_LEN = 64
|
||||||
|
MAX_EMAIL_LEN = 254
|
||||||
|
ALLOWED_EMAIL_DOMAINS = {"seahaven.com", "seahavenind.com"}
|
||||||
|
|
||||||
|
_cached_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(Exception):
|
||||||
|
"""Missing or wrong Bearer token."""
|
||||||
|
|
||||||
|
|
||||||
|
class SecretUnavailable(Exception):
|
||||||
|
"""Token secret could not be read."""
|
||||||
|
|
||||||
|
|
||||||
|
def has_disallowed_chars(value: str, *, allow_space: bool) -> bool:
|
||||||
|
for char in value:
|
||||||
|
if char == " " and allow_space:
|
||||||
|
continue
|
||||||
|
if char.isspace() or unicodedata.category(char).startswith("C"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def expected_token() -> str:
|
||||||
|
global _cached_token
|
||||||
|
if _cached_token:
|
||||||
|
return _cached_token
|
||||||
|
secret_id = os.environ["ROSTER_API_TOKEN_SECRET"]
|
||||||
|
try:
|
||||||
|
token = get_secret(secret_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("roster api token secret read failed")
|
||||||
|
raise SecretUnavailable from None
|
||||||
|
if not isinstance(token, str):
|
||||||
|
raise SecretUnavailable
|
||||||
|
token = token.strip()
|
||||||
|
if not token:
|
||||||
|
raise SecretUnavailable
|
||||||
|
_cached_token = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def authorize_bearer(presented: str) -> None:
|
||||||
|
if not presented:
|
||||||
|
raise AuthError
|
||||||
|
parts = presented.split(None, 1)
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip():
|
||||||
|
raise AuthError
|
||||||
|
token = parts[1].strip()
|
||||||
|
expected = expected_token()
|
||||||
|
try:
|
||||||
|
matched = hmac.compare_digest(token, expected)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise AuthError from None
|
||||||
|
if not matched:
|
||||||
|
raise AuthError
|
||||||
|
|
||||||
|
|
||||||
|
def validate_email(value: str) -> str:
|
||||||
|
if (
|
||||||
|
len(value) > MAX_EMAIL_LEN
|
||||||
|
or "@" not in value
|
||||||
|
or has_disallowed_chars(value, allow_space=False)
|
||||||
|
):
|
||||||
|
raise ValueError("fields")
|
||||||
|
local, _, domain = value.partition("@")
|
||||||
|
if not local or domain.lower() not in ALLOWED_EMAIL_DOMAINS:
|
||||||
|
raise ValueError("fields")
|
||||||
|
return value.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_put(raw: bytes) -> tuple[str, str, str, str | None]:
|
||||||
|
if len(raw) > MAX_BODY_BYTES:
|
||||||
|
raise ValueError("oversized")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
raise ValueError("invalid json") from None
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise TypeError("invalid json")
|
||||||
|
allowed = set(PUT_FIELDS) | set(OPTIONAL_PUT_FIELDS)
|
||||||
|
if not set(PUT_FIELDS).issubset(parsed) or not set(parsed).issubset(allowed):
|
||||||
|
raise ValueError("fields")
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for field in PUT_FIELDS:
|
||||||
|
value = parsed[field]
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise TypeError("fields")
|
||||||
|
trimmed = value.strip()
|
||||||
|
if not trimmed:
|
||||||
|
raise ValueError("fields")
|
||||||
|
values[field] = trimmed
|
||||||
|
|
||||||
|
name = values["name"]
|
||||||
|
extension = values["extension"]
|
||||||
|
slack_user_id = values["slack_user_id"]
|
||||||
|
email = None
|
||||||
|
if "email" in parsed:
|
||||||
|
raw_email = parsed["email"]
|
||||||
|
if not isinstance(raw_email, str):
|
||||||
|
raise TypeError("fields")
|
||||||
|
trimmed_email = raw_email.strip()
|
||||||
|
if not trimmed_email:
|
||||||
|
raise ValueError("fields")
|
||||||
|
email = validate_email(trimmed_email)
|
||||||
|
|
||||||
|
if len(name) > MAX_NAME_LEN or has_disallowed_chars(name, allow_space=True):
|
||||||
|
raise ValueError("fields")
|
||||||
|
if (
|
||||||
|
len(extension) > MAX_EXTENSION_LEN
|
||||||
|
or not extension.isdigit()
|
||||||
|
or has_disallowed_chars(extension, allow_space=False)
|
||||||
|
):
|
||||||
|
raise ValueError("fields")
|
||||||
|
if (
|
||||||
|
len(slack_user_id) > MAX_SLACK_ID_LEN
|
||||||
|
or not slack_user_id.isalnum()
|
||||||
|
or has_disallowed_chars(slack_user_id, allow_space=False)
|
||||||
|
):
|
||||||
|
raise ValueError("fields")
|
||||||
|
return name, extension, slack_user_id, email
|
||||||
|
|
||||||
|
|
||||||
|
def validate_extension(raw: str) -> str:
|
||||||
|
extension = raw.strip()
|
||||||
|
if (
|
||||||
|
not extension
|
||||||
|
or len(extension) > MAX_EXTENSION_LEN
|
||||||
|
or not extension.isdigit()
|
||||||
|
or has_disallowed_chars(extension, allow_space=False)
|
||||||
|
):
|
||||||
|
raise ValueError("extension")
|
||||||
|
return extension
|
||||||
|
|
||||||
|
|
||||||
|
def upsert(raw: bytes) -> None:
|
||||||
|
name, extension, slack_user_id, email = validate_put(raw)
|
||||||
|
ShiftSchedule().upsert_roster_entry(extension, name, slack_user_id, email=email)
|
||||||
|
logger.info("roster upserted extension=%s", extension)
|
||||||
|
|
||||||
|
|
||||||
|
def remove(extension: str) -> None:
|
||||||
|
ShiftSchedule().remove_roster_entry(extension)
|
||||||
|
logger.info("roster deleted extension=%s", extension)
|
||||||
|
|
@ -128,13 +128,24 @@ def _git_sha():
|
||||||
return str(GIT_SHA or "").strip()
|
return str(GIT_SHA or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _integrations():
|
||||||
|
if os.environ.get("AWS_LAMBDA_FUNCTION_NAME"):
|
||||||
|
return [AwsLambdaIntegration(timeout_warning=True)]
|
||||||
|
try:
|
||||||
|
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||||
|
|
||||||
|
return [FlaskIntegration()]
|
||||||
|
except Exception:
|
||||||
|
return [AwsLambdaIntegration(timeout_warning=True)]
|
||||||
|
|
||||||
|
|
||||||
def init_sentry():
|
def init_sentry():
|
||||||
dsn = os.environ.get("SENTRY_DSN")
|
dsn = os.environ.get("SENTRY_DSN")
|
||||||
if not dsn:
|
if not dsn:
|
||||||
return
|
return
|
||||||
kwargs = {
|
kwargs = {
|
||||||
"dsn": dsn,
|
"dsn": dsn,
|
||||||
"integrations": [AwsLambdaIntegration(timeout_warning=True)],
|
"integrations": _integrations(),
|
||||||
"send_default_pii": False,
|
"send_default_pii": False,
|
||||||
"include_local_variables": False,
|
"include_local_variables": False,
|
||||||
"enable_logs": False,
|
"enable_logs": False,
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ from shared.blocks import (
|
||||||
from shared.ring_scheduler import update_queue_routing
|
from shared.ring_scheduler import update_queue_routing
|
||||||
from shared.schedule import FALLBACK_EXTENSION
|
from shared.schedule import FALLBACK_EXTENSION
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
from shared.shift_clock import holiday_window_active, is_active_shift_type, is_today
|
from shared.shift_clock import is_active_shift_type, is_today
|
||||||
from shared.three_cx_client import ThreeCXClient
|
from shared.three_cx_client import ThreeCXClient, oauth_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
EASTERN = ZoneInfo("America/New_York")
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
@ -86,17 +86,23 @@ def maybe_repoint_today(date_str: str, shift_type: str, extension: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_cached_3cx: ThreeCXClient | None = None
|
||||||
|
|
||||||
|
|
||||||
def make_3cx_client() -> ThreeCXClient | None:
|
def make_3cx_client() -> ThreeCXClient | None:
|
||||||
|
global _cached_3cx
|
||||||
|
if _cached_3cx is not None:
|
||||||
|
return _cached_3cx
|
||||||
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
||||||
if not secret_prefix:
|
if not secret_prefix:
|
||||||
logger.warning("3CX env vars not set — skipping 3CX call")
|
logger.warning("3CX env vars not set — skipping 3CX call")
|
||||||
return None
|
return None
|
||||||
return ThreeCXClient(
|
_cached_3cx = oauth_client(
|
||||||
domain=get_secret(f"{secret_prefix}domain"),
|
domain=get_secret(f"{secret_prefix}domain"),
|
||||||
auth_mode="oauth",
|
|
||||||
client_id=get_secret(f"{secret_prefix}client-id"),
|
client_id=get_secret(f"{secret_prefix}client-id"),
|
||||||
client_secret=get_secret(f"{secret_prefix}client-secret"),
|
client_secret=get_secret(f"{secret_prefix}client-secret"),
|
||||||
)
|
)
|
||||||
|
return _cached_3cx
|
||||||
|
|
||||||
|
|
||||||
def set_holiday_queue_agents(schedule, date_str: str) -> None:
|
def set_holiday_queue_agents(schedule, date_str: str) -> None:
|
||||||
|
|
@ -117,6 +123,14 @@ def set_holiday_queue_agents(schedule, date_str: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
def activate_holiday_inline(schedule, date_str: str) -> None:
|
def activate_holiday_inline(schedule, date_str: str) -> None:
|
||||||
|
if os.environ.get("JOBS_QUEUE_URL", "").strip():
|
||||||
|
from shared.holiday_flow import activate
|
||||||
|
|
||||||
|
try:
|
||||||
|
activate(schedule, date_str)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed in-process holiday activate for %s", date_str)
|
||||||
|
return
|
||||||
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
||||||
if not router_arn:
|
if not router_arn:
|
||||||
logger.warning("HOLIDAY_ROUTER_ARN not set — skipping inline activation")
|
logger.warning("HOLIDAY_ROUTER_ARN not set — skipping inline activation")
|
||||||
|
|
@ -136,13 +150,34 @@ def holiday_schedule_names(date_str: str) -> tuple[str, str]:
|
||||||
return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}"
|
return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}"
|
||||||
|
|
||||||
|
|
||||||
def create_holiday_schedules(date_str: str) -> list[str]:
|
def _holiday_schedule_target(action: str, date_str: str) -> dict | None:
|
||||||
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
|
||||||
role_arn = os.environ.get("HOLIDAY_SCHEDULER_ROLE_ARN")
|
role_arn = os.environ.get("HOLIDAY_SCHEDULER_ROLE_ARN")
|
||||||
|
queue_arn = os.environ.get("JOBS_QUEUE_ARN", "").strip()
|
||||||
|
if queue_arn and role_arn:
|
||||||
|
return {
|
||||||
|
"Arn": queue_arn,
|
||||||
|
"RoleArn": role_arn,
|
||||||
|
"Input": json.dumps(
|
||||||
|
{"event": "holiday", "action": action, "date": date_str}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
||||||
|
if router_arn and role_arn:
|
||||||
|
return {
|
||||||
|
"Arn": router_arn,
|
||||||
|
"RoleArn": role_arn,
|
||||||
|
"Input": json.dumps({"action": action, "date": date_str}),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_holiday_schedules(date_str: str) -> list[str]:
|
||||||
group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default")
|
group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default")
|
||||||
if not router_arn or not role_arn:
|
target = _holiday_schedule_target("activate", date_str)
|
||||||
|
if target is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"HOLIDAY_ROUTER_ARN/HOLIDAY_SCHEDULER_ROLE_ARN not set — skipping schedules"
|
"HOLIDAY_SCHEDULER_ROLE_ARN plus JOBS_QUEUE_ARN or HOLIDAY_ROUTER_ARN "
|
||||||
|
"not set — skipping schedules"
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
activate_name, deactivate_name = holiday_schedule_names(date_str)
|
activate_name, deactivate_name = holiday_schedule_names(date_str)
|
||||||
|
|
@ -160,11 +195,7 @@ def create_holiday_schedules(date_str: str) -> list[str]:
|
||||||
ScheduleExpressionTimezone="America/New_York",
|
ScheduleExpressionTimezone="America/New_York",
|
||||||
FlexibleTimeWindow={"Mode": "OFF"},
|
FlexibleTimeWindow={"Mode": "OFF"},
|
||||||
ActionAfterCompletion="DELETE",
|
ActionAfterCompletion="DELETE",
|
||||||
Target={
|
Target=_holiday_schedule_target(action, date_str),
|
||||||
"Arn": router_arn,
|
|
||||||
"RoleArn": role_arn,
|
|
||||||
"Input": json.dumps({"action": action, "date": date_str}),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
created.append(name)
|
created.append(name)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -240,7 +271,14 @@ def post_shift_change(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def dm_swap_request(token: str | None, requester_slack: str, target_slack: str, date_str: str, shift_type: str, requester_name: str) -> bool:
|
def dm_swap_request(
|
||||||
|
token: str | None,
|
||||||
|
requester_slack: str,
|
||||||
|
target_slack: str,
|
||||||
|
date_str: str,
|
||||||
|
shift_type: str,
|
||||||
|
requester_name: str,
|
||||||
|
) -> bool:
|
||||||
if not token or not target_slack:
|
if not token or not target_slack:
|
||||||
return False
|
return False
|
||||||
return slack_call(
|
return slack_call(
|
||||||
|
|
@ -258,7 +296,14 @@ def dm_text(token: str | None, user_id: str, text: str) -> None:
|
||||||
slack_call("chat.postMessage", token, channel=user_id, text=text)
|
slack_call("chat.postMessage", token, channel=user_id, text=text)
|
||||||
|
|
||||||
|
|
||||||
def dm_late_pickup_admins(schedule, token: str | None, employee: dict, date_str: str, shift_type: str, is_holiday: bool) -> int:
|
def dm_late_pickup_admins(
|
||||||
|
schedule,
|
||||||
|
token: str | None,
|
||||||
|
employee: dict,
|
||||||
|
date_str: str,
|
||||||
|
shift_type: str,
|
||||||
|
is_holiday: bool,
|
||||||
|
) -> int:
|
||||||
if not token:
|
if not token:
|
||||||
return 0
|
return 0
|
||||||
blocks = build_pickup_request_blocks(
|
blocks = build_pickup_request_blocks(
|
||||||
|
|
@ -272,12 +317,16 @@ def dm_late_pickup_admins(schedule, token: str | None, employee: dict, date_str:
|
||||||
text = f"{employee['name']} wants to pick up the already-started {date_str} shift"
|
text = f"{employee['name']} wants to pick up the already-started {date_str} shift"
|
||||||
delivered = 0
|
delivered = 0
|
||||||
for admin_id in schedule.get_admin_users():
|
for admin_id in schedule.get_admin_users():
|
||||||
if slack_call("chat.postMessage", token, channel=admin_id, blocks=blocks, text=text):
|
if slack_call(
|
||||||
|
"chat.postMessage", token, channel=admin_id, blocks=blocks, text=text
|
||||||
|
):
|
||||||
delivered += 1
|
delivered += 1
|
||||||
return delivered
|
return delivered
|
||||||
|
|
||||||
|
|
||||||
def post_holiday_added(token: str | None, date_str: str, label: str, slots: int, multiplier) -> None:
|
def post_holiday_added(
|
||||||
|
token: str | None, date_str: str, label: str, slots: int, multiplier
|
||||||
|
) -> None:
|
||||||
channel = os.environ.get("SHIFT_CHANNEL")
|
channel = os.environ.get("SHIFT_CHANNEL")
|
||||||
if not channel or not token:
|
if not channel or not token:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,23 @@ import requests
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_oauth_clients: dict[tuple[str, str], "ThreeCXClient"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_client(domain: str, client_id: str, client_secret: str) -> "ThreeCXClient":
|
||||||
|
"""Reuse one OAuth client per (domain, client_id) in this process."""
|
||||||
|
key = (domain, client_id)
|
||||||
|
client = _oauth_clients.get(key)
|
||||||
|
if client is None:
|
||||||
|
client = ThreeCXClient(
|
||||||
|
domain=domain,
|
||||||
|
auth_mode="oauth",
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
)
|
||||||
|
_oauth_clients[key] = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
class ThreeCXClient:
|
class ThreeCXClient:
|
||||||
"""Client for 3CX V20 cloud-hosted management API (XAPI)."""
|
"""Client for 3CX V20 cloud-hosted management API (XAPI)."""
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ is a thin wiring layer that registers the Bolt routes and delegates to them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -191,85 +190,24 @@ def _set_holiday_queue_agents(schedule, date_str: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _activate_holiday_inline(schedule, date_str: str) -> None:
|
def _activate_holiday_inline(schedule, date_str: str) -> None:
|
||||||
"""Invoke the holiday router's activate path now, for a holiday added late.
|
"""Activate a late-added holiday in-process or via the holiday-router Lambda."""
|
||||||
|
from shared.side_effects import activate_holiday_inline
|
||||||
|
|
||||||
When an admin schedules a holiday whose window is already open (08:00 ≤ now <
|
activate_holiday_inline(schedule, date_str)
|
||||||
17:00 ET), the 08:00 activation schedule has already passed, so the call flow
|
|
||||||
must be repointed immediately. We invoke the holiday-router Lambda
|
|
||||||
asynchronously so the (idempotent) activate logic — IVR capture/repoint,
|
|
||||||
queue membership, ``activated`` flag — runs exactly as it would at 08:00.
|
|
||||||
Best-effort: a missing ARN or invoke failure is logged, not raised.
|
|
||||||
"""
|
|
||||||
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
|
||||||
if not router_arn:
|
|
||||||
logger.warning("HOLIDAY_ROUTER_ARN not set — skipping inline activation")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
boto3.client("lambda").invoke(
|
|
||||||
FunctionName=router_arn,
|
|
||||||
InvocationType="Event",
|
|
||||||
Payload=json.dumps({"action": "activate", "date": date_str}).encode(),
|
|
||||||
)
|
|
||||||
logger.info("Invoked holiday router inline activate for %s", date_str)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to invoke holiday router for %s", date_str)
|
|
||||||
|
|
||||||
|
|
||||||
def _holiday_schedule_names(date_str: str) -> tuple[str, str]:
|
def _holiday_schedule_names(date_str: str) -> tuple[str, str]:
|
||||||
"""The (activate, deactivate) one-off schedule names for a holiday date."""
|
"""The (activate, deactivate) one-off schedule names for a holiday date."""
|
||||||
compact = date_str.replace("-", "")
|
from shared.side_effects import holiday_schedule_names
|
||||||
return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}"
|
|
||||||
|
return holiday_schedule_names(date_str)
|
||||||
|
|
||||||
|
|
||||||
def _create_holiday_schedules(date_str: str) -> list[str]:
|
def _create_holiday_schedules(date_str: str) -> list[str]:
|
||||||
"""Create the two one-off EventBridge schedules for a holiday and return names.
|
"""Create the two one-off EventBridge schedules for a holiday and return names."""
|
||||||
|
from shared.side_effects import create_holiday_schedules
|
||||||
|
|
||||||
One schedule fires the holiday router's ``activate`` at 08:00 ET on the
|
return create_holiday_schedules(date_str)
|
||||||
date, the other its ``deactivate`` at 17:00 ET. Both use a flexible
|
|
||||||
one-time ``at(...)`` expression in ``America/New_York``,
|
|
||||||
``ActionAfterCompletion=DELETE`` (self-cleanup once fired), and target the
|
|
||||||
holiday-router Lambda via the passed scheduler execution role.
|
|
||||||
|
|
||||||
Returns the created schedule names (stored on the HOLIDAY record so a later
|
|
||||||
``remove`` can delete any that have not yet fired). Best-effort: returns the
|
|
||||||
names it managed to create; missing config short-circuits to ``[]``.
|
|
||||||
"""
|
|
||||||
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
|
||||||
role_arn = os.environ.get("HOLIDAY_SCHEDULER_ROLE_ARN")
|
|
||||||
group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default")
|
|
||||||
if not router_arn or not role_arn:
|
|
||||||
logger.warning(
|
|
||||||
"HOLIDAY_ROUTER_ARN/HOLIDAY_SCHEDULER_ROLE_ARN not set — "
|
|
||||||
"skipping schedule creation"
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
activate_name, deactivate_name = _holiday_schedule_names(date_str)
|
|
||||||
client = boto3.client("scheduler")
|
|
||||||
created: list[str] = []
|
|
||||||
specs = [
|
|
||||||
(activate_name, "activate", "08:00:00"),
|
|
||||||
(deactivate_name, "deactivate", "17:00:00"),
|
|
||||||
]
|
|
||||||
for name, action, at_time in specs:
|
|
||||||
try:
|
|
||||||
client.create_schedule(
|
|
||||||
Name=name,
|
|
||||||
GroupName=group,
|
|
||||||
ScheduleExpression=f"at({date_str}T{at_time})",
|
|
||||||
ScheduleExpressionTimezone="America/New_York",
|
|
||||||
FlexibleTimeWindow={"Mode": "OFF"},
|
|
||||||
ActionAfterCompletion="DELETE",
|
|
||||||
Target={
|
|
||||||
"Arn": router_arn,
|
|
||||||
"RoleArn": role_arn,
|
|
||||||
"Input": json.dumps({"action": action, "date": date_str}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
created.append(name)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to create %s schedule for %s", action, date_str)
|
|
||||||
return created
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_holiday_schedules(schedule_names: list[str]) -> None:
|
def _delete_holiday_schedules(schedule_names: list[str]) -> None:
|
||||||
|
|
@ -2230,19 +2168,31 @@ def _admin_holiday_list(respond, schedule):
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=1)
|
@functools.lru_cache(maxsize=1)
|
||||||
def _changelog_text() -> str:
|
def _changelog_text() -> str:
|
||||||
"""Read the CHANGELOG shipped in this function's package.
|
"""Read the CHANGELOG shipped next to this module.
|
||||||
|
|
||||||
Lazy (never at import) and tolerant of a missing file, so the App Home tab
|
Lazy (never at import) and tolerant of a missing file, so the App Home tab
|
||||||
degrades to "no What's New section" rather than erroring. The copy lives at
|
degrades to "no What's New section" rather than erroring. Lambda zips and
|
||||||
``$LAMBDA_TASK_ROOT/CHANGELOG.md`` (synced from the repo root).
|
the Fargate image both keep ``CHANGELOG.md`` beside ``app.py``.
|
||||||
|
``LAMBDA_TASK_ROOT`` remains a fallback for the zip layout.
|
||||||
"""
|
"""
|
||||||
path = os.path.join(os.environ.get("LAMBDA_TASK_ROOT", "."), "CHANGELOG.md")
|
tried = []
|
||||||
try:
|
for path in _changelog_paths():
|
||||||
with open(path, encoding="utf-8") as fh:
|
tried.append(path)
|
||||||
return fh.read()
|
try:
|
||||||
except OSError:
|
with open(path, encoding="utf-8") as fh:
|
||||||
logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", path)
|
return fh.read()
|
||||||
return ""
|
except OSError:
|
||||||
|
continue
|
||||||
|
logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", tried)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _changelog_paths() -> list[str]:
|
||||||
|
paths = [os.path.join(os.path.dirname(os.path.abspath(__file__)), "CHANGELOG.md")]
|
||||||
|
task_root = os.environ.get("LAMBDA_TASK_ROOT", "").strip()
|
||||||
|
if task_root:
|
||||||
|
paths.append(os.path.join(task_root, "CHANGELOG.md"))
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
_HOME_OVERVIEW_DAYS = 60
|
_HOME_OVERVIEW_DAYS = 60
|
||||||
|
|
|
||||||
14
terraform/acm.tf
Normal file
14
terraform/acm.tf
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# ACM certificate for ALB HTTPS. Lookup only; do not mint. The issued wildcard
|
||||||
|
# already exists in the account (portal pattern).
|
||||||
|
#
|
||||||
|
# Bootstrap order if the listener is ever rebuilt from nothing:
|
||||||
|
# 1. Confirm an ISSUED certificate for local.acm_wildcard_domain exists.
|
||||||
|
# 2. Set attach_custom_domain=true and apply. Until the lookup finds ISSUED,
|
||||||
|
# the plan fails closed.
|
||||||
|
|
||||||
|
data "aws_acm_certificate" "wildcard" {
|
||||||
|
count = var.attach_custom_domain ? 1 : 0
|
||||||
|
domain = local.acm_wildcard_domain
|
||||||
|
statuses = ["ISSUED"]
|
||||||
|
most_recent = true
|
||||||
|
}
|
||||||
|
|
@ -153,3 +153,53 @@ resource "aws_cloudwatch_metric_alarm" "api_latency" {
|
||||||
treat_missing_data = "notBreaching"
|
treat_missing_data = "notBreaching"
|
||||||
alarm_actions = [local.site_alerts_arn]
|
alarm_actions = [local.site_alerts_arn]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
|
||||||
|
alarm_name = "ALB-5xx-${local.project}"
|
||||||
|
alarm_description = "ALB 5xx from afterhours-shift-manager"
|
||||||
|
namespace = "AWS/ApplicationELB"
|
||||||
|
metric_name = "HTTPCode_Target_5XX_Count"
|
||||||
|
dimensions = { LoadBalancer = aws_lb.api.arn_suffix }
|
||||||
|
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" "alb_latency" {
|
||||||
|
alarm_name = "ALB-Latency-${local.project}"
|
||||||
|
alarm_description = "p99 target response time on the afterhours ALB exceeded 3s"
|
||||||
|
namespace = "AWS/ApplicationELB"
|
||||||
|
metric_name = "TargetResponseTime"
|
||||||
|
dimensions = { LoadBalancer = aws_lb.api.arn_suffix }
|
||||||
|
extended_statistic = "p99"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = 3
|
||||||
|
datapoints_to_alarm = 2
|
||||||
|
threshold = 3
|
||||||
|
comparison_operator = "GreaterThanOrEqualToThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [local.site_alerts_arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "alb_unhealthy_hosts" {
|
||||||
|
alarm_name = "ALB-UnhealthyHost-${local.project}"
|
||||||
|
alarm_description = "Unhealthy Fargate targets on the afterhours ALB"
|
||||||
|
namespace = "AWS/ApplicationELB"
|
||||||
|
metric_name = "UnHealthyHostCount"
|
||||||
|
dimensions = {
|
||||||
|
LoadBalancer = aws_lb.api.arn_suffix
|
||||||
|
TargetGroup = aws_lb_target_group.api.arn_suffix
|
||||||
|
}
|
||||||
|
statistic = "Maximum"
|
||||||
|
period = 60
|
||||||
|
evaluation_periods = 3
|
||||||
|
datapoints_to_alarm = 3
|
||||||
|
threshold = 0
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [local.site_alerts_arn]
|
||||||
|
}
|
||||||
|
|
|
||||||
25
terraform/data.tf
Normal file
25
terraform/data.tf
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
data "aws_caller_identity" "current" {}
|
||||||
|
|
||||||
|
# Resource names in locals.tf embed the account ID. If the workspace is ever
|
||||||
|
# pointed at another account, fail the plan here rather than creating a parallel
|
||||||
|
# set of oddly-named resources somewhere else.
|
||||||
|
check "correct_account" {
|
||||||
|
assert {
|
||||||
|
condition = data.aws_caller_identity.current.account_id == local.account_id
|
||||||
|
error_message = "This configuration targets account ${local.account_id} (${var.environment}), but the credentials resolve to ${data.aws_caller_identity.current.account_id}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check "dev_has_no_paychex" {
|
||||||
|
assert {
|
||||||
|
condition = local.is_prod || var.checkcomponents_queue_url == ""
|
||||||
|
error_message = "checkcomponents_queue_url must be empty in non-prod so weekly_post cannot send to the prod Paychex queue."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check "checkcomponents_pair" {
|
||||||
|
assert {
|
||||||
|
condition = (var.checkcomponents_queue_url == "") == (var.checkcomponents_queue_arn == "")
|
||||||
|
error_message = "checkcomponents_queue_url and checkcomponents_queue_arn must both be set or both be empty."
|
||||||
|
}
|
||||||
|
}
|
||||||
310
terraform/ecs.tf
Normal file
310
terraform/ecs.tf
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
# Always-on afterhours API: Fargate behind an ALB. GitHub Actions owns the
|
||||||
|
# image; Terraform ignores container_definitions after the bootstrap task
|
||||||
|
# definition. Dual-run with API Gateway until the Fargate cutover.
|
||||||
|
|
||||||
|
data "aws_vpc" "default" {
|
||||||
|
default = true
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_subnets" "default" {
|
||||||
|
filter {
|
||||||
|
name = "vpc-id"
|
||||||
|
values = [data.aws_vpc.default.id]
|
||||||
|
}
|
||||||
|
|
||||||
|
filter {
|
||||||
|
name = "default-for-az"
|
||||||
|
values = ["true"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecr_repository" "api" {
|
||||||
|
name = local.project
|
||||||
|
image_tag_mutability = "MUTABLE"
|
||||||
|
force_delete = !local.is_prod
|
||||||
|
|
||||||
|
image_scanning_configuration {
|
||||||
|
scan_on_push = true
|
||||||
|
}
|
||||||
|
|
||||||
|
encryption_configuration {
|
||||||
|
encryption_type = "AES256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecr_lifecycle_policy" "api" {
|
||||||
|
repository = aws_ecr_repository.api.name
|
||||||
|
|
||||||
|
policy = jsonencode({
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
rulePriority = 1
|
||||||
|
description = "Keep the last 20 images"
|
||||||
|
selection = {
|
||||||
|
tagStatus = "any"
|
||||||
|
countType = "imageCountMoreThan"
|
||||||
|
countNumber = 20
|
||||||
|
}
|
||||||
|
action = {
|
||||||
|
type = "expire"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_security_group" "alb" {
|
||||||
|
name = "${local.project}-alb"
|
||||||
|
description = "Public ALB for afterhours-shift-manager"
|
||||||
|
vpc_id = data.aws_vpc.default.id
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
description = "HTTP from the internet (health and pre-DNS)"
|
||||||
|
from_port = 80
|
||||||
|
to_port = 80
|
||||||
|
protocol = "tcp"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "ingress" {
|
||||||
|
for_each = var.attach_custom_domain ? [1] : []
|
||||||
|
content {
|
||||||
|
description = "HTTPS from the internet"
|
||||||
|
from_port = 443
|
||||||
|
to_port = 443
|
||||||
|
protocol = "tcp"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
egress {
|
||||||
|
from_port = 0
|
||||||
|
to_port = 0
|
||||||
|
protocol = "-1"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_security_group" "api" {
|
||||||
|
name = "${local.project}-api"
|
||||||
|
description = "Fargate tasks for afterhours-shift-manager"
|
||||||
|
vpc_id = data.aws_vpc.default.id
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
description = "From ALB"
|
||||||
|
from_port = 8080
|
||||||
|
to_port = 8080
|
||||||
|
protocol = "tcp"
|
||||||
|
security_groups = [aws_security_group.alb.id]
|
||||||
|
}
|
||||||
|
|
||||||
|
egress {
|
||||||
|
from_port = 0
|
||||||
|
to_port = 0
|
||||||
|
protocol = "-1"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb" "api" {
|
||||||
|
name = local.project
|
||||||
|
load_balancer_type = "application"
|
||||||
|
idle_timeout = 120
|
||||||
|
security_groups = [aws_security_group.alb.id]
|
||||||
|
subnets = data.aws_subnets.default.ids
|
||||||
|
|
||||||
|
drop_invalid_header_fields = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb_target_group" "api" {
|
||||||
|
name = "${local.project}-api"
|
||||||
|
port = 8080
|
||||||
|
protocol = "HTTP"
|
||||||
|
vpc_id = data.aws_vpc.default.id
|
||||||
|
target_type = "ip"
|
||||||
|
|
||||||
|
health_check {
|
||||||
|
enabled = true
|
||||||
|
path = "/api/health"
|
||||||
|
matcher = "200"
|
||||||
|
interval = 30
|
||||||
|
timeout = 5
|
||||||
|
healthy_threshold = 2
|
||||||
|
unhealthy_threshold = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb_listener" "http" {
|
||||||
|
load_balancer_arn = aws_lb.api.arn
|
||||||
|
port = 80
|
||||||
|
protocol = "HTTP"
|
||||||
|
|
||||||
|
dynamic "default_action" {
|
||||||
|
for_each = var.attach_custom_domain ? [1] : []
|
||||||
|
content {
|
||||||
|
type = "redirect"
|
||||||
|
redirect {
|
||||||
|
port = "443"
|
||||||
|
protocol = "HTTPS"
|
||||||
|
status_code = "HTTP_301"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "default_action" {
|
||||||
|
for_each = var.attach_custom_domain ? [] : [1]
|
||||||
|
content {
|
||||||
|
type = "forward"
|
||||||
|
target_group_arn = aws_lb_target_group.api.arn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb_listener" "https" {
|
||||||
|
count = var.attach_custom_domain ? 1 : 0
|
||||||
|
|
||||||
|
load_balancer_arn = aws_lb.api.arn
|
||||||
|
port = 443
|
||||||
|
protocol = "HTTPS"
|
||||||
|
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
|
||||||
|
certificate_arn = data.aws_acm_certificate.wildcard[0].arn
|
||||||
|
|
||||||
|
default_action {
|
||||||
|
type = "forward"
|
||||||
|
target_group_arn = aws_lb_target_group.api.arn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_cluster" "api" {
|
||||||
|
name = local.project
|
||||||
|
|
||||||
|
setting {
|
||||||
|
name = "containerInsights"
|
||||||
|
value = local.is_prod ? "enabled" : "disabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
api_container_name = "api"
|
||||||
|
bootstrap_command = [
|
||||||
|
"python",
|
||||||
|
"-c",
|
||||||
|
"from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler\nclass H(BaseHTTPRequestHandler):\n def do_GET(self):\n body = b'{\"stage\":\"bootstrap\",\"sha\":\"bootstrap\"}'\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.send_header('Content-Length', str(len(body)))\n self.end_headers()\n self.wfile.write(body)\nThreadingHTTPServer(('0.0.0.0', 8080), H).serve_forever()",
|
||||||
|
]
|
||||||
|
|
||||||
|
api_environment = [
|
||||||
|
{ name = "STAGE", value = var.environment },
|
||||||
|
{ name = "GIT_SHA", value = "bootstrap" },
|
||||||
|
{ name = "SHIFT_TABLE", value = aws_dynamodb_table.shifts.name },
|
||||||
|
{ name = "SLACK_BOT_TOKEN_SECRET", value = "afterhours-shift-manager/slack-bot-token" },
|
||||||
|
{ name = "SLACK_SIGNING_SECRET", value = "afterhours-shift-manager/slack-signing-secret" },
|
||||||
|
{ name = "SHIFT_CHANNEL", value = var.shift_channel },
|
||||||
|
{ name = "TCX_SECRET_PREFIX", value = "afterhours-shift-manager/3cx-" },
|
||||||
|
{ name = "QUEUE_NUMBER", value = var.queue_number },
|
||||||
|
{ name = "TZ", value = var.timezone },
|
||||||
|
{ name = "HOLIDAY_ROUTER_ARN", value = local.holiday_router_arn },
|
||||||
|
{ name = "HOLIDAY_SCHEDULER_ROLE_ARN", value = local.holiday_scheduler_role_arn },
|
||||||
|
{ name = "HOLIDAY_SCHEDULE_GROUP", value = "default" },
|
||||||
|
{ name = "SENTRY_DSN", value = var.sentry_dsn },
|
||||||
|
{ name = "PORTAL_COGNITO_ISSUER", value = var.portal_cognito_issuer },
|
||||||
|
{ name = "PORTAL_COGNITO_AUDIENCE", value = var.portal_cognito_audience },
|
||||||
|
{ name = "PORTAL_COGNITO_TRUST", value = jsonencode(concat(
|
||||||
|
var.portal_cognito_issuer != "" && var.portal_cognito_audience != "" ? [{ issuer = var.portal_cognito_issuer, audience = var.portal_cognito_audience }] : [],
|
||||||
|
var.portal_cognito_extra_trust,
|
||||||
|
)) },
|
||||||
|
{ name = "PAY_REPORT_USER", value = var.pay_report_user },
|
||||||
|
{ name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url },
|
||||||
|
{ name = "ROSTER_API_TOKEN_SECRET", value = "afterhours-shift-manager/roster-api-token" },
|
||||||
|
{ name = "SYNC_GROUP", value = "DEFAULT" },
|
||||||
|
{ name = "JOBS_QUEUE_URL", value = aws_sqs_queue.jobs.id },
|
||||||
|
{ name = "JOBS_QUEUE_ARN", value = aws_sqs_queue.jobs.arn },
|
||||||
|
{ name = "AWS_DEFAULT_REGION", value = var.aws_region },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_task_definition" "api" {
|
||||||
|
family = local.project
|
||||||
|
requires_compatibilities = ["FARGATE"]
|
||||||
|
network_mode = "awsvpc"
|
||||||
|
cpu = "512"
|
||||||
|
memory = "1024"
|
||||||
|
execution_role_arn = aws_iam_role.ecs_execution.arn
|
||||||
|
task_role_arn = aws_iam_role.ecs_task.arn
|
||||||
|
|
||||||
|
runtime_platform {
|
||||||
|
operating_system_family = "LINUX"
|
||||||
|
cpu_architecture = "ARM64"
|
||||||
|
}
|
||||||
|
|
||||||
|
container_definitions = jsonencode([
|
||||||
|
{
|
||||||
|
name = local.api_container_name
|
||||||
|
image = "public.ecr.aws/docker/library/python:3.12-slim"
|
||||||
|
essential = true
|
||||||
|
command = local.bootstrap_command
|
||||||
|
portMappings = [
|
||||||
|
{
|
||||||
|
containerPort = 8080
|
||||||
|
protocol = "tcp"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
environment = local.api_environment
|
||||||
|
logConfiguration = {
|
||||||
|
logDriver = "awslogs"
|
||||||
|
options = {
|
||||||
|
"awslogs-group" = aws_cloudwatch_log_group.api.name
|
||||||
|
"awslogs-region" = var.aws_region
|
||||||
|
"awslogs-stream-prefix" = "ecs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [container_definitions]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_service" "api" {
|
||||||
|
name = local.project
|
||||||
|
cluster = aws_ecs_cluster.api.id
|
||||||
|
task_definition = aws_ecs_task_definition.api.arn
|
||||||
|
desired_count = local.is_prod ? 2 : 1
|
||||||
|
launch_type = "FARGATE"
|
||||||
|
health_check_grace_period_seconds = 60
|
||||||
|
deployment_minimum_healthy_percent = local.is_prod ? 50 : 0
|
||||||
|
deployment_maximum_percent = 200
|
||||||
|
|
||||||
|
network_configuration {
|
||||||
|
subnets = data.aws_subnets.default.ids
|
||||||
|
security_groups = [aws_security_group.api.id]
|
||||||
|
assign_public_ip = true
|
||||||
|
}
|
||||||
|
|
||||||
|
load_balancer {
|
||||||
|
target_group_arn = aws_lb_target_group.api.arn
|
||||||
|
container_name = local.api_container_name
|
||||||
|
container_port = 8080
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [task_definition, desired_count]
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [aws_lb_listener.http]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_sqs_queue" "jobs_dlq" {
|
||||||
|
name = "${local.project}-jobs-dlq"
|
||||||
|
message_retention_seconds = 1209600
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_sqs_queue" "jobs" {
|
||||||
|
name = "${local.project}-jobs"
|
||||||
|
visibility_timeout_seconds = 180
|
||||||
|
receive_wait_time_seconds = 20
|
||||||
|
redrive_policy = jsonencode({
|
||||||
|
deadLetterTargetArn = aws_sqs_queue.jobs_dlq.arn
|
||||||
|
maxReceiveCount = 3
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -147,7 +147,7 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
|
||||||
}
|
}
|
||||||
|
|
||||||
statement {
|
statement {
|
||||||
sid = "PassExecRolesToLambda"
|
sid = "PassExecRolesToCompute"
|
||||||
effect = "Allow"
|
effect = "Allow"
|
||||||
actions = ["iam:PassRole"]
|
actions = ["iam:PassRole"]
|
||||||
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"]
|
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"]
|
||||||
|
|
@ -155,7 +155,7 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
|
||||||
condition {
|
condition {
|
||||||
test = "StringEquals"
|
test = "StringEquals"
|
||||||
variable = "iam:PassedToService"
|
variable = "iam:PassedToService"
|
||||||
values = ["lambda.amazonaws.com"]
|
values = ["lambda.amazonaws.com", "ecs-tasks.amazonaws.com", "scheduler.amazonaws.com"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,6 +336,8 @@ data "aws_iam_policy_document" "hcptf_apply_services" {
|
||||||
"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/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",
|
||||||
"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:*",
|
||||||
|
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/ecs/afterhours-shift-manager",
|
||||||
|
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/ecs/afterhours-shift-manager:*",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,6 +492,7 @@ data "aws_iam_policy_document" "hcptf_apply_services" {
|
||||||
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:Lambda-*-afterhours-*",
|
"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:DDB-*-afterhours-shifts",
|
||||||
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:ApiGateway-*",
|
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:ApiGateway-*",
|
||||||
|
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:ALB-*-afterhours-shift-manager",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -541,6 +544,8 @@ data "aws_iam_policy_document" "hcptf_apply_services" {
|
||||||
]
|
]
|
||||||
resources = [
|
resources = [
|
||||||
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*",
|
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*",
|
||||||
|
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/${local.project}/*",
|
||||||
|
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule-group/${local.project}",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -551,6 +556,143 @@ data "aws_iam_policy_document" "hcptf_apply_services" {
|
||||||
"scheduler:ListSchedules",
|
"scheduler:ListSchedules",
|
||||||
"scheduler:ListScheduleGroups",
|
"scheduler:ListScheduleGroups",
|
||||||
"scheduler:GetScheduleGroup",
|
"scheduler:GetScheduleGroup",
|
||||||
|
"scheduler:CreateScheduleGroup",
|
||||||
|
"scheduler:DeleteScheduleGroup",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcsWorkload"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecs:*",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:ecs:${var.aws_region}:${local.account_id}:cluster/${local.project}",
|
||||||
|
"arn:aws:ecs:${var.aws_region}:${local.account_id}:service/${local.project}/${local.project}",
|
||||||
|
"arn:aws:ecs:${var.aws_region}:${local.account_id}:task-definition/${local.project}:*",
|
||||||
|
"arn:aws:ecs:${var.aws_region}:${local.account_id}:task-definition/${local.project}",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcsAccount"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecs:CreateCluster",
|
||||||
|
"ecs:CreateService",
|
||||||
|
"ecs:DeleteService",
|
||||||
|
"ecs:DeregisterTaskDefinition",
|
||||||
|
"ecs:DescribeClusters",
|
||||||
|
"ecs:DescribeServices",
|
||||||
|
"ecs:DescribeTaskDefinition",
|
||||||
|
"ecs:ListClusters",
|
||||||
|
"ecs:ListServices",
|
||||||
|
"ecs:ListTaskDefinitions",
|
||||||
|
"ecs:RegisterTaskDefinition",
|
||||||
|
"ecs:TagResource",
|
||||||
|
"ecs:UntagResource",
|
||||||
|
"ecs:UpdateService",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "ElbWorkload"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"elasticloadbalancing:*",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:elasticloadbalancing:${var.aws_region}:${local.account_id}:loadbalancer/app/${local.project}/*",
|
||||||
|
"arn:aws:elasticloadbalancing:${var.aws_region}:${local.account_id}:targetgroup/${local.project}-api/*",
|
||||||
|
"arn:aws:elasticloadbalancing:${var.aws_region}:${local.account_id}:listener/app/${local.project}/*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "ElbDescribe"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"elasticloadbalancing:Describe*",
|
||||||
|
"elasticloadbalancing:CreateLoadBalancer",
|
||||||
|
"elasticloadbalancing:CreateTargetGroup",
|
||||||
|
"elasticloadbalancing:CreateListener",
|
||||||
|
"elasticloadbalancing:CreateRule",
|
||||||
|
"elasticloadbalancing:AddTags",
|
||||||
|
"elasticloadbalancing:ModifyLoadBalancerAttributes",
|
||||||
|
"elasticloadbalancing:ModifyTargetGroup",
|
||||||
|
"elasticloadbalancing:ModifyTargetGroupAttributes",
|
||||||
|
"elasticloadbalancing:ModifyListener",
|
||||||
|
"elasticloadbalancing:SetSecurityGroups",
|
||||||
|
"elasticloadbalancing:SetSubnets",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcrRepo"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecr:*",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:ecr:${var.aws_region}:${local.account_id}:repository/${local.project}",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcrAccount"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecr:DescribeRepositories",
|
||||||
|
"ecr:GetAuthorizationToken",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "JobsQueues"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"sqs:*",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs",
|
||||||
|
"arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs-dlq",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "VpcSecurityGroups"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ec2:DescribeVpcs",
|
||||||
|
"ec2:DescribeSubnets",
|
||||||
|
"ec2:DescribeSecurityGroups",
|
||||||
|
"ec2:DescribeNetworkInterfaces",
|
||||||
|
"ec2:DescribeAccountAttributes",
|
||||||
|
"ec2:CreateSecurityGroup",
|
||||||
|
"ec2:DeleteSecurityGroup",
|
||||||
|
"ec2:AuthorizeSecurityGroupIngress",
|
||||||
|
"ec2:AuthorizeSecurityGroupEgress",
|
||||||
|
"ec2:RevokeSecurityGroupIngress",
|
||||||
|
"ec2:RevokeSecurityGroupEgress",
|
||||||
|
"ec2:CreateTags",
|
||||||
|
"ec2:DeleteTags",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "AcmLookup"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"acm:ListCertificates",
|
||||||
|
"acm:DescribeCertificate",
|
||||||
|
"acm:ListTagsForCertificate",
|
||||||
|
"acm:GetCertificate",
|
||||||
]
|
]
|
||||||
resources = ["*"]
|
resources = ["*"]
|
||||||
}
|
}
|
||||||
|
|
@ -717,6 +859,8 @@ data "aws_iam_policy_document" "hcptf_plan_refresh" {
|
||||||
]
|
]
|
||||||
resources = [
|
resources = [
|
||||||
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*",
|
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*",
|
||||||
|
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/${local.project}/*",
|
||||||
|
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule-group/${local.project}",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -731,6 +875,78 @@ data "aws_iam_policy_document" "hcptf_plan_refresh" {
|
||||||
resources = ["*"]
|
resources = ["*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "RefreshEcs"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecs:DescribeClusters",
|
||||||
|
"ecs:DescribeServices",
|
||||||
|
"ecs:DescribeTaskDefinition",
|
||||||
|
"ecs:DescribeTaskSets",
|
||||||
|
"ecs:ListClusters",
|
||||||
|
"ecs:ListServices",
|
||||||
|
"ecs:ListTaskDefinitions",
|
||||||
|
"ecs:ListTagsForResource",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "RefreshElb"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"elasticloadbalancing:DescribeLoadBalancers",
|
||||||
|
"elasticloadbalancing:DescribeLoadBalancerAttributes",
|
||||||
|
"elasticloadbalancing:DescribeListeners",
|
||||||
|
"elasticloadbalancing:DescribeListenerAttributes",
|
||||||
|
"elasticloadbalancing:DescribeTargetGroups",
|
||||||
|
"elasticloadbalancing:DescribeTargetGroupAttributes",
|
||||||
|
"elasticloadbalancing:DescribeTags",
|
||||||
|
"elasticloadbalancing:DescribeRules",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "RefreshEcr"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecr:DescribeRepositories",
|
||||||
|
"ecr:DescribeImages",
|
||||||
|
"ecr:GetLifecyclePolicy",
|
||||||
|
"ecr:ListTagsForResource",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:ecr:${var.aws_region}:${local.account_id}:repository/${local.project}",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "RefreshSqs"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"sqs:GetQueueAttributes",
|
||||||
|
"sqs:GetQueueUrl",
|
||||||
|
"sqs:ListQueueTags",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs",
|
||||||
|
"arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs-dlq",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "RefreshAcm"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"acm:DescribeCertificate",
|
||||||
|
"acm:ListCertificates",
|
||||||
|
"acm:ListTagsForCertificate",
|
||||||
|
"acm:GetCertificate",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
statement {
|
statement {
|
||||||
sid = "RefreshSsm"
|
sid = "RefreshSsm"
|
||||||
effect = "Allow"
|
effect = "Allow"
|
||||||
|
|
|
||||||
191
terraform/iam.tf
Normal file
191
terraform/iam.tf
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
# Execution, task, and EventBridge Scheduler roles for the Fargate API.
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "ecs_assume" {
|
||||||
|
statement {
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sts:AssumeRole"]
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Service"
|
||||||
|
identifiers = ["ecs-tasks.amazonaws.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "jobs_scheduler_assume" {
|
||||||
|
statement {
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sts:AssumeRole"]
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Service"
|
||||||
|
identifiers = ["scheduler.amazonaws.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "ecs_task_boundary" {
|
||||||
|
statement {
|
||||||
|
sid = "DdbCrud"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"dynamodb:BatchGetItem",
|
||||||
|
"dynamodb:BatchWriteItem",
|
||||||
|
"dynamodb:ConditionCheckItem",
|
||||||
|
"dynamodb:DeleteItem",
|
||||||
|
"dynamodb:DescribeTable",
|
||||||
|
"dynamodb:GetItem",
|
||||||
|
"dynamodb:PutItem",
|
||||||
|
"dynamodb:Query",
|
||||||
|
"dynamodb:Scan",
|
||||||
|
"dynamodb:UpdateItem",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
aws_dynamodb_table.shifts.arn,
|
||||||
|
"${aws_dynamodb_table.shifts.arn}/index/*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "Secrets"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["secretsmanager:GetSecretValue"]
|
||||||
|
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "HolidaySchedules"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"scheduler:CreateSchedule",
|
||||||
|
"scheduler:DeleteSchedule",
|
||||||
|
"scheduler:GetSchedule",
|
||||||
|
]
|
||||||
|
resources = ["arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "PassHolidayScheduler"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["iam:PassRole"]
|
||||||
|
resources = [local.holiday_scheduler_role_arn]
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "iam:PassedToService"
|
||||||
|
values = ["scheduler.amazonaws.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "InvokeHolidayRouter"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["lambda:InvokeFunction"]
|
||||||
|
resources = [local.holiday_router_arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "JobsQueue"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"sqs:SendMessage",
|
||||||
|
"sqs:ReceiveMessage",
|
||||||
|
"sqs:DeleteMessage",
|
||||||
|
"sqs:GetQueueAttributes",
|
||||||
|
]
|
||||||
|
resources = [aws_sqs_queue.jobs.arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "CheckcomponentsSend"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sqs:SendMessage"]
|
||||||
|
resources = compact([var.checkcomponents_queue_arn])
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcrAuth"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["ecr:GetAuthorizationToken"]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcrPull"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecr:BatchCheckLayerAvailability",
|
||||||
|
"ecr:BatchGetImage",
|
||||||
|
"ecr:GetDownloadUrlForLayer",
|
||||||
|
]
|
||||||
|
resources = [aws_ecr_repository.api.arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "TaskLogs"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"logs:CreateLogStream",
|
||||||
|
"logs:PutLogEvents",
|
||||||
|
"logs:CreateLogGroup",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
aws_cloudwatch_log_group.api.arn,
|
||||||
|
"${aws_cloudwatch_log_group.api.arn}:*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_policy" "ecs_task_boundary" {
|
||||||
|
name = "${local.project}-ecs-task-boundary"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
description = "Permissions boundary for the afterhours-shift-manager ECS task role"
|
||||||
|
policy = data.aws_iam_policy_document.ecs_task_boundary.json
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "ecs_execution" {
|
||||||
|
name = "${local.project}-ecs-exec"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
|
||||||
|
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "ecs_execution" {
|
||||||
|
role = aws_iam_role.ecs_execution.name
|
||||||
|
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "ecs_task" {
|
||||||
|
name = "${local.project}-api"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
|
||||||
|
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "ecs_task" {
|
||||||
|
name = "api-runtime"
|
||||||
|
role = aws_iam_role.ecs_task.id
|
||||||
|
policy = data.aws_iam_policy_document.ecs_task_boundary.json
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "jobs_scheduler" {
|
||||||
|
name = "${local.project}-scheduler"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.jobs_scheduler_assume.json
|
||||||
|
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "jobs_scheduler" {
|
||||||
|
statement {
|
||||||
|
sid = "SendJobs"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sqs:SendMessage"]
|
||||||
|
resources = [aws_sqs_queue.jobs.arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "jobs_scheduler" {
|
||||||
|
name = "enqueue-jobs"
|
||||||
|
role = aws_iam_role.jobs_scheduler.id
|
||||||
|
policy = data.aws_iam_policy_document.jobs_scheduler.json
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
# GitHub Actions OIDC role for .github/workflows/deploy.yaml.
|
# GitHub Actions OIDC role for .github/workflows/deploy.yaml and deploy-api.yaml.
|
||||||
#
|
#
|
||||||
# Trust is pinned three ways: aud, sub to Environment prod (immutable and
|
# One role: GitHub Environments have a single DEPLOY_ROLE_ARN. Trust is pinned
|
||||||
# classic subject forms), and job_workflow_ref to deploy.yaml at
|
# to Environments dev and prod (immutable and classic subject forms) and
|
||||||
# refs/heads/main only. Live GitHub Actions presented the classic sub; both
|
# job_workflow_ref to deploy.yaml at main plus deploy-api.yaml at main and v*.
|
||||||
# forms are listed. 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" {
|
data "aws_iam_policy_document" "github_deploy_assume" {
|
||||||
statement {
|
statement {
|
||||||
|
|
@ -30,17 +24,16 @@ data "aws_iam_policy_document" "github_deploy_assume" {
|
||||||
condition {
|
condition {
|
||||||
test = "StringEquals"
|
test = "StringEquals"
|
||||||
variable = "token.actions.githubusercontent.com:sub"
|
variable = "token.actions.githubusercontent.com:sub"
|
||||||
values = [
|
values = local.github_oidc_subs
|
||||||
local.github_oidc_sub,
|
|
||||||
"repo:${var.github_repo}:environment:prod",
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
condition {
|
condition {
|
||||||
test = "StringEquals"
|
test = "StringLike"
|
||||||
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
||||||
values = [
|
values = [
|
||||||
"${var.github_repo}/.github/workflows/deploy.yaml@refs/heads/${var.github_deploy_branch}",
|
"${var.github_repo}/.github/workflows/deploy.yaml@refs/heads/${var.github_deploy_branch}",
|
||||||
|
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/heads/${var.github_deploy_branch}",
|
||||||
|
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/tags/v*",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +42,7 @@ data "aws_iam_policy_document" "github_deploy_assume" {
|
||||||
resource "aws_iam_role" "github_deploy" {
|
resource "aws_iam_role" "github_deploy" {
|
||||||
name = local.deploy_role
|
name = local.deploy_role
|
||||||
path = "/tf-managed/"
|
path = "/tf-managed/"
|
||||||
description = "GitHub Actions Lambda deploy role for ${var.github_repo} Environment prod"
|
description = "GitHub Actions zip and image deploy role for ${var.github_repo}"
|
||||||
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
|
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
|
||||||
max_session_duration = 3600
|
max_session_duration = 3600
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +79,56 @@ data "aws_iam_policy_document" "github_deploy" {
|
||||||
resources = [for fn in local.functions : "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:${fn.function_name}"]
|
resources = [for fn in local.functions : "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:${fn.function_name}"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcrAuth"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecr:GetAuthorizationToken",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcrPush"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecr:BatchCheckLayerAvailability",
|
||||||
|
"ecr:BatchGetImage",
|
||||||
|
"ecr:CompleteLayerUpload",
|
||||||
|
"ecr:GetDownloadUrlForLayer",
|
||||||
|
"ecr:InitiateLayerUpload",
|
||||||
|
"ecr:PutImage",
|
||||||
|
"ecr:UploadLayerPart",
|
||||||
|
"ecr:DescribeRepositories",
|
||||||
|
"ecr:DescribeImages",
|
||||||
|
]
|
||||||
|
resources = [aws_ecr_repository.api.arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcsDeploy"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecs:DescribeServices",
|
||||||
|
"ecs:DescribeTaskDefinition",
|
||||||
|
"ecs:DescribeTasks",
|
||||||
|
"ecs:ListTasks",
|
||||||
|
"ecs:RegisterTaskDefinition",
|
||||||
|
"ecs:UpdateService",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "PassTaskRoles"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["iam:PassRole"]
|
||||||
|
resources = [
|
||||||
|
aws_iam_role.ecs_task.arn,
|
||||||
|
aws_iam_role.ecs_execution.arn,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
statement {
|
statement {
|
||||||
sid = "DeployParams"
|
sid = "DeployParams"
|
||||||
effect = "Allow"
|
effect = "Allow"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
locals {
|
locals {
|
||||||
project = "afterhours-shift-manager"
|
project = "afterhours-shift-manager"
|
||||||
account_id = "011934824531"
|
is_prod = var.environment == "prod"
|
||||||
environment = "prod"
|
account_id = local.is_prod ? "011934824531" : "710827005802"
|
||||||
|
environment = var.environment
|
||||||
|
|
||||||
hcp_project = "seahaven-prod"
|
hcp_project = "seahaven-${var.environment}"
|
||||||
hcp_workspace = "afterhours-shift-manager-prod"
|
hcp_workspace = "${local.project}-${var.environment}"
|
||||||
apply_role = "hcptf-afterhours-shift-manager"
|
apply_role = "hcptf-afterhours-shift-manager"
|
||||||
plan_role = "hcptf-afterhours-shift-manager-plan"
|
plan_role = "hcptf-afterhours-shift-manager-plan"
|
||||||
deploy_role = "githubdeploy-afterhours-shift-manager"
|
deploy_role = "githubdeploy-afterhours-shift-manager"
|
||||||
|
|
@ -18,7 +19,16 @@ locals {
|
||||||
|
|
||||||
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
|
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
|
||||||
# Org has Actions OIDC use_immutable_subject=true.
|
# Org has Actions OIDC use_immutable_subject=true.
|
||||||
github_oidc_sub = "repo:Sea-Haven-Industries@183236204/afterhours-shift-manager@1200846448:environment:prod"
|
github_oidc_subs = [
|
||||||
|
"repo:${var.github_repo}:environment:dev",
|
||||||
|
"repo:${var.github_repo}:environment:prod",
|
||||||
|
"repo:Sea-Haven-Industries@183236204/afterhours-shift-manager@1200846448:environment:dev",
|
||||||
|
"repo:Sea-Haven-Industries@183236204/afterhours-shift-manager@1200846448:environment:prod",
|
||||||
|
]
|
||||||
|
|
||||||
|
domain_name = var.domain_name != "" ? var.domain_name : (local.is_prod ? "afterhours.seahaven.com" : "afterhours.dev.seahaven.com")
|
||||||
|
acm_wildcard_domain = local.is_prod ? "*.seahaven.com" : "*.dev.seahaven.com"
|
||||||
|
api_url = var.attach_custom_domain ? "https://${local.domain_name}" : "http://${aws_lb.api.dns_name}"
|
||||||
|
|
||||||
secret_names = [
|
secret_names = [
|
||||||
"afterhours-shift-manager/slack-bot-token",
|
"afterhours-shift-manager/slack-bot-token",
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,8 @@ resource "aws_cloudwatch_log_group" "api_access" {
|
||||||
name = "/aws/apigateway/${local.project}"
|
name = "/aws/apigateway/${local.project}"
|
||||||
retention_in_days = 90
|
retention_in_days = 90
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_log_group" "api" {
|
||||||
|
name = "/ecs/${local.project}"
|
||||||
|
retention_in_days = local.is_prod ? 60 : 14
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ output "holiday_scheduler_role_arn" {
|
||||||
}
|
}
|
||||||
|
|
||||||
output "github_deploy_role_arn" {
|
output "github_deploy_role_arn" {
|
||||||
description = "OIDC role ARN for .github/workflows/deploy.yaml (GitHub Environment prod variable DEPLOY_ROLE_ARN)."
|
description = "OIDC role ARN for deploy.yaml and deploy-api.yaml (GitHub Environment variable DEPLOY_ROLE_ARN)."
|
||||||
value = aws_iam_role.github_deploy.arn
|
value = aws_iam_role.github_deploy.arn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,6 +28,26 @@ output "artifacts_bucket_name" {
|
||||||
value = aws_s3_bucket.artifacts.id
|
value = aws_s3_bucket.artifacts.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
output "alb_dns_name" {
|
||||||
|
description = "ALB DNS name. Public DNS for afterhours.seahaven.com is out of band."
|
||||||
|
value = aws_lb.api.dns_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "fargate_origin" {
|
||||||
|
description = "Fargate origin for Slack/Paychex/portal cutover. HTTPS after attach_custom_domain."
|
||||||
|
value = local.api_url
|
||||||
|
}
|
||||||
|
|
||||||
|
output "jobs_queue_arn" {
|
||||||
|
description = "SQS ARN EventBridge Scheduler holiday one-offs target after cutover."
|
||||||
|
value = aws_sqs_queue.jobs.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "ecs_task_role_arn" {
|
||||||
|
description = "ECS task role. paychex-checkcomponents queue policy must allow this ARN before Fargate weekly_post."
|
||||||
|
value = aws_iam_role.ecs_task.arn
|
||||||
|
}
|
||||||
|
|
||||||
output "hcptf_apply_role_arn" {
|
output "hcptf_apply_role_arn" {
|
||||||
description = "HCP apply role ARN. Set TFC_AWS_APPLY_ROLE_ARN after the bootstrap window."
|
description = "HCP apply role ARN. Set TFC_AWS_APPLY_ROLE_ARN after the bootstrap window."
|
||||||
value = aws_iam_role.hcptf_apply.arn
|
value = aws_iam_role.hcptf_apply.arn
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ provider "aws" {
|
||||||
default_tags {
|
default_tags {
|
||||||
tags = {
|
tags = {
|
||||||
Project = local.project
|
Project = local.project
|
||||||
Environment = "prod"
|
Environment = var.environment
|
||||||
ManagedBy = "terraform"
|
ManagedBy = "terraform"
|
||||||
Workspace = local.hcp_workspace
|
Workspace = local.hcp_workspace
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# EventBridge Scheduler execution role. slack-bot creates one-off holiday-*
|
# EventBridge Scheduler execution role. slack-bot creates one-off holiday-*
|
||||||
# schedules at runtime; Terraform does not create those schedules.
|
# schedules at runtime; Terraform does not create those schedules.
|
||||||
|
# Recurring jobs use America/New_York Scheduler -> SQS (not dual EST/EDT rules).
|
||||||
|
|
||||||
data "aws_iam_policy_document" "holiday_scheduler_assume" {
|
data "aws_iam_policy_document" "holiday_scheduler_assume" {
|
||||||
statement {
|
statement {
|
||||||
|
|
@ -29,9 +30,9 @@ data "aws_iam_policy_document" "holiday_scheduler_assume" {
|
||||||
resource "aws_iam_role" "holiday_scheduler" {
|
resource "aws_iam_role" "holiday_scheduler" {
|
||||||
name = local.holiday_scheduler_role_name
|
name = local.holiday_scheduler_role_name
|
||||||
path = "/tf-managed/"
|
path = "/tf-managed/"
|
||||||
description = "EventBridge Scheduler assumes this role to invoke afterhours-holiday-router"
|
description = "EventBridge Scheduler assumes this role to enqueue holiday jobs or invoke afterhours-holiday-router"
|
||||||
assume_role_policy = data.aws_iam_policy_document.holiday_scheduler_assume.json
|
assume_role_policy = data.aws_iam_policy_document.holiday_scheduler_assume.json
|
||||||
permissions_boundary = aws_iam_policy.lambda_boundary.arn
|
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
|
||||||
}
|
}
|
||||||
|
|
||||||
data "aws_iam_policy_document" "holiday_scheduler" {
|
data "aws_iam_policy_document" "holiday_scheduler" {
|
||||||
|
|
@ -41,6 +42,13 @@ data "aws_iam_policy_document" "holiday_scheduler" {
|
||||||
actions = ["lambda:InvokeFunction"]
|
actions = ["lambda:InvokeFunction"]
|
||||||
resources = [local.holiday_router_arn]
|
resources = [local.holiday_router_arn]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "SendHolidayJobs"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sqs:SendMessage"]
|
||||||
|
resources = [aws_sqs_queue.jobs.arn]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_role_policy" "holiday_scheduler" {
|
resource "aws_iam_role_policy" "holiday_scheduler" {
|
||||||
|
|
@ -48,3 +56,52 @@ resource "aws_iam_role_policy" "holiday_scheduler" {
|
||||||
role = aws_iam_role.holiday_scheduler.id
|
role = aws_iam_role.holiday_scheduler.id
|
||||||
policy = data.aws_iam_policy_document.holiday_scheduler.json
|
policy = data.aws_iam_policy_document.holiday_scheduler.json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
job_schedules = {
|
||||||
|
weekly-post = {
|
||||||
|
description = "Post weekly schedule Monday 7am Eastern"
|
||||||
|
schedule = "cron(0 7 ? * MON *)"
|
||||||
|
event = "weekly_post"
|
||||||
|
}
|
||||||
|
roster-sync = {
|
||||||
|
description = "Sync roster from 3CX at 6am Eastern"
|
||||||
|
schedule = "cron(0 6 ? * * *)"
|
||||||
|
event = "roster_sync"
|
||||||
|
}
|
||||||
|
ring-scheduler-daily = {
|
||||||
|
description = "Update 3CX queue at 8am Eastern"
|
||||||
|
schedule = "cron(0 8 ? * * *)"
|
||||||
|
event = "ring_scheduler_daily"
|
||||||
|
}
|
||||||
|
ring-scheduler-weekend = {
|
||||||
|
description = "Update 3CX queue at 5pm Eastern weekends"
|
||||||
|
schedule = "cron(0 17 ? * SAT,SUN *)"
|
||||||
|
event = "ring_scheduler_weekend"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_scheduler_schedule_group" "jobs" {
|
||||||
|
name = local.project
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_scheduler_schedule" "jobs" {
|
||||||
|
for_each = local.job_schedules
|
||||||
|
|
||||||
|
name = "${local.project}-${each.key}"
|
||||||
|
group_name = aws_scheduler_schedule_group.jobs.name
|
||||||
|
description = each.value.description
|
||||||
|
schedule_expression = each.value.schedule
|
||||||
|
schedule_expression_timezone = "America/New_York"
|
||||||
|
state = var.ecs_schedules_enabled ? "ENABLED" : "DISABLED"
|
||||||
|
flexible_time_window {
|
||||||
|
mode = "OFF"
|
||||||
|
}
|
||||||
|
|
||||||
|
target {
|
||||||
|
arn = aws_sqs_queue.jobs.arn
|
||||||
|
role_arn = aws_iam_role.jobs_scheduler.arn
|
||||||
|
input = jsonencode({ event = each.value.event })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,45 @@ resource "aws_ssm_parameter" "deploy_function_name" {
|
||||||
value = each.value.function_name
|
value = each.value.function_name
|
||||||
description = "Lambda function name for ${each.key}; deploy.yaml calls update-function-code"
|
description = "Lambda function name for ${each.key}; deploy.yaml calls update-function-code"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_api_url" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/api-url"
|
||||||
|
type = "String"
|
||||||
|
value = local.api_url
|
||||||
|
description = "API origin for deploy-api.yaml health check"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_cluster" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/cluster"
|
||||||
|
type = "String"
|
||||||
|
value = aws_ecs_cluster.api.name
|
||||||
|
description = "ECS cluster name for deploy-api.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_service" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/service"
|
||||||
|
type = "String"
|
||||||
|
value = aws_ecs_service.api.name
|
||||||
|
description = "ECS service name for deploy-api.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_task_family" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/task-family"
|
||||||
|
type = "String"
|
||||||
|
value = aws_ecs_task_definition.api.family
|
||||||
|
description = "ECS task definition family for deploy-api.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_ecr_repository" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/ecr-repository"
|
||||||
|
type = "String"
|
||||||
|
value = aws_ecr_repository.api.repository_url
|
||||||
|
description = "ECR repository URL for deploy-api.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_container_name" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/container-name"
|
||||||
|
type = "String"
|
||||||
|
value = local.api_container_name
|
||||||
|
description = "Container name in the ECS task definition"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,37 @@ variable "sentry_dsn" {
|
||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "environment" {
|
||||||
|
description = "HCP workspace stage. Selects account and workspace name."
|
||||||
|
type = string
|
||||||
|
default = "prod"
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = contains(["dev", "prod"], var.environment)
|
||||||
|
error_message = "environment must be \"dev\" or \"prod\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "domain_name" {
|
||||||
|
description = "Public hostname on the ALB when attach_custom_domain is true. Empty selects afterhours.seahaven.com or afterhours.dev.seahaven.com from environment."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "attach_custom_domain" {
|
||||||
|
description = "When true, attach an HTTPS listener using the issued wildcard ACM certificate. Keep false until public DNS points at the ALB."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
variable "schedules_enabled" {
|
variable "schedules_enabled" {
|
||||||
description = "When false, EventBridge rules exist but do not fire. Keep false until Slack and Paychex point at this stack."
|
description = "When false, EventBridge Lambda rules exist but do not fire. Keep false until Slack and Paychex point at this stack, and after Fargate jobs are enabled."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ecs_schedules_enabled" {
|
||||||
|
description = "When false, EventBridge Scheduler jobs exist but do not fire. Enable at Fargate cutover after the image is healthy; keep Lambda EventBridge rules disabled."
|
||||||
type = bool
|
type = bool
|
||||||
default = false
|
default = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ terraform {
|
||||||
organization = "seahaven"
|
organization = "seahaven"
|
||||||
|
|
||||||
workspaces {
|
workspaces {
|
||||||
name = "afterhours-shift-manager-prod"
|
tags = ["app:afterhours-shift-manager"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ def aws_env(monkeypatch):
|
||||||
monkeypatch.delenv("QUEUE_NUMBER", raising=False)
|
monkeypatch.delenv("QUEUE_NUMBER", raising=False)
|
||||||
monkeypatch.delenv("TCX_SECRET_PREFIX", raising=False)
|
monkeypatch.delenv("TCX_SECRET_PREFIX", raising=False)
|
||||||
monkeypatch.delenv("SENTRY_DSN", raising=False)
|
monkeypatch.delenv("SENTRY_DSN", raising=False)
|
||||||
|
monkeypatch.delenv("JOBS_QUEUE_URL", raising=False)
|
||||||
|
monkeypatch.delenv("JOBS_QUEUE_ARN", raising=False)
|
||||||
|
|
||||||
|
|
||||||
def _create_table(dynamodb):
|
def _create_table(dynamodb):
|
||||||
|
|
|
||||||
|
|
@ -31,17 +31,49 @@ def test_lambda_ignore_changes_includes_code_attributes():
|
||||||
|
|
||||||
|
|
||||||
def test_schedules_disabled_by_default():
|
def test_schedules_disabled_by_default():
|
||||||
chunk = (TERRAFORM / "variables.tf").read_text().split('variable "schedules_enabled"')[1]
|
chunk = (
|
||||||
|
(TERRAFORM / "variables.tf")
|
||||||
|
.read_text()
|
||||||
|
.split('variable "schedules_enabled"')[1]
|
||||||
|
)
|
||||||
chunk = chunk.split("variable ")[0]
|
chunk = chunk.split("variable ")[0]
|
||||||
assert "default = false" in chunk or "default = false" in chunk
|
assert "default = false" in chunk or "default = false" in chunk
|
||||||
|
|
||||||
|
|
||||||
def test_prod_only_workspace():
|
def test_ecs_schedules_disabled_by_default():
|
||||||
|
chunk = (
|
||||||
|
(TERRAFORM / "variables.tf")
|
||||||
|
.read_text()
|
||||||
|
.split('variable "ecs_schedules_enabled"')[1]
|
||||||
|
)
|
||||||
|
chunk = chunk.split("variable ")[0]
|
||||||
|
assert "default = false" in chunk or "default = false" in chunk
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspaces_use_app_tag():
|
||||||
versions = (TERRAFORM / "versions.tf").read_text()
|
versions = (TERRAFORM / "versions.tf").read_text()
|
||||||
assert "afterhours-shift-manager-prod" in versions
|
assert 'tags = ["app:afterhours-shift-manager"]' in versions
|
||||||
assert "afterhours-shift-manager-dev" not in versions
|
assert 'name = "afterhours-shift-manager-prod"' not in versions
|
||||||
assert 'environment = "prod"' in LOCALS
|
assert 'hcp_workspace = "${local.project}-${var.environment}"' in LOCALS
|
||||||
assert "seahaven-dev" not in LOCALS
|
assert "seahaven-${var.environment}" in LOCALS
|
||||||
|
|
||||||
|
|
||||||
|
def test_ecs_ignore_changes_and_task_size():
|
||||||
|
ecs = (TERRAFORM / "ecs.tf").read_text()
|
||||||
|
assert "ignore_changes = [container_definitions]" in ecs
|
||||||
|
assert "ignore_changes = [task_definition, desired_count]" in ecs
|
||||||
|
assert 'cpu = "512"' in ecs
|
||||||
|
assert 'memory = "1024"' in ecs
|
||||||
|
assert 'cpu_architecture = "ARM64"' in ecs
|
||||||
|
assert 'path = "/api/health"' in ecs
|
||||||
|
|
||||||
|
|
||||||
|
def test_deploy_api_workflow_exists():
|
||||||
|
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
|
||||||
|
assert "environment: ${{ needs.target.outputs.environment }}" in deploy_api
|
||||||
|
assert "/afterhours-shift-manager/deploy/cluster" in deploy_api
|
||||||
|
assert "linux/arm64" in deploy_api
|
||||||
|
assert "gh release create" in deploy_api
|
||||||
|
|
||||||
|
|
||||||
def test_in_repo_hcptf_roles():
|
def test_in_repo_hcptf_roles():
|
||||||
|
|
@ -74,6 +106,9 @@ def test_checkcomponents_queue_arn_variable_matches_iam_references():
|
||||||
assert "var.checkcomponents_queue_arn" in LAMBDA_TF
|
assert "var.checkcomponents_queue_arn" in LAMBDA_TF
|
||||||
boundary = (TERRAFORM / "lambda_boundary.tf").read_text()
|
boundary = (TERRAFORM / "lambda_boundary.tf").read_text()
|
||||||
assert "var.checkcomponents_queue_arn" in boundary
|
assert "var.checkcomponents_queue_arn" in boundary
|
||||||
|
data_tf = (TERRAFORM / "data.tf").read_text()
|
||||||
|
assert "dev_has_no_paychex" in data_tf
|
||||||
|
assert "checkcomponents_pair" in data_tf
|
||||||
|
|
||||||
|
|
||||||
def test_eight_functions_named():
|
def test_eight_functions_named():
|
||||||
|
|
@ -94,12 +129,15 @@ def test_weekly_post_role_is_tf_managed_name():
|
||||||
assert 'role_name = "afterhours-shift-manager-weekly-post"' in LOCALS
|
assert 'role_name = "afterhours-shift-manager-weekly-post"' in LOCALS
|
||||||
|
|
||||||
|
|
||||||
def test_github_deploy_trust_is_environment_prod():
|
def test_github_deploy_trust_covers_zip_and_image():
|
||||||
iam = (TERRAFORM / "iam_github_deploy.tf").read_text()
|
iam = (TERRAFORM / "iam_github_deploy.tf").read_text()
|
||||||
assert "environment:prod" in iam or "environment:prod" in LOCALS
|
assert "environment:prod" in iam or "environment:prod" in LOCALS
|
||||||
|
assert "environment:dev" in iam or "environment:dev" in LOCALS
|
||||||
assert "deploy.yaml@refs/heads/${var.github_deploy_branch}" in iam
|
assert "deploy.yaml@refs/heads/${var.github_deploy_branch}" in iam
|
||||||
|
assert "deploy-api.yaml@refs/heads/${var.github_deploy_branch}" in iam
|
||||||
|
assert "deploy-api.yaml@refs/tags/v*" in iam
|
||||||
assert "deploy.yaml@*" not in iam
|
assert "deploy.yaml@*" not in iam
|
||||||
assert "refs/tags/v*" not in iam
|
assert "ecs:ListTasks" in iam
|
||||||
|
|
||||||
|
|
||||||
def test_plan_refresh_includes_provider6_s3_gets():
|
def test_plan_refresh_includes_provider6_s3_gets():
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,7 @@ def _event(method="GET", path="/api/shifts", body=None, token="id-token", origin
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def identity(portalapi_app, monkeypatch):
|
def identity(portalapi_app, monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
portalapi_app,
|
"shared.portal_http.verify_cognito_id_token",
|
||||||
"verify_cognito_id_token",
|
|
||||||
lambda _token: {"name": "Alice", "email": "alice@seahavenind.com"},
|
lambda _token: {"name": "Alice", "email": "alice@seahavenind.com"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -67,9 +66,7 @@ def test_linked_snapshot_and_admin_flag(portalapi_app, schedule, seed, identity)
|
||||||
|
|
||||||
|
|
||||||
def test_non_admin_cannot_override(portalapi_app, schedule, seed, identity):
|
def test_non_admin_cannot_override(portalapi_app, schedule, seed, identity):
|
||||||
seed.roster(
|
seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com")
|
||||||
"114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com"
|
|
||||||
)
|
|
||||||
seed.config(admin_users=["U_OTHER"])
|
seed.config(admin_users=["U_OTHER"])
|
||||||
result = portalapi_app.handler(
|
result = portalapi_app.handler(
|
||||||
_event(
|
_event(
|
||||||
|
|
@ -83,7 +80,22 @@ def test_non_admin_cannot_override(portalapi_app, schedule, seed, identity):
|
||||||
|
|
||||||
|
|
||||||
def test_cors_header_for_portal_origin(portalapi_app, schedule, identity):
|
def test_cors_header_for_portal_origin(portalapi_app, schedule, identity):
|
||||||
result = portalapi_app.handler(
|
result = portalapi_app.handler(_event(origin="https://internal.seahaven.com"), None)
|
||||||
_event(origin="https://internal.seahaven.com"), None
|
assert (
|
||||||
|
result["headers"]["Access-Control-Allow-Origin"]
|
||||||
|
== "https://internal.seahaven.com"
|
||||||
)
|
)
|
||||||
assert result["headers"]["Access-Control-Allow-Origin"] == "https://internal.seahaven.com"
|
|
||||||
|
|
||||||
|
def test_unexpected_failure_keeps_cors(portalapi_app, identity, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"shared.portal_http.ShiftSchedule",
|
||||||
|
lambda: (_ for _ in ()).throw(RuntimeError("ddb down")),
|
||||||
|
)
|
||||||
|
result = portalapi_app.handler(_event(origin="https://internal.seahaven.com"), None)
|
||||||
|
assert result["statusCode"] == 500
|
||||||
|
assert (
|
||||||
|
result["headers"]["Access-Control-Allow-Origin"]
|
||||||
|
== "https://internal.seahaven.com"
|
||||||
|
)
|
||||||
|
assert json.loads(result["body"])["error"]["code"] == "INTERNAL"
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,5 @@ responses>=0.26.2
|
||||||
freezegun>=1.5.5
|
freezegun>=1.5.5
|
||||||
sentry-sdk==2.68.1
|
sentry-sdk==2.68.1
|
||||||
PyJWT[crypto]==2.14.0
|
PyJWT[crypto]==2.14.0
|
||||||
|
flask==3.1.3
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,6 @@ def _load(name, relpath):
|
||||||
def rosterapi_app():
|
def rosterapi_app():
|
||||||
mod = _load("rosterapi_app", "src/roster-api/app.py")
|
mod = _load("rosterapi_app", "src/roster-api/app.py")
|
||||||
yield mod
|
yield mod
|
||||||
mod._cached_token = None
|
import shared.roster_http as roster_http
|
||||||
|
|
||||||
|
roster_http._cached_token = None
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import shared.roster_http as roster_http
|
||||||
|
|
||||||
TOKEN = "roster-test-token"
|
TOKEN = "roster-test-token"
|
||||||
SECRET_NAME = "afterhours-shift-manager/roster-api-token"
|
SECRET_NAME = "afterhours-shift-manager/roster-api-token"
|
||||||
|
|
||||||
|
|
@ -18,8 +20,8 @@ def env(monkeypatch):
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def secrets(rosterapi_app, monkeypatch, env):
|
def secrets(rosterapi_app, monkeypatch, env):
|
||||||
monkeypatch.setattr(rosterapi_app, "get_secret", lambda _id: TOKEN)
|
monkeypatch.setattr(roster_http, "get_secret", lambda _id: TOKEN)
|
||||||
rosterapi_app._cached_token = None
|
roster_http._cached_token = None
|
||||||
|
|
||||||
|
|
||||||
def _event(
|
def _event(
|
||||||
|
|
@ -174,16 +176,16 @@ def test_401_wrong_token(rosterapi_app, schedule, secrets):
|
||||||
def test_authorize_strips_secret_trailing_newline(
|
def test_authorize_strips_secret_trailing_newline(
|
||||||
rosterapi_app, schedule, env, monkeypatch
|
rosterapi_app, schedule, env, monkeypatch
|
||||||
):
|
):
|
||||||
monkeypatch.setattr(rosterapi_app, "get_secret", lambda _id: TOKEN + "\n")
|
monkeypatch.setattr(roster_http, "get_secret", lambda _id: TOKEN + "\n")
|
||||||
rosterapi_app._cached_token = None
|
roster_http._cached_token = None
|
||||||
result = rosterapi_app.handler(_event(body=_put_body()), None)
|
result = rosterapi_app.handler(_event(body=_put_body()), None)
|
||||||
assert result["statusCode"] == 200
|
assert result["statusCode"] == 200
|
||||||
assert schedule.get_employee_by_extension("110")["slack_user_id"] == "U123ABCDE"
|
assert schedule.get_employee_by_extension("110")["slack_user_id"] == "U123ABCDE"
|
||||||
|
|
||||||
|
|
||||||
def test_503_when_secret_is_whitespace_only(rosterapi_app, schedule, env, monkeypatch):
|
def test_503_when_secret_is_whitespace_only(rosterapi_app, schedule, env, monkeypatch):
|
||||||
monkeypatch.setattr(rosterapi_app, "get_secret", lambda _id: "\n")
|
monkeypatch.setattr(roster_http, "get_secret", lambda _id: "\n")
|
||||||
rosterapi_app._cached_token = None
|
roster_http._cached_token = None
|
||||||
result = rosterapi_app.handler(_event(body=_put_body()), None)
|
result = rosterapi_app.handler(_event(body=_put_body()), None)
|
||||||
assert result["statusCode"] == 503
|
assert result["statusCode"] == 503
|
||||||
assert schedule.get_employee_by_extension("110") is None
|
assert schedule.get_employee_by_extension("110") is None
|
||||||
|
|
@ -193,8 +195,8 @@ def test_503_when_secret_read_fails(rosterapi_app, schedule, env, monkeypatch):
|
||||||
def boom(_id):
|
def boom(_id):
|
||||||
raise RuntimeError("secrets down")
|
raise RuntimeError("secrets down")
|
||||||
|
|
||||||
monkeypatch.setattr(rosterapi_app, "get_secret", boom)
|
monkeypatch.setattr(roster_http, "get_secret", boom)
|
||||||
rosterapi_app._cached_token = None
|
roster_http._cached_token = None
|
||||||
result = rosterapi_app.handler(_event(body=_put_body()), None)
|
result = rosterapi_app.handler(_event(body=_put_body()), None)
|
||||||
assert result["statusCode"] == 503
|
assert result["statusCode"] == 503
|
||||||
assert schedule.get_employee_by_extension("110") is None
|
assert schedule.get_employee_by_extension("110") is None
|
||||||
|
|
@ -300,7 +302,7 @@ def test_never_calls_roster_sync(rosterapi_app):
|
||||||
|
|
||||||
def test_500_on_unexpected_failure(rosterapi_app, secrets, monkeypatch):
|
def test_500_on_unexpected_failure(rosterapi_app, secrets, monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
rosterapi_app,
|
roster_http,
|
||||||
"ShiftSchedule",
|
"ShiftSchedule",
|
||||||
MagicMock(side_effect=RuntimeError("ddb down")),
|
MagicMock(side_effect=RuntimeError("ddb down")),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
52
tests/scripts/test_retarget_holiday_schedules_to_sqs.py
Normal file
52
tests/scripts/test_retarget_holiday_schedules_to_sqs.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""retarget_holiday_schedules_to_sqs builds the SQS holiday payload."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _load():
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"retarget_holiday_schedules_to_sqs",
|
||||||
|
ROOT / "scripts" / "cutover" / "retarget_holiday_schedules_to_sqs.py",
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules["retarget_holiday_schedules_to_sqs"] = mod
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
mod = _load()
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_and_date_from_lambda_input():
|
||||||
|
action, date = mod.action_and_date(
|
||||||
|
"holiday-activate-20260704",
|
||||||
|
'{"action":"activate","date":"2026-07-04"}',
|
||||||
|
)
|
||||||
|
assert action == "activate"
|
||||||
|
assert date == "2026-07-04"
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_and_date_from_schedule_name_when_input_empty():
|
||||||
|
action, date = mod.action_and_date("holiday-deactivate-20260704", "")
|
||||||
|
assert action == "deactivate"
|
||||||
|
assert date == "2026-07-04"
|
||||||
|
|
||||||
|
|
||||||
|
def test_holiday_sqs_input_shape():
|
||||||
|
payload = mod.holiday_sqs_input("activate", "2026-07-04")
|
||||||
|
assert payload == '{"event": "holiday", "action": "activate", "date": "2026-07-04"}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_holiday_scheduler_role_arn_follows_caller_account():
|
||||||
|
assert mod.holiday_scheduler_role_arn(mod.PROD_ACCOUNT) == (
|
||||||
|
"arn:aws:iam::011934824531:role/tf-managed/"
|
||||||
|
"afterhours-shift-manager-holiday-scheduler"
|
||||||
|
)
|
||||||
|
assert mod.holiday_scheduler_role_arn(mod.DEV_ACCOUNT) == (
|
||||||
|
"arn:aws:iam::710827005802:role/tf-managed/"
|
||||||
|
"afterhours-shift-manager-holiday-scheduler"
|
||||||
|
)
|
||||||
113
tests/server/test_app.py
Normal file
113
tests/server/test_app.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Flask routes: health, CORS, portal auth fail-closed, roster auth."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from server.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
app = create_app()
|
||||||
|
app.testing = True
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_reports_stage_and_sha(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("STAGE", "dev")
|
||||||
|
monkeypatch.setenv("GIT_SHA", "abc123")
|
||||||
|
response = client.get("/api/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json() == {"stage": "dev", "sha": "abc123"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_options_is_204_with_cors(client):
|
||||||
|
response = client.options(
|
||||||
|
"/api/shifts",
|
||||||
|
headers={"Origin": "https://internal.seahaven.com"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert (
|
||||||
|
response.headers["Access-Control-Allow-Origin"]
|
||||||
|
== "https://internal.seahaven.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_unknown_origin_has_no_acao(client):
|
||||||
|
response = client.options(
|
||||||
|
"/api/shifts",
|
||||||
|
headers={"Origin": "https://evil.example"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert "Access-Control-Allow-Origin" not in response.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_missing_bearer_is_401(client):
|
||||||
|
response = client.get("/api/shifts")
|
||||||
|
assert response.status_code == 401
|
||||||
|
body = response.get_json()
|
||||||
|
assert body["error"]["code"] == "UNAUTHORIZED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_unexpected_failure_keeps_cors(client):
|
||||||
|
with patch(
|
||||||
|
"shared.portal_http.verify_cognito_id_token",
|
||||||
|
return_value={"name": "Alice", "email": "alice@seahavenind.com"},
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"shared.portal_http.ShiftSchedule",
|
||||||
|
side_effect=RuntimeError("ddb down"),
|
||||||
|
):
|
||||||
|
response = client.get(
|
||||||
|
"/api/shifts",
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer token",
|
||||||
|
"Origin": "https://internal.seahaven.com",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert (
|
||||||
|
response.headers["Access-Control-Allow-Origin"]
|
||||||
|
== "https://internal.seahaven.com"
|
||||||
|
)
|
||||||
|
assert response.get_json()["error"]["code"] == "INTERNAL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_invalid_token_is_401(client):
|
||||||
|
with patch(
|
||||||
|
"shared.portal_http.verify_cognito_id_token",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
response = client.get(
|
||||||
|
"/api/shifts",
|
||||||
|
headers={"Authorization": "Bearer nope"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_roster_missing_bearer_is_401(client, monkeypatch):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"ROSTER_API_TOKEN_SECRET", "afterhours-shift-manager/roster-api-token"
|
||||||
|
)
|
||||||
|
with patch("shared.roster_http.get_secret", return_value="expected"):
|
||||||
|
response = client.put(
|
||||||
|
"/roster", json={"name": "Pat", "extension": "110", "slack_user_id": "U1"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_roster_wrong_token_is_401(client, monkeypatch):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"ROSTER_API_TOKEN_SECRET", "afterhours-shift-manager/roster-api-token"
|
||||||
|
)
|
||||||
|
import shared.roster_http as roster_http
|
||||||
|
|
||||||
|
roster_http._cached_token = None
|
||||||
|
with patch("shared.roster_http.get_secret", return_value="expected"):
|
||||||
|
response = client.put(
|
||||||
|
"/roster",
|
||||||
|
headers={"Authorization": "Bearer nope"},
|
||||||
|
json={"name": "Pat", "extension": "110", "slack_user_id": "UABC"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
60
tests/server/test_jobs.py
Normal file
60
tests/server/test_jobs.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""Worker dispatch: holiday SQS payload and known job events."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from server import jobs
|
||||||
|
|
||||||
|
|
||||||
|
def test_holiday_activate_payload_calls_holiday_flow(monkeypatch):
|
||||||
|
activate = MagicMock(return_value={"action": "activate", "date": "2026-07-04"})
|
||||||
|
monkeypatch.setattr("server.jobs.activate", activate)
|
||||||
|
monkeypatch.setattr("server.jobs.ShiftSchedule", MagicMock)
|
||||||
|
result = jobs.run_job(
|
||||||
|
{"event": "holiday", "action": "activate", "date": "2026-07-04"}
|
||||||
|
)
|
||||||
|
assert result["action"] == "activate"
|
||||||
|
activate.assert_called_once()
|
||||||
|
assert activate.call_args.args[1] == "2026-07-04"
|
||||||
|
|
||||||
|
|
||||||
|
def test_holiday_deactivate_payload_calls_holiday_flow(monkeypatch):
|
||||||
|
deactivate = MagicMock(return_value={"action": "deactivate", "date": "2026-07-04"})
|
||||||
|
monkeypatch.setattr("server.jobs.deactivate", deactivate)
|
||||||
|
monkeypatch.setattr("server.jobs.ShiftSchedule", MagicMock)
|
||||||
|
result = jobs.run_job(
|
||||||
|
{"event": "holiday", "action": "deactivate", "date": "2026-07-04"}
|
||||||
|
)
|
||||||
|
assert result["action"] == "deactivate"
|
||||||
|
deactivate.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_job_raises():
|
||||||
|
with pytest.raises(ValueError, match="unknown job event"):
|
||||||
|
jobs.run_job({"event": "nope"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_post_dispatches_with_force(monkeypatch):
|
||||||
|
handler = MagicMock(return_value={"posted": True})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
jobs,
|
||||||
|
"_load_lambda_app",
|
||||||
|
lambda name: MagicMock(handler=handler) if name == "weekly-post" else None,
|
||||||
|
)
|
||||||
|
result = jobs.run_job({"event": "weekly_post"})
|
||||||
|
assert result == {"posted": True}
|
||||||
|
assert handler.call_args.args[0]["force"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_scheduler_events_share_handler(monkeypatch):
|
||||||
|
handler = MagicMock(return_value={"ok": True})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
jobs,
|
||||||
|
"_load_lambda_app",
|
||||||
|
lambda name: MagicMock(handler=handler),
|
||||||
|
)
|
||||||
|
jobs.run_job({"event": "ring_scheduler_daily"})
|
||||||
|
jobs.run_job({"event": "ring_scheduler_weekend"})
|
||||||
|
assert handler.call_count == 2
|
||||||
|
assert handler.call_args.args[0]["force"] is True
|
||||||
|
|
@ -25,7 +25,11 @@ class FakeThreeCXClient:
|
||||||
|
|
||||||
def test_update_queue_routing_points_queue_at_extension(monkeypatch):
|
def test_update_queue_routing_points_queue_at_extension(monkeypatch):
|
||||||
FakeThreeCXClient.instances = []
|
FakeThreeCXClient.instances = []
|
||||||
monkeypatch.setattr(ring_scheduler, "ThreeCXClient", FakeThreeCXClient)
|
|
||||||
|
def fake_oauth(domain, client_id, client_secret):
|
||||||
|
return FakeThreeCXClient(domain, "oauth", client_id, client_secret)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ring_scheduler, "oauth_client", fake_oauth)
|
||||||
|
|
||||||
result = ring_scheduler.update_queue_routing(
|
result = ring_scheduler.update_queue_routing(
|
||||||
extension="114",
|
extension="114",
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ def test_empty_dsn_does_not_init(monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def test_set_dsn_inits_lambda_integration(monkeypatch):
|
def test_set_dsn_inits_lambda_integration(monkeypatch):
|
||||||
|
monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "afterhours-shift-manager")
|
||||||
mocked = _reexec(monkeypatch, dsn="https://key@o1.ingest.sentry.io/1")
|
mocked = _reexec(monkeypatch, dsn="https://key@o1.ingest.sentry.io/1")
|
||||||
mocked.assert_called_once()
|
mocked.assert_called_once()
|
||||||
kwargs = mocked.call_args.kwargs
|
kwargs = mocked.call_args.kwargs
|
||||||
|
|
@ -48,6 +49,7 @@ def test_set_dsn_inits_lambda_integration(monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def test_build_info_sha_sets_sentry_release(monkeypatch):
|
def test_build_info_sha_sets_sentry_release(monkeypatch):
|
||||||
|
monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "afterhours-shift-manager")
|
||||||
monkeypatch.setenv("SENTRY_DSN", "https://key@o1.ingest.sentry.io/1")
|
monkeypatch.setenv("SENTRY_DSN", "https://key@o1.ingest.sentry.io/1")
|
||||||
fake = ModuleType("shared.build_info")
|
fake = ModuleType("shared.build_info")
|
||||||
fake.GIT_SHA = "abc123def"
|
fake.GIT_SHA = "abc123def"
|
||||||
|
|
|
||||||
81
tests/shared/test_side_effects_holidays.py
Normal file
81
tests/shared/test_side_effects_holidays.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Holiday schedule targets: SQS when JOBS_QUEUE_ARN is set, else Lambda."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from shared.side_effects import (
|
||||||
|
_holiday_schedule_target,
|
||||||
|
activate_holiday_inline,
|
||||||
|
create_holiday_schedules,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqs_target_when_jobs_queue_arn_set(monkeypatch):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"JOBS_QUEUE_ARN", "arn:aws:sqs:us-east-1:1:afterhours-shift-manager-jobs"
|
||||||
|
)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"HOLIDAY_SCHEDULER_ROLE_ARN", "arn:aws:iam::1:role/tf-managed/holiday"
|
||||||
|
)
|
||||||
|
monkeypatch.delenv("HOLIDAY_ROUTER_ARN", raising=False)
|
||||||
|
target = _holiday_schedule_target("activate", "2026-07-04")
|
||||||
|
assert target["Arn"].endswith(":afterhours-shift-manager-jobs")
|
||||||
|
payload = json.loads(target["Input"])
|
||||||
|
assert payload == {
|
||||||
|
"event": "holiday",
|
||||||
|
"action": "activate",
|
||||||
|
"date": "2026-07-04",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_lambda_target_when_jobs_queue_unset(monkeypatch):
|
||||||
|
monkeypatch.delenv("JOBS_QUEUE_ARN", raising=False)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"HOLIDAY_ROUTER_ARN",
|
||||||
|
"arn:aws:lambda:us-east-1:1:function:afterhours-holiday-router",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"HOLIDAY_SCHEDULER_ROLE_ARN", "arn:aws:iam::1:role/tf-managed/holiday"
|
||||||
|
)
|
||||||
|
target = _holiday_schedule_target("deactivate", "2026-07-04")
|
||||||
|
assert "function:afterhours-holiday-router" in target["Arn"]
|
||||||
|
payload = json.loads(target["Input"])
|
||||||
|
assert payload == {"action": "deactivate", "date": "2026-07-04"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_activate_in_process_when_jobs_queue_url_set(monkeypatch):
|
||||||
|
monkeypatch.setenv("JOBS_QUEUE_URL", "https://sqs.us-east-1.amazonaws.com/1/jobs")
|
||||||
|
activate = MagicMock()
|
||||||
|
monkeypatch.setattr("shared.holiday_flow.activate", activate)
|
||||||
|
activate_holiday_inline(MagicMock(), "2026-07-04")
|
||||||
|
activate.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_activate_invokes_lambda_without_jobs_queue(monkeypatch):
|
||||||
|
monkeypatch.delenv("JOBS_QUEUE_URL", raising=False)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"HOLIDAY_ROUTER_ARN", "arn:aws:lambda:us-east-1:1:function:router"
|
||||||
|
)
|
||||||
|
client = MagicMock()
|
||||||
|
monkeypatch.setattr("shared.side_effects.boto3.client", lambda _svc: client)
|
||||||
|
activate_holiday_inline(MagicMock(), "2026-07-04")
|
||||||
|
client.invoke.assert_called_once()
|
||||||
|
assert client.invoke.call_args.kwargs["FunctionName"].endswith(":function:router")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_holiday_schedules_uses_sqs_target(monkeypatch):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"JOBS_QUEUE_ARN", "arn:aws:sqs:us-east-1:1:afterhours-shift-manager-jobs"
|
||||||
|
)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"HOLIDAY_SCHEDULER_ROLE_ARN", "arn:aws:iam::1:role/tf-managed/holiday"
|
||||||
|
)
|
||||||
|
client = MagicMock()
|
||||||
|
monkeypatch.setattr("shared.side_effects.boto3.client", lambda _svc: client)
|
||||||
|
names = create_holiday_schedules("2026-07-04")
|
||||||
|
assert names == ["holiday-activate-20260704", "holiday-deactivate-20260704"]
|
||||||
|
assert client.create_schedule.call_count == 2
|
||||||
|
first_input = json.loads(
|
||||||
|
client.create_schedule.call_args_list[0].kwargs["Target"]["Input"]
|
||||||
|
)
|
||||||
|
assert first_input["event"] == "holiday"
|
||||||
|
|
@ -216,3 +216,19 @@ def test_extract_ivr_routes_missing_key0_is_none():
|
||||||
{"Forwards": [], "TimeoutForwardDN": None}
|
{"Forwards": [], "TimeoutForwardDN": None}
|
||||||
)
|
)
|
||||||
assert routes == {"key0": None, "timeout": None}
|
assert routes == {"key0": None, "timeout": None}
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_oauth_client_reuses_process_cache():
|
||||||
|
from shared import three_cx_client as tcx
|
||||||
|
|
||||||
|
tcx._oauth_clients.clear()
|
||||||
|
_stub_oauth()
|
||||||
|
first = tcx.oauth_client("test.3cx.us", "cid", "secret")
|
||||||
|
second = tcx.oauth_client("test.3cx.us", "cid", "secret")
|
||||||
|
assert first is second
|
||||||
|
token_posts = [
|
||||||
|
c for c in responses.calls if c.request.url.endswith("/connect/token")
|
||||||
|
]
|
||||||
|
assert len(token_posts) == 1
|
||||||
|
tcx._oauth_clients.clear()
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,18 @@ def test_publish_home_publishes_latest_entry(slackbot_app, client):
|
||||||
assert "v1.9.2" not in str(kwargs["view"]) # only the newest entry
|
assert "v1.9.2" not in str(kwargs["view"]) # only the newest entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_changelog_paths_include_package_copy(slackbot_app):
|
||||||
|
paths = slackbot_app._changelog_paths()
|
||||||
|
assert any(p.endswith("src/slack-bot/CHANGELOG.md") for p in paths)
|
||||||
|
|
||||||
|
|
||||||
|
def test_changelog_text_reads_package_copy(slackbot_app):
|
||||||
|
slackbot_app._changelog_text.cache_clear()
|
||||||
|
text = slackbot_app._changelog_text()
|
||||||
|
assert text
|
||||||
|
assert "## " in text
|
||||||
|
|
||||||
|
|
||||||
def test_publish_home_degrades_without_changelog(slackbot_app, client):
|
def test_publish_home_degrades_without_changelog(slackbot_app, client):
|
||||||
slackbot_app.publish_home(client, "U_BOB", "")
|
slackbot_app.publish_home(client, "U_BOB", "")
|
||||||
view = client.views_publish.call_args.kwargs["view"]
|
view = client.views_publish.call_args.kwargs["view"]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue