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/shared/requirements.txt
|
||||
pip install -r src/portal-api/requirements.txt
|
||||
pip install -r requirements-api.txt
|
||||
|
||||
- name: 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
|
||||
|
||||
- **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`)
|
||||
- **IaC**: HCP Terraform workspace `afterhours-shift-manager-prod` (containers) plus GitHub Actions `deploy.yaml` (zips). `src/shared` is bundled into each function zip. Terraform does not package `src/`.
|
||||
- **Slack**: Slack Bolt framework with `/oncall` slash command
|
||||
- **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 on `POST /slack/events`
|
||||
- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI
|
||||
- **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/
|
||||
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
|
||||
roster-sync/ Daily 3CX roster sync
|
||||
roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard
|
||||
|
|
@ -89,10 +90,10 @@ src/
|
|||
ring-scheduler/ 3CX queue routing updates
|
||||
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
|
||||
release-notifier/ Posts release announcements to Slack
|
||||
shared/ Bundled into each function zip (schedule, blocks, changelog, 3CX client, secrets)
|
||||
terraform/ HCP Terraform (function skeletons, API, DDB, IAM, schedules)
|
||||
scripts/ changelog CLI + CI guard + in-package copy sync
|
||||
tests/ pytest suite (mirrors src/, one dir per Lambda + shared)
|
||||
shared/ Bundled into each function zip and the Fargate image
|
||||
terraform/ HCP Terraform (Lambda skeletons, ECS/ALB, API, DDB, IAM, schedules)
|
||||
scripts/ changelog CLI + CI guard + in-package copy sync + cutover
|
||||
tests/ pytest suite (mirrors src/, one dir per Lambda + shared + server)
|
||||
```
|
||||
|
||||
### 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 src/slack-bot/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
|
||||
```
|
||||
|
||||
|
|
@ -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
|
||||
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.
|
||||
|
|
|
|||
46
SETUP.md
46
SETUP.md
|
|
@ -75,11 +75,15 @@ before the identity processor PUTs `/roster`.
|
|||
|
||||
## 3. HCP Terraform and GitHub Environment
|
||||
|
||||
Prod only. Workspace `afterhours-shift-manager-prod` in project `seahaven-prod`
|
||||
(account `011934824531`). No seahaven-dev workspace.
|
||||
Workspaces tagged `app:afterhours-shift-manager`:
|
||||
`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
|
||||
`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.
|
||||
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`.
|
||||
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`.
|
||||
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`.
|
||||
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`,
|
||||
`holiday_scheduler_role_arn`, `github_deploy_role_arn`.
|
||||
HCP outputs to copy: `slack_request_url`, `api_origin`, `fargate_origin`,
|
||||
`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
|
||||
|
||||
|
|
@ -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
|
||||
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
|
||||
|
||||
| Command | Description |
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
# `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
|
||||
# per-package conftest (importlib mode) to avoid the four-`app.py` collision.
|
||||
pythonpath = ["src/shared"]
|
||||
pythonpath = ["src", "src/shared"]
|
||||
testpaths = ["tests"]
|
||||
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.
|
||||
At 08:00 this Lambda is invoked with ``{"action": "activate", "date": ...}`` and
|
||||
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
|
||||
|
|
@ -28,7 +10,8 @@ import logging
|
|||
import os
|
||||
|
||||
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.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:
|
||||
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 = _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,
|
||||
}
|
||||
return activate(schedule, date, client_factory=_make_client)
|
||||
|
||||
|
||||
def _deactivate(schedule: ShiftSchedule, date: str) -> 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 = _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),
|
||||
}
|
||||
return deactivate(schedule, date, client_factory=_make_client)
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
|
|
|
|||
|
|
@ -2,17 +2,12 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import base64
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
import shared.sentry_init # noqa: F401
|
||||
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
|
||||
from shared.portal_http import cors_headers, encode_body, handle
|
||||
from shared.portal_ops import ActionError
|
||||
|
||||
logger = logging.getLogger()
|
||||
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:
|
||||
headers = event.get("headers") or {}
|
||||
if not isinstance(headers, dict):
|
||||
|
|
@ -83,179 +44,50 @@ def _route(event: dict) -> tuple[str, str]:
|
|||
return method, path.rstrip("/") or "/"
|
||||
|
||||
|
||||
def _json_body(event: dict) -> dict:
|
||||
raw = event.get("body")
|
||||
if raw in (None, ""):
|
||||
return {}
|
||||
def _raw_body(event: dict) -> bytes | str | None:
|
||||
body = event.get("body")
|
||||
if body is None:
|
||||
return None
|
||||
if event.get("isBase64Encoded"):
|
||||
import base64
|
||||
|
||||
raw = base64.b64decode(raw).decode("utf-8")
|
||||
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 _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
|
||||
if isinstance(body, bytes):
|
||||
body = body.decode("ascii")
|
||||
return base64.b64decode(body)
|
||||
return body
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
try:
|
||||
method, path = _route(event)
|
||||
if method == "OPTIONS":
|
||||
return _response(event, 204, {})
|
||||
identity = _identity(event)
|
||||
schedule = ShiftSchedule()
|
||||
if method == "GET" and path == "/api/shifts":
|
||||
employee = schedule.get_employee_by_email(identity["email"])
|
||||
if not employee:
|
||||
return _response(
|
||||
event,
|
||||
200,
|
||||
{
|
||||
"linked": False,
|
||||
"email": identity["email"],
|
||||
"isAdmin": False,
|
||||
},
|
||||
status, headers, payload = handle(
|
||||
method=method,
|
||||
path=path,
|
||||
origin=_header(event, "origin"),
|
||||
authorization=_header(event, "authorization"),
|
||||
query=event.get("queryStringParameters")
|
||||
if isinstance(event.get("queryStringParameters"), dict)
|
||||
else {},
|
||||
body=_raw_body(event),
|
||||
)
|
||||
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)
|
||||
if status == 204:
|
||||
return {"statusCode": 204, "headers": headers, "body": ""}
|
||||
return {
|
||||
"statusCode": status,
|
||||
"headers": headers,
|
||||
"body": encode_body(payload),
|
||||
}
|
||||
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:
|
||||
logger.exception("portal api unexpected failure")
|
||||
return _response(event, 500, {"error": {"code": "INTERNAL", "message": "Internal error"}})
|
||||
|
||||
|
||||
def _dispatch(event, method, path, schedule, employee, body):
|
||||
parts = [p for p in path.split("/") if p]
|
||||
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"),
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": cors_headers(_header(event, "origin")),
|
||||
"body": encode_body(
|
||||
{"error": {"code": "INTERNAL", "message": "Internal error"}}
|
||||
),
|
||||
)
|
||||
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
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
import shared.sentry_init # noqa: F401
|
||||
from shared.schedule import ShiftSchedule
|
||||
from shared.secrets import get_secret
|
||||
from shared.roster_http import (
|
||||
AuthError,
|
||||
SecretUnavailable,
|
||||
authorize_bearer,
|
||||
remove,
|
||||
upsert,
|
||||
validate_extension,
|
||||
)
|
||||
|
||||
logger = logging.getLogger()
|
||||
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:
|
||||
if status == 204:
|
||||
|
|
@ -95,116 +79,6 @@ def _raw_body(event: dict) -> bytes | 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:
|
||||
params = event.get("pathParameters") or {}
|
||||
if not isinstance(params, dict):
|
||||
|
|
@ -212,15 +86,7 @@ def _delete_extension(event: dict) -> str:
|
|||
raw = params.get("extension")
|
||||
if not isinstance(raw, str):
|
||||
raise TypeError("extension")
|
||||
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
|
||||
return validate_extension(raw)
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
|
|
@ -228,7 +94,7 @@ def handler(event, context):
|
|||
method, path = _route(event)
|
||||
logger.info("roster api %s %s", method, path)
|
||||
try:
|
||||
_authorize(event)
|
||||
authorize_bearer(_header(event, "authorization"))
|
||||
except AuthError:
|
||||
return _json_response(401, {"error": "unauthorized"})
|
||||
except SecretUnavailable:
|
||||
|
|
@ -239,13 +105,9 @@ def handler(event, context):
|
|||
if raw is None:
|
||||
return _json_response(400, {"error": "invalid request"})
|
||||
try:
|
||||
name, extension, slack_user_id, email = _validate_put(raw)
|
||||
upsert(raw)
|
||||
except (TypeError, ValueError):
|
||||
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})
|
||||
|
||||
if method == "DELETE" and path.startswith("/roster/"):
|
||||
|
|
@ -253,8 +115,7 @@ def handler(event, context):
|
|||
extension = _delete_extension(event)
|
||||
except (TypeError, ValueError):
|
||||
return _json_response(400, {"error": "invalid request"})
|
||||
ShiftSchedule().remove_roster_entry(extension)
|
||||
logger.info("roster deleted extension=%s", extension)
|
||||
remove(extension)
|
||||
return _json_response(204)
|
||||
|
||||
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
|
||||
|
||||
from shared.three_cx_client import ThreeCXClient
|
||||
from shared.three_cx_client import oauth_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -15,12 +15,7 @@ def update_queue_routing(
|
|||
client_secret: str,
|
||||
) -> dict:
|
||||
"""Update 3CX queue forwarding to route calls to the given extension."""
|
||||
client = ThreeCXClient(
|
||||
domain=domain,
|
||||
auth_mode="oauth",
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
client = oauth_client(domain, client_id, client_secret)
|
||||
queue = client.get_queue(queue_number)
|
||||
client.update_queue_forwarding(
|
||||
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()
|
||||
|
||||
|
||||
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():
|
||||
dsn = os.environ.get("SENTRY_DSN")
|
||||
if not dsn:
|
||||
return
|
||||
kwargs = {
|
||||
"dsn": dsn,
|
||||
"integrations": [AwsLambdaIntegration(timeout_warning=True)],
|
||||
"integrations": _integrations(),
|
||||
"send_default_pii": False,
|
||||
"include_local_variables": False,
|
||||
"enable_logs": False,
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ from shared.blocks import (
|
|||
from shared.ring_scheduler import update_queue_routing
|
||||
from shared.schedule import FALLBACK_EXTENSION
|
||||
from shared.secrets import get_secret
|
||||
from shared.shift_clock import holiday_window_active, is_active_shift_type, is_today
|
||||
from shared.three_cx_client import ThreeCXClient
|
||||
from shared.shift_clock import is_active_shift_type, is_today
|
||||
from shared.three_cx_client import ThreeCXClient, oauth_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
|
|
@ -86,17 +86,23 @@ def maybe_repoint_today(date_str: str, shift_type: str, extension: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
_cached_3cx: ThreeCXClient | None = 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")
|
||||
if not secret_prefix:
|
||||
logger.warning("3CX env vars not set — skipping 3CX call")
|
||||
return None
|
||||
return ThreeCXClient(
|
||||
_cached_3cx = oauth_client(
|
||||
domain=get_secret(f"{secret_prefix}domain"),
|
||||
auth_mode="oauth",
|
||||
client_id=get_secret(f"{secret_prefix}client-id"),
|
||||
client_secret=get_secret(f"{secret_prefix}client-secret"),
|
||||
)
|
||||
return _cached_3cx
|
||||
|
||||
|
||||
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:
|
||||
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")
|
||||
if not router_arn:
|
||||
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}"
|
||||
|
||||
|
||||
def create_holiday_schedules(date_str: str) -> list[str]:
|
||||
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
||||
def _holiday_schedule_target(action: str, date_str: str) -> dict | None:
|
||||
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")
|
||||
if not router_arn or not role_arn:
|
||||
target = _holiday_schedule_target("activate", date_str)
|
||||
if target is None:
|
||||
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 []
|
||||
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",
|
||||
FlexibleTimeWindow={"Mode": "OFF"},
|
||||
ActionAfterCompletion="DELETE",
|
||||
Target={
|
||||
"Arn": router_arn,
|
||||
"RoleArn": role_arn,
|
||||
"Input": json.dumps({"action": action, "date": date_str}),
|
||||
},
|
||||
Target=_holiday_schedule_target(action, date_str),
|
||||
)
|
||||
created.append(name)
|
||||
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:
|
||||
return False
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
return 0
|
||||
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"
|
||||
delivered = 0
|
||||
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
|
||||
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")
|
||||
if not channel or not token:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -3,6 +3,23 @@ import requests
|
|||
|
||||
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:
|
||||
"""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 json
|
||||
import logging
|
||||
import os
|
||||
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:
|
||||
"""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 <
|
||||
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)
|
||||
activate_holiday_inline(schedule, date_str)
|
||||
|
||||
|
||||
def _holiday_schedule_names(date_str: str) -> tuple[str, str]:
|
||||
"""The (activate, deactivate) one-off schedule names for a holiday date."""
|
||||
compact = date_str.replace("-", "")
|
||||
return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}"
|
||||
from shared.side_effects import holiday_schedule_names
|
||||
|
||||
return holiday_schedule_names(date_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
|
||||
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
|
||||
return create_holiday_schedules(date_str)
|
||||
|
||||
|
||||
def _delete_holiday_schedules(schedule_names: list[str]) -> None:
|
||||
|
|
@ -2230,21 +2168,33 @@ def _admin_holiday_list(respond, schedule):
|
|||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
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
|
||||
degrades to "no What's New section" rather than erroring. The copy lives at
|
||||
``$LAMBDA_TASK_ROOT/CHANGELOG.md`` (synced from the repo root).
|
||||
degrades to "no What's New section" rather than erroring. Lambda zips and
|
||||
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 = []
|
||||
for path in _changelog_paths():
|
||||
tried.append(path)
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
except OSError:
|
||||
logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", path)
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
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"
|
||||
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 {
|
||||
sid = "PassExecRolesToLambda"
|
||||
sid = "PassExecRolesToCompute"
|
||||
effect = "Allow"
|
||||
actions = ["iam:PassRole"]
|
||||
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 {
|
||||
test = "StringEquals"
|
||||
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/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:DDB-*-afterhours-shifts",
|
||||
"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 = [
|
||||
"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:ListScheduleGroups",
|
||||
"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 = ["*"]
|
||||
}
|
||||
|
|
@ -717,6 +859,8 @@ data "aws_iam_policy_document" "hcptf_plan_refresh" {
|
|||
]
|
||||
resources = [
|
||||
"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 = ["*"]
|
||||
}
|
||||
|
||||
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 {
|
||||
sid = "RefreshSsm"
|
||||
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
|
||||
# classic subject forms), and job_workflow_ref to deploy.yaml at
|
||||
# refs/heads/main only. Live GitHub Actions presented the classic sub; both
|
||||
# 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.
|
||||
# One role: GitHub Environments have a single DEPLOY_ROLE_ARN. Trust is pinned
|
||||
# to Environments dev and prod (immutable and classic subject forms) and
|
||||
# job_workflow_ref to deploy.yaml at main plus deploy-api.yaml at main and v*.
|
||||
|
||||
data "aws_iam_policy_document" "github_deploy_assume" {
|
||||
statement {
|
||||
|
|
@ -30,17 +24,16 @@ data "aws_iam_policy_document" "github_deploy_assume" {
|
|||
condition {
|
||||
test = "StringEquals"
|
||||
variable = "token.actions.githubusercontent.com:sub"
|
||||
values = [
|
||||
local.github_oidc_sub,
|
||||
"repo:${var.github_repo}:environment:prod",
|
||||
]
|
||||
values = local.github_oidc_subs
|
||||
}
|
||||
|
||||
condition {
|
||||
test = "StringEquals"
|
||||
test = "StringLike"
|
||||
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
||||
values = [
|
||||
"${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" {
|
||||
name = local.deploy_role
|
||||
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
|
||||
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}"]
|
||||
}
|
||||
|
||||
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 {
|
||||
sid = "DeployParams"
|
||||
effect = "Allow"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
locals {
|
||||
project = "afterhours-shift-manager"
|
||||
account_id = "011934824531"
|
||||
environment = "prod"
|
||||
is_prod = var.environment == "prod"
|
||||
account_id = local.is_prod ? "011934824531" : "710827005802"
|
||||
environment = var.environment
|
||||
|
||||
hcp_project = "seahaven-prod"
|
||||
hcp_workspace = "afterhours-shift-manager-prod"
|
||||
hcp_project = "seahaven-${var.environment}"
|
||||
hcp_workspace = "${local.project}-${var.environment}"
|
||||
apply_role = "hcptf-afterhours-shift-manager"
|
||||
plan_role = "hcptf-afterhours-shift-manager-plan"
|
||||
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"
|
||||
# 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 = [
|
||||
"afterhours-shift-manager/slack-bot-token",
|
||||
|
|
|
|||
|
|
@ -9,3 +9,8 @@ resource "aws_cloudwatch_log_group" "api_access" {
|
|||
name = "/aws/apigateway/${local.project}"
|
||||
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" {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +28,26 @@ output "artifacts_bucket_name" {
|
|||
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" {
|
||||
description = "HCP apply role ARN. Set TFC_AWS_APPLY_ROLE_ARN after the bootstrap window."
|
||||
value = aws_iam_role.hcptf_apply.arn
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ provider "aws" {
|
|||
default_tags {
|
||||
tags = {
|
||||
Project = local.project
|
||||
Environment = "prod"
|
||||
Environment = var.environment
|
||||
ManagedBy = "terraform"
|
||||
Workspace = local.hcp_workspace
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# EventBridge Scheduler execution role. slack-bot creates one-off holiday-*
|
||||
# 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" {
|
||||
statement {
|
||||
|
|
@ -29,9 +30,9 @@ data "aws_iam_policy_document" "holiday_scheduler_assume" {
|
|||
resource "aws_iam_role" "holiday_scheduler" {
|
||||
name = local.holiday_scheduler_role_name
|
||||
path = "/tf-managed/"
|
||||
description = "EventBridge Scheduler assumes this role to invoke afterhours-holiday-router"
|
||||
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
|
||||
permissions_boundary = aws_iam_policy.lambda_boundary.arn
|
||||
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "holiday_scheduler" {
|
||||
|
|
@ -41,6 +42,13 @@ data "aws_iam_policy_document" "holiday_scheduler" {
|
|||
actions = ["lambda:InvokeFunction"]
|
||||
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" {
|
||||
|
|
@ -48,3 +56,52 @@ resource "aws_iam_role_policy" "holiday_scheduler" {
|
|||
role = aws_iam_role.holiday_scheduler.id
|
||||
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
|
||||
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 = ""
|
||||
}
|
||||
|
||||
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" {
|
||||
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
|
||||
default = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ terraform {
|
|||
organization = "seahaven"
|
||||
|
||||
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("TCX_SECRET_PREFIX", 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):
|
||||
|
|
|
|||
|
|
@ -31,17 +31,49 @@ def test_lambda_ignore_changes_includes_code_attributes():
|
|||
|
||||
|
||||
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]
|
||||
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()
|
||||
assert "afterhours-shift-manager-prod" in versions
|
||||
assert "afterhours-shift-manager-dev" not in versions
|
||||
assert 'environment = "prod"' in LOCALS
|
||||
assert "seahaven-dev" not in LOCALS
|
||||
assert 'tags = ["app:afterhours-shift-manager"]' in versions
|
||||
assert 'name = "afterhours-shift-manager-prod"' not in versions
|
||||
assert 'hcp_workspace = "${local.project}-${var.environment}"' 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():
|
||||
|
|
@ -74,6 +106,9 @@ def test_checkcomponents_queue_arn_variable_matches_iam_references():
|
|||
assert "var.checkcomponents_queue_arn" in LAMBDA_TF
|
||||
boundary = (TERRAFORM / "lambda_boundary.tf").read_text()
|
||||
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():
|
||||
|
|
@ -94,12 +129,15 @@ def test_weekly_post_role_is_tf_managed_name():
|
|||
assert 'role_name = "afterhours-shift-manager-weekly-post"' in LOCALS
|
||||
|
||||
|
||||
def test_github_deploy_trust_is_environment_prod():
|
||||
def test_github_deploy_trust_covers_zip_and_image():
|
||||
iam = (TERRAFORM / "iam_github_deploy.tf").read_text()
|
||||
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-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 "refs/tags/v*" not in iam
|
||||
assert "ecs:ListTasks" in iam
|
||||
|
||||
|
||||
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
|
||||
def identity(portalapi_app, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
portalapi_app,
|
||||
"verify_cognito_id_token",
|
||||
"shared.portal_http.verify_cognito_id_token",
|
||||
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):
|
||||
seed.roster(
|
||||
"114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com"
|
||||
)
|
||||
seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com")
|
||||
seed.config(admin_users=["U_OTHER"])
|
||||
result = portalapi_app.handler(
|
||||
_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):
|
||||
result = portalapi_app.handler(
|
||||
_event(origin="https://internal.seahaven.com"), None
|
||||
result = portalapi_app.handler(_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
|
||||
sentry-sdk==2.68.1
|
||||
PyJWT[crypto]==2.14.0
|
||||
flask==3.1.3
|
||||
|
||||
|
|
|
|||
|
|
@ -21,4 +21,6 @@ def _load(name, relpath):
|
|||
def rosterapi_app():
|
||||
mod = _load("rosterapi_app", "src/roster-api/app.py")
|
||||
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 shared.roster_http as roster_http
|
||||
|
||||
TOKEN = "roster-test-token"
|
||||
SECRET_NAME = "afterhours-shift-manager/roster-api-token"
|
||||
|
||||
|
|
@ -18,8 +20,8 @@ def env(monkeypatch):
|
|||
|
||||
@pytest.fixture
|
||||
def secrets(rosterapi_app, monkeypatch, env):
|
||||
monkeypatch.setattr(rosterapi_app, "get_secret", lambda _id: TOKEN)
|
||||
rosterapi_app._cached_token = None
|
||||
monkeypatch.setattr(roster_http, "get_secret", lambda _id: TOKEN)
|
||||
roster_http._cached_token = None
|
||||
|
||||
|
||||
def _event(
|
||||
|
|
@ -174,16 +176,16 @@ def test_401_wrong_token(rosterapi_app, schedule, secrets):
|
|||
def test_authorize_strips_secret_trailing_newline(
|
||||
rosterapi_app, schedule, env, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(rosterapi_app, "get_secret", lambda _id: TOKEN + "\n")
|
||||
rosterapi_app._cached_token = None
|
||||
monkeypatch.setattr(roster_http, "get_secret", lambda _id: TOKEN + "\n")
|
||||
roster_http._cached_token = None
|
||||
result = rosterapi_app.handler(_event(body=_put_body()), None)
|
||||
assert result["statusCode"] == 200
|
||||
assert schedule.get_employee_by_extension("110")["slack_user_id"] == "U123ABCDE"
|
||||
|
||||
|
||||
def test_503_when_secret_is_whitespace_only(rosterapi_app, schedule, env, monkeypatch):
|
||||
monkeypatch.setattr(rosterapi_app, "get_secret", lambda _id: "\n")
|
||||
rosterapi_app._cached_token = None
|
||||
monkeypatch.setattr(roster_http, "get_secret", lambda _id: "\n")
|
||||
roster_http._cached_token = None
|
||||
result = rosterapi_app.handler(_event(body=_put_body()), None)
|
||||
assert result["statusCode"] == 503
|
||||
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):
|
||||
raise RuntimeError("secrets down")
|
||||
|
||||
monkeypatch.setattr(rosterapi_app, "get_secret", boom)
|
||||
rosterapi_app._cached_token = None
|
||||
monkeypatch.setattr(roster_http, "get_secret", boom)
|
||||
roster_http._cached_token = None
|
||||
result = rosterapi_app.handler(_event(body=_put_body()), None)
|
||||
assert result["statusCode"] == 503
|
||||
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):
|
||||
monkeypatch.setattr(
|
||||
rosterapi_app,
|
||||
roster_http,
|
||||
"ShiftSchedule",
|
||||
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):
|
||||
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(
|
||||
extension="114",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ def test_empty_dsn_does_not_init(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.assert_called_once()
|
||||
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):
|
||||
monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "afterhours-shift-manager")
|
||||
monkeypatch.setenv("SENTRY_DSN", "https://key@o1.ingest.sentry.io/1")
|
||||
fake = ModuleType("shared.build_info")
|
||||
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}
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
slackbot_app.publish_home(client, "U_BOB", "")
|
||||
view = client.views_publish.call_args.kwargs["view"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue