diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ceb0406..8cb8bd6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,15 +2,35 @@ name: CI on: pull_request: - branches: [main] + branches: [main, hotfix/**, release/**] merge_group: + push: + branches: [hotfix/**, release/**] permissions: contents: read jobs: - pytest: - name: Pytest + autofix: + if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork + uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16 + permissions: + contents: write + secrets: inherit + with: + presets: ruff,terraform + terraform-version: "1.16.0" + + lint: + needs: autofix + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') + uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16 + with: + python-version: "3.12" + + test: + needs: autofix + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -37,33 +57,16 @@ jobs: run: pytest terraform: - name: Terraform - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: terraform - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 - with: - terraform_version: "1.16.0" - terraform_wrapper: false - - - name: Terraform fmt - run: terraform fmt -check -recursive - - - name: Terraform init - run: terraform init -backend=false - - - name: Terraform validate - run: terraform validate + needs: autofix + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') + uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16 + with: + terraform-version: "1.16.0" openapi: name: OpenAPI + needs: autofix + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -82,35 +85,22 @@ jobs: - name: Lint OpenAPI run: npm run openapi:lint - ci: - name: ci / ci - needs: [pytest, terraform, openapi] - if: ${{ always() && !cancelled() }} + ci-complete: + name: ci-complete + needs: [autofix, lint, test, terraform, openapi] + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Check jobs + - name: Require portions env: - PYTEST_RESULT: ${{ needs.pytest.result }} - TERRAFORM_RESULT: ${{ needs.terraform.result }} - OPENAPI_RESULT: ${{ needs.openapi.result }} + LINT: ${{ needs.lint.result }} + TEST: ${{ needs.test.result }} + TERRAFORM: ${{ needs.terraform.result }} + OPENAPI: ${{ needs.openapi.result }} run: | set -euo pipefail - fail=0 - check() { - local name="$1" - local result="$2" - case "${result}" in - success) - echo "${name}: ${result}" - ;; - *) - echo "${name}: ${result}" >&2 - fail=1 - ;; - esac - } - check pytest "${PYTEST_RESULT}" - check terraform "${TERRAFORM_RESULT}" - check openapi "${OPENAPI_RESULT}" - exit "${fail}" + test "${LINT}" = success + test "${TEST}" = success + test "${TERRAFORM}" = success + test "${OPENAPI}" = success diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 71146a0..ce5da30 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -7,4 +7,4 @@ permissions: jobs: review: - uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 + uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16 diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml index 63951e8..5c4cefa 100644 --- a/.github/workflows/deploy-api.yaml +++ b/.github/workflows/deploy-api.yaml @@ -1,8 +1,8 @@ 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. +# 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 @@ -40,206 +40,31 @@ 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 + deploy-dev: + name: Deploy API to dev + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev') + uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16 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 + secrets: inherit + with: + environment: dev + ref: ${{ inputs.ref }} + ssm-prefix: /afterhours-shift-manager/deploy + docker-platform: linux/arm64 - - 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@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 - 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@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - with: - platforms: arm64 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - - 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 + deploy-prod: + name: Deploy API to prod + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod') + uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16 + permissions: + contents: read + id-token: write + secrets: inherit + with: + environment: prod + ref: ${{ github.event.release.tag_name || inputs.ref }} + ssm-prefix: /afterhours-shift-manager/deploy + docker-platform: linux/arm64 + ship-gate: true diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 6eea16a..fdf98f1 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -10,4 +10,4 @@ permissions: jobs: label: - uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 + uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16 diff --git a/scripts/cutover/copy_dynamodb.py b/scripts/cutover/copy_dynamodb.py index d476e2f..8631edb 100644 --- a/scripts/cutover/copy_dynamodb.py +++ b/scripts/cutover/copy_dynamodb.py @@ -64,8 +64,16 @@ def main() -> int: src = _client(args.src_profile, args.region) dst = _client(args.dst_profile, args.region) - src_id = boto3.Session(profile_name=args.src_profile).client("sts").get_caller_identity()["Account"] - dst_id = boto3.Session(profile_name=args.dst_profile).client("sts").get_caller_identity()["Account"] + src_id = ( + boto3.Session(profile_name=args.src_profile) + .client("sts") + .get_caller_identity()["Account"] + ) + dst_id = ( + boto3.Session(profile_name=args.dst_profile) + .client("sts") + .get_caller_identity()["Account"] + ) if src_id != SRC_ACCOUNT: print(f"src account {src_id} is not mgmt {SRC_ACCOUNT}", file=sys.stderr) return 2 diff --git a/scripts/cutover/copy_secrets.py b/scripts/cutover/copy_secrets.py index c7ea130..14a44e8 100644 --- a/scripts/cutover/copy_secrets.py +++ b/scripts/cutover/copy_secrets.py @@ -35,11 +35,17 @@ _NO_VALUE_CODES = frozenset({"ResourceNotFoundException", "InvalidRequestExcepti def _client(profile: str, region: str): - return boto3.Session(profile_name=profile, region_name=region).client("secretsmanager") + return boto3.Session(profile_name=profile, region_name=region).client( + "secretsmanager" + ) def _account(profile: str) -> str: - return boto3.Session(profile_name=profile).client("sts").get_caller_identity()["Account"] + return ( + boto3.Session(profile_name=profile) + .client("sts") + .get_caller_identity()["Account"] + ) def secret_string(client, name: str) -> str | None: @@ -72,10 +78,15 @@ def copy_secrets(src, dst, *, execute: bool) -> int: for name in VERIFY_ONLY: value = secret_string(dst, name) if value is None: - print(f"missing prod secret {name} (expected from PLAT-76)", file=sys.stderr) + print( + f"missing prod secret {name} (expected from PLAT-76)", file=sys.stderr + ) rc = 1 elif not value.strip(): - print(f"empty prod 3cx secret {name} (do not overwrite from mgmt)", file=sys.stderr) + print( + f"empty prod 3cx secret {name} (do not overwrite from mgmt)", + file=sys.stderr, + ) rc = 1 else: print(f"keep existing prod secret {name}") diff --git a/scripts/cutover/recreate_holiday_schedules.py b/scripts/cutover/recreate_holiday_schedules.py index 0308d1c..a0d5f07 100644 --- a/scripts/cutover/recreate_holiday_schedules.py +++ b/scripts/cutover/recreate_holiday_schedules.py @@ -19,7 +19,9 @@ from botocore.exceptions import ClientError SRC_ACCOUNT = "328440206208" DST_ACCOUNT = "011934824531" -PROD_ROUTER_ARN = "arn:aws:lambda:us-east-1:011934824531:function:afterhours-holiday-router" +PROD_ROUTER_ARN = ( + "arn:aws:lambda:us-east-1:011934824531:function:afterhours-holiday-router" +) PROD_ROLE_ARN = "arn:aws:iam::011934824531:role/tf-managed/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})\)$") @@ -30,7 +32,11 @@ def _client(profile: str, region: str): def _account(profile: str) -> str: - return boto3.Session(profile_name=profile).client("sts").get_caller_identity()["Account"] + return ( + boto3.Session(profile_name=profile) + .client("sts") + .get_caller_identity()["Account"] + ) def schedule_when(detail: dict) -> datetime | None: @@ -123,13 +129,17 @@ def main() -> int: if code == "ConflictException": print(f"exists {name}") elif code == "ValidationException": - print(f"skip invalid {name}: {exc.response['Error'].get('Message', code)}") + print( + f"skip invalid {name}: {exc.response['Error'].get('Message', code)}" + ) skipped += 1 else: print(f"failed {name}: {code}", file=sys.stderr) failed += 1 - print(f"created={created} skipped_past={skipped} failed={failed} execute={args.execute}") + print( + f"created={created} skipped_past={skipped} failed={failed} execute={args.execute}" + ) if not args.execute: print("dry-run; pass --execute to CreateSchedule") return 1 if failed else 0 diff --git a/scripts/package_lambdas.py b/scripts/package_lambdas.py index d858b0a..111e060 100644 --- a/scripts/package_lambdas.py +++ b/scripts/package_lambdas.py @@ -58,7 +58,9 @@ def _copy_tree(src: Path, dest: Path) -> None: if item.is_dir(): if item.name == "__pycache__": continue - shutil.copytree(item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + shutil.copytree( + item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc") + ) else: shutil.copy2(item, target) diff --git a/src/shared/shared/portal_ops.py b/src/shared/shared/portal_ops.py index df12747..339a4ea 100644 --- a/src/shared/shared/portal_ops.py +++ b/src/shared/shared/portal_ops.py @@ -5,13 +5,11 @@ from __future__ import annotations import re from datetime import datetime, timedelta from decimal import Decimal -from zoneinfo import ZoneInfo from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule from shared.shift_clock import ( EASTERN, holiday_window_active, - is_today, shift_end, shift_ended, shift_start, @@ -70,7 +68,9 @@ def _json_safe(value): return value -def _slot_payload(schedule: ShiftSchedule, date_str: str, day_name: str, shift_type: str, my_ext: str) -> dict: +def _slot_payload( + schedule: ShiftSchedule, date_str: str, day_name: str, shift_type: str, my_ext: str +) -> dict: ctx = schedule.get_shift_context(date_str, day_name, shift_type) assignees = [ {"extension": a["extension"], "name": a["name"]} for a in ctx["assignees"] @@ -86,8 +86,13 @@ def _slot_payload(schedule: ShiftSchedule, date_str: str, day_name: str, shift_t "multiplier": _json_safe(ctx.get("multiplier") or 1), "assignees": assignees, "mine": mine, - "canPick": (not mine) and open_slots > 0 and not shift_ended(date_str, shift_type), - "canDrop": mine and not within_drop_lock(date_str, "day" if ctx["kind"] == "holiday" else shift_type), + "canPick": (not mine) + and open_slots > 0 + and not shift_ended(date_str, shift_type), + "canDrop": mine + and not within_drop_lock( + date_str, "day" if ctx["kind"] == "holiday" else shift_type + ), "latePickup": (not mine) and open_slots > 0 and shift_started(date_str, shift_type) @@ -192,7 +197,9 @@ def _pickup_sk_parts(sk: str) -> tuple[str, str, str]: return date_str, shift_type, ext -def pick(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None) -> dict: +def pick( + schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None +) -> dict: date = _parse_date(date_str) date_str = date.strftime(DATE_FMT) if date_str < datetime.now(EASTERN).strftime(DATE_FMT): @@ -203,7 +210,9 @@ def pick(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str token = effects.slack_token() if ctx["kind"] == "holiday": return _pick_holiday(schedule, employee, date, date_str, ctx, token) - return _pick_regular(schedule, employee, date, date_str, day_name, shift_type, ctx, token) + return _pick_regular( + schedule, employee, date, date_str, day_name, shift_type, ctx, token + ) def _resolve_pick_type(schedule, date_str, day_name, explicit) -> str: @@ -219,7 +228,9 @@ def _resolve_pick_type(schedule, date_str, day_name, explicit) -> str: return "night" -def _pick_regular(schedule, employee, date, date_str, day_name, shift_type, ctx, token) -> dict: +def _pick_regular( + schedule, employee, date, date_str, day_name, shift_type, ctx, token +) -> dict: assignees = ctx["assignees"] if assignees and assignees[0]["extension"] != employee["extension"]: raise ActionError( @@ -230,14 +241,20 @@ def _pick_regular(schedule, employee, date, date_str, day_name, shift_type, ctx, if shift_ended(date_str, shift_type): raise ActionError(400, "ENDED", "That shift has already ended.") if shift_started(date_str, shift_type): - return _request_late_pickup(schedule, employee, date_str, shift_type, False, token) - already_mine = bool(assignees) and assignees[0]["extension"] == employee["extension"] + return _request_late_pickup( + schedule, employee, date_str, shift_type, False, token + ) + already_mine = ( + bool(assignees) and assignees[0]["extension"] == employee["extension"] + ) if not already_mine: claimed = schedule.claim_open_shift( date_str, employee["extension"], employee["name"], shift_type ) if not claimed: - raise ActionError(409, "TAKEN", "That shift was just picked up by someone else.") + raise ActionError( + 409, "TAKEN", "That shift was just picked up by someone else." + ) effects.maybe_repoint_today(date_str, shift_type, employee["extension"]) effects.post_shift_change( token, @@ -278,7 +295,9 @@ def _pick_holiday(schedule, employee, date, date_str, ctx, token) -> dict: return {"ok": True, "latePickup": False, "message": "Holiday slot claimed."} -def _request_late_pickup(schedule, employee, date_str, shift_type, is_holiday, token) -> dict: +def _request_late_pickup( + schedule, employee, date_str, shift_type, is_holiday, token +) -> dict: schedule.create_pickup_request( date_str, shift_type, @@ -303,7 +322,9 @@ def _request_late_pickup(schedule, employee, date_str, shift_type, is_holiday, t } -def drop(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None) -> dict: +def drop( + schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None +) -> dict: date = _parse_date(date_str) date_str = date.strftime(DATE_FMT) if date_str < datetime.now(EASTERN).strftime(DATE_FMT): @@ -317,7 +338,9 @@ def drop(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str elif target in ("day", "night"): target = target else: - raise ActionError(400, "INVALID_SHIFT", "Shift type must be day, night, or holiday.") + raise ActionError( + 400, "INVALID_SHIFT", "Shift type must be day, night, or holiday." + ) if target == "day" and "holiday" in held: target = "holiday" if target not in held: @@ -450,7 +473,11 @@ def swap( def respond_swap( - schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str, accept: bool + schedule: ShiftSchedule, + employee: dict, + date_str: str, + shift_type: str, + accept: bool, ) -> dict: date = _parse_date(date_str) date_str = date.strftime(DATE_FMT) @@ -474,11 +501,16 @@ def respond_swap( return {"ok": True, "message": "Swap declined."} if shift_started(date_str, shift_type): schedule.clear_swap(date_str, shift_type) - raise ActionError(409, "EXPIRED", "This swap expired because the shift has started.") + raise ActionError( + 409, "EXPIRED", "This swap expired because the shift has started." + ) is_holiday = shift_type == "day" and schedule.get_holiday(date_str) is not None if is_holiday: moved = schedule.swap_holiday_assignee( - date_str, swap_row["requester_ext"], swap_row["target_ext"], employee["name"] + date_str, + swap_row["requester_ext"], + swap_row["target_ext"], + employee["name"], ) if not moved: schedule.clear_swap(date_str, shift_type) @@ -583,11 +615,15 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) -> date = _parse_date(date_str) date_str = date.strftime(DATE_FMT) if date_str < datetime.now(EASTERN).strftime(DATE_FMT): - raise ActionError(400, "PAST_SHIFT", "You can't schedule a holiday in the past.") + raise ActionError( + 400, "PAST_SHIFT", "You can't schedule a holiday in the past." + ) try: slot_count = int(slots) except (TypeError, ValueError) as exc: - raise ActionError(400, "INVALID_SLOTS", "Slots must be a whole number.") from exc + raise ActionError( + 400, "INVALID_SLOTS", "Slots must be a whole number." + ) from exc if slot_count < 1: raise ActionError(400, "INVALID_SLOTS", "Slots must be at least 1.") name = (label or "").strip() @@ -620,7 +656,11 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) -> effects.activate_holiday_inline(schedule, date_str) holiday = schedule.get_holiday(date_str) effects.post_holiday_added( - token, date_str, name, slot_count, holiday["multiplier"] if holiday else multiplier + token, + date_str, + name, + slot_count, + holiday["multiplier"] if holiday else multiplier, ) effects.refresh_schedule_post(schedule, token) return {"ok": True, "message": f"Scheduled {name}."} @@ -639,7 +679,9 @@ def admin_holiday_remove(schedule, employee, date_str) -> dict: return {"ok": True, "message": "Holiday removed."} -def admin_pickup(schedule, employee, date_str, shift_type, extension, approve: bool) -> dict: +def admin_pickup( + schedule, employee, date_str, shift_type, extension, approve: bool +) -> dict: require_admin(schedule, employee) date = _parse_date(date_str) date_str = date.strftime(DATE_FMT) diff --git a/src/shared/shared/schedule.py b/src/shared/shared/schedule.py index 91db439..c894770 100644 --- a/src/shared/shared/schedule.py +++ b/src/shared/shared/schedule.py @@ -293,7 +293,9 @@ class ShiftSchedule: def list_pending_swaps(self) -> list[dict]: resp = self.table.query(KeyConditionExpression=Key("PK").eq("SWAP")) - return [item for item in resp.get("Items", []) if item.get("status") == "pending"] + return [ + item for item in resp.get("Items", []) if item.get("status") == "pending" + ] # ── Late-pickup requests ───────────────────────────────────────────── @@ -381,7 +383,9 @@ class ShiftSchedule: def list_pending_pickup_requests(self) -> list[dict]: resp = self.table.query(KeyConditionExpression=Key("PK").eq("PICKUP_REQUEST")) - return [item for item in resp.get("Items", []) if item.get("status") == "pending"] + return [ + item for item in resp.get("Items", []) if item.get("status") == "pending" + ] # ── Holidays ──────────────────────────────────────────────────────── diff --git a/terraform/iam_github_deploy.tf b/terraform/iam_github_deploy.tf index b0210e7..53f3476 100644 --- a/terraform/iam_github_deploy.tf +++ b/terraform/iam_github_deploy.tf @@ -1,8 +1,9 @@ -# GitHub Actions OIDC role for .github/workflows/deploy-api.yaml. +# GitHub Actions OIDC role for the thin deploy-api.yaml caller of +# org reusable cd-hcp-fargate.yaml. # # 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-api.yaml at main and v*. +# to Environments dev and prod. job_workflow_ref matches the reusable at any ref. +# AWS STS GitHub condition keys include job_workflow_ref, not workflow_ref. data "aws_iam_policy_document" "github_deploy_assume" { statement { @@ -31,8 +32,7 @@ data "aws_iam_policy_document" "github_deploy_assume" { test = "StringLike" variable = "token.actions.githubusercontent.com:job_workflow_ref" values = [ - "${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*", + "Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*", ] } } diff --git a/terraform/variables.tf b/terraform/variables.tf index cf35874..063e6e4 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -76,12 +76,6 @@ variable "github_repo" { default = "Sea-Haven-Industries/afterhours-shift-manager" } -variable "github_deploy_branch" { - description = "Git branch pinned in job_workflow_ref for the deploy role." - type = string - default = "main" -} - variable "checkcomponents_queue_url" { description = "paychex-checkcomponents SQS URL. Empty skips the weekly SendMessage." type = string diff --git a/tests/infra/test_hcp_contract.py b/tests/infra/test_hcp_contract.py index 1e69e9d..00fad73 100644 --- a/tests/infra/test_hcp_contract.py +++ b/tests/infra/test_hcp_contract.py @@ -77,13 +77,13 @@ def test_stack_owns_a_vpc_instead_of_looking_up_default(): assert "aws_vpc.this.id" in ecs assert "aws_subnet.public[*].id" in ecs assert "ec2:CreateVpc" in HCP_IAM - assert "sid = \"RefreshVpc\"" in HCP_IAM + assert 'sid = "RefreshVpc"' in HCP_IAM assert "afterhours-shift-manager-ecs" in HCP_IAM assert "hcptf_apply_ecs" in HCP_IAM assert 'resource "aws_iam_policy" "hcptf_apply_ecs"' in HCP_IAM assert 'resource "aws_iam_role_policy" "hcptf_apply_ecs"' not in HCP_IAM assert "aws_iam_policy.hcptf_apply_ecs.arn" in HCP_IAM - assert "sid = \"CreateElbAndEcsServiceLinkedRoles\"" in HCP_IAM + assert 'sid = "CreateElbAndEcsServiceLinkedRoles"' in HCP_IAM assert "iam:CreateServiceLinkedRole" in HCP_IAM @@ -101,10 +101,13 @@ def test_ecs_task_boundary_uses_static_arns(): 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 + pin = "cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" + assert deploy_api.count(pin) == 2 + assert "ssm-prefix: /afterhours-shift-manager/deploy" in deploy_api + assert "docker-platform: linux/arm64" in deploy_api + assert "ship-gate: true" in deploy_api assert "gh release create" in deploy_api + assert "needs.target.outputs.environment" not in deploy_api def test_in_repo_hcptf_roles(): @@ -116,10 +119,12 @@ def test_in_repo_hcptf_roles(): def test_ci_runs_pytest_and_terraform_validate(): assert "ci-python-sam" not in CI + assert "ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI + assert "ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI + assert "ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI + assert "name: ci-complete" in CI assert "pytest" in CI - assert "terraform fmt -check" in CI - assert "terraform init -backend=false" in CI - assert "terraform validate" in CI + assert "terraform fmt -check" not in CI assert "openapi:lint" in CI assert "npm ci" in CI @@ -138,7 +143,7 @@ def test_openapi_uses_redocly_recommended(): health = spec.split("/api/health:", 1)[1].split("\n /", 1)[0] assert '"403":' in health assert '"400":' not in health - assert 'root: openapi.yaml' in redocly + assert "root: openapi.yaml" in redocly assert '"openapi:lint"' in package assert '"@redocly/cli": "2.52.1"' in package assert "openapi: 3.1.0" in spec @@ -191,8 +196,8 @@ def test_github_deploy_trust_covers_image_only(): assert "environment:prod" in iam or "environment:prod" in LOCALS assert "environment:dev" in iam or "environment:dev" in LOCALS assert "deploy.yaml@" not 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-api.yaml@" not in iam + assert "Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*" in iam assert "ecs:ListTasks" in iam @@ -205,10 +210,10 @@ def test_plan_refresh_includes_provider6_s3_gets(): def test_origins_use_fargate_url(): outputs = (TERRAFORM / "outputs.tf").read_text() assert "aws_apigatewayv2_api.http" not in outputs - assert '${local.api_url}/slack/events' in outputs + assert "${local.api_url}/slack/events" in outputs assert "value = local.api_url" in outputs - assert "output \"vpc_id\"" in outputs - assert "output \"public_subnet_ids\"" in outputs + assert 'output "vpc_id"' in outputs + assert 'output "public_subnet_ids"' in outputs assert "aws_vpc.this.id" in outputs diff --git a/tests/roster_sync/test_handler.py b/tests/roster_sync/test_handler.py index 885acbb..b99acd2 100644 --- a/tests/roster_sync/test_handler.py +++ b/tests/roster_sync/test_handler.py @@ -60,9 +60,7 @@ def test_adds_removes_and_preserves_links( def test_renames_changed_member_preserving_link( rostersync_app, schedule, seed, env, fake_3cx ): - 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") fake_3cx([{"Number": "114", "MemberName": "Alicia", "Type": "Extension"}]) result = rostersync_app.handler({"force": True}, None) diff --git a/tests/scripts/test_copy_secrets.py b/tests/scripts/test_copy_secrets.py index ae96426..5fc3002 100644 --- a/tests/scripts/test_copy_secrets.py +++ b/tests/scripts/test_copy_secrets.py @@ -64,7 +64,9 @@ def test_execute_puts_into_empty_terraform_shells(): rc = mod.copy_secrets(src, dst, execute=True) assert rc == 0 assert [name for name, _ in dst.puts] == list(mod.COPY) - assert all(value.endswith("-value") and not value.endswith("\n") for _, value in dst.puts) + assert all( + value.endswith("-value") and not value.endswith("\n") for _, value in dst.puts + ) def test_skip_populated_copy_targets_and_never_write_3cx(): diff --git a/tests/scripts/test_recreate_holiday_schedules.py b/tests/scripts/test_recreate_holiday_schedules.py index be7885a..dd8b9c4 100644 --- a/tests/scripts/test_recreate_holiday_schedules.py +++ b/tests/scripts/test_recreate_holiday_schedules.py @@ -29,9 +29,9 @@ def test_schedule_when_parses_at_expression_in_eastern(): "ScheduleExpressionTimezone": "America/New_York", } when = mod.schedule_when(detail) - expected = datetime(2026, 7, 4, 8, 0, 0, tzinfo=ZoneInfo("America/New_York")).astimezone( - timezone.utc - ) + expected = datetime( + 2026, 7, 4, 8, 0, 0, tzinfo=ZoneInfo("America/New_York") + ).astimezone(timezone.utc) assert when == expected diff --git a/tests/shared/test_portal_ops.py b/tests/shared/test_portal_ops.py index 27966d9..58c8d67 100644 --- a/tests/shared/test_portal_ops.py +++ b/tests/shared/test_portal_ops.py @@ -33,9 +33,7 @@ def quiet_slack(monkeypatch): def _alice(schedule, seed): - 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.roster("115", "Bob", slack_user_id="U_BOB", email="bob@seahavenind.com") return schedule.get_employee_by_extension("114") @@ -143,10 +141,9 @@ def test_admin_open_repoints_active_shift_to_fallback( monkeypatch.setattr( effects, "maybe_repoint_today", - lambda date, shift_type, extension: calls.append( - (date, shift_type, extension) - ) - or True, + lambda date, shift_type, extension: ( + calls.append((date, shift_type, extension)) or True + ), ) with freezegun.freeze_time("2026-06-10 20:00:00-04:00"): result = admin_open(schedule, employee, "2026-06-10", "night") @@ -164,10 +161,9 @@ def test_admin_clear_repoints_active_shift_to_resolved_holder( monkeypatch.setattr( effects, "maybe_repoint_today", - lambda date, shift_type, extension: calls.append( - (date, shift_type, extension) - ) - or True, + lambda date, shift_type, extension: ( + calls.append((date, shift_type, extension)) or True + ), ) with freezegun.freeze_time("2026-06-10 20:00:00-04:00"): result = admin_clear(schedule, employee, "2026-06-10", "night") diff --git a/tests/shared/test_schedule.py b/tests/shared/test_schedule.py index d8a5955..cb5029f 100644 --- a/tests/shared/test_schedule.py +++ b/tests/shared/test_schedule.py @@ -226,7 +226,9 @@ class TestRoster: assert emp["email"] == "Alice@Seahavenind.com" def test_upsert_without_email_preserves_existing_email(self, schedule, seed): - seed.roster("114", "Alice", slack_user_id="U_OLD", email="alice@seahavenind.com") + seed.roster( + "114", "Alice", slack_user_id="U_OLD", email="alice@seahavenind.com" + ) schedule.upsert_roster_entry("114", "Alicia", "U_ALICE") emp = schedule.get_employee_by_extension("114") assert emp["email"] == "alice@seahavenind.com"