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/weekly-post/requirements.txt
pip install -r src/shared/requirements.txt pip install -r src/shared/requirements.txt
pip install -r src/portal-api/requirements.txt pip install -r src/portal-api/requirements.txt
pip install -r requirements-api.txt
- name: Pytest - name: Pytest
run: pytest run: pytest

247
.github/workflows/deploy-api.yaml vendored Normal file
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 ## Architecture
- **Runtime**: Python 3.12 on AWS Lambda (arm64), seahaven-prod `011934824531` - **Runtime**: Python 3.12 on ECS Fargate (arm64) plus dual-run Lambdas until cutover. seahaven-prod `011934824531`, seahaven-dev `710827005802`
- **Data**: DynamoDB single-table (`afterhours-shifts`) - **Data**: DynamoDB single-table (`afterhours-shifts`)
- **IaC**: HCP Terraform workspace `afterhours-shift-manager-prod` (containers) plus GitHub Actions `deploy.yaml` (zips). `src/shared` is bundled into each function zip. Terraform does not package `src/`. - **IaC**: HCP Terraform workspaces tagged `app:afterhours-shift-manager` (`afterhours-shift-manager-dev` / `-prod`) plus GitHub Actions `deploy.yaml` (zips) and `deploy-api.yaml` (image). Terraform does not package `src/`.
- **Slack**: Slack Bolt framework with `/oncall` slash command - **Slack**: Slack Bolt on `POST /slack/events`
- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI - **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI
- **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`) - **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`)
@ -81,7 +81,8 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
``` ```
src/ src/
slack-bot/ Slack Bolt Lambda (handler + app); ships CHANGELOG.md for App Home server/ Flask + gunicorn + SQS worker (Fargate)
slack-bot/ Slack Bolt app; Lambda handler until cutover; ships CHANGELOG.md for App Home
weekly-post/ Monday schedule + pay post weekly-post/ Monday schedule + pay post
roster-sync/ Daily 3CX roster sync roster-sync/ Daily 3CX roster sync
roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard
@ -89,10 +90,10 @@ src/
ring-scheduler/ 3CX queue routing updates ring-scheduler/ 3CX queue routing updates
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate) holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
release-notifier/ Posts release announcements to Slack release-notifier/ Posts release announcements to Slack
shared/ Bundled into each function zip (schedule, blocks, changelog, 3CX client, secrets) shared/ Bundled into each function zip and the Fargate image
terraform/ HCP Terraform (function skeletons, API, DDB, IAM, schedules) terraform/ HCP Terraform (Lambda skeletons, ECS/ALB, API, DDB, IAM, schedules)
scripts/ changelog CLI + CI guard + in-package copy sync scripts/ changelog CLI + CI guard + in-package copy sync + cutover
tests/ pytest suite (mirrors src/, one dir per Lambda + shared) tests/ pytest suite (mirrors src/, one dir per Lambda + shared + server)
``` ```
### DynamoDB Schema ### DynamoDB Schema
@ -299,7 +300,9 @@ python -m venv .venv && source .venv/bin/activate
pip install -r tests/requirements.txt # test-only deps pip install -r tests/requirements.txt # test-only deps
pip install -r src/slack-bot/requirements.txt \ pip install -r src/slack-bot/requirements.txt \
-r src/weekly-post/requirements.txt \ -r src/weekly-post/requirements.txt \
-r src/shared/requirements.txt # runtime deps the imports need -r src/shared/requirements.txt \
-r src/portal-api/requirements.txt \
-r requirements-api.txt
pytest pytest
``` ```
@ -307,4 +310,15 @@ Each Lambda has its own `app.py`, so the per-package `conftest.py` loads each on
under a unique module name (importlib mode) to avoid collisions. CI runs the same under a unique module name (importlib mode) to avoid collisions. CI runs the same
suite on every PR via pytest plus `terraform fmt` / `init -backend=false` / `validate`. suite on every PR via pytest plus `terraform fmt` / `init -backend=false` / `validate`.
Local Fargate process (needs the same DynamoDB table and Secrets Manager names
the Lambdas use, plus `JOBS_QUEUE_URL` to consume jobs):
```bash
export PYTHONPATH=src:src/shared:src/slack-bot
export STAGE=local GIT_SHA=dev
python -m server.entrypoint
```
Health is `GET /api/health` on port 8080.
See [SETUP.md](SETUP.md) for full deployment and Slack app creation instructions. See [SETUP.md](SETUP.md) for full deployment and Slack app creation instructions.

View file

@ -75,11 +75,15 @@ before the identity processor PUTs `/roster`.
## 3. HCP Terraform and GitHub Environment ## 3. HCP Terraform and GitHub Environment
Prod only. Workspace `afterhours-shift-manager-prod` in project `seahaven-prod` Workspaces tagged `app:afterhours-shift-manager`:
(account `011934824531`). No seahaven-dev workspace. `afterhours-shift-manager-prod` in `seahaven-prod` (account `011934824531`) and
`afterhours-shift-manager-dev` in `seahaven-dev` (account `710827005802`).
Create the dev workspace before any Slack URL flip. HCP variable `environment`
is `prod` or `dev`.
First apply uses the hcptf-bootstrap window (exact `StringEquals` trust, never First apply uses the hcptf-bootstrap window (exact `StringEquals` trust, never
`StringLike`): `StringLike`). Creating `afterhours-shift-manager-ecs-task-boundary` is
`iam:CreatePolicy` and needs that window.
1. Create the HCP workspace. Auto-apply off. No project-level variable set. 1. Create the HCP workspace. Auto-apply off. No project-level variable set.
Working directory `terraform`. File trigger prefix `terraform/**` only. Working directory `terraform`. File trigger prefix `terraform/**` only.
@ -99,14 +103,21 @@ First apply uses the hcptf-bootstrap window (exact `StringEquals` trust, never
`--allow-workspace`. `--allow-workspace`.
6. Second manual apply as the scoped role. Then seal auto-apply on. 6. Second manual apply as the scoped role. Then seal auto-apply on.
GitHub Environment `prod`: reviewers, branch policy `main` only, Environment GitHub Environment `prod`: reviewers, branch policy `main` and `v*`, Environment
variable `DEPLOY_ROLE_ARN` = Terraform output `github_deploy_role_arn`. variable `DEPLOY_ROLE_ARN` = Terraform output `github_deploy_role_arn`.
GitHub Environment `dev`: no reviewers, `DEPLOY_ROLE_ARN` from the seahaven-dev
apply of the same output. Set `checkcomponents_queue_url` and
`checkcomponents_queue_arn` empty on the dev workspace.
Function zips: Actions → Deploy on push to `main`, or `workflow_dispatch`. Function zips: Actions → Deploy on push to `main`, or `workflow_dispatch`.
Keep `schedules_enabled=false` until Slack and Paychex point at this stack. Image CD: Actions → Deploy API (`deploy-api.yaml`). Keep `schedules_enabled=false`
and `ecs_schedules_enabled=false` until the Fargate cutover below.
HCP outputs to copy: `slack_request_url`, `api_origin`, HCP outputs to copy: `slack_request_url`, `api_origin`, `fargate_origin`,
`holiday_scheduler_role_arn`, `github_deploy_role_arn`. `holiday_scheduler_role_arn`, `github_deploy_role_arn`, `jobs_queue_arn`,
`ecs_task_role_arn`. Add `ecs_task_role_arn` to paychex-checkcomponents
(alongside `afterhours-shift-manager-weekly-post`) before Fargate weekly_post
runs. Dual-run keeps the Lambda principal until zip CD is retired.
## 4. Set the Slack Request URL ## 4. Set the Slack Request URL
@ -174,6 +185,27 @@ scripts first (`--execute` is required for writes).
Do not dual-run 3CX writers. Do not flip `afterhours_base_url` before DynamoDB Do not dual-run 3CX writers. Do not flip `afterhours_base_url` before DynamoDB
is copied. is copied.
## 9. Fargate cutover (PLAT-216)
Dual-run ECS beside API Gateway. Do not dual-write 3CX. Do not flip Slack
without the `afterhours-shift-manager-dev` workspace already serving
`/api/health`.
1. Image deploy via `deploy-api.yaml`. `GET /api/health` reports the real sha.
2. Recreate outstanding `holiday-activate-*` / `holiday-deactivate-*` onto
jobs SQS (same class of work as `recreate_holiday_schedules.py`):
`python scripts/cutover/retarget_holiday_schedules_to_sqs.py --profile prod --queue-arn <jobs_queue_arn>`
then `--execute`.
3. Instant cut: Slack Request URL, Paychex `AFTERHOURS_BASE_URL`, portal
`VITE_SHIFTS_API_BASE` → `https://afterhours.seahaven.com` (dev hostname in
portal-dev). Smoke `/oncall`, roster PUT/DELETE, `GET /api/shifts`, one
holiday GetSchedule, ring job.
4. Set `ecs_schedules_enabled=true` and keep `schedules_enabled=false`. Confirm
no 3CX writers remain on Lambda.
5. After smoke, remove API Gateway, the eight Lambdas, zip packaging, and
Lambda alarms in a follow-up apply. ALB 5xx/latency and unhealthy-host
alarms stay.
## Commands Reference ## Commands Reference
| Command | Description | | Command | Description |

View file

@ -2,6 +2,6 @@
# `src/shared` on the path makes the `shared` layer package importable as it is at # `src/shared` on the path makes the `shared` layer package importable as it is at
# runtime. Each Lambda's own `app.py` is loaded under a unique name by the # runtime. Each Lambda's own `app.py` is loaded under a unique name by the
# per-package conftest (importlib mode) to avoid the four-`app.py` collision. # per-package conftest (importlib mode) to avoid the four-`app.py` collision.
pythonpath = ["src/shared"] pythonpath = ["src", "src/shared"]
testpaths = ["tests"] testpaths = ["tests"]
addopts = "--import-mode=importlib" addopts = "--import-mode=importlib"

2
requirements-api.txt Normal file
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. A holiday is a day-only shift (08:00-17:00 ET), one HOLIDAY record per date.
At 08:00 this Lambda is invoked with ``{"action": "activate", "date": ...}`` and At 08:00 this Lambda is invoked with ``{"action": "activate", "date": ...}`` and
at 17:00 with ``{"action": "deactivate", ...}``. at 17:00 with ``{"action": "deactivate", ...}``.
Activate:
* Capture both IVR 800 routes (key-0 and no-input/timeout) into
``CONFIG.captured_ivr_routes`` — but only if they are NOT already pointed at
the holiday queue (so a re-run never overwrites the real originals).
* Set queue 802's agents to the holiday's assignees, or ``[FALLBACK_EXTENSION]``
("100") when no slots are filled. Membership is set ONCE here.
* Repoint BOTH IVR 800 routes to the holiday queue (802).
* Mark the holiday ``activated = True``.
Idempotent: no-op if the record is gone or already activated.
Deactivate:
* Restore both IVR routes from ``CONFIG.captured_ivr_routes`` — only the routes
that currently point at the queue are reverted (defensive against manual
changes); clear the captured routes afterwards.
* Clear queue 802's agents.
* Mark the holiday ``activated = False``.
Idempotent: no-op if the record is gone or not activated.
""" """
import json import json
@ -28,7 +10,8 @@ import logging
import os import os
import shared.sentry_init # noqa: F401 import shared.sentry_init # noqa: F401
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule from shared.holiday_flow import activate, deactivate
from shared.schedule import ShiftSchedule
from shared.secrets import get_secret from shared.secrets import get_secret
from shared.three_cx_client import ThreeCXClient from shared.three_cx_client import ThreeCXClient
@ -46,121 +29,12 @@ def _make_client() -> ThreeCXClient:
) )
def _holiday_extensions(holiday: dict) -> list[str]:
"""Assignee extensions for the holiday, or the fallback when none claimed."""
assignees = holiday.get("assignees", {}) or {}
extensions = list(assignees.keys())
return extensions or [FALLBACK_EXTENSION]
def _activate(schedule: ShiftSchedule, date: str) -> dict: def _activate(schedule: ShiftSchedule, date: str) -> dict:
holiday = schedule.get_holiday(date) return activate(schedule, date, client_factory=_make_client)
if holiday is None:
logger.info("No holiday record for %s — nothing to activate", date)
return {"action": "activate", "date": date, "skipped": "no_record"}
if holiday.get("activated"):
logger.info("Holiday %s already activated — no-op", date)
return {"action": "activate", "date": date, "skipped": "already_active"}
queue_number = schedule.get_holiday_queue()
ivr_number = schedule.get_ivr_number()
extensions = _holiday_extensions(holiday)
client = _make_client()
# Capture the live IVR routes BEFORE repointing — but guard against storing
# holiday-state routes: if both routes already target the queue, a prior
# activation is in effect, so keep whatever originals we already captured.
ivr = client.get_ivr(ivr_number)
ivr_id = ivr["Id"]
current = client.extract_ivr_routes(ivr)
already_queue = str(current.get("key0")) == str(queue_number) and str(
current.get("timeout")
) == str(queue_number)
if already_queue:
logger.info(
"IVR %s already points at queue %s — not re-capturing routes",
ivr_number,
queue_number,
)
else:
schedule.set_captured_ivr_routes(current)
# Set queue membership ONCE, then repoint both IVR routes to the queue.
queue = client.get_queue(queue_number)
client.set_queue_agents(queue["Id"], extensions)
client.set_ivr_routes(ivr_id, key0_dn=queue_number, timeout_dn=queue_number)
schedule.set_holiday_activated(date, True)
logger.info(
"Activated holiday %s: queue %s agents=%s, IVR %s -> queue",
date,
queue_number,
extensions,
ivr_number,
)
return {
"action": "activate",
"date": date,
"queue": str(queue_number),
"ivr": str(ivr_number),
"agents": extensions,
}
def _deactivate(schedule: ShiftSchedule, date: str) -> dict: def _deactivate(schedule: ShiftSchedule, date: str) -> dict:
holiday = schedule.get_holiday(date) return deactivate(schedule, date, client_factory=_make_client)
if holiday is None:
logger.info("No holiday record for %s — nothing to deactivate", date)
return {"action": "deactivate", "date": date, "skipped": "no_record"}
if not holiday.get("activated"):
logger.info("Holiday %s not activated — no-op", date)
return {"action": "deactivate", "date": date, "skipped": "not_active"}
queue_number = schedule.get_holiday_queue()
ivr_number = schedule.get_ivr_number()
captured = schedule.get_captured_ivr_routes() or {}
client = _make_client()
ivr = client.get_ivr(ivr_number)
ivr_id = ivr["Id"]
live = client.extract_ivr_routes(ivr)
# Only restore a route if it currently points at the queue; otherwise leave
# whatever destination it has now (it was changed outside this flow).
def _restore(which: str) -> str | None:
live_dn = live.get(which)
if str(live_dn) == str(queue_number):
# Restore the captured pre-holiday DN; if it was lost, keep the live
# value rather than blanking the IVR destination.
return captured.get(which) or live_dn
return None # not pointing at the holiday queue — leave it untouched
client.set_ivr_routes(
ivr_id,
key0_dn=_restore("key0"),
timeout_dn=_restore("timeout"),
)
queue = client.get_queue(queue_number)
client.set_queue_agents(queue["Id"], [])
schedule.set_captured_ivr_routes(None)
schedule.set_holiday_activated(date, False)
logger.info(
"Deactivated holiday %s: restored IVR %s, cleared queue %s",
date,
ivr_number,
queue_number,
)
return {
"action": "deactivate",
"date": date,
"queue": str(queue_number),
"ivr": str(ivr_number),
}
def handler(event, context): def handler(event, context):

View file

@ -2,17 +2,12 @@
from __future__ import annotations from __future__ import annotations
import json import base64
import logging import logging
from decimal import Decimal
from typing import Any
from urllib.parse import unquote
import shared.sentry_init # noqa: F401 import shared.sentry_init # noqa: F401
from shared.cognito import CognitoVerificationUnavailable, verify_cognito_id_token from shared.portal_http import cors_headers, encode_body, handle
from shared.portal_ops import ActionError, snapshot from shared.portal_ops import ActionError
from shared.schedule import ShiftSchedule
from shared import portal_ops as ops
logger = logging.getLogger() logger = logging.getLogger()
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
@ -25,40 +20,6 @@ CORS_ORIGINS = {
} }
class DecimalEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, Decimal):
return float(o)
return super().default(o)
def _cors_headers(event: dict) -> dict[str, str]:
headers = event.get("headers") or {}
origin = ""
for key, value in headers.items():
if str(key).lower() == "origin":
origin = "" if value is None else str(value)
break
allowed = origin if origin in CORS_ORIGINS else ""
out = {
"Content-Type": "application/json",
"Vary": "Origin",
}
if allowed:
out["Access-Control-Allow-Origin"] = allowed
out["Access-Control-Allow-Headers"] = "Authorization,Content-Type"
out["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"
return out
def _response(event: dict, status: int, body: dict[str, Any] | None = None) -> dict:
return {
"statusCode": status,
"headers": _cors_headers(event),
"body": json.dumps({} if body is None else body, cls=DecimalEncoder, separators=(",", ":")),
}
def _header(event: dict, name: str) -> str: def _header(event: dict, name: str) -> str:
headers = event.get("headers") or {} headers = event.get("headers") or {}
if not isinstance(headers, dict): if not isinstance(headers, dict):
@ -83,179 +44,50 @@ def _route(event: dict) -> tuple[str, str]:
return method, path.rstrip("/") or "/" return method, path.rstrip("/") or "/"
def _json_body(event: dict) -> dict: def _raw_body(event: dict) -> bytes | str | None:
raw = event.get("body") body = event.get("body")
if raw in (None, ""): if body is None:
return {} return None
if event.get("isBase64Encoded"): if event.get("isBase64Encoded"):
import base64 if isinstance(body, bytes):
body = body.decode("ascii")
raw = base64.b64decode(raw).decode("utf-8") return base64.b64decode(body)
if isinstance(raw, bytes): return body
raw = raw.decode("utf-8")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise ActionError(400, "INVALID_JSON", "Invalid JSON body.") from exc
if not isinstance(parsed, dict):
raise ActionError(400, "INVALID_JSON", "JSON body must be an object.")
return parsed
def _identity(event: dict) -> dict:
presented = _header(event, "authorization")
parts = presented.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip():
raise ActionError(401, "UNAUTHORIZED", "Sign in to continue.")
try:
identity = verify_cognito_id_token(parts[1].strip())
except CognitoVerificationUnavailable as exc:
raise ActionError(503, "AUTH_UNAVAILABLE", "Sign-in verification is unavailable.") from exc
if not identity:
raise ActionError(401, "UNAUTHORIZED", "Sign in to continue.")
return identity
def _employee(schedule: ShiftSchedule, identity: dict) -> dict:
employee = schedule.get_employee_by_email(identity["email"])
if not employee:
raise ActionError(
404,
"UNLINKED",
"Your Google account is not on the after-hours roster yet.",
)
return employee
def handler(event, context): def handler(event, context):
try: try:
method, path = _route(event) method, path = _route(event)
if method == "OPTIONS": status, headers, payload = handle(
return _response(event, 204, {}) method=method,
identity = _identity(event) path=path,
schedule = ShiftSchedule() origin=_header(event, "origin"),
if method == "GET" and path == "/api/shifts": authorization=_header(event, "authorization"),
employee = schedule.get_employee_by_email(identity["email"]) query=event.get("queryStringParameters")
if not employee: if isinstance(event.get("queryStringParameters"), dict)
return _response( else {},
event, body=_raw_body(event),
200, )
{ if status == 204:
"linked": False, return {"statusCode": 204, "headers": headers, "body": ""}
"email": identity["email"], return {
"isAdmin": False, "statusCode": status,
}, "headers": headers,
) "body": encode_body(payload),
params = event.get("queryStringParameters") or {} }
week = (params.get("week") if isinstance(params, dict) else None) or "this"
return _response(event, 200, snapshot(schedule, employee, week))
employee = _employee(schedule, identity)
body = _json_body(event) if method in {"POST", "PUT", "PATCH"} else {}
return _dispatch(event, method, path, schedule, employee, body)
except ActionError as exc: except ActionError as exc:
return _response(event, exc.status, {"error": {"code": exc.code, "message": exc.message}}) logger.exception("portal api action error")
return {
"statusCode": exc.status,
"headers": cors_headers(_header(event, "origin")),
"body": encode_body({"error": {"code": exc.code, "message": exc.message}}),
}
except Exception: except Exception:
logger.exception("portal api unexpected failure") logger.exception("portal api unexpected failure")
return _response(event, 500, {"error": {"code": "INTERNAL", "message": "Internal error"}}) return {
"statusCode": 500,
"headers": cors_headers(_header(event, "origin")),
def _dispatch(event, method, path, schedule, employee, body): "body": encode_body(
parts = [p for p in path.split("/") if p] {"error": {"code": "INTERNAL", "message": "Internal error"}}
if method == "POST" and path == "/api/shifts/pick":
return _response(
event,
200,
ops.pick(schedule, employee, body.get("date", ""), body.get("shiftType")),
)
if method == "POST" and path == "/api/shifts/drop":
return _response(
event,
200,
ops.drop(schedule, employee, body.get("date", ""), body.get("shiftType")),
)
if method == "POST" and path == "/api/shifts/swap":
return _response(
event,
200,
ops.swap(
schedule,
employee,
body.get("date", ""),
body.get("targetExtension", ""),
body.get("shiftType"),
), ),
) }
if method == "POST" and len(parts) == 6 and parts[:3] == ["api", "shifts", "swaps"] and parts[5] in ("accept", "decline"):
return _response(
event,
200,
ops.respond_swap(
schedule,
employee,
unquote(parts[3]),
unquote(parts[4]),
accept=parts[5] == "accept",
),
)
if method == "POST" and path == "/api/shifts/admin/override":
return _response(
event,
200,
ops.admin_override(
schedule,
employee,
body.get("date", ""),
body.get("extension", ""),
body.get("shiftType"),
),
)
if method == "POST" and path == "/api/shifts/admin/open":
return _response(
event,
200,
ops.admin_open(schedule, employee, body.get("date", ""), body.get("shiftType")),
)
if method == "POST" and path == "/api/shifts/admin/clear":
return _response(
event,
200,
ops.admin_clear(schedule, employee, body.get("date", ""), body.get("shiftType")),
)
if method == "POST" and path == "/api/shifts/admin/holidays":
return _response(
event,
200,
ops.admin_holiday_add(
schedule,
employee,
body.get("date", ""),
body.get("slots"),
body.get("label", ""),
body.get("multiplier"),
),
)
if method == "DELETE" and len(parts) == 5 and parts[:4] == ["api", "shifts", "admin", "holidays"]:
return _response(
event,
200,
ops.admin_holiday_remove(schedule, employee, unquote(parts[4])),
)
if (
method == "POST"
and len(parts) == 8
and parts[:4] == ["api", "shifts", "admin", "pickups"]
and parts[7] in ("approve", "deny")
):
return _response(
event,
200,
ops.admin_pickup(
schedule,
employee,
unquote(parts[4]),
unquote(parts[5]),
unquote(parts[6]),
approve=parts[7] == "approve",
),
)
return _response(event, 405, {"error": {"code": "METHOD", "message": "Method not allowed"}})

View file

@ -10,39 +10,23 @@ Authorization header, the token, or the request body.
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hmac
import json import json
import logging import logging
import os
import unicodedata
from typing import Any from typing import Any
import shared.sentry_init # noqa: F401 import shared.sentry_init # noqa: F401
from shared.schedule import ShiftSchedule from shared.roster_http import (
from shared.secrets import get_secret AuthError,
SecretUnavailable,
authorize_bearer,
remove,
upsert,
validate_extension,
)
logger = logging.getLogger() logger = logging.getLogger()
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
PUT_FIELDS = ("name", "extension", "slack_user_id")
OPTIONAL_PUT_FIELDS = ("email",)
MAX_BODY_BYTES = 4096
MAX_NAME_LEN = 128
MAX_EXTENSION_LEN = 16
MAX_SLACK_ID_LEN = 64
MAX_EMAIL_LEN = 254
ALLOWED_EMAIL_DOMAINS = {"seahaven.com", "seahavenind.com"}
_cached_token: str | None = None
class AuthError(Exception):
"""Missing or wrong Bearer token."""
class SecretUnavailable(Exception):
"""Token secret could not be read."""
def _json_response(status: int, body: dict[str, Any] | None = None) -> dict: def _json_response(status: int, body: dict[str, Any] | None = None) -> dict:
if status == 204: if status == 204:
@ -95,116 +79,6 @@ def _raw_body(event: dict) -> bytes | None:
return None return None
def _has_disallowed_chars(value: str, *, allow_space: bool) -> bool:
for char in value:
if char == " " and allow_space:
continue
if char.isspace() or unicodedata.category(char).startswith("C"):
return True
return False
def _expected_token() -> str:
global _cached_token
if _cached_token:
return _cached_token
secret_id = os.environ["ROSTER_API_TOKEN_SECRET"]
try:
token = get_secret(secret_id)
except Exception:
logger.exception("roster api token secret read failed")
raise SecretUnavailable from None
if not isinstance(token, str):
raise SecretUnavailable
token = token.strip()
if not token:
raise SecretUnavailable
_cached_token = token
return token
def _authorize(event: dict) -> None:
presented = _header(event, "authorization")
if not presented:
raise AuthError
parts = presented.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip():
raise AuthError
token = parts[1].strip()
expected = _expected_token()
try:
matched = hmac.compare_digest(token, expected)
except (TypeError, ValueError):
raise AuthError from None
if not matched:
raise AuthError
def _validate_email(value: str) -> str:
if (
len(value) > MAX_EMAIL_LEN
or "@" not in value
or _has_disallowed_chars(value, allow_space=False)
):
raise ValueError("fields")
local, _, domain = value.partition("@")
if not local or domain.lower() not in ALLOWED_EMAIL_DOMAINS:
raise ValueError("fields")
return value.lower()
def _validate_put(raw: bytes) -> tuple[str, str, str, str | None]:
if len(raw) > MAX_BODY_BYTES:
raise ValueError("oversized")
try:
parsed = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise ValueError("invalid json") from None
if not isinstance(parsed, dict):
raise TypeError("invalid json")
allowed = set(PUT_FIELDS) | set(OPTIONAL_PUT_FIELDS)
if not set(PUT_FIELDS).issubset(parsed) or not set(parsed).issubset(allowed):
raise ValueError("fields")
values: dict[str, str] = {}
for field in PUT_FIELDS:
value = parsed[field]
if not isinstance(value, str):
raise TypeError("fields")
trimmed = value.strip()
if not trimmed:
raise ValueError("fields")
values[field] = trimmed
name = values["name"]
extension = values["extension"]
slack_user_id = values["slack_user_id"]
email = None
if "email" in parsed:
raw_email = parsed["email"]
if not isinstance(raw_email, str):
raise TypeError("fields")
trimmed_email = raw_email.strip()
if not trimmed_email:
raise ValueError("fields")
email = _validate_email(trimmed_email)
if len(name) > MAX_NAME_LEN or _has_disallowed_chars(name, allow_space=True):
raise ValueError("fields")
if (
len(extension) > MAX_EXTENSION_LEN
or not extension.isdigit()
or _has_disallowed_chars(extension, allow_space=False)
):
raise ValueError("fields")
if (
len(slack_user_id) > MAX_SLACK_ID_LEN
or not slack_user_id.isalnum()
or _has_disallowed_chars(slack_user_id, allow_space=False)
):
raise ValueError("fields")
return name, extension, slack_user_id, email
def _delete_extension(event: dict) -> str: def _delete_extension(event: dict) -> str:
params = event.get("pathParameters") or {} params = event.get("pathParameters") or {}
if not isinstance(params, dict): if not isinstance(params, dict):
@ -212,15 +86,7 @@ def _delete_extension(event: dict) -> str:
raw = params.get("extension") raw = params.get("extension")
if not isinstance(raw, str): if not isinstance(raw, str):
raise TypeError("extension") raise TypeError("extension")
extension = raw.strip() return validate_extension(raw)
if (
not extension
or len(extension) > MAX_EXTENSION_LEN
or not extension.isdigit()
or _has_disallowed_chars(extension, allow_space=False)
):
raise ValueError("extension")
return extension
def handler(event, context): def handler(event, context):
@ -228,7 +94,7 @@ def handler(event, context):
method, path = _route(event) method, path = _route(event)
logger.info("roster api %s %s", method, path) logger.info("roster api %s %s", method, path)
try: try:
_authorize(event) authorize_bearer(_header(event, "authorization"))
except AuthError: except AuthError:
return _json_response(401, {"error": "unauthorized"}) return _json_response(401, {"error": "unauthorized"})
except SecretUnavailable: except SecretUnavailable:
@ -239,13 +105,9 @@ def handler(event, context):
if raw is None: if raw is None:
return _json_response(400, {"error": "invalid request"}) return _json_response(400, {"error": "invalid request"})
try: try:
name, extension, slack_user_id, email = _validate_put(raw) upsert(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
return _json_response(400, {"error": "invalid request"}) return _json_response(400, {"error": "invalid request"})
ShiftSchedule().upsert_roster_entry(
extension, name, slack_user_id, email=email
)
logger.info("roster upserted extension=%s", extension)
return _json_response(200, {"ok": True}) return _json_response(200, {"ok": True})
if method == "DELETE" and path.startswith("/roster/"): if method == "DELETE" and path.startswith("/roster/"):
@ -253,8 +115,7 @@ def handler(event, context):
extension = _delete_extension(event) extension = _delete_extension(event)
except (TypeError, ValueError): except (TypeError, ValueError):
return _json_response(400, {"error": "invalid request"}) return _json_response(400, {"error": "invalid request"})
ShiftSchedule().remove_roster_entry(extension) remove(extension)
logger.info("roster deleted extension=%s", extension)
return _json_response(204) return _json_response(204)
return _json_response(405, {"error": "method not allowed"}) return _json_response(405, {"error": "method not allowed"})

1
src/server/__init__.py Normal file
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 import logging
from shared.three_cx_client import ThreeCXClient from shared.three_cx_client import oauth_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -15,12 +15,7 @@ def update_queue_routing(
client_secret: str, client_secret: str,
) -> dict: ) -> dict:
"""Update 3CX queue forwarding to route calls to the given extension.""" """Update 3CX queue forwarding to route calls to the given extension."""
client = ThreeCXClient( client = oauth_client(domain, client_id, client_secret)
domain=domain,
auth_mode="oauth",
client_id=client_id,
client_secret=client_secret,
)
queue = client.get_queue(queue_number) queue = client.get_queue(queue_number)
client.update_queue_forwarding( client.update_queue_forwarding(
queue_id=queue["Id"], queue_id=queue["Id"],

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() return str(GIT_SHA or "").strip()
def _integrations():
if os.environ.get("AWS_LAMBDA_FUNCTION_NAME"):
return [AwsLambdaIntegration(timeout_warning=True)]
try:
from sentry_sdk.integrations.flask import FlaskIntegration
return [FlaskIntegration()]
except Exception:
return [AwsLambdaIntegration(timeout_warning=True)]
def init_sentry(): def init_sentry():
dsn = os.environ.get("SENTRY_DSN") dsn = os.environ.get("SENTRY_DSN")
if not dsn: if not dsn:
return return
kwargs = { kwargs = {
"dsn": dsn, "dsn": dsn,
"integrations": [AwsLambdaIntegration(timeout_warning=True)], "integrations": _integrations(),
"send_default_pii": False, "send_default_pii": False,
"include_local_variables": False, "include_local_variables": False,
"enable_logs": False, "enable_logs": False,

View file

@ -21,8 +21,8 @@ from shared.blocks import (
from shared.ring_scheduler import update_queue_routing from shared.ring_scheduler import update_queue_routing
from shared.schedule import FALLBACK_EXTENSION from shared.schedule import FALLBACK_EXTENSION
from shared.secrets import get_secret from shared.secrets import get_secret
from shared.shift_clock import holiday_window_active, is_active_shift_type, is_today from shared.shift_clock import is_active_shift_type, is_today
from shared.three_cx_client import ThreeCXClient from shared.three_cx_client import ThreeCXClient, oauth_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
EASTERN = ZoneInfo("America/New_York") EASTERN = ZoneInfo("America/New_York")
@ -86,17 +86,23 @@ def maybe_repoint_today(date_str: str, shift_type: str, extension: str) -> bool:
return False return False
_cached_3cx: ThreeCXClient | None = None
def make_3cx_client() -> ThreeCXClient | None: def make_3cx_client() -> ThreeCXClient | None:
global _cached_3cx
if _cached_3cx is not None:
return _cached_3cx
secret_prefix = os.environ.get("TCX_SECRET_PREFIX") secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
if not secret_prefix: if not secret_prefix:
logger.warning("3CX env vars not set — skipping 3CX call") logger.warning("3CX env vars not set — skipping 3CX call")
return None return None
return ThreeCXClient( _cached_3cx = oauth_client(
domain=get_secret(f"{secret_prefix}domain"), domain=get_secret(f"{secret_prefix}domain"),
auth_mode="oauth",
client_id=get_secret(f"{secret_prefix}client-id"), client_id=get_secret(f"{secret_prefix}client-id"),
client_secret=get_secret(f"{secret_prefix}client-secret"), client_secret=get_secret(f"{secret_prefix}client-secret"),
) )
return _cached_3cx
def set_holiday_queue_agents(schedule, date_str: str) -> None: def set_holiday_queue_agents(schedule, date_str: str) -> None:
@ -117,6 +123,14 @@ def set_holiday_queue_agents(schedule, date_str: str) -> None:
def activate_holiday_inline(schedule, date_str: str) -> None: def activate_holiday_inline(schedule, date_str: str) -> None:
if os.environ.get("JOBS_QUEUE_URL", "").strip():
from shared.holiday_flow import activate
try:
activate(schedule, date_str)
except Exception:
logger.exception("Failed in-process holiday activate for %s", date_str)
return
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN") router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
if not router_arn: if not router_arn:
logger.warning("HOLIDAY_ROUTER_ARN not set — skipping inline activation") logger.warning("HOLIDAY_ROUTER_ARN not set — skipping inline activation")
@ -136,13 +150,34 @@ def holiday_schedule_names(date_str: str) -> tuple[str, str]:
return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}" return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}"
def create_holiday_schedules(date_str: str) -> list[str]: def _holiday_schedule_target(action: str, date_str: str) -> dict | None:
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
role_arn = os.environ.get("HOLIDAY_SCHEDULER_ROLE_ARN") role_arn = os.environ.get("HOLIDAY_SCHEDULER_ROLE_ARN")
queue_arn = os.environ.get("JOBS_QUEUE_ARN", "").strip()
if queue_arn and role_arn:
return {
"Arn": queue_arn,
"RoleArn": role_arn,
"Input": json.dumps(
{"event": "holiday", "action": action, "date": date_str}
),
}
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
if router_arn and role_arn:
return {
"Arn": router_arn,
"RoleArn": role_arn,
"Input": json.dumps({"action": action, "date": date_str}),
}
return None
def create_holiday_schedules(date_str: str) -> list[str]:
group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default") group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default")
if not router_arn or not role_arn: target = _holiday_schedule_target("activate", date_str)
if target is None:
logger.warning( logger.warning(
"HOLIDAY_ROUTER_ARN/HOLIDAY_SCHEDULER_ROLE_ARN not set — skipping schedules" "HOLIDAY_SCHEDULER_ROLE_ARN plus JOBS_QUEUE_ARN or HOLIDAY_ROUTER_ARN "
"not set — skipping schedules"
) )
return [] return []
activate_name, deactivate_name = holiday_schedule_names(date_str) activate_name, deactivate_name = holiday_schedule_names(date_str)
@ -160,11 +195,7 @@ def create_holiday_schedules(date_str: str) -> list[str]:
ScheduleExpressionTimezone="America/New_York", ScheduleExpressionTimezone="America/New_York",
FlexibleTimeWindow={"Mode": "OFF"}, FlexibleTimeWindow={"Mode": "OFF"},
ActionAfterCompletion="DELETE", ActionAfterCompletion="DELETE",
Target={ Target=_holiday_schedule_target(action, date_str),
"Arn": router_arn,
"RoleArn": role_arn,
"Input": json.dumps({"action": action, "date": date_str}),
},
) )
created.append(name) created.append(name)
except Exception: except Exception:
@ -240,7 +271,14 @@ def post_shift_change(
) )
def dm_swap_request(token: str | None, requester_slack: str, target_slack: str, date_str: str, shift_type: str, requester_name: str) -> bool: def dm_swap_request(
token: str | None,
requester_slack: str,
target_slack: str,
date_str: str,
shift_type: str,
requester_name: str,
) -> bool:
if not token or not target_slack: if not token or not target_slack:
return False return False
return slack_call( return slack_call(
@ -258,7 +296,14 @@ def dm_text(token: str | None, user_id: str, text: str) -> None:
slack_call("chat.postMessage", token, channel=user_id, text=text) slack_call("chat.postMessage", token, channel=user_id, text=text)
def dm_late_pickup_admins(schedule, token: str | None, employee: dict, date_str: str, shift_type: str, is_holiday: bool) -> int: def dm_late_pickup_admins(
schedule,
token: str | None,
employee: dict,
date_str: str,
shift_type: str,
is_holiday: bool,
) -> int:
if not token: if not token:
return 0 return 0
blocks = build_pickup_request_blocks( blocks = build_pickup_request_blocks(
@ -272,12 +317,16 @@ def dm_late_pickup_admins(schedule, token: str | None, employee: dict, date_str:
text = f"{employee['name']} wants to pick up the already-started {date_str} shift" text = f"{employee['name']} wants to pick up the already-started {date_str} shift"
delivered = 0 delivered = 0
for admin_id in schedule.get_admin_users(): for admin_id in schedule.get_admin_users():
if slack_call("chat.postMessage", token, channel=admin_id, blocks=blocks, text=text): if slack_call(
"chat.postMessage", token, channel=admin_id, blocks=blocks, text=text
):
delivered += 1 delivered += 1
return delivered return delivered
def post_holiday_added(token: str | None, date_str: str, label: str, slots: int, multiplier) -> None: def post_holiday_added(
token: str | None, date_str: str, label: str, slots: int, multiplier
) -> None:
channel = os.environ.get("SHIFT_CHANNEL") channel = os.environ.get("SHIFT_CHANNEL")
if not channel or not token: if not channel or not token:
return return

View file

@ -3,6 +3,23 @@ import requests
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_oauth_clients: dict[tuple[str, str], "ThreeCXClient"] = {}
def oauth_client(domain: str, client_id: str, client_secret: str) -> "ThreeCXClient":
"""Reuse one OAuth client per (domain, client_id) in this process."""
key = (domain, client_id)
client = _oauth_clients.get(key)
if client is None:
client = ThreeCXClient(
domain=domain,
auth_mode="oauth",
client_id=client_id,
client_secret=client_secret,
)
_oauth_clients[key] = client
return client
class ThreeCXClient: class ThreeCXClient:
"""Client for 3CX V20 cloud-hosted management API (XAPI).""" """Client for 3CX V20 cloud-hosted management API (XAPI)."""

View file

@ -7,7 +7,6 @@ is a thin wiring layer that registers the Bolt routes and delegates to them.
""" """
import functools import functools
import json
import logging import logging
import os import os
import re import re
@ -191,85 +190,24 @@ def _set_holiday_queue_agents(schedule, date_str: str) -> None:
def _activate_holiday_inline(schedule, date_str: str) -> None: def _activate_holiday_inline(schedule, date_str: str) -> None:
"""Invoke the holiday router's activate path now, for a holiday added late. """Activate a late-added holiday in-process or via the holiday-router Lambda."""
from shared.side_effects import activate_holiday_inline
When an admin schedules a holiday whose window is already open (08:00 ≤ now < activate_holiday_inline(schedule, date_str)
17:00 ET), the 08:00 activation schedule has already passed, so the call flow
must be repointed immediately. We invoke the holiday-router Lambda
asynchronously so the (idempotent) activate logic — IVR capture/repoint,
queue membership, ``activated`` flag — runs exactly as it would at 08:00.
Best-effort: a missing ARN or invoke failure is logged, not raised.
"""
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
if not router_arn:
logger.warning("HOLIDAY_ROUTER_ARN not set — skipping inline activation")
return
try:
boto3.client("lambda").invoke(
FunctionName=router_arn,
InvocationType="Event",
Payload=json.dumps({"action": "activate", "date": date_str}).encode(),
)
logger.info("Invoked holiday router inline activate for %s", date_str)
except Exception:
logger.exception("Failed to invoke holiday router for %s", date_str)
def _holiday_schedule_names(date_str: str) -> tuple[str, str]: def _holiday_schedule_names(date_str: str) -> tuple[str, str]:
"""The (activate, deactivate) one-off schedule names for a holiday date.""" """The (activate, deactivate) one-off schedule names for a holiday date."""
compact = date_str.replace("-", "") from shared.side_effects import holiday_schedule_names
return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}"
return holiday_schedule_names(date_str)
def _create_holiday_schedules(date_str: str) -> list[str]: def _create_holiday_schedules(date_str: str) -> list[str]:
"""Create the two one-off EventBridge schedules for a holiday and return names. """Create the two one-off EventBridge schedules for a holiday and return names."""
from shared.side_effects import create_holiday_schedules
One schedule fires the holiday router's ``activate`` at 08:00 ET on the return create_holiday_schedules(date_str)
date, the other its ``deactivate`` at 17:00 ET. Both use a flexible
one-time ``at(...)`` expression in ``America/New_York``,
``ActionAfterCompletion=DELETE`` (self-cleanup once fired), and target the
holiday-router Lambda via the passed scheduler execution role.
Returns the created schedule names (stored on the HOLIDAY record so a later
``remove`` can delete any that have not yet fired). Best-effort: returns the
names it managed to create; missing config short-circuits to ``[]``.
"""
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
role_arn = os.environ.get("HOLIDAY_SCHEDULER_ROLE_ARN")
group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default")
if not router_arn or not role_arn:
logger.warning(
"HOLIDAY_ROUTER_ARN/HOLIDAY_SCHEDULER_ROLE_ARN not set — "
"skipping schedule creation"
)
return []
activate_name, deactivate_name = _holiday_schedule_names(date_str)
client = boto3.client("scheduler")
created: list[str] = []
specs = [
(activate_name, "activate", "08:00:00"),
(deactivate_name, "deactivate", "17:00:00"),
]
for name, action, at_time in specs:
try:
client.create_schedule(
Name=name,
GroupName=group,
ScheduleExpression=f"at({date_str}T{at_time})",
ScheduleExpressionTimezone="America/New_York",
FlexibleTimeWindow={"Mode": "OFF"},
ActionAfterCompletion="DELETE",
Target={
"Arn": router_arn,
"RoleArn": role_arn,
"Input": json.dumps({"action": action, "date": date_str}),
},
)
created.append(name)
except Exception:
logger.exception("Failed to create %s schedule for %s", action, date_str)
return created
def _delete_holiday_schedules(schedule_names: list[str]) -> None: def _delete_holiday_schedules(schedule_names: list[str]) -> None:
@ -2230,19 +2168,31 @@ def _admin_holiday_list(respond, schedule):
@functools.lru_cache(maxsize=1) @functools.lru_cache(maxsize=1)
def _changelog_text() -> str: def _changelog_text() -> str:
"""Read the CHANGELOG shipped in this function's package. """Read the CHANGELOG shipped next to this module.
Lazy (never at import) and tolerant of a missing file, so the App Home tab Lazy (never at import) and tolerant of a missing file, so the App Home tab
degrades to "no What's New section" rather than erroring. The copy lives at degrades to "no What's New section" rather than erroring. Lambda zips and
``$LAMBDA_TASK_ROOT/CHANGELOG.md`` (synced from the repo root). the Fargate image both keep ``CHANGELOG.md`` beside ``app.py``.
``LAMBDA_TASK_ROOT`` remains a fallback for the zip layout.
""" """
path = os.path.join(os.environ.get("LAMBDA_TASK_ROOT", "."), "CHANGELOG.md") tried = []
try: for path in _changelog_paths():
with open(path, encoding="utf-8") as fh: tried.append(path)
return fh.read() try:
except OSError: with open(path, encoding="utf-8") as fh:
logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", path) return fh.read()
return "" except OSError:
continue
logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", tried)
return ""
def _changelog_paths() -> list[str]:
paths = [os.path.join(os.path.dirname(os.path.abspath(__file__)), "CHANGELOG.md")]
task_root = os.environ.get("LAMBDA_TASK_ROOT", "").strip()
if task_root:
paths.append(os.path.join(task_root, "CHANGELOG.md"))
return paths
_HOME_OVERVIEW_DAYS = 60 _HOME_OVERVIEW_DAYS = 60

14
terraform/acm.tf Normal file
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" treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn] alarm_actions = [local.site_alerts_arn]
} }
resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
alarm_name = "ALB-5xx-${local.project}"
alarm_description = "ALB 5xx from afterhours-shift-manager"
namespace = "AWS/ApplicationELB"
metric_name = "HTTPCode_Target_5XX_Count"
dimensions = { LoadBalancer = aws_lb.api.arn_suffix }
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "alb_latency" {
alarm_name = "ALB-Latency-${local.project}"
alarm_description = "p99 target response time on the afterhours ALB exceeded 3s"
namespace = "AWS/ApplicationELB"
metric_name = "TargetResponseTime"
dimensions = { LoadBalancer = aws_lb.api.arn_suffix }
extended_statistic = "p99"
period = 300
evaluation_periods = 3
datapoints_to_alarm = 2
threshold = 3
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "alb_unhealthy_hosts" {
alarm_name = "ALB-UnhealthyHost-${local.project}"
alarm_description = "Unhealthy Fargate targets on the afterhours ALB"
namespace = "AWS/ApplicationELB"
metric_name = "UnHealthyHostCount"
dimensions = {
LoadBalancer = aws_lb.api.arn_suffix
TargetGroup = aws_lb_target_group.api.arn_suffix
}
statistic = "Maximum"
period = 60
evaluation_periods = 3
datapoints_to_alarm = 3
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}

25
terraform/data.tf Normal file
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 { statement {
sid = "PassExecRolesToLambda" sid = "PassExecRolesToCompute"
effect = "Allow" effect = "Allow"
actions = ["iam:PassRole"] actions = ["iam:PassRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"] resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"]
@ -155,7 +155,7 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
condition { condition {
test = "StringEquals" test = "StringEquals"
variable = "iam:PassedToService" variable = "iam:PassedToService"
values = ["lambda.amazonaws.com"] values = ["lambda.amazonaws.com", "ecs-tasks.amazonaws.com", "scheduler.amazonaws.com"]
} }
} }
@ -336,6 +336,8 @@ data "aws_iam_policy_document" "hcptf_apply_services" {
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/lambda/afterhours-*", "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/lambda/afterhours-*",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/afterhours-shift-manager", "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/afterhours-shift-manager",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/afterhours-shift-manager:*", "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/afterhours-shift-manager:*",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/ecs/afterhours-shift-manager",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/ecs/afterhours-shift-manager:*",
] ]
} }
@ -490,6 +492,7 @@ data "aws_iam_policy_document" "hcptf_apply_services" {
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:Lambda-*-afterhours-*", "arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:Lambda-*-afterhours-*",
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:DDB-*-afterhours-shifts", "arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:DDB-*-afterhours-shifts",
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:ApiGateway-*", "arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:ApiGateway-*",
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:ALB-*-afterhours-shift-manager",
] ]
} }
@ -541,6 +544,8 @@ data "aws_iam_policy_document" "hcptf_apply_services" {
] ]
resources = [ resources = [
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*", "arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*",
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/${local.project}/*",
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule-group/${local.project}",
] ]
} }
@ -551,6 +556,143 @@ data "aws_iam_policy_document" "hcptf_apply_services" {
"scheduler:ListSchedules", "scheduler:ListSchedules",
"scheduler:ListScheduleGroups", "scheduler:ListScheduleGroups",
"scheduler:GetScheduleGroup", "scheduler:GetScheduleGroup",
"scheduler:CreateScheduleGroup",
"scheduler:DeleteScheduleGroup",
]
resources = ["*"]
}
statement {
sid = "EcsWorkload"
effect = "Allow"
actions = [
"ecs:*",
]
resources = [
"arn:aws:ecs:${var.aws_region}:${local.account_id}:cluster/${local.project}",
"arn:aws:ecs:${var.aws_region}:${local.account_id}:service/${local.project}/${local.project}",
"arn:aws:ecs:${var.aws_region}:${local.account_id}:task-definition/${local.project}:*",
"arn:aws:ecs:${var.aws_region}:${local.account_id}:task-definition/${local.project}",
]
}
statement {
sid = "EcsAccount"
effect = "Allow"
actions = [
"ecs:CreateCluster",
"ecs:CreateService",
"ecs:DeleteService",
"ecs:DeregisterTaskDefinition",
"ecs:DescribeClusters",
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:ListClusters",
"ecs:ListServices",
"ecs:ListTaskDefinitions",
"ecs:RegisterTaskDefinition",
"ecs:TagResource",
"ecs:UntagResource",
"ecs:UpdateService",
]
resources = ["*"]
}
statement {
sid = "ElbWorkload"
effect = "Allow"
actions = [
"elasticloadbalancing:*",
]
resources = [
"arn:aws:elasticloadbalancing:${var.aws_region}:${local.account_id}:loadbalancer/app/${local.project}/*",
"arn:aws:elasticloadbalancing:${var.aws_region}:${local.account_id}:targetgroup/${local.project}-api/*",
"arn:aws:elasticloadbalancing:${var.aws_region}:${local.account_id}:listener/app/${local.project}/*",
]
}
statement {
sid = "ElbDescribe"
effect = "Allow"
actions = [
"elasticloadbalancing:Describe*",
"elasticloadbalancing:CreateLoadBalancer",
"elasticloadbalancing:CreateTargetGroup",
"elasticloadbalancing:CreateListener",
"elasticloadbalancing:CreateRule",
"elasticloadbalancing:AddTags",
"elasticloadbalancing:ModifyLoadBalancerAttributes",
"elasticloadbalancing:ModifyTargetGroup",
"elasticloadbalancing:ModifyTargetGroupAttributes",
"elasticloadbalancing:ModifyListener",
"elasticloadbalancing:SetSecurityGroups",
"elasticloadbalancing:SetSubnets",
]
resources = ["*"]
}
statement {
sid = "EcrRepo"
effect = "Allow"
actions = [
"ecr:*",
]
resources = [
"arn:aws:ecr:${var.aws_region}:${local.account_id}:repository/${local.project}",
]
}
statement {
sid = "EcrAccount"
effect = "Allow"
actions = [
"ecr:DescribeRepositories",
"ecr:GetAuthorizationToken",
]
resources = ["*"]
}
statement {
sid = "JobsQueues"
effect = "Allow"
actions = [
"sqs:*",
]
resources = [
"arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs",
"arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs-dlq",
]
}
statement {
sid = "VpcSecurityGroups"
effect = "Allow"
actions = [
"ec2:DescribeVpcs",
"ec2:DescribeSubnets",
"ec2:DescribeSecurityGroups",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeAccountAttributes",
"ec2:CreateSecurityGroup",
"ec2:DeleteSecurityGroup",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:AuthorizeSecurityGroupEgress",
"ec2:RevokeSecurityGroupIngress",
"ec2:RevokeSecurityGroupEgress",
"ec2:CreateTags",
"ec2:DeleteTags",
]
resources = ["*"]
}
statement {
sid = "AcmLookup"
effect = "Allow"
actions = [
"acm:ListCertificates",
"acm:DescribeCertificate",
"acm:ListTagsForCertificate",
"acm:GetCertificate",
] ]
resources = ["*"] resources = ["*"]
} }
@ -717,6 +859,8 @@ data "aws_iam_policy_document" "hcptf_plan_refresh" {
] ]
resources = [ resources = [
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*", "arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*",
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/${local.project}/*",
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule-group/${local.project}",
] ]
} }
@ -731,6 +875,78 @@ data "aws_iam_policy_document" "hcptf_plan_refresh" {
resources = ["*"] resources = ["*"]
} }
statement {
sid = "RefreshEcs"
effect = "Allow"
actions = [
"ecs:DescribeClusters",
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:DescribeTaskSets",
"ecs:ListClusters",
"ecs:ListServices",
"ecs:ListTaskDefinitions",
"ecs:ListTagsForResource",
]
resources = ["*"]
}
statement {
sid = "RefreshElb"
effect = "Allow"
actions = [
"elasticloadbalancing:DescribeLoadBalancers",
"elasticloadbalancing:DescribeLoadBalancerAttributes",
"elasticloadbalancing:DescribeListeners",
"elasticloadbalancing:DescribeListenerAttributes",
"elasticloadbalancing:DescribeTargetGroups",
"elasticloadbalancing:DescribeTargetGroupAttributes",
"elasticloadbalancing:DescribeTags",
"elasticloadbalancing:DescribeRules",
]
resources = ["*"]
}
statement {
sid = "RefreshEcr"
effect = "Allow"
actions = [
"ecr:DescribeRepositories",
"ecr:DescribeImages",
"ecr:GetLifecyclePolicy",
"ecr:ListTagsForResource",
]
resources = [
"arn:aws:ecr:${var.aws_region}:${local.account_id}:repository/${local.project}",
]
}
statement {
sid = "RefreshSqs"
effect = "Allow"
actions = [
"sqs:GetQueueAttributes",
"sqs:GetQueueUrl",
"sqs:ListQueueTags",
]
resources = [
"arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs",
"arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs-dlq",
]
}
statement {
sid = "RefreshAcm"
effect = "Allow"
actions = [
"acm:DescribeCertificate",
"acm:ListCertificates",
"acm:ListTagsForCertificate",
"acm:GetCertificate",
]
resources = ["*"]
}
statement { statement {
sid = "RefreshSsm" sid = "RefreshSsm"
effect = "Allow" effect = "Allow"

191
terraform/iam.tf Normal file
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 # One role: GitHub Environments have a single DEPLOY_ROLE_ARN. Trust is pinned
# classic subject forms), and job_workflow_ref to deploy.yaml at # to Environments dev and prod (immutable and classic subject forms) and
# refs/heads/main only. Live GitHub Actions presented the classic sub; both # job_workflow_ref to deploy.yaml at main plus deploy-api.yaml at main and v*.
# forms are listed. No v* tags until a later release ticket. A job with
# environment: does not present ref:refs/heads/main.
#
# Not a Lambda execution role: no permissions_boundary. Path /tf-managed/ so
# seahaven-hcptf-iam-management DenySelfMutation (role/githubdeploy-*) does not
# match.
data "aws_iam_policy_document" "github_deploy_assume" { data "aws_iam_policy_document" "github_deploy_assume" {
statement { statement {
@ -30,17 +24,16 @@ data "aws_iam_policy_document" "github_deploy_assume" {
condition { condition {
test = "StringEquals" test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub" variable = "token.actions.githubusercontent.com:sub"
values = [ values = local.github_oidc_subs
local.github_oidc_sub,
"repo:${var.github_repo}:environment:prod",
]
} }
condition { condition {
test = "StringEquals" test = "StringLike"
variable = "token.actions.githubusercontent.com:job_workflow_ref" variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = [ values = [
"${var.github_repo}/.github/workflows/deploy.yaml@refs/heads/${var.github_deploy_branch}", "${var.github_repo}/.github/workflows/deploy.yaml@refs/heads/${var.github_deploy_branch}",
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/heads/${var.github_deploy_branch}",
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/tags/v*",
] ]
} }
} }
@ -49,7 +42,7 @@ data "aws_iam_policy_document" "github_deploy_assume" {
resource "aws_iam_role" "github_deploy" { resource "aws_iam_role" "github_deploy" {
name = local.deploy_role name = local.deploy_role
path = "/tf-managed/" path = "/tf-managed/"
description = "GitHub Actions Lambda deploy role for ${var.github_repo} Environment prod" description = "GitHub Actions zip and image deploy role for ${var.github_repo}"
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
max_session_duration = 3600 max_session_duration = 3600
} }
@ -86,6 +79,56 @@ data "aws_iam_policy_document" "github_deploy" {
resources = [for fn in local.functions : "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:${fn.function_name}"] resources = [for fn in local.functions : "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:${fn.function_name}"]
} }
statement {
sid = "EcrAuth"
effect = "Allow"
actions = [
"ecr:GetAuthorizationToken",
]
resources = ["*"]
}
statement {
sid = "EcrPush"
effect = "Allow"
actions = [
"ecr:BatchCheckLayerAvailability",
"ecr:BatchGetImage",
"ecr:CompleteLayerUpload",
"ecr:GetDownloadUrlForLayer",
"ecr:InitiateLayerUpload",
"ecr:PutImage",
"ecr:UploadLayerPart",
"ecr:DescribeRepositories",
"ecr:DescribeImages",
]
resources = [aws_ecr_repository.api.arn]
}
statement {
sid = "EcsDeploy"
effect = "Allow"
actions = [
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:DescribeTasks",
"ecs:ListTasks",
"ecs:RegisterTaskDefinition",
"ecs:UpdateService",
]
resources = ["*"]
}
statement {
sid = "PassTaskRoles"
effect = "Allow"
actions = ["iam:PassRole"]
resources = [
aws_iam_role.ecs_task.arn,
aws_iam_role.ecs_execution.arn,
]
}
statement { statement {
sid = "DeployParams" sid = "DeployParams"
effect = "Allow" effect = "Allow"

View file

@ -1,10 +1,11 @@
locals { locals {
project = "afterhours-shift-manager" project = "afterhours-shift-manager"
account_id = "011934824531" is_prod = var.environment == "prod"
environment = "prod" account_id = local.is_prod ? "011934824531" : "710827005802"
environment = var.environment
hcp_project = "seahaven-prod" hcp_project = "seahaven-${var.environment}"
hcp_workspace = "afterhours-shift-manager-prod" hcp_workspace = "${local.project}-${var.environment}"
apply_role = "hcptf-afterhours-shift-manager" apply_role = "hcptf-afterhours-shift-manager"
plan_role = "hcptf-afterhours-shift-manager-plan" plan_role = "hcptf-afterhours-shift-manager-plan"
deploy_role = "githubdeploy-afterhours-shift-manager" deploy_role = "githubdeploy-afterhours-shift-manager"
@ -18,7 +19,16 @@ locals {
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com" github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
# Org has Actions OIDC use_immutable_subject=true. # Org has Actions OIDC use_immutable_subject=true.
github_oidc_sub = "repo:Sea-Haven-Industries@183236204/afterhours-shift-manager@1200846448:environment:prod" github_oidc_subs = [
"repo:${var.github_repo}:environment:dev",
"repo:${var.github_repo}:environment:prod",
"repo:Sea-Haven-Industries@183236204/afterhours-shift-manager@1200846448:environment:dev",
"repo:Sea-Haven-Industries@183236204/afterhours-shift-manager@1200846448:environment:prod",
]
domain_name = var.domain_name != "" ? var.domain_name : (local.is_prod ? "afterhours.seahaven.com" : "afterhours.dev.seahaven.com")
acm_wildcard_domain = local.is_prod ? "*.seahaven.com" : "*.dev.seahaven.com"
api_url = var.attach_custom_domain ? "https://${local.domain_name}" : "http://${aws_lb.api.dns_name}"
secret_names = [ secret_names = [
"afterhours-shift-manager/slack-bot-token", "afterhours-shift-manager/slack-bot-token",

View file

@ -9,3 +9,8 @@ resource "aws_cloudwatch_log_group" "api_access" {
name = "/aws/apigateway/${local.project}" name = "/aws/apigateway/${local.project}"
retention_in_days = 90 retention_in_days = 90
} }
resource "aws_cloudwatch_log_group" "api" {
name = "/ecs/${local.project}"
retention_in_days = local.is_prod ? 60 : 14
}

View file

@ -19,7 +19,7 @@ output "holiday_scheduler_role_arn" {
} }
output "github_deploy_role_arn" { output "github_deploy_role_arn" {
description = "OIDC role ARN for .github/workflows/deploy.yaml (GitHub Environment prod variable DEPLOY_ROLE_ARN)." description = "OIDC role ARN for deploy.yaml and deploy-api.yaml (GitHub Environment variable DEPLOY_ROLE_ARN)."
value = aws_iam_role.github_deploy.arn value = aws_iam_role.github_deploy.arn
} }
@ -28,6 +28,26 @@ output "artifacts_bucket_name" {
value = aws_s3_bucket.artifacts.id value = aws_s3_bucket.artifacts.id
} }
output "alb_dns_name" {
description = "ALB DNS name. Public DNS for afterhours.seahaven.com is out of band."
value = aws_lb.api.dns_name
}
output "fargate_origin" {
description = "Fargate origin for Slack/Paychex/portal cutover. HTTPS after attach_custom_domain."
value = local.api_url
}
output "jobs_queue_arn" {
description = "SQS ARN EventBridge Scheduler holiday one-offs target after cutover."
value = aws_sqs_queue.jobs.arn
}
output "ecs_task_role_arn" {
description = "ECS task role. paychex-checkcomponents queue policy must allow this ARN before Fargate weekly_post."
value = aws_iam_role.ecs_task.arn
}
output "hcptf_apply_role_arn" { output "hcptf_apply_role_arn" {
description = "HCP apply role ARN. Set TFC_AWS_APPLY_ROLE_ARN after the bootstrap window." description = "HCP apply role ARN. Set TFC_AWS_APPLY_ROLE_ARN after the bootstrap window."
value = aws_iam_role.hcptf_apply.arn value = aws_iam_role.hcptf_apply.arn

View file

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

View file

@ -1,5 +1,6 @@
# EventBridge Scheduler execution role. slack-bot creates one-off holiday-* # EventBridge Scheduler execution role. slack-bot creates one-off holiday-*
# schedules at runtime; Terraform does not create those schedules. # schedules at runtime; Terraform does not create those schedules.
# Recurring jobs use America/New_York Scheduler -> SQS (not dual EST/EDT rules).
data "aws_iam_policy_document" "holiday_scheduler_assume" { data "aws_iam_policy_document" "holiday_scheduler_assume" {
statement { statement {
@ -29,9 +30,9 @@ data "aws_iam_policy_document" "holiday_scheduler_assume" {
resource "aws_iam_role" "holiday_scheduler" { resource "aws_iam_role" "holiday_scheduler" {
name = local.holiday_scheduler_role_name name = local.holiday_scheduler_role_name
path = "/tf-managed/" path = "/tf-managed/"
description = "EventBridge Scheduler assumes this role to invoke afterhours-holiday-router" description = "EventBridge Scheduler assumes this role to enqueue holiday jobs or invoke afterhours-holiday-router"
assume_role_policy = data.aws_iam_policy_document.holiday_scheduler_assume.json assume_role_policy = data.aws_iam_policy_document.holiday_scheduler_assume.json
permissions_boundary = aws_iam_policy.lambda_boundary.arn permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
} }
data "aws_iam_policy_document" "holiday_scheduler" { data "aws_iam_policy_document" "holiday_scheduler" {
@ -41,6 +42,13 @@ data "aws_iam_policy_document" "holiday_scheduler" {
actions = ["lambda:InvokeFunction"] actions = ["lambda:InvokeFunction"]
resources = [local.holiday_router_arn] resources = [local.holiday_router_arn]
} }
statement {
sid = "SendHolidayJobs"
effect = "Allow"
actions = ["sqs:SendMessage"]
resources = [aws_sqs_queue.jobs.arn]
}
} }
resource "aws_iam_role_policy" "holiday_scheduler" { resource "aws_iam_role_policy" "holiday_scheduler" {
@ -48,3 +56,52 @@ resource "aws_iam_role_policy" "holiday_scheduler" {
role = aws_iam_role.holiday_scheduler.id role = aws_iam_role.holiday_scheduler.id
policy = data.aws_iam_policy_document.holiday_scheduler.json policy = data.aws_iam_policy_document.holiday_scheduler.json
} }
locals {
job_schedules = {
weekly-post = {
description = "Post weekly schedule Monday 7am Eastern"
schedule = "cron(0 7 ? * MON *)"
event = "weekly_post"
}
roster-sync = {
description = "Sync roster from 3CX at 6am Eastern"
schedule = "cron(0 6 ? * * *)"
event = "roster_sync"
}
ring-scheduler-daily = {
description = "Update 3CX queue at 8am Eastern"
schedule = "cron(0 8 ? * * *)"
event = "ring_scheduler_daily"
}
ring-scheduler-weekend = {
description = "Update 3CX queue at 5pm Eastern weekends"
schedule = "cron(0 17 ? * SAT,SUN *)"
event = "ring_scheduler_weekend"
}
}
}
resource "aws_scheduler_schedule_group" "jobs" {
name = local.project
}
resource "aws_scheduler_schedule" "jobs" {
for_each = local.job_schedules
name = "${local.project}-${each.key}"
group_name = aws_scheduler_schedule_group.jobs.name
description = each.value.description
schedule_expression = each.value.schedule
schedule_expression_timezone = "America/New_York"
state = var.ecs_schedules_enabled ? "ENABLED" : "DISABLED"
flexible_time_window {
mode = "OFF"
}
target {
arn = aws_sqs_queue.jobs.arn
role_arn = aws_iam_role.jobs_scheduler.arn
input = jsonencode({ event = each.value.event })
}
}

View file

@ -13,3 +13,45 @@ resource "aws_ssm_parameter" "deploy_function_name" {
value = each.value.function_name value = each.value.function_name
description = "Lambda function name for ${each.key}; deploy.yaml calls update-function-code" description = "Lambda function name for ${each.key}; deploy.yaml calls update-function-code"
} }
resource "aws_ssm_parameter" "deploy_api_url" {
name = "${local.ssm_prefix}/deploy/api-url"
type = "String"
value = local.api_url
description = "API origin for deploy-api.yaml health check"
}
resource "aws_ssm_parameter" "deploy_cluster" {
name = "${local.ssm_prefix}/deploy/cluster"
type = "String"
value = aws_ecs_cluster.api.name
description = "ECS cluster name for deploy-api.yaml"
}
resource "aws_ssm_parameter" "deploy_service" {
name = "${local.ssm_prefix}/deploy/service"
type = "String"
value = aws_ecs_service.api.name
description = "ECS service name for deploy-api.yaml"
}
resource "aws_ssm_parameter" "deploy_task_family" {
name = "${local.ssm_prefix}/deploy/task-family"
type = "String"
value = aws_ecs_task_definition.api.family
description = "ECS task definition family for deploy-api.yaml"
}
resource "aws_ssm_parameter" "deploy_ecr_repository" {
name = "${local.ssm_prefix}/deploy/ecr-repository"
type = "String"
value = aws_ecr_repository.api.repository_url
description = "ECR repository URL for deploy-api.yaml"
}
resource "aws_ssm_parameter" "deploy_container_name" {
name = "${local.ssm_prefix}/deploy/container-name"
type = "String"
value = local.api_container_name
description = "Container name in the ECS task definition"
}

View file

@ -35,8 +35,37 @@ variable "sentry_dsn" {
default = "" default = ""
} }
variable "environment" {
description = "HCP workspace stage. Selects account and workspace name."
type = string
default = "prod"
validation {
condition = contains(["dev", "prod"], var.environment)
error_message = "environment must be \"dev\" or \"prod\"."
}
}
variable "domain_name" {
description = "Public hostname on the ALB when attach_custom_domain is true. Empty selects afterhours.seahaven.com or afterhours.dev.seahaven.com from environment."
type = string
default = ""
}
variable "attach_custom_domain" {
description = "When true, attach an HTTPS listener using the issued wildcard ACM certificate. Keep false until public DNS points at the ALB."
type = bool
default = false
}
variable "schedules_enabled" { variable "schedules_enabled" {
description = "When false, EventBridge rules exist but do not fire. Keep false until Slack and Paychex point at this stack." description = "When false, EventBridge Lambda rules exist but do not fire. Keep false until Slack and Paychex point at this stack, and after Fargate jobs are enabled."
type = bool
default = false
}
variable "ecs_schedules_enabled" {
description = "When false, EventBridge Scheduler jobs exist but do not fire. Enable at Fargate cutover after the image is healthy; keep Lambda EventBridge rules disabled."
type = bool type = bool
default = false default = false
} }

View file

@ -16,7 +16,7 @@ terraform {
organization = "seahaven" organization = "seahaven"
workspaces { 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("QUEUE_NUMBER", raising=False)
monkeypatch.delenv("TCX_SECRET_PREFIX", raising=False) monkeypatch.delenv("TCX_SECRET_PREFIX", raising=False)
monkeypatch.delenv("SENTRY_DSN", raising=False) monkeypatch.delenv("SENTRY_DSN", raising=False)
monkeypatch.delenv("JOBS_QUEUE_URL", raising=False)
monkeypatch.delenv("JOBS_QUEUE_ARN", raising=False)
def _create_table(dynamodb): def _create_table(dynamodb):

View file

@ -31,17 +31,49 @@ def test_lambda_ignore_changes_includes_code_attributes():
def test_schedules_disabled_by_default(): def test_schedules_disabled_by_default():
chunk = (TERRAFORM / "variables.tf").read_text().split('variable "schedules_enabled"')[1] chunk = (
(TERRAFORM / "variables.tf")
.read_text()
.split('variable "schedules_enabled"')[1]
)
chunk = chunk.split("variable ")[0] chunk = chunk.split("variable ")[0]
assert "default = false" in chunk or "default = false" in chunk assert "default = false" in chunk or "default = false" in chunk
def test_prod_only_workspace(): def test_ecs_schedules_disabled_by_default():
chunk = (
(TERRAFORM / "variables.tf")
.read_text()
.split('variable "ecs_schedules_enabled"')[1]
)
chunk = chunk.split("variable ")[0]
assert "default = false" in chunk or "default = false" in chunk
def test_workspaces_use_app_tag():
versions = (TERRAFORM / "versions.tf").read_text() versions = (TERRAFORM / "versions.tf").read_text()
assert "afterhours-shift-manager-prod" in versions assert 'tags = ["app:afterhours-shift-manager"]' in versions
assert "afterhours-shift-manager-dev" not in versions assert 'name = "afterhours-shift-manager-prod"' not in versions
assert 'environment = "prod"' in LOCALS assert 'hcp_workspace = "${local.project}-${var.environment}"' in LOCALS
assert "seahaven-dev" not in LOCALS assert "seahaven-${var.environment}" in LOCALS
def test_ecs_ignore_changes_and_task_size():
ecs = (TERRAFORM / "ecs.tf").read_text()
assert "ignore_changes = [container_definitions]" in ecs
assert "ignore_changes = [task_definition, desired_count]" in ecs
assert 'cpu = "512"' in ecs
assert 'memory = "1024"' in ecs
assert 'cpu_architecture = "ARM64"' in ecs
assert 'path = "/api/health"' in ecs
def test_deploy_api_workflow_exists():
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
assert "environment: ${{ needs.target.outputs.environment }}" in deploy_api
assert "/afterhours-shift-manager/deploy/cluster" in deploy_api
assert "linux/arm64" in deploy_api
assert "gh release create" in deploy_api
def test_in_repo_hcptf_roles(): def test_in_repo_hcptf_roles():
@ -74,6 +106,9 @@ def test_checkcomponents_queue_arn_variable_matches_iam_references():
assert "var.checkcomponents_queue_arn" in LAMBDA_TF assert "var.checkcomponents_queue_arn" in LAMBDA_TF
boundary = (TERRAFORM / "lambda_boundary.tf").read_text() boundary = (TERRAFORM / "lambda_boundary.tf").read_text()
assert "var.checkcomponents_queue_arn" in boundary assert "var.checkcomponents_queue_arn" in boundary
data_tf = (TERRAFORM / "data.tf").read_text()
assert "dev_has_no_paychex" in data_tf
assert "checkcomponents_pair" in data_tf
def test_eight_functions_named(): def test_eight_functions_named():
@ -94,12 +129,15 @@ def test_weekly_post_role_is_tf_managed_name():
assert 'role_name = "afterhours-shift-manager-weekly-post"' in LOCALS assert 'role_name = "afterhours-shift-manager-weekly-post"' in LOCALS
def test_github_deploy_trust_is_environment_prod(): def test_github_deploy_trust_covers_zip_and_image():
iam = (TERRAFORM / "iam_github_deploy.tf").read_text() iam = (TERRAFORM / "iam_github_deploy.tf").read_text()
assert "environment:prod" in iam or "environment:prod" in LOCALS assert "environment:prod" in iam or "environment:prod" in LOCALS
assert "environment:dev" in iam or "environment:dev" in LOCALS
assert "deploy.yaml@refs/heads/${var.github_deploy_branch}" in iam assert "deploy.yaml@refs/heads/${var.github_deploy_branch}" in iam
assert "deploy-api.yaml@refs/heads/${var.github_deploy_branch}" in iam
assert "deploy-api.yaml@refs/tags/v*" in iam
assert "deploy.yaml@*" not in iam assert "deploy.yaml@*" not in iam
assert "refs/tags/v*" not in iam assert "ecs:ListTasks" in iam
def test_plan_refresh_includes_provider6_s3_gets(): def test_plan_refresh_includes_provider6_s3_gets():

View file

@ -27,8 +27,7 @@ def _event(method="GET", path="/api/shifts", body=None, token="id-token", origin
@pytest.fixture @pytest.fixture
def identity(portalapi_app, monkeypatch): def identity(portalapi_app, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
portalapi_app, "shared.portal_http.verify_cognito_id_token",
"verify_cognito_id_token",
lambda _token: {"name": "Alice", "email": "alice@seahavenind.com"}, lambda _token: {"name": "Alice", "email": "alice@seahavenind.com"},
) )
@ -67,9 +66,7 @@ def test_linked_snapshot_and_admin_flag(portalapi_app, schedule, seed, identity)
def test_non_admin_cannot_override(portalapi_app, schedule, seed, identity): def test_non_admin_cannot_override(portalapi_app, schedule, seed, identity):
seed.roster( seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com")
"114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com"
)
seed.config(admin_users=["U_OTHER"]) seed.config(admin_users=["U_OTHER"])
result = portalapi_app.handler( result = portalapi_app.handler(
_event( _event(
@ -83,7 +80,22 @@ def test_non_admin_cannot_override(portalapi_app, schedule, seed, identity):
def test_cors_header_for_portal_origin(portalapi_app, schedule, identity): def test_cors_header_for_portal_origin(portalapi_app, schedule, identity):
result = portalapi_app.handler( result = portalapi_app.handler(_event(origin="https://internal.seahaven.com"), None)
_event(origin="https://internal.seahaven.com"), None assert (
result["headers"]["Access-Control-Allow-Origin"]
== "https://internal.seahaven.com"
) )
assert result["headers"]["Access-Control-Allow-Origin"] == "https://internal.seahaven.com"
def test_unexpected_failure_keeps_cors(portalapi_app, identity, monkeypatch):
monkeypatch.setattr(
"shared.portal_http.ShiftSchedule",
lambda: (_ for _ in ()).throw(RuntimeError("ddb down")),
)
result = portalapi_app.handler(_event(origin="https://internal.seahaven.com"), None)
assert result["statusCode"] == 500
assert (
result["headers"]["Access-Control-Allow-Origin"]
== "https://internal.seahaven.com"
)
assert json.loads(result["body"])["error"]["code"] == "INTERNAL"

View file

@ -6,4 +6,5 @@ responses>=0.26.2
freezegun>=1.5.5 freezegun>=1.5.5
sentry-sdk==2.68.1 sentry-sdk==2.68.1
PyJWT[crypto]==2.14.0 PyJWT[crypto]==2.14.0
flask==3.1.3

View file

@ -21,4 +21,6 @@ def _load(name, relpath):
def rosterapi_app(): def rosterapi_app():
mod = _load("rosterapi_app", "src/roster-api/app.py") mod = _load("rosterapi_app", "src/roster-api/app.py")
yield mod yield mod
mod._cached_token = None import shared.roster_http as roster_http
roster_http._cached_token = None

View file

@ -7,6 +7,8 @@ from unittest.mock import MagicMock
import pytest import pytest
import shared.roster_http as roster_http
TOKEN = "roster-test-token" TOKEN = "roster-test-token"
SECRET_NAME = "afterhours-shift-manager/roster-api-token" SECRET_NAME = "afterhours-shift-manager/roster-api-token"
@ -18,8 +20,8 @@ def env(monkeypatch):
@pytest.fixture @pytest.fixture
def secrets(rosterapi_app, monkeypatch, env): def secrets(rosterapi_app, monkeypatch, env):
monkeypatch.setattr(rosterapi_app, "get_secret", lambda _id: TOKEN) monkeypatch.setattr(roster_http, "get_secret", lambda _id: TOKEN)
rosterapi_app._cached_token = None roster_http._cached_token = None
def _event( def _event(
@ -174,16 +176,16 @@ def test_401_wrong_token(rosterapi_app, schedule, secrets):
def test_authorize_strips_secret_trailing_newline( def test_authorize_strips_secret_trailing_newline(
rosterapi_app, schedule, env, monkeypatch rosterapi_app, schedule, env, monkeypatch
): ):
monkeypatch.setattr(rosterapi_app, "get_secret", lambda _id: TOKEN + "\n") monkeypatch.setattr(roster_http, "get_secret", lambda _id: TOKEN + "\n")
rosterapi_app._cached_token = None roster_http._cached_token = None
result = rosterapi_app.handler(_event(body=_put_body()), None) result = rosterapi_app.handler(_event(body=_put_body()), None)
assert result["statusCode"] == 200 assert result["statusCode"] == 200
assert schedule.get_employee_by_extension("110")["slack_user_id"] == "U123ABCDE" assert schedule.get_employee_by_extension("110")["slack_user_id"] == "U123ABCDE"
def test_503_when_secret_is_whitespace_only(rosterapi_app, schedule, env, monkeypatch): def test_503_when_secret_is_whitespace_only(rosterapi_app, schedule, env, monkeypatch):
monkeypatch.setattr(rosterapi_app, "get_secret", lambda _id: "\n") monkeypatch.setattr(roster_http, "get_secret", lambda _id: "\n")
rosterapi_app._cached_token = None roster_http._cached_token = None
result = rosterapi_app.handler(_event(body=_put_body()), None) result = rosterapi_app.handler(_event(body=_put_body()), None)
assert result["statusCode"] == 503 assert result["statusCode"] == 503
assert schedule.get_employee_by_extension("110") is None assert schedule.get_employee_by_extension("110") is None
@ -193,8 +195,8 @@ def test_503_when_secret_read_fails(rosterapi_app, schedule, env, monkeypatch):
def boom(_id): def boom(_id):
raise RuntimeError("secrets down") raise RuntimeError("secrets down")
monkeypatch.setattr(rosterapi_app, "get_secret", boom) monkeypatch.setattr(roster_http, "get_secret", boom)
rosterapi_app._cached_token = None roster_http._cached_token = None
result = rosterapi_app.handler(_event(body=_put_body()), None) result = rosterapi_app.handler(_event(body=_put_body()), None)
assert result["statusCode"] == 503 assert result["statusCode"] == 503
assert schedule.get_employee_by_extension("110") is None assert schedule.get_employee_by_extension("110") is None
@ -300,7 +302,7 @@ def test_never_calls_roster_sync(rosterapi_app):
def test_500_on_unexpected_failure(rosterapi_app, secrets, monkeypatch): def test_500_on_unexpected_failure(rosterapi_app, secrets, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
rosterapi_app, roster_http,
"ShiftSchedule", "ShiftSchedule",
MagicMock(side_effect=RuntimeError("ddb down")), MagicMock(side_effect=RuntimeError("ddb down")),
) )

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): def test_update_queue_routing_points_queue_at_extension(monkeypatch):
FakeThreeCXClient.instances = [] FakeThreeCXClient.instances = []
monkeypatch.setattr(ring_scheduler, "ThreeCXClient", FakeThreeCXClient)
def fake_oauth(domain, client_id, client_secret):
return FakeThreeCXClient(domain, "oauth", client_id, client_secret)
monkeypatch.setattr(ring_scheduler, "oauth_client", fake_oauth)
result = ring_scheduler.update_queue_routing( result = ring_scheduler.update_queue_routing(
extension="114", extension="114",

View file

@ -31,6 +31,7 @@ def test_empty_dsn_does_not_init(monkeypatch):
def test_set_dsn_inits_lambda_integration(monkeypatch): def test_set_dsn_inits_lambda_integration(monkeypatch):
monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "afterhours-shift-manager")
mocked = _reexec(monkeypatch, dsn="https://key@o1.ingest.sentry.io/1") mocked = _reexec(monkeypatch, dsn="https://key@o1.ingest.sentry.io/1")
mocked.assert_called_once() mocked.assert_called_once()
kwargs = mocked.call_args.kwargs kwargs = mocked.call_args.kwargs
@ -48,6 +49,7 @@ def test_set_dsn_inits_lambda_integration(monkeypatch):
def test_build_info_sha_sets_sentry_release(monkeypatch): def test_build_info_sha_sets_sentry_release(monkeypatch):
monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "afterhours-shift-manager")
monkeypatch.setenv("SENTRY_DSN", "https://key@o1.ingest.sentry.io/1") monkeypatch.setenv("SENTRY_DSN", "https://key@o1.ingest.sentry.io/1")
fake = ModuleType("shared.build_info") fake = ModuleType("shared.build_info")
fake.GIT_SHA = "abc123def" fake.GIT_SHA = "abc123def"

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} {"Forwards": [], "TimeoutForwardDN": None}
) )
assert routes == {"key0": None, "timeout": None} assert routes == {"key0": None, "timeout": None}
@responses.activate
def test_oauth_client_reuses_process_cache():
from shared import three_cx_client as tcx
tcx._oauth_clients.clear()
_stub_oauth()
first = tcx.oauth_client("test.3cx.us", "cid", "secret")
second = tcx.oauth_client("test.3cx.us", "cid", "secret")
assert first is second
token_posts = [
c for c in responses.calls if c.request.url.endswith("/connect/token")
]
assert len(token_posts) == 1
tcx._oauth_clients.clear()

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 assert "v1.9.2" not in str(kwargs["view"]) # only the newest entry
def test_changelog_paths_include_package_copy(slackbot_app):
paths = slackbot_app._changelog_paths()
assert any(p.endswith("src/slack-bot/CHANGELOG.md") for p in paths)
def test_changelog_text_reads_package_copy(slackbot_app):
slackbot_app._changelog_text.cache_clear()
text = slackbot_app._changelog_text()
assert text
assert "## " in text
def test_publish_home_degrades_without_changelog(slackbot_app, client): def test_publish_home_degrades_without_changelog(slackbot_app, client):
slackbot_app.publish_home(client, "U_BOB", "") slackbot_app.publish_home(client, "U_BOB", "")
view = client.views_publish.call_args.kwargs["view"] view = client.views_publish.call_args.kwargs["view"]