From 1ad265b1778990d5ff0fdef043b20cfb9052cd6d Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 5 Aug 2026 18:38:12 -0400 Subject: [PATCH] feat(terraform): migrate front-integrations to HCP Terraform Freeze SAM CD and add Terraform for seahaven-prod (Lambdas, DynamoDB, EventBridge, alarms) so HCP becomes the sole deploy path. Include prod secret ARNs in the tfvars example for workspace wiring. --- .github/dependabot.yml | 11 +++ .github/workflows/ci-terraform.yaml | 35 +++++++ .github/workflows/ci.yaml | 2 + .github/workflows/deploy.yaml | 22 ----- .gitignore | 4 + README.md | 122 ++++++----------------- terraform/.terraform.lock.hcl | 74 ++++++++++++++ terraform/alarms.tf | 143 +++++++++++++++++++++++++++ terraform/artifacts.tf | 53 ++++++++++ terraform/build_packages.sh | 29 ++++++ terraform/build_packages_external.sh | 16 +++ terraform/dynamodb.tf | 15 +++ terraform/events.tf | 41 ++++++++ terraform/iam.tf | 53 ++++++++++ terraform/lambda.tf | 60 +++++++++++ terraform/locals.tf | 77 +++++++++++++++ terraform/outputs.tf | 19 ++++ terraform/providers.tf | 11 +++ terraform/terraform.tfvars.example | 7 ++ terraform/variables.tf | 91 +++++++++++++++++ terraform/versions.tf | 26 +++++ 21 files changed, 799 insertions(+), 112 deletions(-) create mode 100644 .github/workflows/ci-terraform.yaml delete mode 100644 .github/workflows/deploy.yaml create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/alarms.tf create mode 100644 terraform/artifacts.tf create mode 100755 terraform/build_packages.sh create mode 100755 terraform/build_packages_external.sh create mode 100644 terraform/dynamodb.tf create mode 100644 terraform/events.tf create mode 100644 terraform/iam.tf create mode 100644 terraform/lambda.tf create mode 100644 terraform/locals.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/providers.tf create mode 100644 terraform/terraform.tfvars.example create mode 100644 terraform/variables.tf create mode 100644 terraform/versions.tf diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 34f3e5f..5422cfd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -33,3 +33,14 @@ updates: update-types: - "minor" - "patch" + - package-ecosystem: "terraform" + directory: "/terraform" + schedule: + interval: "weekly" + commit-message: + prefix: "chore(deps)" + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" diff --git a/.github/workflows/ci-terraform.yaml b/.github/workflows/ci-terraform.yaml new file mode 100644 index 0000000..f747330 --- /dev/null +++ b/.github/workflows/ci-terraform.yaml @@ -0,0 +1,35 @@ +name: Terraform CI +on: + pull_request: + branches: [main] + paths: + - "terraform/**" + - ".github/workflows/ci-terraform.yaml" + +permissions: + contents: read + +jobs: + terraform: + runs-on: ubuntu-latest + defaults: + run: + working-directory: terraform + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.9.8" + + - name: Package Lambda zips + run: ./build_packages.sh + + - name: Terraform fmt + run: terraform fmt -check -recursive + + - name: Terraform init + run: terraform init -backend=false + + - name: Terraform validate + run: terraform validate diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0e1f041..113a557 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,3 +11,5 @@ jobs: uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@3f746774229d41770727e2e4fd63ed5f5555a8b3 # v1.0.3 with: source-dirs: "src" + # Deploy path is HCP Terraform; reusable name still covers Python lint/tests. + run-sam-validate: false diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml deleted file mode 100644 index eb7c8ad..0000000 --- a/.github/workflows/deploy.yaml +++ /dev/null @@ -1,22 +0,0 @@ -name: Deploy -on: - push: - branches: [main] - -permissions: - id-token: write - contents: read - -concurrency: - group: deploy - cancel-in-progress: false - -jobs: - deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@3f746774229d41770727e2e4fd63ed5f5555a8b3 # v1.0.3 - with: - stack-name: front-integrations - cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role - secrets: - deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} - parameter-overrides: ${{ secrets.SAM_PARAMETER_OVERRIDES }} diff --git a/.gitignore b/.gitignore index bbb6e3b..0e23ec7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ __pycache__/ *.pyc .env samconfig.toml +terraform/.terraform/ +terraform/build/ +terraform/*.tfvars +!terraform/terraform.tfvars.example diff --git a/README.md b/README.md index 5b3e6cb..eb69851 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # front-integrations ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) -![AWS SAM](https://img.shields.io/badge/AWS-SAM-FF9900?logo=amazonaws&logoColor=white) +![Terraform](https://img.shields.io/badge/Terraform-844FBA?logo=terraform&logoColor=white) ![Slack](https://img.shields.io/badge/Slack-integration-4A154B?logo=slack&logoColor=white) ![CI](https://github.com/Sea-Haven-Industries/front-integrations/actions/workflows/ci.yaml/badge.svg) -Front platform integrations for Sea Haven Industries. Two scheduled Lambdas: +Front platform integrations for Sea Haven Industries. Two scheduled Lambdas deployed to **seahaven-prod** via HCP Terraform workspace `front-integrations-prod` (PLAT-72). 1. **SLA Monitor** — checks Front conversations for SLA breaches and sends tiered Slack alerts 2. **User Sync** — pulls Google Workspace user profiles and syncs job title + phone to Front teammate custom fields @@ -56,8 +56,10 @@ Business time counts weekday hours only (Mon-Fri, Eastern time). Alerts are only ``` front-integrations/ -├── template.yaml # AWS SAM template — all infrastructure (see below) -├── samconfig.toml.example # Deploy config template (copy to samconfig.toml, gitignored) +├── terraform/ # HCP Terraform config (sole deploy path) +│ ├── build_packages.sh # Pip-install user_sync deps for arm64 Lambda zips +│ └── *.tf +├── template.yaml # Legacy SAM template (retained until post-cutover hygiene) ├── slack-app-manifest.yaml # Slack app manifest for the SLA Monitor bot └── src/ ├── sla_monitor/ # front-sla-monitor Lambda @@ -70,70 +72,18 @@ front-integrations/ ## AWS Resources -- **Stack:** `front-integrations` (SAM, us-east-1) +- **Account / region:** seahaven-prod `011934824531`, `us-east-1` +- **IaC:** Terraform under `terraform/` (HCP remote apply, Manual until sealed) - **Lambda:** `front-sla-monitor` — Python 3.12, arm64, 256 MB, 300s timeout, 60-day log retention - **Lambda:** `front-user-sync` — Python 3.12, arm64, 256 MB, 300s timeout, 60-day log retention - **DynamoDB:** `front-sla-alerts` — alert history per conversation + monitor state, 7-day TTL - **EventBridge:** SLA check every 15 min during business hours; user sync daily at 6 AM ET weekdays - -## Infrastructure (`template.yaml`) - -All of the resources above are declared as code in a single AWS SAM template, -[`template.yaml`](template.yaml) at the repository root (`Transform: AWS::Serverless-2016-10-31`). -It is the only source of infrastructure truth — there are no console-created or -CDK resources in this stack. `sam build` / `sam deploy` render it to a -CloudFormation stack named `front-integrations` in `us-east-1`. - -### Global defaults - -`Globals.Function` applies to every Lambda unless overridden per-function: - -- Runtime `python3.12`, architecture `arm64` -- 256 MB memory, 300s timeout -- Permissions boundary `arn:aws:iam::328440206208:policy/seahaven-lambda-execution-boundary` - -### Resources declared - -| Logical ID | Type | Notes | -|---|---|---| -| `SlaMonitorFunction` | `AWS::Serverless::Function` | `front-sla-monitor`; `Schedule` event `cron(0/15 12-22 ? * MON-FRI *)` (UTC) | -| `UserSyncFunction` | `AWS::Serverless::Function` | `front-user-sync`; `Schedule` event `cron(0 11 ? * MON-FRI *)` (UTC) | -| `AlertsTable` | `AWS::DynamoDB::Table` | `front-sla-alerts`; PAY_PER_REQUEST, `conversationId` hash key, `ttl` TTL attribute | -| `SlaMonitorLogGroup`, `UserSyncLogGroup` | `AWS::Logs::LogGroup` | 60-day retention; each function `DependsOn` its group | -| 8 alarms | `AWS::CloudWatch::Alarm` | 3 per Lambda + 2 on the table — see [Monitoring](#monitoring) | - -Each function carries a least-privilege inline IAM policy: `secretsmanager:GetSecretValue` -scoped to only the secret ARNs it reads. `SlaMonitorFunction` additionally gets a -`DynamoDBCrudPolicy` scoped to `AlertsTable`. - -### Parameters - -Secret ARNs and the alert channel are passed in at deploy time (via `parameter_overrides` -in `samconfig.toml` — see [`samconfig.toml.example`](samconfig.toml.example) — or the -`SAM_PARAMETER_OVERRIDES` Actions secret). They have no defaults and must be supplied: - -| Parameter | Description | -|---|---| -| `FrontApiTokenSecretArn` | ARN of the Front API token secret | -| `SlackBotTokenSecretArn` | ARN of the Slack bot token secret | -| `GoogleServiceAccountSecretArn` | ARN of the Google service account key secret | -| `SlackAlertChannel` | Slack channel ID for `#front-sla-alerts` | - -The remaining parameters (`AckSlaMinutes`, `ActionSlaMinutes`, `MonitorInboxes`, -`GoogleOrgUnits`, etc.) tune behavior and ship with sensible defaults — see -[Configuration](#configuration). - -### Outputs - -| Output | Value | -|---|---| -| `SlaMonitorFunctionArn` | `front-sla-monitor` Lambda ARN | -| `UserSyncFunctionArn` | `front-user-sync` Lambda ARN | -| `AlertsTableName` | `front-sla-alerts` table name | +- **IAM:** Execution roles under path `/tf-managed/` with `seahaven-lambda-execution-boundary` +- **Secrets:** ARNs as Terraform variables; values never in state ## Monitoring -CloudWatch alarms publish to the shared `site-alerts` SNS topic (`arn:aws:sns:us-east-1:328440206208:site-alerts`). All alarms are ALARM-only (no OK/recovery notification) and treat missing data as `notBreaching`. +CloudWatch alarms publish to the shared `site-alerts` SNS topic in seahaven-prod. All alarms are ALARM-only (no OK/recovery notification) and treat missing data as `notBreaching`. | Alarm | Metric | Trigger | |---|---|---| @@ -187,64 +137,56 @@ The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This pr 1. Settings > Custom Fields > Teammates tab 2. Create: **Job Title** (String) and **Phone** (String) -### 5. Store Secrets in AWS +### 5. Store Secrets in seahaven-prod + +Create secrets in seahaven-prod (exact ARNs are wired into the Lambda boundary and Terraform variables). Strip trailing newlines before `put-secret-value`. ```bash aws secretsmanager create-secret \ --name "front-integrations/front-api-token" \ --secret-string "YOUR_FRONT_API_TOKEN" \ - --region us-east-1 + --region us-east-1 --profile seahaven-prod aws secretsmanager create-secret \ --name "front-integrations/slack-bot-token" \ --secret-string "xoxb-YOUR-SLACK-BOT-TOKEN" \ - --region us-east-1 + --region us-east-1 --profile seahaven-prod aws secretsmanager create-secret \ --name "front-integrations/google-service-account" \ --secret-string file://path-to-service-account-key.json \ - --region us-east-1 + --region us-east-1 --profile seahaven-prod ``` ### 6. Deploy -```bash -sam build -sam deploy --guided -``` - -Or push to `main` to trigger the GitHub Actions deploy workflow. - -### 7. GitHub Actions Secrets - -| Secret | Value | -|---|---| -| `AWS_DEPLOY_ROLE_ARN` | Org-wide OIDC deploy role (set after OIDC role is added) | -| `SAM_PARAMETER_OVERRIDES` | `FrontApiTokenSecretArn=arn:... SlackBotTokenSecretArn=arn:... GoogleServiceAccountSecretArn=arn:... SlackAlertChannel=CXXXXXXXXXX` | +1. Set workspace variables in HCP (`front-integrations-prod`) from `terraform/terraform.tfvars.example`. +2. Keep `schedules_enabled=false` until DynamoDB row copy and cutover. +3. Apply from the HCP workspace (Manual apply until the stack is sealed). Do not use local `terraform apply` against prod. ## Manual Testing ```bash -aws lambda invoke --function-name front-sla-monitor --payload '{}' /dev/stdout --region us-east-1 -aws lambda invoke --function-name front-user-sync --payload '{}' /dev/stdout --region us-east-1 +aws lambda invoke --function-name front-sla-monitor --payload '{}' /dev/stdout --region us-east-1 --profile seahaven-prod +aws lambda invoke --function-name front-user-sync --payload '{}' /dev/stdout --region us-east-1 --profile seahaven-prod ``` ## Configuration ### SLA Monitor -| Parameter | Default | Description | +| Variable | Default | Description | |---|---|---| -| `AckSlaMinutes` | 60 | Business minutes before Tier 1 alert | -| `ActionSlaMinutes` | 1440 | Business minutes before Tier 2 alert | -| `AdamEmail` | adam@seahavenind.com | Tier 2 escalation recipient | -| `SlackAlertChannel` | — | Channel ID for broadcast alerts | -| `MonitorInboxes` | Triage,California,... | Inbox names to monitor (empty = all shared) | -| `SlaMonitorStartDate` | 2026-05-14 | Date monitoring begins | +| `ack_sla_minutes` | 60 | Business minutes before Tier 1 alert | +| `action_sla_minutes` | 1440 | Business minutes before Tier 2 alert | +| `adam_email` | adam@seahavenind.com | Tier 2 escalation recipient | +| `slack_alert_channel` | — | Channel ID for broadcast alerts | +| `monitor_inboxes` | Triage,California,... | Inbox names to monitor (empty = all shared) | +| `sla_monitor_start_date` | 2026-05-14 | Date monitoring begins | ### User Sync -| Parameter | Default | Description | +| Variable | Default | Description | |---|---|---| -| `GoogleAdminEmail` | adam@seahavenind.com | Google Workspace admin to impersonate | -| `GoogleOrgUnits` | /Office/Scheduling,/Office/Operations | Org unit paths to sync | +| `google_admin_email` | adam@seahavenind.com | Google Workspace admin to impersonate | +| `google_org_units` | /Office/Scheduling,/Office/Operations | Org unit paths to sync | diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..7e949f1 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,74 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.8.0" + constraints = "~> 2.0" + hashes = [ + "h1:WB6H5ksIZiyq1lQlD/PWeh+tn4FLsbSjVnRW3+4xe2Y=", + "h1:cMBtvdHEgvTmglbioVehZCaOIPocumu9+vlGw5Dsaro=", + "h1:jdmKm+xl6ZcQrijxapnZ94RVuz/G4vk7hsIa1N0VT5Q=", + "h1:oRWx6ZDIdlNBF90L8TzV9X7pQ+P403hoCDiBfmUfhC0=", + "zh:0d14713fdc259fb377d0b899ad3c650a34194bd52194c863303ef22a65a580e2", + "zh:369b56040c7a8085d04e7e8ffac1e2b321a3170e502f788819bc34b868ec016f", + "zh:4d1a3b983ed6af5a52bfe12794674ae55cbadfa6021b37106ade68b433ad216a", + "zh:5c547549e26e083573c78a966ca68ce6d7df6bb8f3948f66a575f07da46b74ea", + "zh:6de093e62a975eb19a5e3017ce38e6e3cb639c17b79648d2000e0a8348f0e997", + "zh:7267936c2cdbc448efeb594d73e6b56a53d6a7ae14fe88cdd2a4133adc3302f0", + "zh:7482f023050ed426b4b45116e1761643bc33b1fd4ce4a6fab207ae2571f35940", + "zh:76bbd93b234e5a2927d98b511d86565700f549b570871a194c35f944b96cefb7", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:c6afc4bc1f002bac9c173007dd4da05fde788cd14c2916089f958c33fedb0dfa", + "zh:d3ba40bd806a3a08e9237dece679193c99afb2085de6b45d7f5d1f673cfcd368", + "zh:e1ad7ded53ecd6f0e5b473a3b44eae2b2e885653a56050ab583d387332be02e4", + "zh:e93e78575ce82be6084cc153c24ba8f385dc8d6880888ee66e918460c870953d", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.58.0" + constraints = "~> 6.57" + hashes = [ + "h1:1im6ypdeXLXq3sHElserTE62qmIqNiHLAyC3R5EnFFw=", + "h1:2kpake4zZKRX5437QVIRU3qFYH6Bjw/QE1fgVCPOrUg=", + "h1:OWl47Bo8Vzlf5srTUCmA6v4kvQGfah/P1joRtIYUUMc=", + "h1:UFot9S97tuAPvjKvoxm08sDG/gKYdDK+lMwsZKtLieY=", + "zh:1221253beee5629fb503d79cebc9bc661279cbc4be5d01db9ab4c1b702108250", + "zh:132bd0925bdc4b72446ac750b7ccb1e19b9ba8fbb6df57b2c1423314d2195d4f", + "zh:18cda250b9e82b753808715893c8927f132273c00ffae7a697d65ac1cb577e48", + "zh:204c944f1fb7f440a335bb2083c9691a9d1f677aea9701025dd5816aee41f0ba", + "zh:2dc41df289f2b10a01e650cdd73699955f0ab0645d09cfb114a8cd0f4cc4ede7", + "zh:345633dfa9a234659d52aadd126e6dce658518c3ab5cbf6d871221287ed5ec56", + "zh:4dadcced73e742903158bc9838936d911f3fa4c2c37b5591c1a28f8f2a1902a6", + "zh:5bc60cc2b8c093da98b211d9f6c21c9ecec0f21b944d9ecbe3961fba33086e80", + "zh:6cc8f084938b0033a9c0c910989919dad6b1683e76e0afa1a5c604e39f398a75", + "zh:7db214647f79de9a033b5dfd6cbfaa42d53c4d056b32cb3acc7ad99306dd548a", + "zh:9078589ec881cee7ed9403af262c98ff257fb3e1baae72ff6429a398b1c730af", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:bd5bce6aec4d4922b1127b8575688bd4bc4279670ee28d198ede404709826c7c", + "zh:cd900ecf56d21023873898b06e40234f3f4d350f2343b7d9b980d6c5cb604fae", + "zh:dbe93b276a84421026b956c3c5b4eb8897da6cbb54b93cb89f5d6ebbd30805ca", + "zh:f6b6c7bb2dbf04ee085e5c22f7a65b3ccaebf368ed95584dc0dfee8a22771056", + ] +} + +provider "registry.terraform.io/hashicorp/external" { + version = "2.4.0" + constraints = "~> 2.0" + hashes = [ + "h1:AmY6ZeIvqoTT5ZjzD+P49PeQH6Va1QLMkX+7MUQfYoA=", + "zh:0772afb42b658468ac5e15df33bf2080456f8f0b8ab163bfe9c50d2b2ea02135", + "zh:0ac31a9aaa43dfcff5944b791596cdc94e153348e4bb4642282d034dff548134", + "zh:32d8492b1bdcc956ca3c6d00c6392d0a83942ff11d4820c7ee63ca6796e06950", + "zh:3c0482e894429f528ce6655a76ab0d8a9f7c0dacc6c828865e1515d4a7dbb852", + "zh:61e68100b4db2f930b31491f23c602126382fd5e51252be1b551f0e17f8ddbee", + "zh:6d60f615a0ad85eb962c9eb94f25e3eba7a72684ce276ba5dfb23f36b295a8f8", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:9ced2745eb5f1346203027d2dd7bf856ad1d279a25730ff7dbc6eec187aaca0c", + "zh:a8378558a177d43f55aa0d79d4fae91a704695122a1b109668c1daa8fb76f09d", + "zh:aadd98086133d3ebea67437d56512fdcc6dfb3bd34dfc23f276c0db9272e27b4", + "zh:beff701b653841e70441978137768f54e7dc6c27e7bf12a4589087f01f5bbcee", + "zh:c91c2223b29fdbc0044d20e1936ccc051d010727a13f2ff1e75e51f09bff33a3", + "zh:d491f9c2d32a39dc4031628469ae7c8aec0074312a7c1f0286b173cdcf854a54", + ] +} diff --git a/terraform/alarms.tf b/terraform/alarms.tf new file mode 100644 index 0000000..d778613 --- /dev/null +++ b/terraform/alarms.tf @@ -0,0 +1,143 @@ +resource "aws_cloudwatch_metric_alarm" "sla_monitor_errors" { + alarm_name = "front-sla-monitor-errors" + alarm_description = "front-sla-monitor invocation errors" + namespace = "AWS/Lambda" + metric_name = "Errors" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [var.alarm_sns_topic_arn] + + dimensions = { + FunctionName = aws_lambda_function.sla_monitor.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "sla_monitor_throttles" { + alarm_name = "front-sla-monitor-throttles" + alarm_description = "front-sla-monitor invocations throttled" + namespace = "AWS/Lambda" + metric_name = "Throttles" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [var.alarm_sns_topic_arn] + + dimensions = { + FunctionName = aws_lambda_function.sla_monitor.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "sla_monitor_duration" { + alarm_name = "front-sla-monitor-duration" + alarm_description = "front-sla-monitor approaching its 300s timeout (>90%)" + namespace = "AWS/Lambda" + metric_name = "Duration" + statistic = "Maximum" + period = 300 + evaluation_periods = 1 + threshold = 270000 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [var.alarm_sns_topic_arn] + + dimensions = { + FunctionName = aws_lambda_function.sla_monitor.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "user_sync_errors" { + alarm_name = "front-user-sync-errors" + alarm_description = "front-user-sync invocation errors" + namespace = "AWS/Lambda" + metric_name = "Errors" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [var.alarm_sns_topic_arn] + + dimensions = { + FunctionName = aws_lambda_function.user_sync.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "user_sync_throttles" { + alarm_name = "front-user-sync-throttles" + alarm_description = "front-user-sync invocations throttled" + namespace = "AWS/Lambda" + metric_name = "Throttles" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [var.alarm_sns_topic_arn] + + dimensions = { + FunctionName = aws_lambda_function.user_sync.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "user_sync_duration" { + alarm_name = "front-user-sync-duration" + alarm_description = "front-user-sync approaching its 300s timeout (>90%)" + namespace = "AWS/Lambda" + metric_name = "Duration" + statistic = "Maximum" + period = 300 + evaluation_periods = 1 + threshold = 270000 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [var.alarm_sns_topic_arn] + + dimensions = { + FunctionName = aws_lambda_function.user_sync.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "alerts_read_throttle" { + alarm_name = "front-sla-alerts-read-throttle" + alarm_description = "front-sla-alerts DynamoDB table had one or more read throttle events" + namespace = "AWS/DynamoDB" + metric_name = "ReadThrottleEvents" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [var.alarm_sns_topic_arn] + + dimensions = { + TableName = aws_dynamodb_table.alerts.name + } +} + +resource "aws_cloudwatch_metric_alarm" "alerts_write_throttle" { + alarm_name = "front-sla-alerts-write-throttle" + alarm_description = "front-sla-alerts DynamoDB table had one or more write throttle events" + namespace = "AWS/DynamoDB" + metric_name = "WriteThrottleEvents" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [var.alarm_sns_topic_arn] + + dimensions = { + TableName = aws_dynamodb_table.alerts.name + } +} diff --git a/terraform/artifacts.tf b/terraform/artifacts.tf new file mode 100644 index 0000000..0197ec1 --- /dev/null +++ b/terraform/artifacts.tf @@ -0,0 +1,53 @@ +# HCP plan and apply run on separate workers. archive_file paths from plan are +# not on the apply worker, so zip bytes are carried in the plan via +# content_base64 and uploaded to S3 at apply time for Lambda to consume. +# +# Package build runs during plan via external data (local-exec provisioners +# only run on apply; archive_file needs build/ present at plan time). + +data "external" "package_build" { + program = ["bash", "${path.module}/build_packages_external.sh"] +} + +resource "aws_s3_bucket" "artifacts" { + bucket = "front-integrations-artifacts-${local.account_id}" +} + +resource "aws_s3_bucket_public_access_block" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +data "archive_file" "sla_monitor" { + type = "zip" + source_dir = "${path.module}/build/sla_monitor" + output_path = "${path.module}/build/front-sla-monitor.zip" + + depends_on = [data.external.package_build] +} + +data "archive_file" "user_sync" { + type = "zip" + source_dir = "${path.module}/build/user_sync" + output_path = "${path.module}/build/front-user-sync.zip" + + depends_on = [data.external.package_build] +} + +resource "aws_s3_object" "sla_monitor" { + bucket = aws_s3_bucket.artifacts.id + key = "front-sla-monitor.zip" + content_base64 = filebase64(data.archive_file.sla_monitor.output_path) + source_hash = data.archive_file.sla_monitor.output_base64sha256 +} + +resource "aws_s3_object" "user_sync" { + bucket = aws_s3_bucket.artifacts.id + key = "front-user-sync.zip" + content_base64 = filebase64(data.archive_file.user_sync.output_path) + source_hash = data.archive_file.user_sync.output_base64sha256 +} diff --git a/terraform/build_packages.sh b/terraform/build_packages.sh new file mode 100755 index 0000000..9e28f27 --- /dev/null +++ b/terraform/build_packages.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Package Lambda zips for HCP plan/apply. Runs on the Terraform worker. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +BUILD="${ROOT}/build" +SRC="$(cd "${ROOT}/../src" && pwd)" + +rm -rf "${BUILD}" +mkdir -p "${BUILD}/sla_monitor" "${BUILD}/user_sync" + +cp "${SRC}/sla_monitor/app.py" "${BUILD}/sla_monitor/app.py" + +cp "${SRC}/user_sync/app.py" "${BUILD}/user_sync/app.py" +python3 -m pip install \ + --quiet \ + --disable-pip-version-check \ + -r "${SRC}/user_sync/requirements.txt" \ + -t "${BUILD}/user_sync/" \ + --platform manylinux2014_aarch64 \ + --implementation cp \ + --python-version 3.12 \ + --only-binary=:all: \ + --upgrade + +# Runtime provides boto3; drop the copy to keep the zip smaller. +rm -rf "${BUILD}/user_sync/boto3" "${BUILD}/user_sync/botocore" \ + "${BUILD}/user_sync/s3transfer" "${BUILD}/user_sync/jmespath" \ + "${BUILD}/user_sync/"*.dist-info 2>/dev/null || true diff --git a/terraform/build_packages_external.sh b/terraform/build_packages_external.sh new file mode 100755 index 0000000..e5a6951 --- /dev/null +++ b/terraform/build_packages_external.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Terraform external data source entrypoint. Stdout must be JSON only. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +"${ROOT}/build_packages.sh" >&2 + +hash="$( + { + find "${ROOT}/build/sla_monitor" "${ROOT}/build/user_sync" -type f -print0 2>/dev/null \ + | sort -z \ + | xargs -0 sha256sum + } | sha256sum | awk '{print $1}' +)" + +printf '{"status":"ok","hash":"%s"}\n' "${hash}" diff --git a/terraform/dynamodb.tf b/terraform/dynamodb.tf new file mode 100644 index 0000000..f3a81f2 --- /dev/null +++ b/terraform/dynamodb.tf @@ -0,0 +1,15 @@ +resource "aws_dynamodb_table" "alerts" { + name = "front-sla-alerts" + billing_mode = "PAY_PER_REQUEST" + hash_key = "conversationId" + + attribute { + name = "conversationId" + type = "S" + } + + ttl { + attribute_name = "ttl" + enabled = true + } +} diff --git a/terraform/events.tf b/terraform/events.tf new file mode 100644 index 0000000..0e8d9fc --- /dev/null +++ b/terraform/events.tf @@ -0,0 +1,41 @@ +resource "aws_cloudwatch_event_rule" "sla_monitor" { + name = "front-sla-monitor" + description = "Check Front conversations for SLA breaches every 15 min during business hours" + schedule_expression = var.sla_monitor_schedule + state = local.schedule_state +} + +resource "aws_cloudwatch_event_target" "sla_monitor" { + rule = aws_cloudwatch_event_rule.sla_monitor.name + target_id = "front-sla-monitor" + arn = aws_lambda_function.sla_monitor.arn +} + +resource "aws_lambda_permission" "sla_monitor_events" { + statement_id = "AllowExecutionFromEventBridge" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.sla_monitor.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.sla_monitor.arn +} + +resource "aws_cloudwatch_event_rule" "user_sync" { + name = "front-user-sync" + description = "Sync Google Workspace user profiles to Front daily at 6 AM ET" + schedule_expression = var.user_sync_schedule + state = local.schedule_state +} + +resource "aws_cloudwatch_event_target" "user_sync" { + rule = aws_cloudwatch_event_rule.user_sync.name + target_id = "front-user-sync" + arn = aws_lambda_function.user_sync.arn +} + +resource "aws_lambda_permission" "user_sync_events" { + statement_id = "AllowExecutionFromEventBridge" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.user_sync.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.user_sync.arn +} diff --git a/terraform/iam.tf b/terraform/iam.tf new file mode 100644 index 0000000..3949f40 --- /dev/null +++ b/terraform/iam.tf @@ -0,0 +1,53 @@ +data "aws_iam_policy_document" "lambda_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "sla_monitor" { + name = "front-sla-monitor" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = local.boundary_arn +} + +resource "aws_iam_role_policy_attachment" "sla_monitor_basic" { + role = aws_iam_role.sla_monitor.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "sla_monitor_secrets" { + name = "secretsmanager-get" + role = aws_iam_role.sla_monitor.id + policy = local.sla_monitor_secrets_policy_json +} + +resource "aws_iam_role_policy" "sla_monitor_ddb" { + name = "dynamodb-crud" + role = aws_iam_role.sla_monitor.id + policy = local.sla_monitor_ddb_policy_json +} + +resource "aws_iam_role" "user_sync" { + name = "front-user-sync" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = local.boundary_arn +} + +resource "aws_iam_role_policy_attachment" "user_sync_basic" { + role = aws_iam_role.user_sync.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "user_sync_secrets" { + name = "secretsmanager-get" + role = aws_iam_role.user_sync.id + policy = local.user_sync_secrets_policy_json +} diff --git a/terraform/lambda.tf b/terraform/lambda.tf new file mode 100644 index 0000000..b073d1a --- /dev/null +++ b/terraform/lambda.tf @@ -0,0 +1,60 @@ +resource "aws_cloudwatch_log_group" "sla_monitor" { + name = "/aws/lambda/front-sla-monitor" + retention_in_days = 60 +} + +resource "aws_cloudwatch_log_group" "user_sync" { + name = "/aws/lambda/front-user-sync" + retention_in_days = 60 +} + +resource "aws_lambda_function" "sla_monitor" { + function_name = "front-sla-monitor" + role = aws_iam_role.sla_monitor.arn + handler = "app.handler" + runtime = "python3.12" + architectures = ["arm64"] + memory_size = 256 + timeout = 300 + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.sla_monitor.key + source_code_hash = data.archive_file.sla_monitor.output_base64sha256 + + environment { + variables = local.sla_monitor_env + } + + depends_on = [ + aws_s3_object.sla_monitor, + aws_cloudwatch_log_group.sla_monitor, + aws_iam_role_policy_attachment.sla_monitor_basic, + aws_iam_role_policy.sla_monitor_secrets, + aws_iam_role_policy.sla_monitor_ddb, + ] +} + +resource "aws_lambda_function" "user_sync" { + function_name = "front-user-sync" + role = aws_iam_role.user_sync.arn + handler = "app.handler" + runtime = "python3.12" + architectures = ["arm64"] + memory_size = 256 + timeout = 300 + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.user_sync.key + source_code_hash = data.archive_file.user_sync.output_base64sha256 + + environment { + variables = local.user_sync_env + } + + depends_on = [ + aws_s3_object.user_sync, + aws_cloudwatch_log_group.user_sync, + aws_iam_role_policy_attachment.user_sync_basic, + aws_iam_role_policy.user_sync_secrets, + ] +} diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 0000000..337e6c5 --- /dev/null +++ b/terraform/locals.tf @@ -0,0 +1,77 @@ +locals { + account_id = "011934824531" + boundary_arn = "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary" + + schedule_state = var.schedules_enabled ? "ENABLED" : "DISABLED" + + sla_monitor_env = { + FRONT_SECRET_NAME = var.front_api_token_secret_arn + SLACK_SECRET_NAME = var.slack_bot_token_secret_arn + SLACK_ALERT_CHANNEL = var.slack_alert_channel + ADAM_EMAIL = var.adam_email + TABLE_NAME = aws_dynamodb_table.alerts.name + ACK_SLA_MINUTES = tostring(var.ack_sla_minutes) + ACTION_SLA_MINUTES = tostring(var.action_sla_minutes) + MONITOR_INBOXES = var.monitor_inboxes + START_DATE = var.sla_monitor_start_date + } + + user_sync_env = { + GOOGLE_SECRET_NAME = var.google_service_account_secret_arn + FRONT_SECRET_NAME = var.front_api_token_secret_arn + GOOGLE_ADMIN_EMAIL = var.google_admin_email + GOOGLE_OUS = var.google_org_units + } + + sla_monitor_secrets_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = [ + var.front_api_token_secret_arn, + var.slack_bot_token_secret_arn, + ] + } + ] + }) + + user_sync_secrets_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = [ + var.google_service_account_secret_arn, + var.front_api_token_secret_arn, + ] + } + ] + }) + + sla_monitor_ddb_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:DescribeTable", + ] + Resource = [ + aws_dynamodb_table.alerts.arn, + "${aws_dynamodb_table.alerts.arn}/index/*", + ] + } + ] + }) +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..4ebf502 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,19 @@ +output "sla_monitor_function_arn" { + description = "Front SLA Monitor Lambda ARN" + value = aws_lambda_function.sla_monitor.arn +} + +output "user_sync_function_arn" { + description = "Front User Sync Lambda ARN" + value = aws_lambda_function.user_sync.arn +} + +output "alerts_table_name" { + description = "DynamoDB alerts table name" + value = aws_dynamodb_table.alerts.name +} + +output "alerts_table_arn" { + description = "DynamoDB alerts table ARN" + value = aws_dynamodb_table.alerts.arn +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..6b2469e --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,11 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = "front-integrations" + ManagedBy = "terraform" + Workspace = "front-integrations-prod" + } + } +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..7707241 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,7 @@ +# Wire these as HCP workspace Terraform variables (never commit real .tfvars). +# Prod ARNs created 2026-08-05 (PLAT-72): +front_api_token_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:secret:front-integrations/front-api-token-UXKv0U" +slack_bot_token_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:secret:front-integrations/slack-bot-token-giGfA7" +google_service_account_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:secret:front-integrations/google-service-account-dqv3Bo" +slack_alert_channel = "C0B2XGSV63V" +# schedules_enabled = false until DDB copy + cutover (default) diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..7dc6b08 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,91 @@ +variable "aws_region" { + type = string + description = "AWS region for all resources" + default = "us-east-1" +} + +variable "front_api_token_secret_arn" { + type = string + description = "Secrets Manager ARN for the Front API token (exact ARN, including suffix)" +} + +variable "slack_bot_token_secret_arn" { + type = string + description = "Secrets Manager ARN for the Slack bot token (exact ARN, including suffix)" +} + +variable "google_service_account_secret_arn" { + type = string + description = "Secrets Manager ARN for the Google service account JSON (exact ARN, including suffix)" +} + +variable "slack_alert_channel" { + type = string + description = "Slack channel ID for #front-sla-alerts" +} + +variable "adam_email" { + type = string + description = "Email address for Tier 2 escalation" + default = "adam@seahavenind.com" +} + +variable "ack_sla_minutes" { + type = number + description = "Business minutes before Tier 1 alert" + default = 60 +} + +variable "action_sla_minutes" { + type = number + description = "Business minutes before Tier 2 alert" + default = 1440 +} + +variable "monitor_inboxes" { + type = string + description = "Comma-separated inbox names to monitor (empty = all shared)" + default = "Triage,California,West Coast,Central,East Coast,Vendors" +} + +variable "sla_monitor_start_date" { + type = string + description = "Date when SLA monitoring begins (YYYY-MM-DD, Eastern)" + default = "2026-05-14" +} + +variable "google_admin_email" { + type = string + description = "Google Workspace admin email to impersonate for Directory API" + default = "adam@seahavenind.com" +} + +variable "google_org_units" { + type = string + description = "Comma-separated Google Workspace org unit paths to sync" + default = "/Office/Scheduling,/Office/Operations" +} + +variable "sla_monitor_schedule" { + type = string + description = "EventBridge schedule for SLA monitor (UTC)" + default = "cron(0/15 12-22 ? * MON-FRI *)" +} + +variable "user_sync_schedule" { + type = string + description = "EventBridge schedule for user sync (UTC)" + default = "cron(0 11 ? * MON-FRI *)" +} + +variable "schedules_enabled" { + type = bool + description = "Whether EventBridge schedules are ENABLED (false until DDB copy + cutover)" + default = false +} + +variable "alarm_sns_topic_arn" { + type = string + description = "SNS topic ARN for CloudWatch alarms (site-alerts)" + default = "arn:aws:sns:us-east-1:011934824531:site-alerts" +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..08b35da --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.57" + } + archive = { + source = "hashicorp/archive" + version = "~> 2.0" + } + external = { + source = "hashicorp/external" + version = "~> 2.0" + } + } + + cloud { + organization = "seahaven" + + workspaces { + name = "front-integrations-prod" + } + } +}