diff --git a/README.md b/README.md index 12c9c2d..d679592 100644 --- a/README.md +++ b/README.md @@ -58,16 +58,18 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules ## Architecture -- **Runtime**: Python 3.12 on ECS Fargate (arm64) plus dual-run Lambdas until cutover. seahaven-prod `011934824531`, seahaven-dev `710827005802` +- **Runtime**: Python 3.12 on ECS Fargate (arm64). Cutover completed 2026-09-21. seahaven-prod `011934824531`, seahaven-dev `710827005802` - **Data**: DynamoDB single-table (`afterhours-shifts`) - **IaC**: HCP Terraform workspaces tagged `app:afterhours-shift-manager` (`afterhours-shift-manager-dev` / `-prod`) plus GitHub Actions `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/*`) -### Lambda Functions +### Leftover zip handlers (packaging only) -| Function | Trigger | Purpose | +Fargate is the live path. These `src/*/app.py` zip sources remain for `scripts/package_lambdas.py` only. Leftover Lambda IAM roles were removed after Paychex dropped AfterhoursWeeklyPostSend (PLAT-218). + +| Handler | Former trigger | Purpose | |---|---|---| | `afterhours-shift-manager` | API Gateway (POST /slack/events) | Slack bot — handles `/oncall` commands and interactive buttons | | `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email | @@ -81,17 +83,17 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules ``` src/ 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 - portal-api/ Cognito employee/admin shift API for the internal portal - ring-scheduler/ 3CX queue routing updates - holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate) - shared/ Bundled into each function zip and the Fargate image -terraform/ HCP Terraform (Lambda skeletons, ECS/ALB, API, DDB, IAM, schedules) + slack-bot/ Slack Bolt app; leftover zip source for packaging; ships CHANGELOG.md for App Home + weekly-post/ Monday schedule + pay post (leftover zip source for packaging) + roster-sync/ Daily 3CX roster sync (leftover zip source for packaging) + roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard (leftover zip source for packaging) + portal-api/ Cognito employee/admin shift API for the internal portal (leftover zip source for packaging) + ring-scheduler/ 3CX queue routing updates (leftover zip source for packaging) + holiday-router/ 3CX IVR/queue repoint for holiday day shifts (leftover zip source for packaging) + shared/ Bundled into leftover function zips and the Fargate image +terraform/ HCP Terraform (ECS/ALB, API, DDB, IAM, leftover zip packaging locals, schedules) scripts/ in-package changelog copy sync + cutover -tests/ pytest suite (mirrors src/, one dir per Lambda + shared + server) +tests/ pytest suite (mirrors src/, one dir per leftover zip handler + shared + server) ``` ### DynamoDB Schema diff --git a/SETUP.md b/SETUP.md index fe2c402..9ce1805 100644 --- a/SETUP.md +++ b/SETUP.md @@ -115,9 +115,8 @@ and `ecs_schedules_enabled=false` until the Fargate cutover below. 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. +`ecs_task_role_arn`. Paychex checkcomponents allows `afterhours-shift-manager-api`; +leftover weekly-post principal is gone. ## 4. Set the Slack Request URL diff --git a/terraform/lambda.tf b/terraform/lambda.tf index c2202c0..cc1f46b 100644 --- a/terraform/lambda.tf +++ b/terraform/lambda.tf @@ -1,291 +1,3 @@ -# Leftover Lambda IAM roles from dual-run. The seven functions are gone; -# weekly-post remains so paychex-checkcomponents can keep that principal -# until a follow-up queue-policy apply drops it. - -data "aws_iam_policy_document" "lambda_assume" { - statement { - effect = "Allow" - actions = ["sts:AssumeRole"] - - principals { - type = "Service" - identifiers = ["lambda.amazonaws.com"] - } - } -} - -locals { - table_arn = aws_dynamodb_table.shifts.arn - - lambda_identity = { - slack_bot = [ - { - sid = "DdbCrud" - actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"] - resources = [local.table_arn, "${local.table_arn}/*"] - condition = null - }, - { - sid = "Secrets" - actions = ["secretsmanager:GetSecretValue"] - resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*"] - condition = null - }, - { - sid = "HolidaySchedules" - actions = ["scheduler:CreateSchedule", "scheduler:DeleteSchedule", "scheduler:GetSchedule"] - resources = ["arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*"] - condition = null - }, - { - sid = "PassHolidayScheduler" - actions = ["iam:PassRole"] - resources = [local.holiday_scheduler_role_arn] - condition = { - test = "StringEquals" - variable = "iam:PassedToService" - values = ["scheduler.amazonaws.com"] - } - }, - { - sid = "InvokeHolidayRouter" - actions = ["lambda:InvokeFunction"] - resources = [local.holiday_router_arn] - condition = null - }, - ] - weekly_post = concat( - [ - { - sid = "DdbCrud" - actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"] - resources = [local.table_arn, "${local.table_arn}/*"] - condition = null - }, - { - sid = "Secrets" - actions = ["secretsmanager:GetSecretValue"] - resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/slack-bot-token-*"] - condition = null - }, - ], - var.checkcomponents_queue_arn == "" ? [] : [ - { - sid = "CheckcomponentsSend" - actions = ["sqs:SendMessage"] - resources = [var.checkcomponents_queue_arn] - condition = null - }, - ], - ), - roster_sync = [ - { - sid = "DdbCrud" - actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"] - resources = [local.table_arn, "${local.table_arn}/*"] - condition = null - }, - { - sid = "Secrets" - actions = ["secretsmanager:GetSecretValue"] - resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/3cx-*"] - condition = null - }, - ] - roster_api = [ - { - sid = "DdbWrite" - actions = ["dynamodb:UpdateItem", "dynamodb:DeleteItem"] - resources = [local.table_arn] - condition = null - }, - { - sid = "Secrets" - actions = ["secretsmanager:GetSecretValue"] - resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/roster-api-token-*"] - condition = null - }, - ] - ring_scheduler = [ - { - sid = "DdbRead" - actions = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:DescribeTable"] - resources = [local.table_arn, "${local.table_arn}/*"] - condition = null - }, - { - sid = "Secrets" - actions = ["secretsmanager:GetSecretValue"] - resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/3cx-*"] - condition = null - }, - ] - holiday_router = [ - { - sid = "DdbCrud" - actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"] - resources = [local.table_arn, "${local.table_arn}/*"] - condition = null - }, - { - sid = "Secrets" - actions = ["secretsmanager:GetSecretValue"] - resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/3cx-*"] - condition = null - }, - ] - portal_api = [ - { - sid = "DdbCrud" - actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"] - resources = [local.table_arn, "${local.table_arn}/*"] - condition = null - }, - { - sid = "Secrets" - actions = ["secretsmanager:GetSecretValue"] - resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*"] - condition = null - }, - { - sid = "HolidaySchedules" - actions = ["scheduler:CreateSchedule", "scheduler:DeleteSchedule", "scheduler:GetSchedule"] - resources = ["arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*"] - condition = null - }, - { - sid = "PassHolidayScheduler" - actions = ["iam:PassRole"] - resources = [local.holiday_scheduler_role_arn] - condition = { - test = "StringEquals" - variable = "iam:PassedToService" - values = ["scheduler.amazonaws.com"] - } - }, - { - sid = "InvokeHolidayRouter" - actions = ["lambda:InvokeFunction"] - resources = [local.holiday_router_arn] - condition = null - }, - ] - } - - lambda_env = { - slack_bot = { - SHIFT_TABLE = aws_dynamodb_table.shifts.name - SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token" - SLACK_SIGNING_SECRET = "afterhours-shift-manager/slack-signing-secret" - SHIFT_CHANNEL = var.shift_channel - TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-" - QUEUE_NUMBER = var.queue_number - TZ = var.timezone - HOLIDAY_ROUTER_ARN = local.holiday_router_arn - HOLIDAY_SCHEDULER_ROLE_ARN = local.holiday_scheduler_role_arn - SENTRY_DSN = var.sentry_dsn - } - weekly_post = { - SHIFT_TABLE = aws_dynamodb_table.shifts.name - SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token" - SHIFT_CHANNEL = var.shift_channel - PAY_REPORT_USER = var.pay_report_user - TZ = var.timezone - CHECKCOMPONENTS_QUEUE_URL = var.checkcomponents_queue_url - SENTRY_DSN = var.sentry_dsn - } - roster_sync = { - SHIFT_TABLE = aws_dynamodb_table.shifts.name - TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-" - SYNC_GROUP = "DEFAULT" - TZ = var.timezone - SENTRY_DSN = var.sentry_dsn - } - roster_api = { - SHIFT_TABLE = aws_dynamodb_table.shifts.name - ROSTER_API_TOKEN_SECRET = "afterhours-shift-manager/roster-api-token" - TZ = var.timezone - SENTRY_DSN = var.sentry_dsn - } - ring_scheduler = { - SHIFT_TABLE = aws_dynamodb_table.shifts.name - TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-" - QUEUE_NUMBER = var.queue_number - TZ = var.timezone - SENTRY_DSN = var.sentry_dsn - } - holiday_router = { - SHIFT_TABLE = aws_dynamodb_table.shifts.name - TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-" - TZ = var.timezone - SENTRY_DSN = var.sentry_dsn - } - portal_api = { - SHIFT_TABLE = aws_dynamodb_table.shifts.name - SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token" - SHIFT_CHANNEL = var.shift_channel - TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-" - QUEUE_NUMBER = var.queue_number - TZ = var.timezone - HOLIDAY_ROUTER_ARN = local.holiday_router_arn - HOLIDAY_SCHEDULER_ROLE_ARN = local.holiday_scheduler_role_arn - SENTRY_DSN = var.sentry_dsn - PORTAL_COGNITO_ISSUER = var.portal_cognito_issuer - PORTAL_COGNITO_AUDIENCE = var.portal_cognito_audience - PORTAL_COGNITO_TRUST = 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, - )) - } - } -} - -resource "aws_iam_role" "lambda" { - for_each = local.functions - - name = each.value.role_name - path = "/tf-managed/" - description = "Lambda execution role for ${each.value.function_name}" - assume_role_policy = data.aws_iam_policy_document.lambda_assume.json - permissions_boundary = aws_iam_policy.lambda_boundary.arn -} - -data "aws_iam_policy_document" "lambda" { - for_each = local.functions - - dynamic "statement" { - for_each = local.lambda_identity[each.key] - - content { - sid = statement.value.sid - effect = "Allow" - actions = statement.value.actions - resources = statement.value.resources - - dynamic "condition" { - for_each = try(statement.value.condition, null) == null ? [] : [statement.value.condition] - - content { - test = condition.value.test - variable = condition.value.variable - values = condition.value.values - } - } - } - } -} - -resource "aws_iam_role_policy" "lambda" { - for_each = local.functions - - name = each.key - role = aws_iam_role.lambda[each.key].id - policy = data.aws_iam_policy_document.lambda[each.key].json -} - -resource "aws_iam_role_policy_attachment" "lambda_basic" { - for_each = local.functions - - role = aws_iam_role.lambda[each.key].name - policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" -} +# Leftover Lambda execution roles were removed after Paychex dropped +# AfterhoursWeeklyPostSend (PLAT-218). Zip handlers in src/*/app.py remain for +# packaging via scripts/package_lambdas.py. diff --git a/terraform/outputs.tf b/terraform/outputs.tf index 6e5ecca..20523e0 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -44,7 +44,7 @@ output "jobs_queue_arn" { } output "ecs_task_role_arn" { - description = "ECS task role. paychex-checkcomponents queue policy must allow this ARN before Fargate weekly_post." + description = "ECS task role. Paychex already allows this ARN." value = aws_iam_role.ecs_task.arn } diff --git a/tests/infra/test_hcp_contract.py b/tests/infra/test_hcp_contract.py index c622fab..0ec1a48 100644 --- a/tests/infra/test_hcp_contract.py +++ b/tests/infra/test_hcp_contract.py @@ -4,13 +4,16 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] TERRAFORM = ROOT / "terraform" -LAMBDA_TF = (TERRAFORM / "lambda.tf").read_text() HCP_IAM = (TERRAFORM / "hcp_iam.tf").read_text() CI = (ROOT / ".github" / "workflows" / "ci.yaml").read_text() LOCALS = (TERRAFORM / "locals.tf").read_text() VARIABLES = (TERRAFORM / "variables.tf").read_text() +def _tf_without_comments(text: str) -> str: + return "\n".join(line.split("#", 1)[0] for line in text.splitlines()) + + def test_sam_template_removed(): assert not (ROOT / "template.yaml").exists() assert not (ROOT / "samconfig.toml.example").exists() @@ -18,7 +21,8 @@ def test_sam_template_removed(): def test_zip_cd_removed(): assert not (ROOT / ".github" / "workflows" / "deploy.yaml").exists() - assert 'resource "aws_lambda_function"' not in LAMBDA_TF + for path in TERRAFORM.glob("*.tf"): + assert 'resource "aws_lambda_function"' not in path.read_text() assert not (TERRAFORM / "apigateway.tf").exists() assert not (TERRAFORM / "events.tf").exists() @@ -120,7 +124,8 @@ def test_ci_runs_pytest_and_terraform_validate(): def test_checkcomponents_queue_arn_variable_matches_iam_references(): assert 'variable "checkcomponents_queue_arn"' in VARIABLES - assert "var.checkcomponents_queue_arn" in LAMBDA_TF + iam = (TERRAFORM / "iam.tf").read_text() + assert "var.checkcomponents_queue_arn" in iam boundary = (TERRAFORM / "lambda_boundary.tf").read_text() assert "var.checkcomponents_queue_arn" in boundary data_tf = (TERRAFORM / "data.tf").read_text() @@ -128,7 +133,22 @@ def test_checkcomponents_queue_arn_variable_matches_iam_references(): assert "checkcomponents_pair" in data_tf -def test_seven_function_names_still_on_leftover_roles(): +def test_leftover_lambda_iam_roles_removed(): + for path in TERRAFORM.glob("*.tf"): + body = _tf_without_comments(path.read_text()) + assert 'resource "aws_iam_role" "lambda"' not in body + assert 'resource "aws_iam_role_policy" "lambda"' not in body + assert 'resource "aws_iam_role_policy_attachment" "lambda_basic"' not in body + assert 'data "aws_iam_policy_document" "lambda_assume"' not in body + + +def test_lambda_boundary_policy_remains(): + boundary = (TERRAFORM / "lambda_boundary.tf").read_text() + assert 'resource "aws_iam_policy" "lambda_boundary"' in boundary + assert "afterhours-shift-manager-lambda-boundary" in boundary + + +def test_local_functions_still_lists_packaging_keys(): for name in ( "afterhours-shift-manager", "afterhours-weekly-post", @@ -140,10 +160,7 @@ def test_seven_function_names_still_on_leftover_roles(): ): assert name in LOCALS assert "afterhours-release-notifier" not in LOCALS - - -def test_weekly_post_role_is_tf_managed_name(): - assert 'role_name = "afterhours-shift-manager-weekly-post"' in LOCALS + assert "weekly_post" in LOCALS def test_github_deploy_trust_covers_image_only():