feat(terraform): migrate front-integrations to HCP Terraform (PLAT-72) (#41)

* 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.

* fix(terraform): reject symlink sources in Lambda package build
This commit is contained in:
Adam Moussa 2026-08-05 18:48:16 -04:00 • committed by GitHub
parent b30ed47321
commit 7bbdbabe3a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 832 additions and 112 deletions

View file

@ -33,3 +33,14 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "patch"
- package-ecosystem: "terraform"
directory: "/terraform"
schedule:
interval: "weekly"
commit-message:
prefix: "chore(deps)"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"

35
.github/workflows/ci-terraform.yaml vendored Normal file
View file

@ -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

View file

@ -11,3 +11,5 @@ jobs:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@3f746774229d41770727e2e4fd63ed5f5555a8b3 # v1.0.3 uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@3f746774229d41770727e2e4fd63ed5f5555a8b3 # v1.0.3
with: with:
source-dirs: "src" source-dirs: "src"
# Deploy path is HCP Terraform; reusable name still covers Python lint/tests.
run-sam-validate: false

View file

@ -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 }}

4
.gitignore vendored
View file

@ -3,3 +3,7 @@ __pycache__/
*.pyc *.pyc
.env .env
samconfig.toml samconfig.toml
terraform/.terraform/
terraform/build/
terraform/*.tfvars
!terraform/terraform.tfvars.example

122
README.md
View file

@ -1,11 +1,11 @@
# front-integrations # front-integrations
![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![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) ![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) ![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 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 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/ front-integrations/
├── template.yaml # AWS SAM template — all infrastructure (see below) ├── terraform/ # HCP Terraform config (sole deploy path)
├── samconfig.toml.example # Deploy config template (copy to samconfig.toml, gitignored) │ ├── 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 ├── slack-app-manifest.yaml # Slack app manifest for the SLA Monitor bot
└── src/ └── src/
├── sla_monitor/ # front-sla-monitor Lambda ├── sla_monitor/ # front-sla-monitor Lambda
@ -70,70 +72,18 @@ front-integrations/
## AWS Resources ## 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-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 - **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 - **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 - **EventBridge:** SLA check every 15 min during business hours; user sync daily at 6 AM ET weekdays
- **IAM:** Execution roles under path `/tf-managed/` with `seahaven-lambda-execution-boundary`
## Infrastructure (`template.yaml`) - **Secrets:** ARNs as Terraform variables; values never in state
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 |
## Monitoring ## 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 | | 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 1. Settings > Custom Fields > Teammates tab
2. Create: **Job Title** (String) and **Phone** (String) 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 ```bash
aws secretsmanager create-secret \ aws secretsmanager create-secret \
--name "front-integrations/front-api-token" \ --name "front-integrations/front-api-token" \
--secret-string "YOUR_FRONT_API_TOKEN" \ --secret-string "YOUR_FRONT_API_TOKEN" \
--region us-east-1 --region us-east-1 --profile seahaven-prod
aws secretsmanager create-secret \ aws secretsmanager create-secret \
--name "front-integrations/slack-bot-token" \ --name "front-integrations/slack-bot-token" \
--secret-string "xoxb-YOUR-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 \ aws secretsmanager create-secret \
--name "front-integrations/google-service-account" \ --name "front-integrations/google-service-account" \
--secret-string file://path-to-service-account-key.json \ --secret-string file://path-to-service-account-key.json \
--region us-east-1 --region us-east-1 --profile seahaven-prod
``` ```
### 6. Deploy ### 6. Deploy
```bash 1. Set workspace variables in HCP (`front-integrations-prod`) from `terraform/terraform.tfvars.example`.
sam build 2. Keep `schedules_enabled=false` until DynamoDB row copy and cutover.
sam deploy --guided 3. Apply from the HCP workspace (Manual apply until the stack is sealed). Do not use local `terraform apply` against prod.
```
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` |
## Manual Testing ## Manual Testing
```bash ```bash
aws lambda invoke --function-name front-sla-monitor --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 aws lambda invoke --function-name front-user-sync --payload '{}' /dev/stdout --region us-east-1 --profile seahaven-prod
``` ```
## Configuration ## Configuration
### SLA Monitor ### SLA Monitor
| Parameter | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `AckSlaMinutes` | 60 | Business minutes before Tier 1 alert | | `ack_sla_minutes` | 60 | Business minutes before Tier 1 alert |
| `ActionSlaMinutes` | 1440 | Business minutes before Tier 2 alert | | `action_sla_minutes` | 1440 | Business minutes before Tier 2 alert |
| `AdamEmail` | adam@seahavenind.com | Tier 2 escalation recipient | | `adam_email` | adam@seahavenind.com | Tier 2 escalation recipient |
| `SlackAlertChannel` | — | Channel ID for broadcast alerts | | `slack_alert_channel` | — | Channel ID for broadcast alerts |
| `MonitorInboxes` | Triage,California,... | Inbox names to monitor (empty = all shared) | | `monitor_inboxes` | Triage,California,... | Inbox names to monitor (empty = all shared) |
| `SlaMonitorStartDate` | 2026-05-14 | Date monitoring begins | | `sla_monitor_start_date` | 2026-05-14 | Date monitoring begins |
### User Sync ### User Sync
| Parameter | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `GoogleAdminEmail` | adam@seahavenind.com | Google Workspace admin to impersonate | | `google_admin_email` | adam@seahavenind.com | Google Workspace admin to impersonate |
| `GoogleOrgUnits` | /Office/Scheduling,/Office/Operations | Org unit paths to sync | | `google_org_units` | /Office/Scheduling,/Office/Operations | Org unit paths to sync |

74
terraform/.terraform.lock.hcl generated Normal file
View file

@ -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",
]
}

143
terraform/alarms.tf Normal file
View file

@ -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
}
}

53
terraform/artifacts.tf Normal file
View file

@ -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
}

61
terraform/build_packages.sh Executable file
View file

@ -0,0 +1,61 @@
#!/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)"
# Copy only regular files that resolve inside SRC (no symlink escape).
copy_src_file() {
local rel="$1"
local dest="$2"
local src_path="${SRC}/${rel}"
if [[ -L "${src_path}" ]]; then
echo "error: refusing symlink source: ${src_path}" >&2
exit 1
fi
if [[ ! -f "${src_path}" ]]; then
echo "error: missing regular file: ${src_path}" >&2
exit 1
fi
local resolved
resolved="$(cd "$(dirname "${src_path}")" && pwd)/$(basename "${src_path}")"
case "${resolved}" in
"${SRC}"/*) ;;
*)
echo "error: path escapes src tree: ${resolved}" >&2
exit 1
;;
esac
mkdir -p "$(dirname "${dest}")"
# -P: never follow symlinks if the destination path is replaced mid-run.
cp -P "${src_path}" "${dest}"
}
rm -rf "${BUILD}"
mkdir -p "${BUILD}/sla_monitor" "${BUILD}/user_sync"
copy_src_file "sla_monitor/app.py" "${BUILD}/sla_monitor/app.py"
copy_src_file "user_sync/app.py" "${BUILD}/user_sync/app.py"
copy_src_file "user_sync/requirements.txt" "${BUILD}/user_sync/requirements.txt"
python3 -m pip install \
--quiet \
--disable-pip-version-check \
-r "${BUILD}/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
rm -f "${BUILD}/user_sync/requirements.txt"

View file

@ -0,0 +1,17 @@
#!/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="$(
{
# -P: do not follow symlinks; only hash regular files under build/.
find -P "${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}"

15
terraform/dynamodb.tf Normal file
View file

@ -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
}
}

41
terraform/events.tf Normal file
View file

@ -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
}

53
terraform/iam.tf Normal file
View file

@ -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
}

60
terraform/lambda.tf Normal file
View file

@ -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,
]
}

77
terraform/locals.tf Normal file
View file

@ -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/*",
]
}
]
})
}

19
terraform/outputs.tf Normal file
View file

@ -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
}

11
terraform/providers.tf Normal file
View file

@ -0,0 +1,11 @@
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "front-integrations"
ManagedBy = "terraform"
Workspace = "front-integrations-prod"
}
}
}

View file

@ -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)

91
terraform/variables.tf Normal file
View file

@ -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"
}

26
terraform/versions.tf Normal file
View file

@ -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"
}
}
}