Merge pull request #47 from Sea-Haven-Industries/feature/hcp-terraform-migration

feat(iac): add hcp terraform config for prod migration (PLAT-56)
This commit is contained in:
Adam Moussa 2026-08-05 12:51:07 -04:00 • committed by GitHub
commit c35896e970
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 421 additions and 34 deletions

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

@ -0,0 +1,32 @@
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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: hashicorp/setup-terraform@a1502cd9e758c50496cc9ac5308c4843bcd56d36 # v3.0.0
with:
terraform_version: "1.9.8"
- name: Terraform fmt
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate

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: afi-backup-monitor
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 }}

6
.gitignore vendored
View file

@ -3,3 +3,9 @@ __pycache__/
*.pyc *.pyc
.env .env
samconfig.toml samconfig.toml
terraform/build/
terraform/.terraform/
*.tfvars
!terraform/*.tfvars.example
.terraform.tfstate*
crash.log

View file

@ -2,10 +2,12 @@
![CI](https://github.com/Sea-Haven-Industries/afi-backup-monitor/actions/workflows/ci.yaml/badge.svg) ![CI](https://github.com/Sea-Haven-Industries/afi-backup-monitor/actions/workflows/ci.yaml/badge.svg)
![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)
AWS SAM stack with two Lambda functions that integrate the Afi.ai backup API with Slack. HCP Terraform stack with two Lambda functions that integrate the Afi.ai backup API with Slack. Deployed to **seahaven-prod** via workspace `afi-backup-monitor-prod` (PLAT-56).
`template.yaml` remains as the historical SAM reference for the mgmt stack during cutover; deploy path is HCP Terraform only.
## Functions ## Functions
@ -16,14 +18,17 @@ AWS SAM stack with two Lambda functions that integrate the Afi.ai backup API wit
## Architecture ## Architecture
- **Runtime:** Python 3.12 (arm64) - **Runtime:** Python 3.12 (arm64)
- **IaC:** Terraform under `terraform/` (HCP remote apply, Manual until sealed)
- **Shared Layer:** Afi API client + Slack webhook helper - **Shared Layer:** Afi API client + Slack webhook helper
- **Secrets:** Afi API key and Slack webhook URL stored in AWS Secrets Manager - **Secrets:** Afi API key and Slack webhook URL in Secrets Manager (ARNs as Terraform variables; values never in state)
- **Scheduling:** EventBridge cron rules (default: Mondays 10am ET) - **Scheduling:** EventBridge cron rules (default: Mondays 10am ET)
- **IAM:** Execution roles under path `/tf-managed/` with `seahaven-lambda-execution-boundary`
## Repository Structure ## Repository Structure
``` ```
template.yaml # SAM stack: Lambdas, shared layer, IAM, EventBridge schedules terraform/ # HCP Terraform config (sole deploy path)
template.yaml # Historical SAM reference (mgmt cutover / rollback)
src/ src/
auto_protect/app.py # afi-auto-protect handler auto_protect/app.py # afi-auto-protect handler
health_digest/app.py # afi-health-digest handler health_digest/app.py # afi-health-digest handler
@ -32,8 +37,7 @@ src/
slack.py # Slack webhook helper slack.py # Slack webhook helper
``` ```
Deployment parameters (secret ARNs, tenant ID, policy ID, schedules) are defined in Workspace Terraform variables: secret ARNs, tenant ID, policy ID (see `terraform/terraform.tfvars.example`).
`template.yaml` and supplied at deploy time via `samconfig.toml`.
## Documentation ## Documentation
@ -43,18 +47,15 @@ The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This pr
## Setup ## Setup
1. Store the Afi API key and Slack webhook URL in Secrets Manager: 1. Create secrets in seahaven-prod (exact ARNs are wired into the Lambda boundary and Terraform variables):
```bash ```bash
aws secretsmanager create-secret --name afi-api-key --secret-string "appkey-YOUR_KEY" aws secretsmanager create-secret --name afi-api-key --secret-string "appkey-YOUR_KEY"
aws secretsmanager create-secret --name afi-slack-webhook --secret-string "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" aws secretsmanager create-secret --name afi-slack-webhook --secret-string "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
``` ```
2. Update `samconfig.toml` with your Secret ARNs (`AfiApiKeySecretArn`, `SlackWebhookSecretArn`), Tenant ID, and Policy ID. 2. Set workspace variables in HCP (`afi-backup-monitor-prod`) from `terraform/terraform.tfvars.example`.
3. Build and deploy: 3. Apply from the HCP workspace (Manual apply until the stack is sealed). Do not use local `terraform apply` against prod.
```bash
sam build && sam deploy
```
## Manual Testing ## Manual Testing

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

@ -0,0 +1,46 @@
# 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=",
"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 = "5.100.0"
constraints = "~> 5.0"
hashes = [
"h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=",
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
]
}

37
terraform/artifacts.tf Normal file
View file

@ -0,0 +1,37 @@
# 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.
resource "aws_s3_bucket" "artifacts" {
bucket = "afi-backup-monitor-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
}
resource "aws_s3_object" "shared_layer" {
bucket = aws_s3_bucket.artifacts.id
key = "afi-shared-layer.zip"
content_base64 = filebase64(data.archive_file.shared_layer.output_path)
source_hash = data.archive_file.shared_layer.output_base64sha256
}
resource "aws_s3_object" "auto_protect" {
bucket = aws_s3_bucket.artifacts.id
key = "afi-auto-protect.zip"
content_base64 = filebase64(data.archive_file.auto_protect.output_path)
source_hash = data.archive_file.auto_protect.output_base64sha256
}
resource "aws_s3_object" "health_digest" {
bucket = aws_s3_bucket.artifacts.id
key = "afi-health-digest.zip"
content_base64 = filebase64(data.archive_file.health_digest.output_path)
source_hash = data.archive_file.health_digest.output_base64sha256
}

41
terraform/events.tf Normal file
View file

@ -0,0 +1,41 @@
resource "aws_cloudwatch_event_rule" "auto_protect" {
name = "afi-auto-protect-weekly"
description = "Weekly check for unprotected users"
schedule_expression = var.auto_protect_schedule
state = "ENABLED"
}
resource "aws_cloudwatch_event_target" "auto_protect" {
rule = aws_cloudwatch_event_rule.auto_protect.name
target_id = "afi-auto-protect"
arn = aws_lambda_function.auto_protect.arn
}
resource "aws_lambda_permission" "auto_protect_events" {
statement_id = "AllowExecutionFromEventBridge"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.auto_protect.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.auto_protect.arn
}
resource "aws_cloudwatch_event_rule" "health_digest" {
name = "afi-health-digest-weekly"
description = "Weekly backup health digest to Slack"
schedule_expression = var.health_digest_schedule
state = "ENABLED"
}
resource "aws_cloudwatch_event_target" "health_digest" {
rule = aws_cloudwatch_event_rule.health_digest.name
target_id = "afi-health-digest"
arn = aws_lambda_function.health_digest.arn
}
resource "aws_lambda_permission" "health_digest_events" {
statement_id = "AllowExecutionFromEventBridge"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.health_digest.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.health_digest.arn
}

47
terraform/iam.tf Normal file
View file

@ -0,0 +1,47 @@
data "aws_iam_policy_document" "lambda_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "auto_protect" {
name = "afi-auto-protect"
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" "auto_protect_basic" {
role = aws_iam_role.auto_protect.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy" "auto_protect_secrets" {
name = "secretsmanager-get"
role = aws_iam_role.auto_protect.id
policy = local.secrets_policy_json
}
resource "aws_iam_role" "health_digest" {
name = "afi-health-digest"
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" "health_digest_basic" {
role = aws_iam_role.health_digest.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy" "health_digest_secrets" {
name = "secretsmanager-get"
role = aws_iam_role.health_digest.id
policy = local.secrets_policy_json
}

85
terraform/lambda.tf Normal file
View file

@ -0,0 +1,85 @@
data "archive_file" "shared_layer" {
type = "zip"
source_dir = "${path.module}/../src/shared"
output_path = "${path.module}/build/afi-shared-layer.zip"
}
data "archive_file" "auto_protect" {
type = "zip"
source_dir = "${path.module}/../src/auto_protect"
output_path = "${path.module}/build/afi-auto-protect.zip"
excludes = ["requirements.txt"]
}
data "archive_file" "health_digest" {
type = "zip"
source_dir = "${path.module}/../src/health_digest"
output_path = "${path.module}/build/afi-health-digest.zip"
excludes = ["requirements.txt"]
}
resource "aws_lambda_layer_version" "shared" {
layer_name = "afi-shared"
description = "Shared Afi API client and utilities"
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.shared_layer.key
source_code_hash = data.archive_file.shared_layer.output_base64sha256
compatible_runtimes = ["python3.12"]
compatible_architectures = ["arm64"]
depends_on = [aws_s3_object.shared_layer]
}
resource "aws_lambda_function" "auto_protect" {
function_name = "afi-auto-protect"
role = aws_iam_role.auto_protect.arn
handler = "app.handler"
runtime = "python3.12"
architectures = ["arm64"]
memory_size = 256
timeout = 120
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.auto_protect.key
source_code_hash = data.archive_file.auto_protect.output_base64sha256
layers = [aws_lambda_layer_version.shared.arn]
environment {
variables = merge(local.common_env, {
AFI_POLICY_ID = var.afi_policy_id
})
}
depends_on = [
aws_s3_object.auto_protect,
aws_iam_role_policy_attachment.auto_protect_basic,
aws_iam_role_policy.auto_protect_secrets,
]
}
resource "aws_lambda_function" "health_digest" {
function_name = "afi-health-digest"
role = aws_iam_role.health_digest.arn
handler = "app.handler"
runtime = "python3.12"
architectures = ["arm64"]
memory_size = 256
timeout = 120
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.health_digest.key
source_code_hash = data.archive_file.health_digest.output_base64sha256
layers = [aws_lambda_layer_version.shared.arn]
environment {
variables = local.common_env
}
depends_on = [
aws_s3_object.health_digest,
aws_iam_role_policy_attachment.health_digest_basic,
aws_iam_role_policy.health_digest_secrets,
]
}

24
terraform/locals.tf Normal file
View file

@ -0,0 +1,24 @@
locals {
account_id = "011934824531"
boundary_arn = "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary"
common_env = {
AFI_API_KEY_SECRET_ARN = var.afi_api_key_secret_arn
AFI_TENANT_ID = var.afi_tenant_id
SLACK_WEBHOOK_SECRET_ARN = var.slack_webhook_secret_arn
}
secrets_policy_json = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = [
var.afi_api_key_secret_arn,
var.slack_webhook_secret_arn,
]
}
]
})
}

14
terraform/outputs.tf Normal file
View file

@ -0,0 +1,14 @@
output "auto_protect_function_arn" {
description = "Auto-protect Lambda ARN"
value = aws_lambda_function.auto_protect.arn
}
output "health_digest_function_arn" {
description = "Health digest Lambda ARN"
value = aws_lambda_function.health_digest.arn
}
output "shared_layer_arn" {
description = "Shared Lambda layer ARN"
value = aws_lambda_layer_version.shared.arn
}

11
terraform/providers.tf Normal file
View file

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

View file

@ -0,0 +1,6 @@
# Wire these as HCP workspace Terraform variables (never commit real .tfvars).
# Prod ARNs created 2026-08-05 (PLAT-56):
afi_api_key_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:secret:afi-api-key-w0E02a"
slack_webhook_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:secret:afi-slack-webhook-T4oR3G"
afi_tenant_id = "01KNHTNY89N7ZM6YETP4R083PB"
afi_policy_id = "01KNHTNY8GCQ0988BFSY6DR13B"

37
terraform/variables.tf Normal file
View file

@ -0,0 +1,37 @@
variable "aws_region" {
type = string
description = "AWS region for all resources"
default = "us-east-1"
}
variable "afi_api_key_secret_arn" {
type = string
description = "Secrets Manager ARN for the Afi API key (exact ARN, including suffix)"
}
variable "slack_webhook_secret_arn" {
type = string
description = "Secrets Manager ARN for the Slack webhook URL (exact ARN, including suffix)"
}
variable "afi_tenant_id" {
type = string
description = "Afi tenant ID for Google Workspace"
}
variable "afi_policy_id" {
type = string
description = "Afi backup policy ID to assign to new users"
}
variable "auto_protect_schedule" {
type = string
description = "EventBridge schedule for auto-protect (default Monday 10am ET)"
default = "cron(0 14 ? * MON *)"
}
variable "health_digest_schedule" {
type = string
description = "EventBridge schedule for health digest (default Monday 10am ET)"
default = "cron(0 14 ? * MON *)"
}

22
terraform/versions.tf Normal file
View file

@ -0,0 +1,22 @@
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.0"
}
}
cloud {
organization = "seahaven"
workspaces {
name = "afi-backup-monitor-prod"
}
}
}