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:
Adam Moussa 2026-09-21 19:13:30 +00:00 • committed by GitHub
parent 969ebf90de
commit 26adb8e6c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 3036 additions and 668 deletions

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
.git
.github
.venv
.cursor
terraform
tests
docs
*.md
!src/slack-bot/CHANGELOG.md
__pycache__
.pytest_cache
.mypy_cache

View file

@ -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
View 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
View 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"]

View file

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

View file

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

View file

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

@ -0,0 +1,2 @@
flask==3.1.3
gunicorn==23.0.0

View 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())

View file

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

View file

@ -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,
},
)
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)
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),
)
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"}})
}

View file

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

@ -0,0 +1 @@
"""Always-on Flask process for afterhours-shift-manager."""

132
src/server/app.py Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
"""Gunicorn entrypoint."""
from server.app import app
__all__ = ["app"]

View 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),
}

View 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"}},
)

View file

@ -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"],

View 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)

View file

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

View file

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

View file

@ -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)."""

View file

@ -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,19 +2168,31 @@ 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")
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)
return ""
tried = []
for path in _changelog_paths():
tried.append(path)
try:
with open(path, encoding="utf-8") as fh:
return fh.read()
except OSError:
continue
logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", tried)
return ""
def _changelog_paths() -> list[str]:
paths = [os.path.join(os.path.dirname(os.path.abspath(__file__)), "CHANGELOG.md")]
task_root = os.environ.get("LAMBDA_TASK_ROOT", "").strip()
if task_root:
paths.append(os.path.join(task_root, "CHANGELOG.md"))
return paths
_HOME_OVERVIEW_DAYS = 60

14
terraform/acm.tf Normal file
View 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
}

View file

@ -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
View 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
View 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
})
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ provider "aws" {
default_tags {
tags = {
Project = local.project
Environment = "prod"
Environment = var.environment
ManagedBy = "terraform"
Workspace = local.hcp_workspace
}

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ terraform {
organization = "seahaven"
workspaces {
name = "afterhours-shift-manager-prod"
tags = ["app:afterhours-shift-manager"]
}
}
}

View file

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

View file

@ -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():

View file

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

View file

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

View file

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

View file

@ -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")),
)

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

View file

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

View file

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

View 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"

View file

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

View file

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