From 26adb8e6c0ae0a7ed476156f8c3d7f1e8f72d9f4 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:13:30 +0000 Subject: [PATCH] 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 --- .dockerignore | 12 + .github/workflows/ci.yaml | 1 + .github/workflows/deploy-api.yaml | 247 ++++++++++++++ Dockerfile | 25 ++ README.md | 32 +- SETUP.md | 46 ++- pyproject.toml | 2 +- requirements-api.txt | 2 + .../retarget_holiday_schedules_to_sqs.py | 181 ++++++++++ src/holiday-router/app.py | 134 +------- src/portal-api/app.py | 248 +++----------- src/roster-api/app.py | 163 +-------- src/server/__init__.py | 1 + src/server/app.py | 132 ++++++++ src/server/entrypoint.py | 71 ++++ src/server/jobs.py | 79 +++++ src/server/worker.py | 58 ++++ src/server/wsgi.py | 5 + src/shared/shared/holiday_flow.py | 123 +++++++ src/shared/shared/portal_http.py | 222 +++++++++++++ src/shared/shared/ring_scheduler.py | 9 +- src/shared/shared/roster_http.py | 165 ++++++++++ src/shared/shared/sentry_init.py | 13 +- src/shared/shared/side_effects.py | 83 ++++- src/shared/shared/three_cx_client.py | 17 + src/slack-bot/app.py | 112 ++----- terraform/acm.tf | 14 + terraform/alarms.tf | 50 +++ terraform/data.tf | 25 ++ terraform/ecs.tf | 310 ++++++++++++++++++ terraform/hcp_iam.tf | 220 ++++++++++++- terraform/iam.tf | 191 +++++++++++ terraform/iam_github_deploy.tf | 75 ++++- terraform/locals.tf | 20 +- terraform/logs.tf | 5 + terraform/outputs.tf | 22 +- terraform/providers.tf | 2 +- terraform/scheduler.tf | 61 +++- terraform/ssm.tf | 42 +++ terraform/variables.tf | 31 +- terraform/versions.tf | 2 +- tests/conftest.py | 2 + tests/infra/test_hcp_contract.py | 54 ++- tests/portal_api/test_handler.py | 28 +- tests/requirements.txt | 1 + tests/roster_api/conftest.py | 4 +- tests/roster_api/test_handler.py | 20 +- .../test_retarget_holiday_schedules_to_sqs.py | 52 +++ tests/server/test_app.py | 113 +++++++ tests/server/test_jobs.py | 60 ++++ tests/shared/test_ring_scheduler.py | 6 +- tests/shared/test_sentry_init.py | 2 + tests/shared/test_side_effects_holidays.py | 81 +++++ tests/shared/test_three_cx_client.py | 16 + tests/slack_bot/test_app_home.py | 12 + 55 files changed, 3036 insertions(+), 668 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/deploy-api.yaml create mode 100644 Dockerfile create mode 100644 requirements-api.txt create mode 100644 scripts/cutover/retarget_holiday_schedules_to_sqs.py create mode 100644 src/server/__init__.py create mode 100644 src/server/app.py create mode 100644 src/server/entrypoint.py create mode 100644 src/server/jobs.py create mode 100644 src/server/worker.py create mode 100644 src/server/wsgi.py create mode 100644 src/shared/shared/holiday_flow.py create mode 100644 src/shared/shared/portal_http.py create mode 100644 src/shared/shared/roster_http.py create mode 100644 terraform/acm.tf create mode 100644 terraform/data.tf create mode 100644 terraform/ecs.tf create mode 100644 terraform/iam.tf create mode 100644 tests/scripts/test_retarget_holiday_schedules_to_sqs.py create mode 100644 tests/server/test_app.py create mode 100644 tests/server/test_jobs.py create mode 100644 tests/shared/test_side_effects_holidays.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..812bd41 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.venv +.cursor +terraform +tests +docs +*.md +!src/slack-bot/CHANGELOG.md +__pycache__ +.pytest_cache +.mypy_cache diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e496392..c16e891 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,6 +31,7 @@ jobs: pip install -r src/weekly-post/requirements.txt pip install -r src/shared/requirements.txt pip install -r src/portal-api/requirements.txt + pip install -r requirements-api.txt - name: Pytest run: pytest diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml new file mode 100644 index 0000000..fcef8d3 --- /dev/null +++ b/.github/workflows/deploy-api.yaml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..85d88b4 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 8b9c580..3093e5b 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,10 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules ## Architecture -- **Runtime**: Python 3.12 on AWS Lambda (arm64), seahaven-prod `011934824531` +- **Runtime**: Python 3.12 on ECS Fargate (arm64) plus dual-run Lambdas until cutover. seahaven-prod `011934824531`, seahaven-dev `710827005802` - **Data**: DynamoDB single-table (`afterhours-shifts`) -- **IaC**: HCP Terraform workspace `afterhours-shift-manager-prod` (containers) plus GitHub Actions `deploy.yaml` (zips). `src/shared` is bundled into each function zip. Terraform does not package `src/`. -- **Slack**: Slack Bolt framework with `/oncall` slash command +- **IaC**: HCP Terraform workspaces tagged `app:afterhours-shift-manager` (`afterhours-shift-manager-dev` / `-prod`) plus GitHub Actions `deploy.yaml` (zips) and `deploy-api.yaml` (image). Terraform does not package `src/`. +- **Slack**: Slack Bolt on `POST /slack/events` - **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI - **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`) @@ -81,7 +81,8 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules ``` src/ - slack-bot/ Slack Bolt Lambda (handler + app); ships CHANGELOG.md for App Home + server/ Flask + gunicorn + SQS worker (Fargate) + slack-bot/ Slack Bolt app; Lambda handler until cutover; ships CHANGELOG.md for App Home weekly-post/ Monday schedule + pay post roster-sync/ Daily 3CX roster sync roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard @@ -89,10 +90,10 @@ src/ ring-scheduler/ 3CX queue routing updates holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate) release-notifier/ Posts release announcements to Slack - shared/ Bundled into each function zip (schedule, blocks, changelog, 3CX client, secrets) -terraform/ HCP Terraform (function skeletons, API, DDB, IAM, schedules) -scripts/ changelog CLI + CI guard + in-package copy sync -tests/ pytest suite (mirrors src/, one dir per Lambda + shared) + shared/ Bundled into each function zip and the Fargate image +terraform/ HCP Terraform (Lambda skeletons, ECS/ALB, API, DDB, IAM, schedules) +scripts/ changelog CLI + CI guard + in-package copy sync + cutover +tests/ pytest suite (mirrors src/, one dir per Lambda + shared + server) ``` ### DynamoDB Schema @@ -299,7 +300,9 @@ python -m venv .venv && source .venv/bin/activate pip install -r tests/requirements.txt # test-only deps pip install -r src/slack-bot/requirements.txt \ -r src/weekly-post/requirements.txt \ - -r src/shared/requirements.txt # runtime deps the imports need + -r src/shared/requirements.txt \ + -r src/portal-api/requirements.txt \ + -r requirements-api.txt pytest ``` @@ -307,4 +310,15 @@ Each Lambda has its own `app.py`, so the per-package `conftest.py` loads each on under a unique module name (importlib mode) to avoid collisions. CI runs the same suite on every PR via pytest plus `terraform fmt` / `init -backend=false` / `validate`. +Local Fargate process (needs the same DynamoDB table and Secrets Manager names +the Lambdas use, plus `JOBS_QUEUE_URL` to consume jobs): + +```bash +export PYTHONPATH=src:src/shared:src/slack-bot +export STAGE=local GIT_SHA=dev +python -m server.entrypoint +``` + +Health is `GET /api/health` on port 8080. + See [SETUP.md](SETUP.md) for full deployment and Slack app creation instructions. diff --git a/SETUP.md b/SETUP.md index a270207..f95b38a 100644 --- a/SETUP.md +++ b/SETUP.md @@ -75,11 +75,15 @@ before the identity processor PUTs `/roster`. ## 3. HCP Terraform and GitHub Environment -Prod only. Workspace `afterhours-shift-manager-prod` in project `seahaven-prod` -(account `011934824531`). No seahaven-dev workspace. +Workspaces tagged `app:afterhours-shift-manager`: +`afterhours-shift-manager-prod` in `seahaven-prod` (account `011934824531`) and +`afterhours-shift-manager-dev` in `seahaven-dev` (account `710827005802`). +Create the dev workspace before any Slack URL flip. HCP variable `environment` +is `prod` or `dev`. First apply uses the hcptf-bootstrap window (exact `StringEquals` trust, never -`StringLike`): +`StringLike`). Creating `afterhours-shift-manager-ecs-task-boundary` is +`iam:CreatePolicy` and needs that window. 1. Create the HCP workspace. Auto-apply off. No project-level variable set. Working directory `terraform`. File trigger prefix `terraform/**` only. @@ -99,14 +103,21 @@ First apply uses the hcptf-bootstrap window (exact `StringEquals` trust, never `--allow-workspace`. 6. Second manual apply as the scoped role. Then seal auto-apply on. -GitHub Environment `prod`: reviewers, branch policy `main` only, Environment +GitHub Environment `prod`: reviewers, branch policy `main` and `v*`, Environment variable `DEPLOY_ROLE_ARN` = Terraform output `github_deploy_role_arn`. +GitHub Environment `dev`: no reviewers, `DEPLOY_ROLE_ARN` from the seahaven-dev +apply of the same output. Set `checkcomponents_queue_url` and +`checkcomponents_queue_arn` empty on the dev workspace. Function zips: Actions → Deploy on push to `main`, or `workflow_dispatch`. -Keep `schedules_enabled=false` until Slack and Paychex point at this stack. +Image CD: Actions → Deploy API (`deploy-api.yaml`). Keep `schedules_enabled=false` +and `ecs_schedules_enabled=false` until the Fargate cutover below. -HCP outputs to copy: `slack_request_url`, `api_origin`, -`holiday_scheduler_role_arn`, `github_deploy_role_arn`. +HCP outputs to copy: `slack_request_url`, `api_origin`, `fargate_origin`, +`holiday_scheduler_role_arn`, `github_deploy_role_arn`, `jobs_queue_arn`, +`ecs_task_role_arn`. Add `ecs_task_role_arn` to paychex-checkcomponents +(alongside `afterhours-shift-manager-weekly-post`) before Fargate weekly_post +runs. Dual-run keeps the Lambda principal until zip CD is retired. ## 4. Set the Slack Request URL @@ -174,6 +185,27 @@ scripts first (`--execute` is required for writes). Do not dual-run 3CX writers. Do not flip `afterhours_base_url` before DynamoDB is copied. +## 9. Fargate cutover (PLAT-216) + +Dual-run ECS beside API Gateway. Do not dual-write 3CX. Do not flip Slack +without the `afterhours-shift-manager-dev` workspace already serving +`/api/health`. + +1. Image deploy via `deploy-api.yaml`. `GET /api/health` reports the real sha. +2. Recreate outstanding `holiday-activate-*` / `holiday-deactivate-*` onto + jobs SQS (same class of work as `recreate_holiday_schedules.py`): + `python scripts/cutover/retarget_holiday_schedules_to_sqs.py --profile prod --queue-arn ` + then `--execute`. +3. Instant cut: Slack Request URL, Paychex `AFTERHOURS_BASE_URL`, portal + `VITE_SHIFTS_API_BASE` → `https://afterhours.seahaven.com` (dev hostname in + portal-dev). Smoke `/oncall`, roster PUT/DELETE, `GET /api/shifts`, one + holiday GetSchedule, ring job. +4. Set `ecs_schedules_enabled=true` and keep `schedules_enabled=false`. Confirm + no 3CX writers remain on Lambda. +5. After smoke, remove API Gateway, the eight Lambdas, zip packaging, and + Lambda alarms in a follow-up apply. ALB 5xx/latency and unhealthy-host + alarms stay. + ## Commands Reference | Command | Description | diff --git a/pyproject.toml b/pyproject.toml index 4ea7077..05aed49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,6 @@ # `src/shared` on the path makes the `shared` layer package importable as it is at # runtime. Each Lambda's own `app.py` is loaded under a unique name by the # per-package conftest (importlib mode) to avoid the four-`app.py` collision. -pythonpath = ["src/shared"] +pythonpath = ["src", "src/shared"] testpaths = ["tests"] addopts = "--import-mode=importlib" diff --git a/requirements-api.txt b/requirements-api.txt new file mode 100644 index 0000000..f480526 --- /dev/null +++ b/requirements-api.txt @@ -0,0 +1,2 @@ +flask==3.1.3 +gunicorn==23.0.0 diff --git a/scripts/cutover/retarget_holiday_schedules_to_sqs.py b/scripts/cutover/retarget_holiday_schedules_to_sqs.py new file mode 100644 index 0000000..3633121 --- /dev/null +++ b/scripts/cutover/retarget_holiday_schedules_to_sqs.py @@ -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()) diff --git a/src/holiday-router/app.py b/src/holiday-router/app.py index 13f6077..c6c7940 100644 --- a/src/holiday-router/app.py +++ b/src/holiday-router/app.py @@ -3,24 +3,6 @@ A holiday is a day-only shift (08:00-17:00 ET), one HOLIDAY record per date. At 08:00 this Lambda is invoked with ``{"action": "activate", "date": ...}`` and at 17:00 with ``{"action": "deactivate", ...}``. - -Activate: - * Capture both IVR 800 routes (key-0 and no-input/timeout) into - ``CONFIG.captured_ivr_routes`` — but only if they are NOT already pointed at - the holiday queue (so a re-run never overwrites the real originals). - * Set queue 802's agents to the holiday's assignees, or ``[FALLBACK_EXTENSION]`` - ("100") when no slots are filled. Membership is set ONCE here. - * Repoint BOTH IVR 800 routes to the holiday queue (802). - * Mark the holiday ``activated = True``. - Idempotent: no-op if the record is gone or already activated. - -Deactivate: - * Restore both IVR routes from ``CONFIG.captured_ivr_routes`` — only the routes - that currently point at the queue are reverted (defensive against manual - changes); clear the captured routes afterwards. - * Clear queue 802's agents. - * Mark the holiday ``activated = False``. - Idempotent: no-op if the record is gone or not activated. """ import json @@ -28,7 +10,8 @@ import logging import os import shared.sentry_init # noqa: F401 -from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule +from shared.holiday_flow import activate, deactivate +from shared.schedule import ShiftSchedule from shared.secrets import get_secret from shared.three_cx_client import ThreeCXClient @@ -46,121 +29,12 @@ def _make_client() -> ThreeCXClient: ) -def _holiday_extensions(holiday: dict) -> list[str]: - """Assignee extensions for the holiday, or the fallback when none claimed.""" - assignees = holiday.get("assignees", {}) or {} - extensions = list(assignees.keys()) - return extensions or [FALLBACK_EXTENSION] - - def _activate(schedule: ShiftSchedule, date: str) -> dict: - holiday = schedule.get_holiday(date) - if holiday is None: - logger.info("No holiday record for %s — nothing to activate", date) - return {"action": "activate", "date": date, "skipped": "no_record"} - if holiday.get("activated"): - logger.info("Holiday %s already activated — no-op", date) - return {"action": "activate", "date": date, "skipped": "already_active"} - - queue_number = schedule.get_holiday_queue() - ivr_number = schedule.get_ivr_number() - extensions = _holiday_extensions(holiday) - - client = _make_client() - - # Capture the live IVR routes BEFORE repointing — but guard against storing - # holiday-state routes: if both routes already target the queue, a prior - # activation is in effect, so keep whatever originals we already captured. - ivr = client.get_ivr(ivr_number) - ivr_id = ivr["Id"] - current = client.extract_ivr_routes(ivr) - already_queue = str(current.get("key0")) == str(queue_number) and str( - current.get("timeout") - ) == str(queue_number) - if already_queue: - logger.info( - "IVR %s already points at queue %s — not re-capturing routes", - ivr_number, - queue_number, - ) - else: - schedule.set_captured_ivr_routes(current) - - # Set queue membership ONCE, then repoint both IVR routes to the queue. - queue = client.get_queue(queue_number) - client.set_queue_agents(queue["Id"], extensions) - - client.set_ivr_routes(ivr_id, key0_dn=queue_number, timeout_dn=queue_number) - - schedule.set_holiday_activated(date, True) - logger.info( - "Activated holiday %s: queue %s agents=%s, IVR %s -> queue", - date, - queue_number, - extensions, - ivr_number, - ) - return { - "action": "activate", - "date": date, - "queue": str(queue_number), - "ivr": str(ivr_number), - "agents": extensions, - } + return activate(schedule, date, client_factory=_make_client) def _deactivate(schedule: ShiftSchedule, date: str) -> dict: - holiday = schedule.get_holiday(date) - if holiday is None: - logger.info("No holiday record for %s — nothing to deactivate", date) - return {"action": "deactivate", "date": date, "skipped": "no_record"} - if not holiday.get("activated"): - logger.info("Holiday %s not activated — no-op", date) - return {"action": "deactivate", "date": date, "skipped": "not_active"} - - queue_number = schedule.get_holiday_queue() - ivr_number = schedule.get_ivr_number() - captured = schedule.get_captured_ivr_routes() or {} - - client = _make_client() - - ivr = client.get_ivr(ivr_number) - ivr_id = ivr["Id"] - live = client.extract_ivr_routes(ivr) - - # Only restore a route if it currently points at the queue; otherwise leave - # whatever destination it has now (it was changed outside this flow). - def _restore(which: str) -> str | None: - live_dn = live.get(which) - if str(live_dn) == str(queue_number): - # Restore the captured pre-holiday DN; if it was lost, keep the live - # value rather than blanking the IVR destination. - return captured.get(which) or live_dn - return None # not pointing at the holiday queue — leave it untouched - - client.set_ivr_routes( - ivr_id, - key0_dn=_restore("key0"), - timeout_dn=_restore("timeout"), - ) - - queue = client.get_queue(queue_number) - client.set_queue_agents(queue["Id"], []) - - schedule.set_captured_ivr_routes(None) - schedule.set_holiday_activated(date, False) - logger.info( - "Deactivated holiday %s: restored IVR %s, cleared queue %s", - date, - ivr_number, - queue_number, - ) - return { - "action": "deactivate", - "date": date, - "queue": str(queue_number), - "ivr": str(ivr_number), - } + return deactivate(schedule, date, client_factory=_make_client) def handler(event, context): diff --git a/src/portal-api/app.py b/src/portal-api/app.py index f0375df..6a6095f 100644 --- a/src/portal-api/app.py +++ b/src/portal-api/app.py @@ -2,17 +2,12 @@ from __future__ import annotations -import json +import base64 import logging -from decimal import Decimal -from typing import Any -from urllib.parse import unquote import shared.sentry_init # noqa: F401 -from shared.cognito import CognitoVerificationUnavailable, verify_cognito_id_token -from shared.portal_ops import ActionError, snapshot -from shared.schedule import ShiftSchedule -from shared import portal_ops as ops +from shared.portal_http import cors_headers, encode_body, handle +from shared.portal_ops import ActionError logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -25,40 +20,6 @@ CORS_ORIGINS = { } -class DecimalEncoder(json.JSONEncoder): - def default(self, o): - if isinstance(o, Decimal): - return float(o) - return super().default(o) - - -def _cors_headers(event: dict) -> dict[str, str]: - headers = event.get("headers") or {} - origin = "" - for key, value in headers.items(): - if str(key).lower() == "origin": - origin = "" if value is None else str(value) - break - allowed = origin if origin in CORS_ORIGINS else "" - out = { - "Content-Type": "application/json", - "Vary": "Origin", - } - if allowed: - out["Access-Control-Allow-Origin"] = allowed - out["Access-Control-Allow-Headers"] = "Authorization,Content-Type" - out["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS" - return out - - -def _response(event: dict, status: int, body: dict[str, Any] | None = None) -> dict: - return { - "statusCode": status, - "headers": _cors_headers(event), - "body": json.dumps({} if body is None else body, cls=DecimalEncoder, separators=(",", ":")), - } - - def _header(event: dict, name: str) -> str: headers = event.get("headers") or {} if not isinstance(headers, dict): @@ -83,179 +44,50 @@ def _route(event: dict) -> tuple[str, str]: return method, path.rstrip("/") or "/" -def _json_body(event: dict) -> dict: - raw = event.get("body") - if raw in (None, ""): - return {} +def _raw_body(event: dict) -> bytes | str | None: + body = event.get("body") + if body is None: + return None if event.get("isBase64Encoded"): - import base64 - - raw = base64.b64decode(raw).decode("utf-8") - if isinstance(raw, bytes): - raw = raw.decode("utf-8") - try: - parsed = json.loads(raw) - except json.JSONDecodeError as exc: - raise ActionError(400, "INVALID_JSON", "Invalid JSON body.") from exc - if not isinstance(parsed, dict): - raise ActionError(400, "INVALID_JSON", "JSON body must be an object.") - return parsed - - -def _identity(event: dict) -> dict: - presented = _header(event, "authorization") - parts = presented.split(None, 1) - if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip(): - raise ActionError(401, "UNAUTHORIZED", "Sign in to continue.") - try: - identity = verify_cognito_id_token(parts[1].strip()) - except CognitoVerificationUnavailable as exc: - raise ActionError(503, "AUTH_UNAVAILABLE", "Sign-in verification is unavailable.") from exc - if not identity: - raise ActionError(401, "UNAUTHORIZED", "Sign in to continue.") - return identity - - -def _employee(schedule: ShiftSchedule, identity: dict) -> dict: - employee = schedule.get_employee_by_email(identity["email"]) - if not employee: - raise ActionError( - 404, - "UNLINKED", - "Your Google account is not on the after-hours roster yet.", - ) - return employee + if isinstance(body, bytes): + body = body.decode("ascii") + return base64.b64decode(body) + return body def handler(event, context): try: method, path = _route(event) - if method == "OPTIONS": - return _response(event, 204, {}) - identity = _identity(event) - schedule = ShiftSchedule() - if method == "GET" and path == "/api/shifts": - employee = schedule.get_employee_by_email(identity["email"]) - if not employee: - return _response( - event, - 200, - { - "linked": False, - "email": identity["email"], - "isAdmin": False, - }, - ) - params = event.get("queryStringParameters") or {} - week = (params.get("week") if isinstance(params, dict) else None) or "this" - return _response(event, 200, snapshot(schedule, employee, week)) - employee = _employee(schedule, identity) - body = _json_body(event) if method in {"POST", "PUT", "PATCH"} else {} - return _dispatch(event, method, path, schedule, employee, body) + status, headers, payload = handle( + method=method, + path=path, + origin=_header(event, "origin"), + authorization=_header(event, "authorization"), + query=event.get("queryStringParameters") + if isinstance(event.get("queryStringParameters"), dict) + else {}, + body=_raw_body(event), + ) + if status == 204: + return {"statusCode": 204, "headers": headers, "body": ""} + return { + "statusCode": status, + "headers": headers, + "body": encode_body(payload), + } except ActionError as exc: - return _response(event, exc.status, {"error": {"code": exc.code, "message": exc.message}}) + logger.exception("portal api action error") + return { + "statusCode": exc.status, + "headers": cors_headers(_header(event, "origin")), + "body": encode_body({"error": {"code": exc.code, "message": exc.message}}), + } except Exception: logger.exception("portal api unexpected failure") - return _response(event, 500, {"error": {"code": "INTERNAL", "message": "Internal error"}}) - - -def _dispatch(event, method, path, schedule, employee, body): - parts = [p for p in path.split("/") if p] - if method == "POST" and path == "/api/shifts/pick": - return _response( - event, - 200, - ops.pick(schedule, employee, body.get("date", ""), body.get("shiftType")), - ) - if method == "POST" and path == "/api/shifts/drop": - return _response( - event, - 200, - ops.drop(schedule, employee, body.get("date", ""), body.get("shiftType")), - ) - if method == "POST" and path == "/api/shifts/swap": - return _response( - event, - 200, - ops.swap( - schedule, - employee, - body.get("date", ""), - body.get("targetExtension", ""), - body.get("shiftType"), + return { + "statusCode": 500, + "headers": cors_headers(_header(event, "origin")), + "body": encode_body( + {"error": {"code": "INTERNAL", "message": "Internal error"}} ), - ) - if method == "POST" and len(parts) == 6 and parts[:3] == ["api", "shifts", "swaps"] and parts[5] in ("accept", "decline"): - return _response( - event, - 200, - ops.respond_swap( - schedule, - employee, - unquote(parts[3]), - unquote(parts[4]), - accept=parts[5] == "accept", - ), - ) - if method == "POST" and path == "/api/shifts/admin/override": - return _response( - event, - 200, - ops.admin_override( - schedule, - employee, - body.get("date", ""), - body.get("extension", ""), - body.get("shiftType"), - ), - ) - if method == "POST" and path == "/api/shifts/admin/open": - return _response( - event, - 200, - ops.admin_open(schedule, employee, body.get("date", ""), body.get("shiftType")), - ) - if method == "POST" and path == "/api/shifts/admin/clear": - return _response( - event, - 200, - ops.admin_clear(schedule, employee, body.get("date", ""), body.get("shiftType")), - ) - if method == "POST" and path == "/api/shifts/admin/holidays": - return _response( - event, - 200, - ops.admin_holiday_add( - schedule, - employee, - body.get("date", ""), - body.get("slots"), - body.get("label", ""), - body.get("multiplier"), - ), - ) - if method == "DELETE" and len(parts) == 5 and parts[:4] == ["api", "shifts", "admin", "holidays"]: - return _response( - event, - 200, - ops.admin_holiday_remove(schedule, employee, unquote(parts[4])), - ) - if ( - method == "POST" - and len(parts) == 8 - and parts[:4] == ["api", "shifts", "admin", "pickups"] - and parts[7] in ("approve", "deny") - ): - return _response( - event, - 200, - ops.admin_pickup( - schedule, - employee, - unquote(parts[4]), - unquote(parts[5]), - unquote(parts[6]), - approve=parts[7] == "approve", - ), - ) - return _response(event, 405, {"error": {"code": "METHOD", "message": "Method not allowed"}}) + } diff --git a/src/roster-api/app.py b/src/roster-api/app.py index 924b891..d5731c6 100644 --- a/src/roster-api/app.py +++ b/src/roster-api/app.py @@ -10,39 +10,23 @@ Authorization header, the token, or the request body. from __future__ import annotations import base64 -import hmac import json import logging -import os -import unicodedata from typing import Any import shared.sentry_init # noqa: F401 -from shared.schedule import ShiftSchedule -from shared.secrets import get_secret +from shared.roster_http import ( + AuthError, + SecretUnavailable, + authorize_bearer, + remove, + upsert, + validate_extension, +) logger = logging.getLogger() logger.setLevel(logging.INFO) -PUT_FIELDS = ("name", "extension", "slack_user_id") -OPTIONAL_PUT_FIELDS = ("email",) -MAX_BODY_BYTES = 4096 -MAX_NAME_LEN = 128 -MAX_EXTENSION_LEN = 16 -MAX_SLACK_ID_LEN = 64 -MAX_EMAIL_LEN = 254 -ALLOWED_EMAIL_DOMAINS = {"seahaven.com", "seahavenind.com"} - -_cached_token: str | None = None - - -class AuthError(Exception): - """Missing or wrong Bearer token.""" - - -class SecretUnavailable(Exception): - """Token secret could not be read.""" - def _json_response(status: int, body: dict[str, Any] | None = None) -> dict: if status == 204: @@ -95,116 +79,6 @@ def _raw_body(event: dict) -> bytes | None: return None -def _has_disallowed_chars(value: str, *, allow_space: bool) -> bool: - for char in value: - if char == " " and allow_space: - continue - if char.isspace() or unicodedata.category(char).startswith("C"): - return True - return False - - -def _expected_token() -> str: - global _cached_token - if _cached_token: - return _cached_token - secret_id = os.environ["ROSTER_API_TOKEN_SECRET"] - try: - token = get_secret(secret_id) - except Exception: - logger.exception("roster api token secret read failed") - raise SecretUnavailable from None - if not isinstance(token, str): - raise SecretUnavailable - token = token.strip() - if not token: - raise SecretUnavailable - _cached_token = token - return token - - -def _authorize(event: dict) -> None: - presented = _header(event, "authorization") - if not presented: - raise AuthError - parts = presented.split(None, 1) - if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip(): - raise AuthError - token = parts[1].strip() - expected = _expected_token() - try: - matched = hmac.compare_digest(token, expected) - except (TypeError, ValueError): - raise AuthError from None - if not matched: - raise AuthError - - -def _validate_email(value: str) -> str: - if ( - len(value) > MAX_EMAIL_LEN - or "@" not in value - or _has_disallowed_chars(value, allow_space=False) - ): - raise ValueError("fields") - local, _, domain = value.partition("@") - if not local or domain.lower() not in ALLOWED_EMAIL_DOMAINS: - raise ValueError("fields") - return value.lower() - - -def _validate_put(raw: bytes) -> tuple[str, str, str, str | None]: - if len(raw) > MAX_BODY_BYTES: - raise ValueError("oversized") - try: - parsed = json.loads(raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - raise ValueError("invalid json") from None - if not isinstance(parsed, dict): - raise TypeError("invalid json") - allowed = set(PUT_FIELDS) | set(OPTIONAL_PUT_FIELDS) - if not set(PUT_FIELDS).issubset(parsed) or not set(parsed).issubset(allowed): - raise ValueError("fields") - values: dict[str, str] = {} - for field in PUT_FIELDS: - value = parsed[field] - if not isinstance(value, str): - raise TypeError("fields") - trimmed = value.strip() - if not trimmed: - raise ValueError("fields") - values[field] = trimmed - - name = values["name"] - extension = values["extension"] - slack_user_id = values["slack_user_id"] - email = None - if "email" in parsed: - raw_email = parsed["email"] - if not isinstance(raw_email, str): - raise TypeError("fields") - trimmed_email = raw_email.strip() - if not trimmed_email: - raise ValueError("fields") - email = _validate_email(trimmed_email) - - if len(name) > MAX_NAME_LEN or _has_disallowed_chars(name, allow_space=True): - raise ValueError("fields") - if ( - len(extension) > MAX_EXTENSION_LEN - or not extension.isdigit() - or _has_disallowed_chars(extension, allow_space=False) - ): - raise ValueError("fields") - if ( - len(slack_user_id) > MAX_SLACK_ID_LEN - or not slack_user_id.isalnum() - or _has_disallowed_chars(slack_user_id, allow_space=False) - ): - raise ValueError("fields") - return name, extension, slack_user_id, email - - def _delete_extension(event: dict) -> str: params = event.get("pathParameters") or {} if not isinstance(params, dict): @@ -212,15 +86,7 @@ def _delete_extension(event: dict) -> str: raw = params.get("extension") if not isinstance(raw, str): raise TypeError("extension") - extension = raw.strip() - if ( - not extension - or len(extension) > MAX_EXTENSION_LEN - or not extension.isdigit() - or _has_disallowed_chars(extension, allow_space=False) - ): - raise ValueError("extension") - return extension + return validate_extension(raw) def handler(event, context): @@ -228,7 +94,7 @@ def handler(event, context): method, path = _route(event) logger.info("roster api %s %s", method, path) try: - _authorize(event) + authorize_bearer(_header(event, "authorization")) except AuthError: return _json_response(401, {"error": "unauthorized"}) except SecretUnavailable: @@ -239,13 +105,9 @@ def handler(event, context): if raw is None: return _json_response(400, {"error": "invalid request"}) try: - name, extension, slack_user_id, email = _validate_put(raw) + upsert(raw) except (TypeError, ValueError): return _json_response(400, {"error": "invalid request"}) - ShiftSchedule().upsert_roster_entry( - extension, name, slack_user_id, email=email - ) - logger.info("roster upserted extension=%s", extension) return _json_response(200, {"ok": True}) if method == "DELETE" and path.startswith("/roster/"): @@ -253,8 +115,7 @@ def handler(event, context): extension = _delete_extension(event) except (TypeError, ValueError): return _json_response(400, {"error": "invalid request"}) - ShiftSchedule().remove_roster_entry(extension) - logger.info("roster deleted extension=%s", extension) + remove(extension) return _json_response(204) return _json_response(405, {"error": "method not allowed"}) diff --git a/src/server/__init__.py b/src/server/__init__.py new file mode 100644 index 0000000..f981a0c --- /dev/null +++ b/src/server/__init__.py @@ -0,0 +1 @@ +"""Always-on Flask process for afterhours-shift-manager.""" diff --git a/src/server/app.py b/src/server/app.py new file mode 100644 index 0000000..493ac0e --- /dev/null +++ b/src/server/app.py @@ -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/", 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/", 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() diff --git a/src/server/entrypoint.py b/src/server/entrypoint.py new file mode 100644 index 0000000..3b50990 --- /dev/null +++ b/src/server/entrypoint.py @@ -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() diff --git a/src/server/jobs.py b/src/server/jobs.py new file mode 100644 index 0000000..f145af4 --- /dev/null +++ b/src/server/jobs.py @@ -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}") diff --git a/src/server/worker.py b/src/server/worker.py new file mode 100644 index 0000000..59d38ce --- /dev/null +++ b/src/server/worker.py @@ -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() diff --git a/src/server/wsgi.py b/src/server/wsgi.py new file mode 100644 index 0000000..23b3233 --- /dev/null +++ b/src/server/wsgi.py @@ -0,0 +1,5 @@ +"""Gunicorn entrypoint.""" + +from server.app import app + +__all__ = ["app"] diff --git a/src/shared/shared/holiday_flow.py b/src/shared/shared/holiday_flow.py new file mode 100644 index 0000000..e631837 --- /dev/null +++ b/src/shared/shared/holiday_flow.py @@ -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), + } diff --git a/src/shared/shared/portal_http.py b/src/shared/shared/portal_http.py new file mode 100644 index 0000000..1ee7108 --- /dev/null +++ b/src/shared/shared/portal_http.py @@ -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"}}, + ) diff --git a/src/shared/shared/ring_scheduler.py b/src/shared/shared/ring_scheduler.py index e34ec50..2f5e5d0 100644 --- a/src/shared/shared/ring_scheduler.py +++ b/src/shared/shared/ring_scheduler.py @@ -2,7 +2,7 @@ import logging -from shared.three_cx_client import ThreeCXClient +from shared.three_cx_client import oauth_client logger = logging.getLogger(__name__) @@ -15,12 +15,7 @@ def update_queue_routing( client_secret: str, ) -> dict: """Update 3CX queue forwarding to route calls to the given extension.""" - client = ThreeCXClient( - domain=domain, - auth_mode="oauth", - client_id=client_id, - client_secret=client_secret, - ) + client = oauth_client(domain, client_id, client_secret) queue = client.get_queue(queue_number) client.update_queue_forwarding( queue_id=queue["Id"], diff --git a/src/shared/shared/roster_http.py b/src/shared/shared/roster_http.py new file mode 100644 index 0000000..33ffccb --- /dev/null +++ b/src/shared/shared/roster_http.py @@ -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) diff --git a/src/shared/shared/sentry_init.py b/src/shared/shared/sentry_init.py index 24ef21e..74a04da 100644 --- a/src/shared/shared/sentry_init.py +++ b/src/shared/shared/sentry_init.py @@ -128,13 +128,24 @@ def _git_sha(): return str(GIT_SHA or "").strip() +def _integrations(): + if os.environ.get("AWS_LAMBDA_FUNCTION_NAME"): + return [AwsLambdaIntegration(timeout_warning=True)] + try: + from sentry_sdk.integrations.flask import FlaskIntegration + + return [FlaskIntegration()] + except Exception: + return [AwsLambdaIntegration(timeout_warning=True)] + + def init_sentry(): dsn = os.environ.get("SENTRY_DSN") if not dsn: return kwargs = { "dsn": dsn, - "integrations": [AwsLambdaIntegration(timeout_warning=True)], + "integrations": _integrations(), "send_default_pii": False, "include_local_variables": False, "enable_logs": False, diff --git a/src/shared/shared/side_effects.py b/src/shared/shared/side_effects.py index 2a12c65..8df4dbe 100644 --- a/src/shared/shared/side_effects.py +++ b/src/shared/shared/side_effects.py @@ -21,8 +21,8 @@ from shared.blocks import ( from shared.ring_scheduler import update_queue_routing from shared.schedule import FALLBACK_EXTENSION from shared.secrets import get_secret -from shared.shift_clock import holiday_window_active, is_active_shift_type, is_today -from shared.three_cx_client import ThreeCXClient +from shared.shift_clock import is_active_shift_type, is_today +from shared.three_cx_client import ThreeCXClient, oauth_client logger = logging.getLogger(__name__) EASTERN = ZoneInfo("America/New_York") @@ -86,17 +86,23 @@ def maybe_repoint_today(date_str: str, shift_type: str, extension: str) -> bool: return False +_cached_3cx: ThreeCXClient | None = None + + def make_3cx_client() -> ThreeCXClient | None: + global _cached_3cx + if _cached_3cx is not None: + return _cached_3cx secret_prefix = os.environ.get("TCX_SECRET_PREFIX") if not secret_prefix: logger.warning("3CX env vars not set — skipping 3CX call") return None - return ThreeCXClient( + _cached_3cx = oauth_client( domain=get_secret(f"{secret_prefix}domain"), - auth_mode="oauth", client_id=get_secret(f"{secret_prefix}client-id"), client_secret=get_secret(f"{secret_prefix}client-secret"), ) + return _cached_3cx def set_holiday_queue_agents(schedule, date_str: str) -> None: @@ -117,6 +123,14 @@ def set_holiday_queue_agents(schedule, date_str: str) -> None: def activate_holiday_inline(schedule, date_str: str) -> None: + if os.environ.get("JOBS_QUEUE_URL", "").strip(): + from shared.holiday_flow import activate + + try: + activate(schedule, date_str) + except Exception: + logger.exception("Failed in-process holiday activate for %s", date_str) + return router_arn = os.environ.get("HOLIDAY_ROUTER_ARN") if not router_arn: logger.warning("HOLIDAY_ROUTER_ARN not set — skipping inline activation") @@ -136,13 +150,34 @@ def holiday_schedule_names(date_str: str) -> tuple[str, str]: return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}" -def create_holiday_schedules(date_str: str) -> list[str]: - router_arn = os.environ.get("HOLIDAY_ROUTER_ARN") +def _holiday_schedule_target(action: str, date_str: str) -> dict | None: role_arn = os.environ.get("HOLIDAY_SCHEDULER_ROLE_ARN") + queue_arn = os.environ.get("JOBS_QUEUE_ARN", "").strip() + if queue_arn and role_arn: + return { + "Arn": queue_arn, + "RoleArn": role_arn, + "Input": json.dumps( + {"event": "holiday", "action": action, "date": date_str} + ), + } + router_arn = os.environ.get("HOLIDAY_ROUTER_ARN") + if router_arn and role_arn: + return { + "Arn": router_arn, + "RoleArn": role_arn, + "Input": json.dumps({"action": action, "date": date_str}), + } + return None + + +def create_holiday_schedules(date_str: str) -> list[str]: group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default") - if not router_arn or not role_arn: + target = _holiday_schedule_target("activate", date_str) + if target is None: logger.warning( - "HOLIDAY_ROUTER_ARN/HOLIDAY_SCHEDULER_ROLE_ARN not set — skipping schedules" + "HOLIDAY_SCHEDULER_ROLE_ARN plus JOBS_QUEUE_ARN or HOLIDAY_ROUTER_ARN " + "not set — skipping schedules" ) return [] activate_name, deactivate_name = holiday_schedule_names(date_str) @@ -160,11 +195,7 @@ def create_holiday_schedules(date_str: str) -> list[str]: ScheduleExpressionTimezone="America/New_York", FlexibleTimeWindow={"Mode": "OFF"}, ActionAfterCompletion="DELETE", - Target={ - "Arn": router_arn, - "RoleArn": role_arn, - "Input": json.dumps({"action": action, "date": date_str}), - }, + Target=_holiday_schedule_target(action, date_str), ) created.append(name) except Exception: @@ -240,7 +271,14 @@ def post_shift_change( ) -def dm_swap_request(token: str | None, requester_slack: str, target_slack: str, date_str: str, shift_type: str, requester_name: str) -> bool: +def dm_swap_request( + token: str | None, + requester_slack: str, + target_slack: str, + date_str: str, + shift_type: str, + requester_name: str, +) -> bool: if not token or not target_slack: return False return slack_call( @@ -258,7 +296,14 @@ def dm_text(token: str | None, user_id: str, text: str) -> None: slack_call("chat.postMessage", token, channel=user_id, text=text) -def dm_late_pickup_admins(schedule, token: str | None, employee: dict, date_str: str, shift_type: str, is_holiday: bool) -> int: +def dm_late_pickup_admins( + schedule, + token: str | None, + employee: dict, + date_str: str, + shift_type: str, + is_holiday: bool, +) -> int: if not token: return 0 blocks = build_pickup_request_blocks( @@ -272,12 +317,16 @@ def dm_late_pickup_admins(schedule, token: str | None, employee: dict, date_str: text = f"{employee['name']} wants to pick up the already-started {date_str} shift" delivered = 0 for admin_id in schedule.get_admin_users(): - if slack_call("chat.postMessage", token, channel=admin_id, blocks=blocks, text=text): + if slack_call( + "chat.postMessage", token, channel=admin_id, blocks=blocks, text=text + ): delivered += 1 return delivered -def post_holiday_added(token: str | None, date_str: str, label: str, slots: int, multiplier) -> None: +def post_holiday_added( + token: str | None, date_str: str, label: str, slots: int, multiplier +) -> None: channel = os.environ.get("SHIFT_CHANNEL") if not channel or not token: return diff --git a/src/shared/shared/three_cx_client.py b/src/shared/shared/three_cx_client.py index 8dde050..37f30d2 100644 --- a/src/shared/shared/three_cx_client.py +++ b/src/shared/shared/three_cx_client.py @@ -3,6 +3,23 @@ import requests logger = logging.getLogger(__name__) +_oauth_clients: dict[tuple[str, str], "ThreeCXClient"] = {} + + +def oauth_client(domain: str, client_id: str, client_secret: str) -> "ThreeCXClient": + """Reuse one OAuth client per (domain, client_id) in this process.""" + key = (domain, client_id) + client = _oauth_clients.get(key) + if client is None: + client = ThreeCXClient( + domain=domain, + auth_mode="oauth", + client_id=client_id, + client_secret=client_secret, + ) + _oauth_clients[key] = client + return client + class ThreeCXClient: """Client for 3CX V20 cloud-hosted management API (XAPI).""" diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index a8c6aad..e6f4c4e 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -7,7 +7,6 @@ is a thin wiring layer that registers the Bolt routes and delegates to them. """ import functools -import json import logging import os import re @@ -191,85 +190,24 @@ def _set_holiday_queue_agents(schedule, date_str: str) -> None: def _activate_holiday_inline(schedule, date_str: str) -> None: - """Invoke the holiday router's activate path now, for a holiday added late. + """Activate a late-added holiday in-process or via the holiday-router Lambda.""" + from shared.side_effects import activate_holiday_inline - When an admin schedules a holiday whose window is already open (08:00 ≤ now < - 17:00 ET), the 08:00 activation schedule has already passed, so the call flow - must be repointed immediately. We invoke the holiday-router Lambda - asynchronously so the (idempotent) activate logic — IVR capture/repoint, - queue membership, ``activated`` flag — runs exactly as it would at 08:00. - Best-effort: a missing ARN or invoke failure is logged, not raised. - """ - router_arn = os.environ.get("HOLIDAY_ROUTER_ARN") - if not router_arn: - logger.warning("HOLIDAY_ROUTER_ARN not set — skipping inline activation") - return - try: - boto3.client("lambda").invoke( - FunctionName=router_arn, - InvocationType="Event", - Payload=json.dumps({"action": "activate", "date": date_str}).encode(), - ) - logger.info("Invoked holiday router inline activate for %s", date_str) - except Exception: - logger.exception("Failed to invoke holiday router for %s", date_str) + activate_holiday_inline(schedule, date_str) def _holiday_schedule_names(date_str: str) -> tuple[str, str]: """The (activate, deactivate) one-off schedule names for a holiday date.""" - compact = date_str.replace("-", "") - return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}" + from shared.side_effects import holiday_schedule_names + + return holiday_schedule_names(date_str) def _create_holiday_schedules(date_str: str) -> list[str]: - """Create the two one-off EventBridge schedules for a holiday and return names. + """Create the two one-off EventBridge schedules for a holiday and return names.""" + from shared.side_effects import create_holiday_schedules - One schedule fires the holiday router's ``activate`` at 08:00 ET on the - date, the other its ``deactivate`` at 17:00 ET. Both use a flexible - one-time ``at(...)`` expression in ``America/New_York``, - ``ActionAfterCompletion=DELETE`` (self-cleanup once fired), and target the - holiday-router Lambda via the passed scheduler execution role. - - Returns the created schedule names (stored on the HOLIDAY record so a later - ``remove`` can delete any that have not yet fired). Best-effort: returns the - names it managed to create; missing config short-circuits to ``[]``. - """ - router_arn = os.environ.get("HOLIDAY_ROUTER_ARN") - role_arn = os.environ.get("HOLIDAY_SCHEDULER_ROLE_ARN") - group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default") - if not router_arn or not role_arn: - logger.warning( - "HOLIDAY_ROUTER_ARN/HOLIDAY_SCHEDULER_ROLE_ARN not set — " - "skipping schedule creation" - ) - return [] - - activate_name, deactivate_name = _holiday_schedule_names(date_str) - client = boto3.client("scheduler") - created: list[str] = [] - specs = [ - (activate_name, "activate", "08:00:00"), - (deactivate_name, "deactivate", "17:00:00"), - ] - for name, action, at_time in specs: - try: - client.create_schedule( - Name=name, - GroupName=group, - ScheduleExpression=f"at({date_str}T{at_time})", - ScheduleExpressionTimezone="America/New_York", - FlexibleTimeWindow={"Mode": "OFF"}, - ActionAfterCompletion="DELETE", - Target={ - "Arn": router_arn, - "RoleArn": role_arn, - "Input": json.dumps({"action": action, "date": date_str}), - }, - ) - created.append(name) - except Exception: - logger.exception("Failed to create %s schedule for %s", action, date_str) - return created + return create_holiday_schedules(date_str) def _delete_holiday_schedules(schedule_names: list[str]) -> None: @@ -2230,19 +2168,31 @@ def _admin_holiday_list(respond, schedule): @functools.lru_cache(maxsize=1) def _changelog_text() -> str: - """Read the CHANGELOG shipped in this function's package. + """Read the CHANGELOG shipped next to this module. Lazy (never at import) and tolerant of a missing file, so the App Home tab - degrades to "no What's New section" rather than erroring. The copy lives at - ``$LAMBDA_TASK_ROOT/CHANGELOG.md`` (synced from the repo root). + degrades to "no What's New section" rather than erroring. Lambda zips and + the Fargate image both keep ``CHANGELOG.md`` beside ``app.py``. + ``LAMBDA_TASK_ROOT`` remains a fallback for the zip layout. """ - path = os.path.join(os.environ.get("LAMBDA_TASK_ROOT", "."), "CHANGELOG.md") - try: - with open(path, encoding="utf-8") as fh: - return fh.read() - except OSError: - logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", path) - return "" + tried = [] + for path in _changelog_paths(): + tried.append(path) + try: + with open(path, encoding="utf-8") as fh: + return fh.read() + except OSError: + continue + logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", tried) + return "" + + +def _changelog_paths() -> list[str]: + paths = [os.path.join(os.path.dirname(os.path.abspath(__file__)), "CHANGELOG.md")] + task_root = os.environ.get("LAMBDA_TASK_ROOT", "").strip() + if task_root: + paths.append(os.path.join(task_root, "CHANGELOG.md")) + return paths _HOME_OVERVIEW_DAYS = 60 diff --git a/terraform/acm.tf b/terraform/acm.tf new file mode 100644 index 0000000..8d2c642 --- /dev/null +++ b/terraform/acm.tf @@ -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 +} diff --git a/terraform/alarms.tf b/terraform/alarms.tf index 24c0a98..793ed43 100644 --- a/terraform/alarms.tf +++ b/terraform/alarms.tf @@ -153,3 +153,53 @@ resource "aws_cloudwatch_metric_alarm" "api_latency" { treat_missing_data = "notBreaching" alarm_actions = [local.site_alerts_arn] } + +resource "aws_cloudwatch_metric_alarm" "alb_5xx" { + alarm_name = "ALB-5xx-${local.project}" + alarm_description = "ALB 5xx from afterhours-shift-manager" + namespace = "AWS/ApplicationELB" + metric_name = "HTTPCode_Target_5XX_Count" + dimensions = { LoadBalancer = aws_lb.api.arn_suffix } + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [local.site_alerts_arn] +} + +resource "aws_cloudwatch_metric_alarm" "alb_latency" { + alarm_name = "ALB-Latency-${local.project}" + alarm_description = "p99 target response time on the afterhours ALB exceeded 3s" + namespace = "AWS/ApplicationELB" + metric_name = "TargetResponseTime" + dimensions = { LoadBalancer = aws_lb.api.arn_suffix } + extended_statistic = "p99" + period = 300 + evaluation_periods = 3 + datapoints_to_alarm = 2 + threshold = 3 + comparison_operator = "GreaterThanOrEqualToThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [local.site_alerts_arn] +} + +resource "aws_cloudwatch_metric_alarm" "alb_unhealthy_hosts" { + alarm_name = "ALB-UnhealthyHost-${local.project}" + alarm_description = "Unhealthy Fargate targets on the afterhours ALB" + namespace = "AWS/ApplicationELB" + metric_name = "UnHealthyHostCount" + dimensions = { + LoadBalancer = aws_lb.api.arn_suffix + TargetGroup = aws_lb_target_group.api.arn_suffix + } + statistic = "Maximum" + period = 60 + evaluation_periods = 3 + datapoints_to_alarm = 3 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [local.site_alerts_arn] +} diff --git a/terraform/data.tf b/terraform/data.tf new file mode 100644 index 0000000..8ad9897 --- /dev/null +++ b/terraform/data.tf @@ -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." + } +} diff --git a/terraform/ecs.tf b/terraform/ecs.tf new file mode 100644 index 0000000..722f522 --- /dev/null +++ b/terraform/ecs.tf @@ -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 + }) +} diff --git a/terraform/hcp_iam.tf b/terraform/hcp_iam.tf index f3261a2..3347cc3 100644 --- a/terraform/hcp_iam.tf +++ b/terraform/hcp_iam.tf @@ -147,7 +147,7 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" { } statement { - sid = "PassExecRolesToLambda" + sid = "PassExecRolesToCompute" effect = "Allow" actions = ["iam:PassRole"] resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"] @@ -155,7 +155,7 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" { condition { test = "StringEquals" variable = "iam:PassedToService" - values = ["lambda.amazonaws.com"] + values = ["lambda.amazonaws.com", "ecs-tasks.amazonaws.com", "scheduler.amazonaws.com"] } } @@ -336,6 +336,8 @@ data "aws_iam_policy_document" "hcptf_apply_services" { "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/lambda/afterhours-*", "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/afterhours-shift-manager", "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/afterhours-shift-manager:*", + "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/ecs/afterhours-shift-manager", + "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/ecs/afterhours-shift-manager:*", ] } @@ -490,6 +492,7 @@ data "aws_iam_policy_document" "hcptf_apply_services" { "arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:Lambda-*-afterhours-*", "arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:DDB-*-afterhours-shifts", "arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:ApiGateway-*", + "arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:ALB-*-afterhours-shift-manager", ] } @@ -541,6 +544,8 @@ data "aws_iam_policy_document" "hcptf_apply_services" { ] resources = [ "arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*", + "arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/${local.project}/*", + "arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule-group/${local.project}", ] } @@ -551,6 +556,143 @@ data "aws_iam_policy_document" "hcptf_apply_services" { "scheduler:ListSchedules", "scheduler:ListScheduleGroups", "scheduler:GetScheduleGroup", + "scheduler:CreateScheduleGroup", + "scheduler:DeleteScheduleGroup", + ] + resources = ["*"] + } + + statement { + sid = "EcsWorkload" + effect = "Allow" + actions = [ + "ecs:*", + ] + resources = [ + "arn:aws:ecs:${var.aws_region}:${local.account_id}:cluster/${local.project}", + "arn:aws:ecs:${var.aws_region}:${local.account_id}:service/${local.project}/${local.project}", + "arn:aws:ecs:${var.aws_region}:${local.account_id}:task-definition/${local.project}:*", + "arn:aws:ecs:${var.aws_region}:${local.account_id}:task-definition/${local.project}", + ] + } + + statement { + sid = "EcsAccount" + effect = "Allow" + actions = [ + "ecs:CreateCluster", + "ecs:CreateService", + "ecs:DeleteService", + "ecs:DeregisterTaskDefinition", + "ecs:DescribeClusters", + "ecs:DescribeServices", + "ecs:DescribeTaskDefinition", + "ecs:ListClusters", + "ecs:ListServices", + "ecs:ListTaskDefinitions", + "ecs:RegisterTaskDefinition", + "ecs:TagResource", + "ecs:UntagResource", + "ecs:UpdateService", + ] + resources = ["*"] + } + + statement { + sid = "ElbWorkload" + effect = "Allow" + actions = [ + "elasticloadbalancing:*", + ] + resources = [ + "arn:aws:elasticloadbalancing:${var.aws_region}:${local.account_id}:loadbalancer/app/${local.project}/*", + "arn:aws:elasticloadbalancing:${var.aws_region}:${local.account_id}:targetgroup/${local.project}-api/*", + "arn:aws:elasticloadbalancing:${var.aws_region}:${local.account_id}:listener/app/${local.project}/*", + ] + } + + statement { + sid = "ElbDescribe" + effect = "Allow" + actions = [ + "elasticloadbalancing:Describe*", + "elasticloadbalancing:CreateLoadBalancer", + "elasticloadbalancing:CreateTargetGroup", + "elasticloadbalancing:CreateListener", + "elasticloadbalancing:CreateRule", + "elasticloadbalancing:AddTags", + "elasticloadbalancing:ModifyLoadBalancerAttributes", + "elasticloadbalancing:ModifyTargetGroup", + "elasticloadbalancing:ModifyTargetGroupAttributes", + "elasticloadbalancing:ModifyListener", + "elasticloadbalancing:SetSecurityGroups", + "elasticloadbalancing:SetSubnets", + ] + resources = ["*"] + } + + statement { + sid = "EcrRepo" + effect = "Allow" + actions = [ + "ecr:*", + ] + resources = [ + "arn:aws:ecr:${var.aws_region}:${local.account_id}:repository/${local.project}", + ] + } + + statement { + sid = "EcrAccount" + effect = "Allow" + actions = [ + "ecr:DescribeRepositories", + "ecr:GetAuthorizationToken", + ] + resources = ["*"] + } + + statement { + sid = "JobsQueues" + effect = "Allow" + actions = [ + "sqs:*", + ] + resources = [ + "arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs", + "arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs-dlq", + ] + } + + statement { + sid = "VpcSecurityGroups" + effect = "Allow" + actions = [ + "ec2:DescribeVpcs", + "ec2:DescribeSubnets", + "ec2:DescribeSecurityGroups", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeAccountAttributes", + "ec2:CreateSecurityGroup", + "ec2:DeleteSecurityGroup", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:RevokeSecurityGroupIngress", + "ec2:RevokeSecurityGroupEgress", + "ec2:CreateTags", + "ec2:DeleteTags", + ] + resources = ["*"] + } + + statement { + sid = "AcmLookup" + effect = "Allow" + actions = [ + "acm:ListCertificates", + "acm:DescribeCertificate", + "acm:ListTagsForCertificate", + "acm:GetCertificate", ] resources = ["*"] } @@ -717,6 +859,8 @@ data "aws_iam_policy_document" "hcptf_plan_refresh" { ] resources = [ "arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*", + "arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/${local.project}/*", + "arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule-group/${local.project}", ] } @@ -731,6 +875,78 @@ data "aws_iam_policy_document" "hcptf_plan_refresh" { resources = ["*"] } + statement { + sid = "RefreshEcs" + effect = "Allow" + actions = [ + "ecs:DescribeClusters", + "ecs:DescribeServices", + "ecs:DescribeTaskDefinition", + "ecs:DescribeTaskSets", + "ecs:ListClusters", + "ecs:ListServices", + "ecs:ListTaskDefinitions", + "ecs:ListTagsForResource", + ] + resources = ["*"] + } + + statement { + sid = "RefreshElb" + effect = "Allow" + actions = [ + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:DescribeLoadBalancerAttributes", + "elasticloadbalancing:DescribeListeners", + "elasticloadbalancing:DescribeListenerAttributes", + "elasticloadbalancing:DescribeTargetGroups", + "elasticloadbalancing:DescribeTargetGroupAttributes", + "elasticloadbalancing:DescribeTags", + "elasticloadbalancing:DescribeRules", + ] + resources = ["*"] + } + + statement { + sid = "RefreshEcr" + effect = "Allow" + actions = [ + "ecr:DescribeRepositories", + "ecr:DescribeImages", + "ecr:GetLifecyclePolicy", + "ecr:ListTagsForResource", + ] + resources = [ + "arn:aws:ecr:${var.aws_region}:${local.account_id}:repository/${local.project}", + ] + } + + statement { + sid = "RefreshSqs" + effect = "Allow" + actions = [ + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl", + "sqs:ListQueueTags", + ] + resources = [ + "arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs", + "arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs-dlq", + ] + } + + statement { + sid = "RefreshAcm" + effect = "Allow" + actions = [ + "acm:DescribeCertificate", + "acm:ListCertificates", + "acm:ListTagsForCertificate", + "acm:GetCertificate", + ] + resources = ["*"] + } + statement { sid = "RefreshSsm" effect = "Allow" diff --git a/terraform/iam.tf b/terraform/iam.tf new file mode 100644 index 0000000..3089692 --- /dev/null +++ b/terraform/iam.tf @@ -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 +} diff --git a/terraform/iam_github_deploy.tf b/terraform/iam_github_deploy.tf index c255e83..8010098 100644 --- a/terraform/iam_github_deploy.tf +++ b/terraform/iam_github_deploy.tf @@ -1,14 +1,8 @@ -# GitHub Actions OIDC role for .github/workflows/deploy.yaml. +# GitHub Actions OIDC role for .github/workflows/deploy.yaml and deploy-api.yaml. # -# Trust is pinned three ways: aud, sub to Environment prod (immutable and -# classic subject forms), and job_workflow_ref to deploy.yaml at -# refs/heads/main only. Live GitHub Actions presented the classic sub; both -# forms are listed. No v* tags until a later release ticket. A job with -# environment: does not present ref:refs/heads/main. -# -# Not a Lambda execution role: no permissions_boundary. Path /tf-managed/ so -# seahaven-hcptf-iam-management DenySelfMutation (role/githubdeploy-*) does not -# match. +# One role: GitHub Environments have a single DEPLOY_ROLE_ARN. Trust is pinned +# to Environments dev and prod (immutable and classic subject forms) and +# job_workflow_ref to deploy.yaml at main plus deploy-api.yaml at main and v*. data "aws_iam_policy_document" "github_deploy_assume" { statement { @@ -30,17 +24,16 @@ data "aws_iam_policy_document" "github_deploy_assume" { condition { test = "StringEquals" variable = "token.actions.githubusercontent.com:sub" - values = [ - local.github_oidc_sub, - "repo:${var.github_repo}:environment:prod", - ] + values = local.github_oidc_subs } condition { - test = "StringEquals" + test = "StringLike" variable = "token.actions.githubusercontent.com:job_workflow_ref" values = [ "${var.github_repo}/.github/workflows/deploy.yaml@refs/heads/${var.github_deploy_branch}", + "${var.github_repo}/.github/workflows/deploy-api.yaml@refs/heads/${var.github_deploy_branch}", + "${var.github_repo}/.github/workflows/deploy-api.yaml@refs/tags/v*", ] } } @@ -49,7 +42,7 @@ data "aws_iam_policy_document" "github_deploy_assume" { resource "aws_iam_role" "github_deploy" { name = local.deploy_role path = "/tf-managed/" - description = "GitHub Actions Lambda deploy role for ${var.github_repo} Environment prod" + description = "GitHub Actions zip and image deploy role for ${var.github_repo}" assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json max_session_duration = 3600 } @@ -86,6 +79,56 @@ data "aws_iam_policy_document" "github_deploy" { resources = [for fn in local.functions : "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:${fn.function_name}"] } + statement { + sid = "EcrAuth" + effect = "Allow" + actions = [ + "ecr:GetAuthorizationToken", + ] + resources = ["*"] + } + + statement { + sid = "EcrPush" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:CompleteLayerUpload", + "ecr:GetDownloadUrlForLayer", + "ecr:InitiateLayerUpload", + "ecr:PutImage", + "ecr:UploadLayerPart", + "ecr:DescribeRepositories", + "ecr:DescribeImages", + ] + resources = [aws_ecr_repository.api.arn] + } + + statement { + sid = "EcsDeploy" + effect = "Allow" + actions = [ + "ecs:DescribeServices", + "ecs:DescribeTaskDefinition", + "ecs:DescribeTasks", + "ecs:ListTasks", + "ecs:RegisterTaskDefinition", + "ecs:UpdateService", + ] + resources = ["*"] + } + + statement { + sid = "PassTaskRoles" + effect = "Allow" + actions = ["iam:PassRole"] + resources = [ + aws_iam_role.ecs_task.arn, + aws_iam_role.ecs_execution.arn, + ] + } + statement { sid = "DeployParams" effect = "Allow" diff --git a/terraform/locals.tf b/terraform/locals.tf index 1d68883..3884e9d 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -1,10 +1,11 @@ locals { project = "afterhours-shift-manager" - account_id = "011934824531" - environment = "prod" + is_prod = var.environment == "prod" + account_id = local.is_prod ? "011934824531" : "710827005802" + environment = var.environment - hcp_project = "seahaven-prod" - hcp_workspace = "afterhours-shift-manager-prod" + hcp_project = "seahaven-${var.environment}" + hcp_workspace = "${local.project}-${var.environment}" apply_role = "hcptf-afterhours-shift-manager" plan_role = "hcptf-afterhours-shift-manager-plan" deploy_role = "githubdeploy-afterhours-shift-manager" @@ -18,7 +19,16 @@ locals { github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com" # Org has Actions OIDC use_immutable_subject=true. - github_oidc_sub = "repo:Sea-Haven-Industries@183236204/afterhours-shift-manager@1200846448:environment:prod" + github_oidc_subs = [ + "repo:${var.github_repo}:environment:dev", + "repo:${var.github_repo}:environment:prod", + "repo:Sea-Haven-Industries@183236204/afterhours-shift-manager@1200846448:environment:dev", + "repo:Sea-Haven-Industries@183236204/afterhours-shift-manager@1200846448:environment:prod", + ] + + domain_name = var.domain_name != "" ? var.domain_name : (local.is_prod ? "afterhours.seahaven.com" : "afterhours.dev.seahaven.com") + acm_wildcard_domain = local.is_prod ? "*.seahaven.com" : "*.dev.seahaven.com" + api_url = var.attach_custom_domain ? "https://${local.domain_name}" : "http://${aws_lb.api.dns_name}" secret_names = [ "afterhours-shift-manager/slack-bot-token", diff --git a/terraform/logs.tf b/terraform/logs.tf index e2e52d0..fa75689 100644 --- a/terraform/logs.tf +++ b/terraform/logs.tf @@ -9,3 +9,8 @@ resource "aws_cloudwatch_log_group" "api_access" { name = "/aws/apigateway/${local.project}" retention_in_days = 90 } + +resource "aws_cloudwatch_log_group" "api" { + name = "/ecs/${local.project}" + retention_in_days = local.is_prod ? 60 : 14 +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf index fd44d8a..cf4f1b3 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -19,7 +19,7 @@ output "holiday_scheduler_role_arn" { } output "github_deploy_role_arn" { - description = "OIDC role ARN for .github/workflows/deploy.yaml (GitHub Environment prod variable DEPLOY_ROLE_ARN)." + description = "OIDC role ARN for deploy.yaml and deploy-api.yaml (GitHub Environment variable DEPLOY_ROLE_ARN)." value = aws_iam_role.github_deploy.arn } @@ -28,6 +28,26 @@ output "artifacts_bucket_name" { value = aws_s3_bucket.artifacts.id } +output "alb_dns_name" { + description = "ALB DNS name. Public DNS for afterhours.seahaven.com is out of band." + value = aws_lb.api.dns_name +} + +output "fargate_origin" { + description = "Fargate origin for Slack/Paychex/portal cutover. HTTPS after attach_custom_domain." + value = local.api_url +} + +output "jobs_queue_arn" { + description = "SQS ARN EventBridge Scheduler holiday one-offs target after cutover." + value = aws_sqs_queue.jobs.arn +} + +output "ecs_task_role_arn" { + description = "ECS task role. paychex-checkcomponents queue policy must allow this ARN before Fargate weekly_post." + value = aws_iam_role.ecs_task.arn +} + output "hcptf_apply_role_arn" { description = "HCP apply role ARN. Set TFC_AWS_APPLY_ROLE_ARN after the bootstrap window." value = aws_iam_role.hcptf_apply.arn diff --git a/terraform/providers.tf b/terraform/providers.tf index c3854cb..9494a0a 100644 --- a/terraform/providers.tf +++ b/terraform/providers.tf @@ -4,7 +4,7 @@ provider "aws" { default_tags { tags = { Project = local.project - Environment = "prod" + Environment = var.environment ManagedBy = "terraform" Workspace = local.hcp_workspace } diff --git a/terraform/scheduler.tf b/terraform/scheduler.tf index ffa839d..0173b90 100644 --- a/terraform/scheduler.tf +++ b/terraform/scheduler.tf @@ -1,5 +1,6 @@ # EventBridge Scheduler execution role. slack-bot creates one-off holiday-* # schedules at runtime; Terraform does not create those schedules. +# Recurring jobs use America/New_York Scheduler -> SQS (not dual EST/EDT rules). data "aws_iam_policy_document" "holiday_scheduler_assume" { statement { @@ -29,9 +30,9 @@ data "aws_iam_policy_document" "holiday_scheduler_assume" { resource "aws_iam_role" "holiday_scheduler" { name = local.holiday_scheduler_role_name path = "/tf-managed/" - description = "EventBridge Scheduler assumes this role to invoke afterhours-holiday-router" + description = "EventBridge Scheduler assumes this role to enqueue holiday jobs or invoke afterhours-holiday-router" assume_role_policy = data.aws_iam_policy_document.holiday_scheduler_assume.json - permissions_boundary = aws_iam_policy.lambda_boundary.arn + permissions_boundary = aws_iam_policy.ecs_task_boundary.arn } data "aws_iam_policy_document" "holiday_scheduler" { @@ -41,6 +42,13 @@ data "aws_iam_policy_document" "holiday_scheduler" { actions = ["lambda:InvokeFunction"] resources = [local.holiday_router_arn] } + + statement { + sid = "SendHolidayJobs" + effect = "Allow" + actions = ["sqs:SendMessage"] + resources = [aws_sqs_queue.jobs.arn] + } } resource "aws_iam_role_policy" "holiday_scheduler" { @@ -48,3 +56,52 @@ resource "aws_iam_role_policy" "holiday_scheduler" { role = aws_iam_role.holiday_scheduler.id policy = data.aws_iam_policy_document.holiday_scheduler.json } + +locals { + job_schedules = { + weekly-post = { + description = "Post weekly schedule Monday 7am Eastern" + schedule = "cron(0 7 ? * MON *)" + event = "weekly_post" + } + roster-sync = { + description = "Sync roster from 3CX at 6am Eastern" + schedule = "cron(0 6 ? * * *)" + event = "roster_sync" + } + ring-scheduler-daily = { + description = "Update 3CX queue at 8am Eastern" + schedule = "cron(0 8 ? * * *)" + event = "ring_scheduler_daily" + } + ring-scheduler-weekend = { + description = "Update 3CX queue at 5pm Eastern weekends" + schedule = "cron(0 17 ? * SAT,SUN *)" + event = "ring_scheduler_weekend" + } + } +} + +resource "aws_scheduler_schedule_group" "jobs" { + name = local.project +} + +resource "aws_scheduler_schedule" "jobs" { + for_each = local.job_schedules + + name = "${local.project}-${each.key}" + group_name = aws_scheduler_schedule_group.jobs.name + description = each.value.description + schedule_expression = each.value.schedule + schedule_expression_timezone = "America/New_York" + state = var.ecs_schedules_enabled ? "ENABLED" : "DISABLED" + flexible_time_window { + mode = "OFF" + } + + target { + arn = aws_sqs_queue.jobs.arn + role_arn = aws_iam_role.jobs_scheduler.arn + input = jsonencode({ event = each.value.event }) + } +} diff --git a/terraform/ssm.tf b/terraform/ssm.tf index bc5886f..6d4897c 100644 --- a/terraform/ssm.tf +++ b/terraform/ssm.tf @@ -13,3 +13,45 @@ resource "aws_ssm_parameter" "deploy_function_name" { value = each.value.function_name description = "Lambda function name for ${each.key}; deploy.yaml calls update-function-code" } + +resource "aws_ssm_parameter" "deploy_api_url" { + name = "${local.ssm_prefix}/deploy/api-url" + type = "String" + value = local.api_url + description = "API origin for deploy-api.yaml health check" +} + +resource "aws_ssm_parameter" "deploy_cluster" { + name = "${local.ssm_prefix}/deploy/cluster" + type = "String" + value = aws_ecs_cluster.api.name + description = "ECS cluster name for deploy-api.yaml" +} + +resource "aws_ssm_parameter" "deploy_service" { + name = "${local.ssm_prefix}/deploy/service" + type = "String" + value = aws_ecs_service.api.name + description = "ECS service name for deploy-api.yaml" +} + +resource "aws_ssm_parameter" "deploy_task_family" { + name = "${local.ssm_prefix}/deploy/task-family" + type = "String" + value = aws_ecs_task_definition.api.family + description = "ECS task definition family for deploy-api.yaml" +} + +resource "aws_ssm_parameter" "deploy_ecr_repository" { + name = "${local.ssm_prefix}/deploy/ecr-repository" + type = "String" + value = aws_ecr_repository.api.repository_url + description = "ECR repository URL for deploy-api.yaml" +} + +resource "aws_ssm_parameter" "deploy_container_name" { + name = "${local.ssm_prefix}/deploy/container-name" + type = "String" + value = local.api_container_name + description = "Container name in the ECS task definition" +} diff --git a/terraform/variables.tf b/terraform/variables.tf index 5cda090..cf35874 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -35,8 +35,37 @@ variable "sentry_dsn" { default = "" } +variable "environment" { + description = "HCP workspace stage. Selects account and workspace name." + type = string + default = "prod" + + validation { + condition = contains(["dev", "prod"], var.environment) + error_message = "environment must be \"dev\" or \"prod\"." + } +} + +variable "domain_name" { + description = "Public hostname on the ALB when attach_custom_domain is true. Empty selects afterhours.seahaven.com or afterhours.dev.seahaven.com from environment." + type = string + default = "" +} + +variable "attach_custom_domain" { + description = "When true, attach an HTTPS listener using the issued wildcard ACM certificate. Keep false until public DNS points at the ALB." + type = bool + default = false +} + variable "schedules_enabled" { - description = "When false, EventBridge rules exist but do not fire. Keep false until Slack and Paychex point at this stack." + description = "When false, EventBridge Lambda rules exist but do not fire. Keep false until Slack and Paychex point at this stack, and after Fargate jobs are enabled." + type = bool + default = false +} + +variable "ecs_schedules_enabled" { + description = "When false, EventBridge Scheduler jobs exist but do not fire. Enable at Fargate cutover after the image is healthy; keep Lambda EventBridge rules disabled." type = bool default = false } diff --git a/terraform/versions.tf b/terraform/versions.tf index 1c61e6e..910dede 100644 --- a/terraform/versions.tf +++ b/terraform/versions.tf @@ -16,7 +16,7 @@ terraform { organization = "seahaven" workspaces { - name = "afterhours-shift-manager-prod" + tags = ["app:afterhours-shift-manager"] } } } diff --git a/tests/conftest.py b/tests/conftest.py index 0cdb295..fc0efe1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,8 @@ def aws_env(monkeypatch): monkeypatch.delenv("QUEUE_NUMBER", raising=False) monkeypatch.delenv("TCX_SECRET_PREFIX", raising=False) monkeypatch.delenv("SENTRY_DSN", raising=False) + monkeypatch.delenv("JOBS_QUEUE_URL", raising=False) + monkeypatch.delenv("JOBS_QUEUE_ARN", raising=False) def _create_table(dynamodb): diff --git a/tests/infra/test_hcp_contract.py b/tests/infra/test_hcp_contract.py index 9198988..a2bc1fb 100644 --- a/tests/infra/test_hcp_contract.py +++ b/tests/infra/test_hcp_contract.py @@ -31,17 +31,49 @@ def test_lambda_ignore_changes_includes_code_attributes(): def test_schedules_disabled_by_default(): - chunk = (TERRAFORM / "variables.tf").read_text().split('variable "schedules_enabled"')[1] + chunk = ( + (TERRAFORM / "variables.tf") + .read_text() + .split('variable "schedules_enabled"')[1] + ) chunk = chunk.split("variable ")[0] assert "default = false" in chunk or "default = false" in chunk -def test_prod_only_workspace(): +def test_ecs_schedules_disabled_by_default(): + chunk = ( + (TERRAFORM / "variables.tf") + .read_text() + .split('variable "ecs_schedules_enabled"')[1] + ) + chunk = chunk.split("variable ")[0] + assert "default = false" in chunk or "default = false" in chunk + + +def test_workspaces_use_app_tag(): versions = (TERRAFORM / "versions.tf").read_text() - assert "afterhours-shift-manager-prod" in versions - assert "afterhours-shift-manager-dev" not in versions - assert 'environment = "prod"' in LOCALS - assert "seahaven-dev" not in LOCALS + assert 'tags = ["app:afterhours-shift-manager"]' in versions + assert 'name = "afterhours-shift-manager-prod"' not in versions + assert 'hcp_workspace = "${local.project}-${var.environment}"' in LOCALS + assert "seahaven-${var.environment}" in LOCALS + + +def test_ecs_ignore_changes_and_task_size(): + ecs = (TERRAFORM / "ecs.tf").read_text() + assert "ignore_changes = [container_definitions]" in ecs + assert "ignore_changes = [task_definition, desired_count]" in ecs + assert 'cpu = "512"' in ecs + assert 'memory = "1024"' in ecs + assert 'cpu_architecture = "ARM64"' in ecs + assert 'path = "/api/health"' in ecs + + +def test_deploy_api_workflow_exists(): + deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text() + assert "environment: ${{ needs.target.outputs.environment }}" in deploy_api + assert "/afterhours-shift-manager/deploy/cluster" in deploy_api + assert "linux/arm64" in deploy_api + assert "gh release create" in deploy_api def test_in_repo_hcptf_roles(): @@ -74,6 +106,9 @@ def test_checkcomponents_queue_arn_variable_matches_iam_references(): assert "var.checkcomponents_queue_arn" in LAMBDA_TF boundary = (TERRAFORM / "lambda_boundary.tf").read_text() assert "var.checkcomponents_queue_arn" in boundary + data_tf = (TERRAFORM / "data.tf").read_text() + assert "dev_has_no_paychex" in data_tf + assert "checkcomponents_pair" in data_tf def test_eight_functions_named(): @@ -94,12 +129,15 @@ def test_weekly_post_role_is_tf_managed_name(): assert 'role_name = "afterhours-shift-manager-weekly-post"' in LOCALS -def test_github_deploy_trust_is_environment_prod(): +def test_github_deploy_trust_covers_zip_and_image(): iam = (TERRAFORM / "iam_github_deploy.tf").read_text() assert "environment:prod" in iam or "environment:prod" in LOCALS + assert "environment:dev" in iam or "environment:dev" in LOCALS assert "deploy.yaml@refs/heads/${var.github_deploy_branch}" in iam + assert "deploy-api.yaml@refs/heads/${var.github_deploy_branch}" in iam + assert "deploy-api.yaml@refs/tags/v*" in iam assert "deploy.yaml@*" not in iam - assert "refs/tags/v*" not in iam + assert "ecs:ListTasks" in iam def test_plan_refresh_includes_provider6_s3_gets(): diff --git a/tests/portal_api/test_handler.py b/tests/portal_api/test_handler.py index 64b6c76..a751473 100644 --- a/tests/portal_api/test_handler.py +++ b/tests/portal_api/test_handler.py @@ -27,8 +27,7 @@ def _event(method="GET", path="/api/shifts", body=None, token="id-token", origin @pytest.fixture def identity(portalapi_app, monkeypatch): monkeypatch.setattr( - portalapi_app, - "verify_cognito_id_token", + "shared.portal_http.verify_cognito_id_token", lambda _token: {"name": "Alice", "email": "alice@seahavenind.com"}, ) @@ -67,9 +66,7 @@ def test_linked_snapshot_and_admin_flag(portalapi_app, schedule, seed, identity) def test_non_admin_cannot_override(portalapi_app, schedule, seed, identity): - seed.roster( - "114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com" - ) + seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com") seed.config(admin_users=["U_OTHER"]) result = portalapi_app.handler( _event( @@ -83,7 +80,22 @@ def test_non_admin_cannot_override(portalapi_app, schedule, seed, identity): def test_cors_header_for_portal_origin(portalapi_app, schedule, identity): - result = portalapi_app.handler( - _event(origin="https://internal.seahaven.com"), None + result = portalapi_app.handler(_event(origin="https://internal.seahaven.com"), None) + assert ( + result["headers"]["Access-Control-Allow-Origin"] + == "https://internal.seahaven.com" ) - assert result["headers"]["Access-Control-Allow-Origin"] == "https://internal.seahaven.com" + + +def test_unexpected_failure_keeps_cors(portalapi_app, identity, monkeypatch): + monkeypatch.setattr( + "shared.portal_http.ShiftSchedule", + lambda: (_ for _ in ()).throw(RuntimeError("ddb down")), + ) + result = portalapi_app.handler(_event(origin="https://internal.seahaven.com"), None) + assert result["statusCode"] == 500 + assert ( + result["headers"]["Access-Control-Allow-Origin"] + == "https://internal.seahaven.com" + ) + assert json.loads(result["body"])["error"]["code"] == "INTERNAL" diff --git a/tests/requirements.txt b/tests/requirements.txt index e5fcf51..8c9815f 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -6,4 +6,5 @@ responses>=0.26.2 freezegun>=1.5.5 sentry-sdk==2.68.1 PyJWT[crypto]==2.14.0 +flask==3.1.3 diff --git a/tests/roster_api/conftest.py b/tests/roster_api/conftest.py index e35823d..59a7765 100644 --- a/tests/roster_api/conftest.py +++ b/tests/roster_api/conftest.py @@ -21,4 +21,6 @@ def _load(name, relpath): def rosterapi_app(): mod = _load("rosterapi_app", "src/roster-api/app.py") yield mod - mod._cached_token = None + import shared.roster_http as roster_http + + roster_http._cached_token = None diff --git a/tests/roster_api/test_handler.py b/tests/roster_api/test_handler.py index f2625d3..f8762d5 100644 --- a/tests/roster_api/test_handler.py +++ b/tests/roster_api/test_handler.py @@ -7,6 +7,8 @@ from unittest.mock import MagicMock import pytest +import shared.roster_http as roster_http + TOKEN = "roster-test-token" SECRET_NAME = "afterhours-shift-manager/roster-api-token" @@ -18,8 +20,8 @@ def env(monkeypatch): @pytest.fixture def secrets(rosterapi_app, monkeypatch, env): - monkeypatch.setattr(rosterapi_app, "get_secret", lambda _id: TOKEN) - rosterapi_app._cached_token = None + monkeypatch.setattr(roster_http, "get_secret", lambda _id: TOKEN) + roster_http._cached_token = None def _event( @@ -174,16 +176,16 @@ def test_401_wrong_token(rosterapi_app, schedule, secrets): def test_authorize_strips_secret_trailing_newline( rosterapi_app, schedule, env, monkeypatch ): - monkeypatch.setattr(rosterapi_app, "get_secret", lambda _id: TOKEN + "\n") - rosterapi_app._cached_token = None + monkeypatch.setattr(roster_http, "get_secret", lambda _id: TOKEN + "\n") + roster_http._cached_token = None result = rosterapi_app.handler(_event(body=_put_body()), None) assert result["statusCode"] == 200 assert schedule.get_employee_by_extension("110")["slack_user_id"] == "U123ABCDE" def test_503_when_secret_is_whitespace_only(rosterapi_app, schedule, env, monkeypatch): - monkeypatch.setattr(rosterapi_app, "get_secret", lambda _id: "\n") - rosterapi_app._cached_token = None + monkeypatch.setattr(roster_http, "get_secret", lambda _id: "\n") + roster_http._cached_token = None result = rosterapi_app.handler(_event(body=_put_body()), None) assert result["statusCode"] == 503 assert schedule.get_employee_by_extension("110") is None @@ -193,8 +195,8 @@ def test_503_when_secret_read_fails(rosterapi_app, schedule, env, monkeypatch): def boom(_id): raise RuntimeError("secrets down") - monkeypatch.setattr(rosterapi_app, "get_secret", boom) - rosterapi_app._cached_token = None + monkeypatch.setattr(roster_http, "get_secret", boom) + roster_http._cached_token = None result = rosterapi_app.handler(_event(body=_put_body()), None) assert result["statusCode"] == 503 assert schedule.get_employee_by_extension("110") is None @@ -300,7 +302,7 @@ def test_never_calls_roster_sync(rosterapi_app): def test_500_on_unexpected_failure(rosterapi_app, secrets, monkeypatch): monkeypatch.setattr( - rosterapi_app, + roster_http, "ShiftSchedule", MagicMock(side_effect=RuntimeError("ddb down")), ) diff --git a/tests/scripts/test_retarget_holiday_schedules_to_sqs.py b/tests/scripts/test_retarget_holiday_schedules_to_sqs.py new file mode 100644 index 0000000..0d97c24 --- /dev/null +++ b/tests/scripts/test_retarget_holiday_schedules_to_sqs.py @@ -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" + ) diff --git a/tests/server/test_app.py b/tests/server/test_app.py new file mode 100644 index 0000000..4976111 --- /dev/null +++ b/tests/server/test_app.py @@ -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 diff --git a/tests/server/test_jobs.py b/tests/server/test_jobs.py new file mode 100644 index 0000000..ba3d8e0 --- /dev/null +++ b/tests/server/test_jobs.py @@ -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 diff --git a/tests/shared/test_ring_scheduler.py b/tests/shared/test_ring_scheduler.py index 55ae19e..b5080d8 100644 --- a/tests/shared/test_ring_scheduler.py +++ b/tests/shared/test_ring_scheduler.py @@ -25,7 +25,11 @@ class FakeThreeCXClient: def test_update_queue_routing_points_queue_at_extension(monkeypatch): FakeThreeCXClient.instances = [] - monkeypatch.setattr(ring_scheduler, "ThreeCXClient", FakeThreeCXClient) + + def fake_oauth(domain, client_id, client_secret): + return FakeThreeCXClient(domain, "oauth", client_id, client_secret) + + monkeypatch.setattr(ring_scheduler, "oauth_client", fake_oauth) result = ring_scheduler.update_queue_routing( extension="114", diff --git a/tests/shared/test_sentry_init.py b/tests/shared/test_sentry_init.py index a0c8948..3b9f433 100644 --- a/tests/shared/test_sentry_init.py +++ b/tests/shared/test_sentry_init.py @@ -31,6 +31,7 @@ def test_empty_dsn_does_not_init(monkeypatch): def test_set_dsn_inits_lambda_integration(monkeypatch): + monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "afterhours-shift-manager") mocked = _reexec(monkeypatch, dsn="https://key@o1.ingest.sentry.io/1") mocked.assert_called_once() kwargs = mocked.call_args.kwargs @@ -48,6 +49,7 @@ def test_set_dsn_inits_lambda_integration(monkeypatch): def test_build_info_sha_sets_sentry_release(monkeypatch): + monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "afterhours-shift-manager") monkeypatch.setenv("SENTRY_DSN", "https://key@o1.ingest.sentry.io/1") fake = ModuleType("shared.build_info") fake.GIT_SHA = "abc123def" diff --git a/tests/shared/test_side_effects_holidays.py b/tests/shared/test_side_effects_holidays.py new file mode 100644 index 0000000..26f921c --- /dev/null +++ b/tests/shared/test_side_effects_holidays.py @@ -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" diff --git a/tests/shared/test_three_cx_client.py b/tests/shared/test_three_cx_client.py index 92a9053..994841c 100644 --- a/tests/shared/test_three_cx_client.py +++ b/tests/shared/test_three_cx_client.py @@ -216,3 +216,19 @@ def test_extract_ivr_routes_missing_key0_is_none(): {"Forwards": [], "TimeoutForwardDN": None} ) assert routes == {"key0": None, "timeout": None} + + +@responses.activate +def test_oauth_client_reuses_process_cache(): + from shared import three_cx_client as tcx + + tcx._oauth_clients.clear() + _stub_oauth() + first = tcx.oauth_client("test.3cx.us", "cid", "secret") + second = tcx.oauth_client("test.3cx.us", "cid", "secret") + assert first is second + token_posts = [ + c for c in responses.calls if c.request.url.endswith("/connect/token") + ] + assert len(token_posts) == 1 + tcx._oauth_clients.clear() diff --git a/tests/slack_bot/test_app_home.py b/tests/slack_bot/test_app_home.py index 902966c..3a688b9 100644 --- a/tests/slack_bot/test_app_home.py +++ b/tests/slack_bot/test_app_home.py @@ -42,6 +42,18 @@ def test_publish_home_publishes_latest_entry(slackbot_app, client): assert "v1.9.2" not in str(kwargs["view"]) # only the newest entry +def test_changelog_paths_include_package_copy(slackbot_app): + paths = slackbot_app._changelog_paths() + assert any(p.endswith("src/slack-bot/CHANGELOG.md") for p in paths) + + +def test_changelog_text_reads_package_copy(slackbot_app): + slackbot_app._changelog_text.cache_clear() + text = slackbot_app._changelog_text() + assert text + assert "## " in text + + def test_publish_home_degrades_without_changelog(slackbot_app, client): slackbot_app.publish_home(client, "U_BOB", "") view = client.views_publish.call_args.kwargs["view"]