feat(terraform): migrate forgejo to HCP Terraform

Move the prod host onto workspace forgejo-prod in the After Hours VPC and freeze CDK push deploys so cutover can happen without applying into seahaven-prod.
This commit is contained in:
Adam Moussa 2026-09-29 18:36:17 -04:00
parent e23afc0693
commit 530bd7210e
No known key found for this signature in database
24 changed files with 2588 additions and 18 deletions

View file

@ -1,15 +1,57 @@
name: CI
# Converted HCP caller. ci-complete aggregates the portions. The CDK job id
# stays `ci` so the org "main branch protection" ruleset still sees `ci / ci`
# until this repo is moved onto "CI complete".
on:
pull_request:
branches: [main]
branches: [main, hotfix/**, release/**]
merge_group:
push:
branches: [hotfix/**, release/**]
permissions:
contents: read
jobs:
autofix:
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@ee5b843ca105422b679c3fdeb9eaa68c6e500a85 # v1.0.20
permissions:
contents: write
secrets: inherit
with:
presets: terraform
terraform-version: "1.16.0"
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@ee5b843ca105422b679c3fdeb9eaa68c6e500a85 # v1.0.20
with:
node-version: "24"
enable-qemu: true
terraform:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@ee5b843ca105422b679c3fdeb9eaa68c6e500a85 # v1.0.20
with:
terraform-version: "1.16.0"
ci-complete:
name: ci-complete
needs: [autofix, ci, terraform]
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require portions
env:
CI: ${{ needs.ci.result }}
TERRAFORM: ${{ needs.terraform.result }}
run: |
set -euo pipefail
test "${CI}" = success
test "${TERRAFORM}" = success

View file

@ -7,4 +7,4 @@ permissions:
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@ee5b843ca105422b679c3fdeb9eaa68c6e500a85 # v1.0.20

View file

@ -1,7 +1,8 @@
name: Deploy
# Push deploy is frozen for PLAT-80. workflow_dispatch remains so the live
# mgmt host can still be patched until those CDK stacks are destroyed.
on:
push:
branches: [main]
workflow_dispatch:
permissions:
id-token: write
@ -13,7 +14,7 @@ concurrency:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@ee5b843ca105422b679c3fdeb9eaa68c6e500a85 # v1.0.20
with:
node-version: "24"
enable-qemu: true

View file

@ -10,4 +10,4 @@ permissions:
jobs:
label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@ee5b843ca105422b679c3fdeb9eaa68c6e500a85 # v1.0.20

4
.gitignore vendored
View file

@ -6,3 +6,7 @@ cdk.out/
*.js.map
docs/*.pdf
__pycache__/
.terraform/
terraform/build/
*.tfstate
*.tfstate.*

View file

@ -5,7 +5,31 @@
![AWS CDK](https://img.shields.io/badge/AWS-CDK-FF9900?logo=amazonaws&logoColor=white)
![CI](https://github.com/Sea-Haven-Industries/forgejo/actions/workflows/ci.yaml/badge.svg)
Self-hosted Forgejo git server for archiving GitHub repos and mirroring active ones. Runs on a single EC2 instance within the Sea Haven VPC, fronted by the `seahaven-com` ALB for HTTPS.
Self-hosted Forgejo git server for archiving GitHub repos and mirroring active ones. The live host is still the mgmt CDK stack until cutover. The replacement is HCP Terraform in seahaven-prod.
## HCP Terraform (PLAT-80)
| | |
|---|---|
| HCP workspace | `forgejo-prod` (project `seahaven-prod`, working directory `terraform/`) |
| VCS triggers | `trigger-patterns = ["terraform/**/*", "lambda/**/*"]` |
| Account | seahaven-prod `011934824531` |
| Plan/apply roles | `hcptf-forgejo-plan` / `hcptf-forgejo` |
| Apply | Manual. Auto-apply stays off until after DNS cutover and one nightly dump in the new bucket. |
A `lambda/`-only merge must still queue a run, so the trigger patterns include `lambda/**/*` as well as `terraform/**/*`. Docs-only commits do not apply.
The instance attaches to the After Hours VPC (`10.70.0.0/16`) through workspace variables `existing_vpc_id` and `existing_public_subnet_ids` (afterhours-shift-manager outputs `vpc_id` and `public_subnet_ids`). The first subnet is the instance availability zone. AMI id is pinned in `terraform/variables.tf` (`ami_id`). Do not switch it to `most_recent`.
DNS stays in the mgmt zone `Z06652411XKH89KTZD3XA`. Terraform does not own `forgejo.seahaven.com`. Cutover is an alias flip to the new ALB. Until `enable_https` is true, the ALB listens on port 80 so a restore can be proved against `alb_dns_name` without moving the public name. Create the `acm_validation_records` output in the mgmt zone before setting `enable_https`.
`enable_schedules` stays false until cutover so the not-running alarm does not page `site-alerts`.
Secret values are not in Terraform. IAM uses name-prefix ARNs because `hcptf-bootstrap-plan` cannot `DescribeSecret`. These names must exist in seahaven-prod before the instance boots: `forgejo/admin-password`, `forgejo/api-token`, `forgejo/github-pat`, `forgejo/gcs-sa-key`, `forgejo/slack-webhook`, `forgejo/gcs-transfer-credentials`. The GCS transfer user is `forgejo-gcs-transfer`. Create its access key by hand and store it in `forgejo/gcs-transfer-credentials`. Do not put the key in Terraform.
First apply uses `hcptf-bootstrap` / `hcptf-bootstrap-plan` after `scripts/create-hcptf-bootstrap-roles.sh --account prod --allow-workspace forgejo-prod` in seahaven-org-baseline. That apply creates IAM and errors on the instance. Retarget `TFC_AWS_APPLY_ROLE_ARN` and `TFC_AWS_PLAN_ROLE_ARN` to `hcptf-forgejo` and `hcptf-forgejo-plan`, drop `--allow-workspace`, then apply again. Do not use a project variable set.
Backup bucket names are `forgejo-backups-011934824531` and `forgejo-backups-replica-011934824531` (us-west-2, Object Lock governance 90 days). The sections below describe the live mgmt host until that cutover.
## Architecture
@ -21,7 +45,7 @@ Self-hosted Forgejo git server for archiving GitHub repos and mirroring active o
- **TLS**: Wildcard cert on ALB, HTTP internally on port 3000
- **Backup**: Nightly `forgejo dump` to S3 + EBS snapshots via DLM (see [3-2-1 Backup Strategy](#3-2-1-backup-strategy))
- **Admin access**: SSM Session Manager (no SSH port exposed)
- **CI/CD**: GitHub Actions with OIDC role `githubdeploy-forgejo`
- **CI/CD**: Pull requests call the org CI workflows (CDK synth, plus Terraform fmt/validate). CDK deploy on push is frozen. `workflow_dispatch` can still patch the live mgmt host. The replacement workspace is `forgejo-prod`.
### Ports
@ -243,16 +267,9 @@ npm run deploy # cdk deploy — deploy (pass -- --all for both stacks)
## Deployment
```bash
npm install
npx cdk deploy --all
```
Pull requests call the org reusables from `.github/workflows/ci.yaml`: CDK synth, and Terraform `fmt` / `init -backend=false` / `validate` on `terraform/`. A merge to `main` does not deploy. The CDK workflow (`.github/workflows/deploy.yaml`) runs only on `workflow_dispatch`, and that path still targets the live mgmt stacks.
This deploys two stacks:
- `forgejo-replica` (us-west-2) — S3 replica bucket with Object Lock
- `forgejo` (us-east-1) — main stack with Forgejo instance, CRR, and verification Lambda
CI/CD is handled by GitHub Actions — PRs run CI, merges to `main` deploy via the reusable CDK workflow.
New infrastructure is the `terraform/` root, applied from HCP workspace `forgejo-prod`. See [HCP Terraform (PLAT-80)](#hcp-terraform-plat-80). Do not `cdk deploy` this repo into seahaven-prod.
## Post-deploy: store Slack webhook

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

@ -0,0 +1,68 @@
# 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.1"
constraints = "~> 2.7"
hashes = [
"h1:aLNmq6dc3cDcqZc8s/8eKtn0I+UQXyJMGrmo4rRFtNw=",
"zh:03de290604114a89fcd45c2e5bc7787d5a1ebfc5f964fb5989306bea7a4c79ec",
"zh:0a7d69dc9fbbc48960bc2f04588c8fb1bd92c78a8f306566b7fb17fc4a4f2058",
"zh:4df1f3981379c35f1757da957470f7f7724496b57219da3485177cf1647bdf59",
"zh:50f0e72ba53bfe6e11b03b7fc899e1f72536354381d302a743a274ae2a3f45f4",
"zh:5c4e15a04c98e2a8cafb1cd9632b48ad318051e8462d105b1153006277985c35",
"zh:66069e604bcf5c4af0278e15997d9e6bd755c54fb3801d78885838b889729c5f",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:979765db3f42601870ab377104ba70befb029547b278337ec4ada980e3582de6",
"zh:b165254da774f49945a73fbccc3ef1b63d70ea00a98fa9e14716665ba80ecaad",
"zh:c0bb2697b525da9fec4511f569ed2bd2b42f25d5c1f9fa00f8f3645b50cfc2e9",
"zh:c48f6695d12df0d0fa5231f7c1b8d518a4feae91a733fdd35a5804af3a303831",
"zh:d589954c93f075180c9f4e2ce91780d5edcb56dfd0d3cda8ba08e13c10b52249",
"zh:f5792ed06da65d0daf7ca3711f5399ff78c7cb4400afe53fbce9a926fbde4477",
]
}
provider "registry.terraform.io/hashicorp/aws" {
version = "6.66.0"
constraints = "~> 6.64"
hashes = [
"h1:OnLj4nhqJnEcUzyyRKUjp1FgWG00Y8maikJEYSf9Zjw=",
"zh:156fe7164a3d26ef6b35734c43e99fb198df90575ed897d1182b8e930b8cd523",
"zh:1af52b22b35be00f8d16e3ebebff9fa699ec4db2ef69e6032ba5c536f80c03d9",
"zh:2545a8478bd551fdc9694f6cc1a1ad24617f6736f8bde0ad6cae90987c65380f",
"zh:4070db1ee369ccb41cb610bfd887386bc0a9b9ecad60aeb4dbce58443d2519dd",
"zh:53da7d3c1840ef875c7d34e967732502a64fe677af0e78824773d4c15a8fe740",
"zh:576a93a28bf611a4de2a2e6ced697a41d5126b8fd31d30782b16797e410a9706",
"zh:58fed5fa9a033355b9d4f3092c817b70d934100e0d8678d6e4c93f3c9493d4e4",
"zh:6a9ca2f24e2ee9156dd785d159a850b35d190e9cf7eca21cb9582970c2db80cd",
"zh:729edd30f99cc16009deba5c013265b0c81eda261a3d0821cbd011d3287fd230",
"zh:7ae460049b75bd4aefee465ef7c53a01ac2df46d4d3e3ac00824afa8b5cb83fb",
"zh:9051fa85c8034ade8a57a5c6f232fd33da28f3800bb5aa40bc8625dbc5e27632",
"zh:906547e4319805e7acf7fbdf2bac28a4b1a7370790a2a430c7adb1b29bb934eb",
"zh:998f27410a66158a35ee5ed142c27e5b21fe8601941da55da2157f8042d6dcca",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9c1804eff1dda0446dc2d215231015bb65a2fc6c3b7ba24584fe45f1ddd3fa9f",
"zh:b03ff5efdee310502aaaeb460144dc059bce72a0d8217e6b989099ef8aef9283",
]
}
provider "registry.terraform.io/hashicorp/external" {
version = "2.4.2"
constraints = "~> 2.3"
hashes = [
"h1:4UInMFuK4GNw4uf2vkUwwDtc0CajvJ88BkAE6xLKOa4=",
"zh:0b51793be4f66934a3666339e44c01fd56e1c6a56256dfc66d1cb391584b4c2f",
"zh:31cdd9b30e4ec63d130befc89471757ef3937b99a8f6cc006769d215365c5ba8",
"zh:61f86de4a3166cfa5da6800eeba8e6a2e4ab6403fc3d7b396508260b45d3de7d",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:817e8d5946aed6ca692e0bb2f6463c28774ef8fb2fdd3922543a495a249229ea",
"zh:b35f1bd1be09ed1a1620b43ab8cb43fe93407cf419586d53fe965691cc1b4a8e",
"zh:bcb170063ec8b5728bc2a4568bc48f539a1991cdaaf31667aa35568aebc34725",
"zh:c0d2c824cc7c047f26ce793bb0cbb6746f9745d1fc6e630d34a0afb5b92850d1",
"zh:cde68f51089b02db50e2c5a17f6e132dc1ead2fc08d3dd267b8dd54ec58133fa",
"zh:d1f3c497aa41f17e8d61067122f6d94e51ef942ab08be5465489864d900854ab",
"zh:e62568bfc0934b63e14f3547c823b01ed60d7475ff16bf12c0e55d5ac8d98ba7",
"zh:ed8890c29dba2b0ac27afcefa75e7395e27253b7025aaaa4258345fc59dad23e",
"zh:f220c56c7e487f01fd158126066f6525178bbd82805b27205354bc523cc7c413",
]
}

41
terraform/alarms.tf Normal file
View file

@ -0,0 +1,41 @@
resource "aws_cloudwatch_metric_alarm" "verification_errors" {
count = var.enable_schedules ? 1 : 0
alarm_name = "forgejo-backup-verification-errors"
alarm_description = "Backup verification Lambda is failing. Slack notifications may not be firing."
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 1
metric_name = "Errors"
namespace = "AWS/Lambda"
period = 3600
statistic = "Sum"
threshold = 1
treat_missing_data = "notBreaching"
dimensions = {
FunctionName = aws_lambda_function.verification.function_name
}
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "verification_not_running" {
count = var.enable_schedules ? 1 : 0
alarm_name = "forgejo-backup-verification-not-running"
alarm_description = "Backup verification Lambda has not run in the last 24h."
comparison_operator = "LessThanThreshold"
evaluation_periods = 1
metric_name = "Invocations"
namespace = "AWS/Lambda"
period = 86400
statistic = "Sum"
threshold = 1
treat_missing_data = "breaching"
dimensions = {
FunctionName = aws_lambda_function.verification.function_name
}
alarm_actions = [local.site_alerts_arn]
}

117
terraform/alb.tf Normal file
View file

@ -0,0 +1,117 @@
resource "aws_security_group" "alb" {
name = "forgejo-alb"
description = "Public entry for the Forgejo ALB"
vpc_id = var.existing_vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
description = "HTTP. Redirects to HTTPS after enable_https."
}
resource "aws_vpc_security_group_ingress_rule" "alb_https" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 443
to_port = 443
ip_protocol = "tcp"
description = "HTTPS"
}
resource "aws_vpc_security_group_egress_rule" "alb_to_instance" {
security_group_id = aws_security_group.alb.id
referenced_security_group_id = aws_security_group.instance.id
from_port = 3000
to_port = 3000
ip_protocol = "tcp"
description = "Forgejo HTTP"
}
resource "aws_lb" "forgejo" {
name = "forgejo"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = var.existing_public_subnet_ids
drop_invalid_header_fields = true
enable_deletion_protection = true
}
resource "aws_lb_target_group" "forgejo" {
name = "forgejo"
port = 3000
protocol = "HTTP"
vpc_id = var.existing_vpc_id
health_check {
path = "/"
matcher = "200,302"
healthy_threshold = 2
unhealthy_threshold = 2
}
}
resource "aws_lb_target_group_attachment" "forgejo" {
target_group_arn = aws_lb_target_group.forgejo.arn
target_id = aws_instance.forgejo.id
port = 3000
}
resource "aws_acm_certificate" "forgejo" {
domain_name = "forgejo.seahaven.com"
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_lb_listener" "http" {
count = var.enable_https ? 0 : 1
load_balancer_arn = aws_lb.forgejo.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.forgejo.arn
}
}
resource "aws_lb_listener" "http_redirect" {
count = var.enable_https ? 1 : 0
load_balancer_arn = aws_lb.forgejo.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
resource "aws_lb_listener" "https" {
count = var.enable_https ? 1 : 0
load_balancer_arn = aws_lb.forgejo.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = aws_acm_certificate.forgejo.arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.forgejo.arn
}
}

25
terraform/build_lambda.sh Normal file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Bundle the backup-verification function for the arm64 Lambda runtime.
# Runs on the Terraform worker during plan. HCP plan and apply use different
# workers, so the zip bytes are carried in the plan (see lambda.tf).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "${ROOT}/.." && pwd)"
SRC="${REPO}/lambda/backup-verification"
BUILD="${ROOT}/build/function"
rm -rf "${BUILD}"
mkdir -p "${BUILD}"
python3 -m pip install \
--target "${BUILD}" \
--platform manylinux2014_aarch64 \
--implementation cp \
--python-version 3.12 \
--only-binary=:all: \
--upgrade \
--requirement "${SRC}/requirements.txt"
cp "${SRC}/app.py" "${BUILD}/app.py"
find "${BUILD}" -type d -name __pycache__ -exec rm -rf {} +

View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Terraform external data source entrypoint. Stdout must be JSON only.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
bash "${ROOT}/build_lambda.sh" >&2
if command -v sha256sum >/dev/null 2>&1; then
SHA=(sha256sum)
else
SHA=(shasum -a 256)
fi
hash="$(
{
find -P "${ROOT}/build/function" -type f -print0 2>/dev/null \
| sort -z \
| xargs -0 "${SHA[@]}"
} | "${SHA[@]}" | awk '{print $1}'
)"
printf '{"status":"ok","hash":"%s"}\n' "${hash}"

33
terraform/data.tf Normal file
View file

@ -0,0 +1,33 @@
data "aws_vpc" "this" {
id = var.existing_vpc_id
}
data "aws_subnet" "public" {
for_each = toset(var.existing_public_subnet_ids)
id = each.value
}
data "aws_subnet" "instance" {
id = local.instance_subnet_id
}
check "vpc_cidr" {
assert {
condition = data.aws_vpc.this.cidr_block == local.vpc_cidr
error_message = "existing_vpc_id must be the After Hours VPC ${local.vpc_cidr}."
}
}
check "public_subnets_in_vpc" {
assert {
condition = alltrue([for subnet in data.aws_subnet.public : subnet.vpc_id == var.existing_vpc_id])
error_message = "existing_public_subnet_ids must all belong to existing_vpc_id."
}
}
check "alb_subnet_azs" {
assert {
condition = length(distinct([for subnet in data.aws_subnet.public : subnet.availability_zone])) >= 2
error_message = "ALB subnets must cover at least two availability zones."
}
}

148
terraform/ec2.tf Normal file
View file

@ -0,0 +1,148 @@
resource "aws_security_group" "instance" {
name = "forgejo"
description = "Forgejo git server"
vpc_id = var.existing_vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "instance_http_alb" {
security_group_id = aws_security_group.instance.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 3000
to_port = 3000
ip_protocol = "tcp"
description = "HTTP from the Forgejo ALB"
}
resource "aws_vpc_security_group_ingress_rule" "instance_http_vpc" {
security_group_id = aws_security_group.instance.id
cidr_ipv4 = local.vpc_cidr
from_port = 3000
to_port = 3000
ip_protocol = "tcp"
description = "HTTP from the prod VPC"
}
resource "aws_vpc_security_group_ingress_rule" "instance_http_office" {
security_group_id = aws_security_group.instance.id
cidr_ipv4 = local.office_vpn_cidr
from_port = 3000
to_port = 3000
ip_protocol = "tcp"
description = "HTTP from the office VPN. 10.10 is not routed to 10.70 yet."
}
resource "aws_vpc_security_group_ingress_rule" "instance_ssh_vpc" {
security_group_id = aws_security_group.instance.id
cidr_ipv4 = local.vpc_cidr
from_port = 2222
to_port = 2222
ip_protocol = "tcp"
description = "Git SSH from the prod VPC"
}
resource "aws_vpc_security_group_ingress_rule" "instance_ssh_office" {
security_group_id = aws_security_group.instance.id
cidr_ipv4 = local.office_vpn_cidr
from_port = 2222
to_port = 2222
ip_protocol = "tcp"
description = "Git SSH from the office VPN. 10.10 is not routed to 10.70 yet."
}
resource "aws_vpc_security_group_egress_rule" "instance_all" {
security_group_id = aws_security_group.instance.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
description = "Outbound for GitHub, Codeberg, S3, and SSM"
}
resource "aws_instance" "forgejo" {
ami = var.ami_id
instance_type = "t4g.small"
subnet_id = local.instance_subnet_id
vpc_security_group_ids = [aws_security_group.instance.id]
iam_instance_profile = aws_iam_instance_profile.forgejo.name
associate_public_ip_address = true
user_data = local.user_data
user_data_replace_on_change = true
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
}
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
delete_on_termination = true
}
tags = {
Name = "forgejo"
forgejo-backup = "true"
}
}
resource "aws_ebs_volume" "data" {
availability_zone = data.aws_subnet.instance.availability_zone
size = 50
type = "gp3"
encrypted = true
tags = {
Name = "forgejo-data"
forgejo-backup = "true"
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_volume_attachment" "data" {
device_name = "/dev/xvdf"
volume_id = aws_ebs_volume.data.id
instance_id = aws_instance.forgejo.id
stop_instance_before_detaching = true
}
resource "aws_ssm_parameter" "backup_prefix" {
name = "/forgejo/backup-s3-prefix"
description = "S3 key prefix for Forgejo backup dumps"
type = "String"
value = local.backup_s3_prefix
}
resource "aws_dlm_lifecycle_policy" "snapshots" {
description = "Nightly EBS snapshots for Forgejo"
execution_role_arn = aws_iam_role.dlm.arn
state = "ENABLED"
policy_details {
resource_types = ["INSTANCE"]
target_tags = {
forgejo-backup = "true"
}
schedule {
name = "forgejo-nightly"
create_rule {
interval = 24
interval_unit = "HOURS"
times = ["06:00"]
}
retain_rule {
count = 30
}
copy_tags = true
tags_to_add = {
forgejo-backup = "true"
}
}
}
}

804
terraform/hcp_iam.tf Normal file
View file

@ -0,0 +1,804 @@
# HCP plan/apply roles for forgejo-prod (PLAT-80).
# Copy of seahaven-org-baseline/examples/hcptf-workspace-iam/hcp_iam.tf.example
# with the Forgejo EC2, ALB, S3 CRR, DLM, and Lambda service set. Create, do not import.
#
# First-apply sequence:
# 1. seahaven-org-baseline scripts/create-hcptf-bootstrap-roles.sh
# --account prod --allow-workspace forgejo-prod
# 2. Point this workspace's TFC_AWS_* at hcptf-bootstrap / hcptf-bootstrap-plan
# (workspace vars, never a project set).
# 3. One Manual apply. Bootstrap can write hcptf-* and policy/tf-managed/*.
# It cannot PassRole to ec2 or create the buckets, so non-IAM resources
# error and stay out of state.
# 4. Point TFC_AWS_* at hcptf-forgejo / hcptf-forgejo-plan.
# 5. Re-run the script without --allow-workspace.
# 6. Second Manual apply creates the instance, buckets, ALB, and Lambda.
# Later edits to these hcptf-* inline policies need the same window.
# DenySelfMutation blocks PutRolePolicy on hcptf-* from the scoped role.
# Do not add StringLike on bootstrap trust. CreatePolicy stays on hcptf-bootstrap.
data "aws_iam_policy_document" "hcptf_apply_trust" {
statement {
sid = "HcpApply"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:apply",
]
}
}
}
data "aws_iam_policy_document" "hcptf_plan_trust" {
statement {
sid = "HcpPlan"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:plan",
]
}
}
}
data "aws_iam_policy_document" "hcptf_scoped_iam" {
statement {
sid = "DenyCreatePolicy"
effect = "Deny"
actions = [
"iam:CreatePolicy",
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
]
resources = ["*"]
}
statement {
sid = "CreateExecRoleWithBoundary"
effect = "Allow"
actions = ["iam:CreateRole"]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*",
]
condition {
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
]
}
}
statement {
sid = "MutateExecRoleWithBoundary"
effect = "Allow"
actions = [
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*",
]
condition {
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
]
}
}
statement {
sid = "WriteExecRoles"
effect = "Allow"
actions = [
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*",
]
}
statement {
sid = "PassInstanceRoleToEc2"
effect = "Allow"
actions = ["iam:PassRole"]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.instance_role_name}",
]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["ec2.amazonaws.com"]
}
}
statement {
sid = "PassDlmRole"
effect = "Allow"
actions = ["iam:PassRole"]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.dlm_role_name}",
]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["dlm.amazonaws.com"]
}
}
statement {
sid = "PassReplicationRoleToS3"
effect = "Allow"
actions = ["iam:PassRole"]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.replication_role_name}",
]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["s3.amazonaws.com"]
}
}
statement {
sid = "PassLambdaRole"
effect = "Allow"
actions = ["iam:PassRole"]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.lambda_role_name}",
]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["lambda.amazonaws.com"]
}
}
statement {
sid = "InstanceProfiles"
effect = "Allow"
actions = [
"iam:AddRoleToInstanceProfile",
"iam:CreateInstanceProfile",
"iam:DeleteInstanceProfile",
"iam:GetInstanceProfile",
"iam:ListInstanceProfileTags",
"iam:RemoveRoleFromInstanceProfile",
"iam:TagInstanceProfile",
"iam:UntagInstanceProfile",
]
resources = [
"arn:aws:iam::${local.account_id}:instance-profile/tf-managed/${local.instance_profile_name}",
]
}
statement {
sid = "GcsTransferUser"
effect = "Allow"
actions = [
"iam:CreateUser",
"iam:DeleteUser",
"iam:GetUser",
"iam:GetUserPolicy",
"iam:ListUserPolicies",
"iam:ListUserTags",
"iam:PutUserPermissionsBoundary",
"iam:PutUserPolicy",
"iam:DeleteUserPolicy",
"iam:TagUser",
"iam:UntagUser",
]
resources = [
"arn:aws:iam::${local.account_id}:user/tf-managed/${local.gcs_user_name}",
]
}
statement {
sid = "IamReadOnly"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfilesForRole",
"iam:ListPolicies",
"iam:ListPolicyVersions",
"iam:ListRolePolicies",
"iam:ListRoleTags",
"iam:ListRoles",
]
resources = ["*"]
}
statement {
sid = "TagExecBoundary"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:ListPolicyTags",
"iam:ListPolicyVersions",
"iam:TagPolicy",
"iam:UntagPolicy",
]
resources = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
]
}
statement {
sid = "DenySelfMutation"
effect = "Deny"
actions = [
"iam:AttachRolePolicy",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DeleteRolePermissionsBoundary",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = [
"arn:aws:iam::${local.account_id}:role/hcptf-*",
"arn:aws:iam::${local.account_id}:role/github-cfn-execution-role",
"arn:aws:iam::${local.account_id}:role/githubdeploy-*",
"arn:aws:iam::${local.account_id}:role/cdk-hnb659fds-*",
"arn:aws:iam::${local.account_id}:role/OrganizationAccountAccessRole",
"arn:aws:iam::${local.account_id}:role/seahaven-*",
]
}
statement {
sid = "DenyBoundaryTampering"
effect = "Deny"
actions = [
"iam:DeleteRolePermissionsBoundary",
"iam:DeleteUserPermissionsBoundary",
]
resources = [
"arn:aws:iam::${local.account_id}:role/*",
"arn:aws:iam::${local.account_id}:user/*",
]
}
statement {
sid = "DenyBoundaryPolicyEdit"
effect = "Deny"
actions = [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
]
resources = [
"arn:aws:iam::${local.account_id}:policy/seahaven-*",
"arn:aws:iam::${local.account_id}:policy/tf-managed/*",
]
}
}
data "aws_iam_policy_document" "hcptf_apply_services" {
statement {
sid = "BackupAndArtifactBuckets"
effect = "Allow"
actions = ["s3:*"]
resources = [
"arn:aws:s3:::${local.backup_bucket_name}",
"arn:aws:s3:::${local.backup_bucket_name}/*",
"arn:aws:s3:::${local.replica_bucket_name}",
"arn:aws:s3:::${local.replica_bucket_name}/*",
"arn:aws:s3:::${local.artifacts_bucket_name}",
"arn:aws:s3:::${local.artifacts_bucket_name}/*",
]
}
# RunInstances and CreateVolume do not support a useful resource ARN.
# This document is a managed policy so it is not counted against the
# apply role's 10,240-character inline quota. The plan role does not get it.
statement {
sid = "Ec2Host"
effect = "Allow"
actions = [
"ec2:AssociateIamInstanceProfile",
"ec2:AttachVolume",
"ec2:AuthorizeSecurityGroupEgress",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:CreateSecurityGroup",
"ec2:CreateTags",
"ec2:CreateVolume",
"ec2:DeleteSecurityGroup",
"ec2:DeleteTags",
"ec2:DeleteVolume",
"ec2:DescribeAccountAttributes",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeIamInstanceProfileAssociations",
"ec2:DescribeImages",
"ec2:DescribeInstanceAttribute",
"ec2:DescribeInstanceCreditSpecifications",
"ec2:DescribeInstanceStatus",
"ec2:DescribeInstanceTypes",
"ec2:DescribeInstances",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeSecurityGroupRules",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeTags",
"ec2:DescribeVolumeAttribute",
"ec2:DescribeVolumeStatus",
"ec2:DescribeVolumes",
"ec2:DescribeVpcAttribute",
"ec2:DescribeVpcs",
"ec2:DetachVolume",
"ec2:DisassociateIamInstanceProfile",
"ec2:ModifyInstanceAttribute",
"ec2:ModifySecurityGroupRules",
"ec2:ModifyVolume",
"ec2:ReplaceIamInstanceProfileAssociation",
"ec2:RevokeSecurityGroupEgress",
"ec2:RevokeSecurityGroupIngress",
"ec2:RunInstances",
"ec2:StartInstances",
"ec2:StopInstances",
"ec2:TerminateInstances",
]
resources = ["*"]
}
# Listener and rule ARNs are allocated at create time.
statement {
sid = "LoadBalancer"
effect = "Allow"
actions = ["elasticloadbalancing:*"]
resources = ["*"]
}
statement {
sid = "Certificate"
effect = "Allow"
actions = ["acm:*"]
resources = ["*"]
}
statement {
sid = "Snapshots"
effect = "Allow"
actions = ["dlm:*"]
resources = ["*"]
}
statement {
sid = "VerificationFunction"
effect = "Allow"
actions = ["lambda:*"]
resources = [
"arn:aws:lambda:${var.aws_region}:${local.account_id}:function:forgejo-backup-verification",
]
}
statement {
sid = "VerificationSchedules"
effect = "Allow"
actions = ["events:*"]
resources = [
"arn:aws:events:${var.aws_region}:${local.account_id}:rule/forgejo-backup-*",
]
}
# CreateLogGroup is not reliable on the log-group ARN before the group exists.
statement {
sid = "CreateVerificationLogGroup"
effect = "Allow"
actions = ["logs:CreateLogGroup"]
resources = ["*"]
}
statement {
sid = "VerificationLogs"
effect = "Allow"
actions = [
"logs:DeleteLogGroup",
"logs:DeleteRetentionPolicy",
"logs:DescribeLogGroups",
"logs:ListTagsForResource",
"logs:PutRetentionPolicy",
"logs:TagResource",
"logs:UntagResource",
]
resources = [
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:${local.verification_log_group}",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:${local.verification_log_group}:*",
]
}
statement {
sid = "VerificationAlarms"
effect = "Allow"
actions = [
"cloudwatch:DeleteAlarms",
"cloudwatch:DescribeAlarms",
"cloudwatch:ListTagsForResource",
"cloudwatch:PutMetricAlarm",
"cloudwatch:TagResource",
"cloudwatch:UntagResource",
]
resources = [
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:forgejo-backup-*",
]
}
statement {
sid = "DescribeAlarms"
effect = "Allow"
actions = ["cloudwatch:DescribeAlarms"]
resources = ["*"]
}
statement {
sid = "BackupPrefixParameter"
effect = "Allow"
actions = [
"ssm:AddTagsToResource",
"ssm:DeleteParameter",
"ssm:GetParameter",
"ssm:ListTagsForResource",
"ssm:PutParameter",
"ssm:RemoveTagsFromResource",
]
resources = [
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter/forgejo/*",
]
}
statement {
sid = "AlertTopicRead"
effect = "Allow"
actions = [
"sns:GetTopicAttributes",
"sns:ListTagsForResource",
]
resources = [local.site_alerts_arn]
}
statement {
sid = "EbsEncryption"
effect = "Allow"
actions = [
"kms:CreateGrant",
"kms:Decrypt",
"kms:DescribeKey",
"kms:GenerateDataKeyWithoutPlaintext",
"kms:ReEncryptFrom",
"kms:ReEncryptTo",
]
resources = ["*"]
condition {
test = "StringEquals"
variable = "kms:ViaService"
values = ["ec2.${var.aws_region}.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "hcptf_plan_refresh" {
statement {
sid = "RefreshIamRoles"
effect = "Allow"
actions = [
"iam:GetInstanceProfile",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:GetUser",
"iam:GetUserPolicy",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfilesForRole",
"iam:ListRolePolicies",
"iam:ListRoleTags",
"iam:ListUserPolicies",
"iam:ListUserTags",
]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${local.account_id}:instance-profile/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${local.account_id}:user/tf-managed/${local.gcs_user_name}",
"arn:aws:iam::${local.account_id}:role/${local.apply_role}",
"arn:aws:iam::${local.account_id}:role/${local.plan_role}",
]
}
statement {
sid = "RefreshManagedPolicies"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
]
resources = ["*"]
}
statement {
sid = "RefreshS3"
effect = "Allow"
actions = [
"s3:GetAccelerateConfiguration",
"s3:GetBucketAcl",
"s3:GetBucketCORS",
"s3:GetBucketLocation",
"s3:GetBucketLogging",
"s3:GetBucketNotification",
"s3:GetBucketObjectLockConfiguration",
"s3:GetBucketOwnershipControls",
"s3:GetBucketPolicy",
"s3:GetBucketPolicyStatus",
"s3:GetBucketPublicAccessBlock",
"s3:GetBucketRequestPayment",
"s3:GetBucketTagging",
"s3:GetBucketVersioning",
"s3:GetEncryptionConfiguration",
"s3:GetLifecycleConfiguration",
"s3:GetReplicationConfiguration",
"s3:ListBucket",
]
resources = [
"arn:aws:s3:::${local.backup_bucket_name}",
"arn:aws:s3:::${local.replica_bucket_name}",
"arn:aws:s3:::${local.artifacts_bucket_name}",
]
}
statement {
sid = "RefreshEc2"
effect = "Allow"
actions = [
"ec2:DescribeAccountAttributes",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeIamInstanceProfileAssociations",
"ec2:DescribeImages",
"ec2:DescribeInstanceAttribute",
"ec2:DescribeInstanceCreditSpecifications",
"ec2:DescribeInstanceStatus",
"ec2:DescribeInstanceTypes",
"ec2:DescribeInstances",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeSecurityGroupRules",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeTags",
"ec2:DescribeVolumeAttribute",
"ec2:DescribeVolumeStatus",
"ec2:DescribeVolumes",
"ec2:DescribeVpcAttribute",
"ec2:DescribeVpcs",
]
resources = ["*"]
}
statement {
sid = "RefreshLoadBalancer"
effect = "Allow"
actions = [
"elasticloadbalancing:DescribeListenerAttributes",
"elasticloadbalancing:DescribeListeners",
"elasticloadbalancing:DescribeLoadBalancerAttributes",
"elasticloadbalancing:DescribeLoadBalancers",
"elasticloadbalancing:DescribeRules",
"elasticloadbalancing:DescribeTags",
"elasticloadbalancing:DescribeTargetGroupAttributes",
"elasticloadbalancing:DescribeTargetGroups",
"elasticloadbalancing:DescribeTargetHealth",
]
resources = ["*"]
}
statement {
sid = "RefreshCertificate"
effect = "Allow"
actions = [
"acm:DescribeCertificate",
"acm:ListCertificates",
"acm:ListTagsForCertificate",
]
resources = ["*"]
}
statement {
sid = "RefreshLambda"
effect = "Allow"
actions = [
"lambda:GetFunction",
"lambda:GetFunctionCodeSigningConfig",
"lambda:GetFunctionConfiguration",
"lambda:GetPolicy",
"lambda:GetRuntimeManagementConfig",
"lambda:ListTags",
"lambda:ListVersionsByFunction",
]
resources = [
"arn:aws:lambda:${var.aws_region}:${local.account_id}:function:forgejo-backup-verification",
]
}
statement {
sid = "RefreshSchedules"
effect = "Allow"
actions = [
"events:DescribeRule",
"events:ListTagsForResource",
"events:ListTargetsByRule",
]
resources = [
"arn:aws:events:${var.aws_region}:${local.account_id}:rule/forgejo-backup-*",
]
}
statement {
sid = "RefreshLogs"
effect = "Allow"
actions = [
"logs:DescribeLogGroups",
"logs:ListTagsForResource",
]
resources = [
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:${local.verification_log_group}",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:${local.verification_log_group}:*",
]
}
statement {
sid = "RefreshAlarms"
effect = "Allow"
actions = [
"cloudwatch:DescribeAlarms",
"cloudwatch:ListTagsForResource",
]
resources = ["*"]
}
statement {
sid = "RefreshParameter"
effect = "Allow"
actions = [
"ssm:GetParameter",
"ssm:ListTagsForResource",
]
resources = [
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter/forgejo/*",
]
}
statement {
sid = "RefreshSnapshots"
effect = "Allow"
actions = [
"dlm:GetLifecyclePolicy",
"dlm:ListTagsForResource",
]
resources = ["arn:aws:dlm:${var.aws_region}:${local.account_id}:policy/*"]
}
statement {
sid = "RefreshSns"
effect = "Allow"
actions = [
"sns:GetTopicAttributes",
"sns:ListTagsForResource",
]
resources = [local.site_alerts_arn]
}
}
resource "aws_iam_role" "hcptf_apply" {
name = local.apply_role
assume_role_policy = data.aws_iam_policy_document.hcptf_apply_trust.json
max_session_duration = 3600
tags = {
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
}
resource "aws_iam_role" "hcptf_plan" {
name = local.plan_role
assume_role_policy = data.aws_iam_policy_document.hcptf_plan_trust.json
max_session_duration = 3600
tags = {
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
}
resource "aws_iam_role_policy" "hcptf_scoped_iam" {
name = "scoped-iam-management"
role = aws_iam_role.hcptf_apply.id
policy = data.aws_iam_policy_document.hcptf_scoped_iam.json
}
# Managed, not inline: the enumerated EC2 list plus scoped-iam-management
# exceeds the 10,240-character inline quota. Bootstrap creates this on the
# first apply. Later document edits need that window (CreatePolicyVersion
# is denied on hcptf-forgejo).
resource "aws_iam_policy" "hcptf_apply_services" {
name = "forgejo-services"
path = "/tf-managed/"
description = "Forgejo HCP apply service permissions (PLAT-80)."
policy = data.aws_iam_policy_document.hcptf_apply_services.json
}
resource "aws_iam_role_policy" "hcptf_plan_refresh" {
name = "forgejo-plan-refresh"
role = aws_iam_role.hcptf_plan.id
policy = data.aws_iam_policy_document.hcptf_plan_refresh.json
}
resource "aws_iam_role_policy_attachment" "hcptf_plan_view_only" {
role = aws_iam_role.hcptf_plan.name
policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"
}
resource "aws_iam_role_policy_attachments_exclusive" "hcptf_apply" {
role_name = aws_iam_role.hcptf_apply.name
policy_arns = [
aws_iam_policy.hcptf_apply_services.arn,
]
}
resource "aws_iam_role_policy_attachments_exclusive" "hcptf_plan" {
role_name = aws_iam_role.hcptf_plan.name
policy_arns = [
"arn:aws:iam::aws:policy/job-function/ViewOnlyAccess",
]
}

429
terraform/iam.tf Normal file
View file

@ -0,0 +1,429 @@
# Exec-role ceiling. CreatePolicy is denied on hcptf-forgejo, so the first
# apply (as hcptf-bootstrap) creates this policy and later document edits need
# that same bootstrap window.
data "aws_iam_policy_document" "exec_boundary" {
statement {
sid = "BackupBuckets"
effect = "Allow"
actions = [
"s3:AbortMultipartUpload",
"s3:GetBucketLocation",
"s3:GetBucketVersioning",
"s3:GetObject",
"s3:GetObjectVersion",
"s3:GetObjectVersionAcl",
"s3:GetObjectVersionForReplication",
"s3:GetObjectVersionTagging",
"s3:GetReplicationConfiguration",
"s3:ListBucket",
"s3:PutObject",
"s3:ReplicateDelete",
"s3:ReplicateObject",
"s3:ReplicateTags",
]
resources = [
"arn:aws:s3:::${local.backup_bucket_name}",
"arn:aws:s3:::${local.backup_bucket_name}/*",
"arn:aws:s3:::${local.replica_bucket_name}",
"arn:aws:s3:::${local.replica_bucket_name}/*",
]
}
statement {
sid = "ForgejoSecrets"
effect = "Allow"
actions = ["secretsmanager:GetSecretValue"]
resources = [
local.secret_arns.api_token,
local.secret_arns.github_pat,
local.secret_arns.gcs_sa_key,
local.secret_arns.slack_webhook,
]
}
statement {
sid = "BackupPrefix"
effect = "Allow"
actions = [
"ssm:GetParameter",
"ssm:GetParameters",
]
resources = [
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter/forgejo/*",
]
}
statement {
sid = "SnapshotLifecycle"
effect = "Allow"
actions = [
"ec2:CopySnapshot",
"ec2:CreateSnapshot",
"ec2:CreateSnapshots",
"ec2:CreateTags",
"ec2:DeleteSnapshot",
"ec2:DeleteTags",
"ec2:Describe*",
"ec2:DisableFastSnapshotRestores",
"ec2:EnableFastSnapshotRestores",
"ec2:ModifySnapshotAttribute",
"ec2:ResetSnapshotAttribute",
]
resources = ["*"]
}
statement {
sid = "VerificationLogs"
effect = "Allow"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = [
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:${local.verification_log_group}",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:${local.verification_log_group}:*",
]
}
statement {
sid = "SsmAgentBuckets"
effect = "Allow"
actions = ["s3:GetObject"]
resources = [
"arn:aws:s3:::aws-ssm-*/*",
"arn:aws:s3:::aws-windows-downloads-*/*",
"arn:aws:s3:::amazon-ssm-*/*",
"arn:aws:s3:::amazon-ssm-packages-*/*",
"arn:aws:s3:::patch-baseline-snapshot-*/*",
]
}
statement {
sid = "SsmManagedInstance"
effect = "Allow"
actions = [
"ssm:DescribeAssociation",
"ssm:DescribeDocument",
"ssm:GetDeployablePatchSnapshotForInstance",
"ssm:GetDocument",
"ssm:GetManifest",
"ssm:ListAssociations",
"ssm:ListInstanceAssociations",
"ssm:PutComplianceItems",
"ssm:PutConfigurePackageResult",
"ssm:PutInventory",
"ssm:UpdateAssociationStatus",
"ssm:UpdateInstanceAssociationStatus",
"ssm:UpdateInstanceInformation",
]
resources = ["*"]
}
statement {
sid = "SsmAgentParameters"
effect = "Allow"
actions = [
"ssm:GetParameter",
"ssm:GetParameters",
]
resources = [
"arn:aws:ssm:${var.aws_region}::parameter/aws/service/*",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter/aws/service/*",
]
}
statement {
sid = "SsmMessages"
effect = "Allow"
actions = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel",
]
resources = ["*"]
}
statement {
sid = "Ec2Messages"
effect = "Allow"
actions = [
"ec2messages:AcknowledgeMessage",
"ec2messages:DeleteMessage",
"ec2messages:FailMessage",
"ec2messages:GetEndpoint",
"ec2messages:GetMessages",
"ec2messages:SendReply",
]
resources = ["*"]
}
}
resource "aws_iam_policy" "exec_boundary" {
name = local.boundary_name
path = "/tf-managed/"
description = "Permissions boundary for Forgejo exec roles (PLAT-80)."
policy = data.aws_iam_policy_document.exec_boundary.json
}
data "aws_iam_policy_document" "instance_assume" {
statement {
sid = "Ec2Assume"
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "instance" {
name = local.instance_role_name
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.instance_assume.json
permissions_boundary = aws_iam_policy.exec_boundary.arn
}
resource "aws_iam_role_policy_attachment" "instance_ssm" {
role = aws_iam_role.instance.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
data "aws_iam_policy_document" "instance" {
statement {
sid = "BackupBucket"
effect = "Allow"
actions = [
"s3:GetBucketLocation",
"s3:GetObject",
"s3:ListBucket",
"s3:PutObject",
]
resources = [
"arn:aws:s3:::${local.backup_bucket_name}",
"arn:aws:s3:::${local.backup_bucket_name}/*",
]
}
statement {
sid = "MirrorSecrets"
effect = "Allow"
actions = ["secretsmanager:GetSecretValue"]
resources = [
local.secret_arns.api_token,
local.secret_arns.github_pat,
]
}
statement {
sid = "BackupPrefix"
effect = "Allow"
actions = ["ssm:GetParameter"]
resources = [
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter/forgejo/backup-s3-prefix",
]
}
}
resource "aws_iam_role_policy" "instance" {
name = "forgejo-instance"
role = aws_iam_role.instance.id
policy = data.aws_iam_policy_document.instance.json
}
resource "aws_iam_instance_profile" "forgejo" {
name = local.instance_profile_name
path = "/tf-managed/"
role = aws_iam_role.instance.name
}
data "aws_iam_policy_document" "dlm_assume" {
statement {
sid = "DlmAssume"
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["dlm.amazonaws.com"]
}
}
}
resource "aws_iam_role" "dlm" {
name = local.dlm_role_name
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.dlm_assume.json
permissions_boundary = aws_iam_policy.exec_boundary.arn
}
resource "aws_iam_role_policy_attachment" "dlm" {
role = aws_iam_role.dlm.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSDataLifecycleManagerServiceRole"
}
data "aws_iam_policy_document" "replication_assume" {
statement {
sid = "S3Assume"
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["s3.amazonaws.com"]
}
}
}
resource "aws_iam_role" "replication" {
name = local.replication_role_name
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.replication_assume.json
permissions_boundary = aws_iam_policy.exec_boundary.arn
}
data "aws_iam_policy_document" "replication" {
statement {
sid = "ReadSource"
effect = "Allow"
actions = [
"s3:GetReplicationConfiguration",
"s3:ListBucket",
]
resources = ["arn:aws:s3:::${local.backup_bucket_name}"]
}
statement {
sid = "ReadSourceObjects"
effect = "Allow"
actions = [
"s3:GetObjectVersion",
"s3:GetObjectVersionAcl",
"s3:GetObjectVersionForReplication",
"s3:GetObjectVersionTagging",
]
resources = ["arn:aws:s3:::${local.backup_bucket_name}/*"]
}
statement {
sid = "WriteReplica"
effect = "Allow"
actions = [
"s3:ReplicateDelete",
"s3:ReplicateObject",
"s3:ReplicateTags",
]
resources = ["arn:aws:s3:::${local.replica_bucket_name}/*"]
}
}
resource "aws_iam_role_policy" "replication" {
name = "forgejo-s3-replication"
role = aws_iam_role.replication.id
policy = data.aws_iam_policy_document.replication.json
}
data "aws_iam_policy_document" "lambda_assume" {
statement {
sid = "LambdaAssume"
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "lambda" {
name = local.lambda_role_name
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = aws_iam_policy.exec_boundary.arn
}
data "aws_iam_policy_document" "lambda" {
statement {
sid = "ReadBackups"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:ListBucket",
]
resources = [
"arn:aws:s3:::${local.backup_bucket_name}",
"arn:aws:s3:::${local.backup_bucket_name}/*",
"arn:aws:s3:::${local.replica_bucket_name}",
"arn:aws:s3:::${local.replica_bucket_name}/*",
]
}
statement {
sid = "VerificationSecrets"
effect = "Allow"
actions = ["secretsmanager:GetSecretValue"]
resources = [
local.secret_arns.gcs_sa_key,
local.secret_arns.slack_webhook,
]
}
statement {
sid = "Snapshots"
effect = "Allow"
actions = ["ec2:DescribeSnapshots"]
resources = ["*"]
}
statement {
sid = "Logs"
effect = "Allow"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = [
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:${local.verification_log_group}",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:${local.verification_log_group}:*",
]
}
}
resource "aws_iam_role_policy" "lambda" {
name = "forgejo-backup-verification"
role = aws_iam_role.lambda.id
policy = data.aws_iam_policy_document.lambda.json
}
resource "aws_iam_user" "gcs_transfer" {
name = local.gcs_user_name
path = "/tf-managed/"
permissions_boundary = aws_iam_policy.exec_boundary.arn
}
data "aws_iam_policy_document" "gcs_transfer" {
statement {
sid = "ReadBackups"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:ListBucket",
]
resources = [
"arn:aws:s3:::${local.backup_bucket_name}",
"arn:aws:s3:::${local.backup_bucket_name}/*",
]
}
}
resource "aws_iam_user_policy" "gcs_transfer" {
name = "forgejo-gcs-transfer"
user = aws_iam_user.gcs_transfer.name
policy = data.aws_iam_policy_document.gcs_transfer.json
}

105
terraform/lambda.tf Normal file
View file

@ -0,0 +1,105 @@
data "external" "lambda_package" {
program = ["bash", "${path.module}/build_lambda_external.sh"]
}
data "archive_file" "lambda_package" {
type = "zip"
source_dir = "${path.module}/build/function"
output_path = "${path.module}/build/forgejo-backup-verification.zip"
depends_on = [data.external.lambda_package]
}
resource "aws_s3_object" "lambda_package" {
bucket = aws_s3_bucket.artifacts.id
key = "functions/forgejo-backup-verification.zip"
content_base64 = filebase64(data.archive_file.lambda_package.output_path)
source_hash = data.archive_file.lambda_package.output_base64sha256
}
resource "aws_cloudwatch_log_group" "verification" {
name = local.verification_log_group
retention_in_days = 60
}
resource "aws_lambda_function" "verification" {
function_name = "forgejo-backup-verification"
role = aws_iam_role.lambda.arn
runtime = "python3.12"
architectures = ["arm64"]
handler = "app.handler"
memory_size = 512
timeout = 300
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.lambda_package.key
source_code_hash = data.archive_file.lambda_package.output_base64sha256
ephemeral_storage {
size = 4096
}
environment {
variables = {
SOURCE_BUCKET = aws_s3_bucket.backups.id
REPLICA_BUCKET = aws_s3_bucket.replica.id
GCS_BUCKET = "forgejo-backups-offsite-seahaven"
GCS_SA_SECRET_NAME = "forgejo/gcs-sa-key"
SLACK_WEBHOOK_SECRET_NAME = "forgejo/slack-webhook"
}
}
depends_on = [
aws_cloudwatch_log_group.verification,
aws_iam_role_policy.lambda,
aws_s3_object.lambda_package,
]
}
resource "aws_cloudwatch_event_rule" "daily" {
name = "forgejo-backup-daily-check"
description = "Daily Forgejo backup verification"
schedule_expression = "cron(0 8 * * ? *)"
state = var.enable_schedules ? "ENABLED" : "DISABLED"
}
resource "aws_cloudwatch_event_target" "daily" {
rule = aws_cloudwatch_event_rule.daily.name
arn = aws_lambda_function.verification.arn
input = jsonencode({
mode = "daily"
})
}
resource "aws_lambda_permission" "daily" {
statement_id = "AllowDailyCheck"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.verification.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.daily.arn
}
resource "aws_cloudwatch_event_rule" "monthly" {
name = "forgejo-backup-monthly-restore-test"
description = "Monthly Forgejo restore test"
schedule_expression = "cron(0 9 1 * ? *)"
state = var.enable_schedules ? "ENABLED" : "DISABLED"
}
resource "aws_cloudwatch_event_target" "monthly" {
rule = aws_cloudwatch_event_rule.monthly.name
arn = aws_lambda_function.verification.arn
input = jsonencode({
mode = "restore-test"
})
}
resource "aws_lambda_permission" "monthly" {
statement_id = "AllowMonthlyRestoreTest"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.verification.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.monthly.arn
}

58
terraform/locals.tf Normal file
View file

@ -0,0 +1,58 @@
locals {
project = "forgejo"
account_id = "011934824531"
environment = "prod"
hcp_project = "seahaven-prod"
hcp_workspace = "forgejo-prod"
apply_role = "hcptf-forgejo"
plan_role = "hcptf-forgejo-plan"
stack_name = local.project
stack_prefix = "forgejo-"
instance_role_name = "forgejo-instance"
instance_profile_name = "forgejo-profile"
dlm_role_name = "forgejo-dlm"
replication_role_name = "forgejo-s3-replication"
lambda_role_name = "forgejo-backup-verification"
gcs_user_name = "forgejo-gcs-transfer"
boundary_name = "forgejo-exec-boundary"
verification_log_group = "/aws/lambda/forgejo-backup-verification"
vpc_cidr = "10.70.0.0/16"
office_vpn_cidr = "10.10.0.0/16"
site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts"
backup_s3_prefix = "archive"
backup_bucket_name = "forgejo-backups-${local.account_id}"
replica_bucket_name = "forgejo-backups-replica-${local.account_id}"
artifacts_bucket_name = "forgejo-lambda-artifacts-${local.account_id}"
# Name-prefix ARNs. AWS appends a random suffix. hcptf-bootstrap-plan is
# ViewOnly and cannot DescribeSecret, so the first plan cannot use a secret
# data source. Values are never read.
secret_arns = {
admin_password = "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:forgejo/admin-password-*"
api_token = "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:forgejo/api-token-*"
github_pat = "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:forgejo/github-pat-*"
gcs_sa_key = "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:forgejo/gcs-sa-key-*"
gcs_transfer_credentials = "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:forgejo/gcs-transfer-credentials-*"
slack_webhook = "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:forgejo/slack-webhook-*"
}
instance_subnet_id = var.existing_public_subnet_ids[0]
user_data = replace(
replace(
replace(
file("${path.module}/user_data.sh"),
"__FORGEJO_VERSION__",
var.forgejo_version,
),
"__BACKUP_BUCKET__",
local.backup_bucket_name,
),
"__AWS_REGION__",
var.aws_region,
)
}

46
terraform/outputs.tf Normal file
View file

@ -0,0 +1,46 @@
output "instance_id" {
description = "Forgejo instance. Use with SSM Session Manager."
value = aws_instance.forgejo.id
}
output "private_ip" {
value = aws_instance.forgejo.private_ip
}
output "alb_dns_name" {
description = "Proof hostname before the mgmt Route53 flip. Not forgejo.seahaven.com."
value = aws_lb.forgejo.dns_name
}
output "alb_zone_id" {
description = "Alias target zone for the out-of-band record in Z06652411XKH89KTZD3XA."
value = aws_lb.forgejo.zone_id
}
output "acm_validation_records" {
description = "Create these in the mgmt seahaven.com zone before setting enable_https."
value = [
for record in aws_acm_certificate.forgejo.domain_validation_options : {
name = record.resource_record_name
type = record.resource_record_type
value = record.resource_record_value
}
]
}
output "backup_bucket" {
value = aws_s3_bucket.backups.id
}
output "replica_bucket" {
value = aws_s3_bucket.replica.id
}
output "data_volume_id" {
value = aws_ebs_volume.data.id
}
output "secret_arns" {
description = "Name-prefix ARNs. Secret values are not in this state."
value = local.secret_arns
}

26
terraform/providers.tf Normal file
View file

@ -0,0 +1,26 @@
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = local.project
Environment = "prod"
ManagedBy = "terraform"
Workspace = local.hcp_workspace
}
}
}
provider "aws" {
alias = "replica"
region = "us-west-2"
default_tags {
tags = {
Project = local.project
Environment = "prod"
ManagedBy = "terraform"
Workspace = local.hcp_workspace
}
}
}

246
terraform/s3.tf Normal file
View file

@ -0,0 +1,246 @@
resource "aws_s3_bucket" "backups" {
bucket = local.backup_bucket_name
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "backups" {
bucket = aws_s3_bucket.backups.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "backups" {
bucket = aws_s3_bucket.backups.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "backups" {
bucket = aws_s3_bucket.backups.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "backups" {
bucket = aws_s3_bucket.backups.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "backups" {
bucket = aws_s3_bucket.backups.id
rule {
id = "archive-to-glacier"
status = "Enabled"
filter {}
transition {
days = 30
storage_class = "GLACIER"
}
}
rule {
id = "cleanup-noncurrent-versions"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = 90
}
}
depends_on = [aws_s3_bucket_versioning.backups]
}
resource "aws_s3_bucket" "replica" {
provider = aws.replica
bucket = local.replica_bucket_name
object_lock_enabled = true
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "replica" {
provider = aws.replica
bucket = aws_s3_bucket.replica.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_object_lock_configuration" "replica" {
provider = aws.replica
bucket = aws_s3_bucket.replica.id
rule {
default_retention {
mode = "GOVERNANCE"
days = 90
}
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "replica" {
provider = aws.replica
bucket = aws_s3_bucket.replica.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "replica" {
provider = aws.replica
bucket = aws_s3_bucket.replica.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "replica" {
provider = aws.replica
bucket = aws_s3_bucket.replica.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "replica" {
provider = aws.replica
bucket = aws_s3_bucket.replica.id
rule {
id = "archive-to-glacier"
status = "Enabled"
filter {}
transition {
days = 30
storage_class = "GLACIER"
}
}
rule {
id = "cleanup-noncurrent-versions"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = 90
}
}
depends_on = [aws_s3_bucket_versioning.replica]
}
resource "aws_s3_bucket_replication_configuration" "backups" {
bucket = aws_s3_bucket.backups.id
role = aws_iam_role.replication.arn
rule {
id = "replicate-to-west"
status = "Enabled"
priority = 1
filter {}
delete_marker_replication {
status = "Disabled"
}
destination {
bucket = aws_s3_bucket.replica.arn
storage_class = "STANDARD"
}
}
depends_on = [
aws_s3_bucket_versioning.backups,
aws_s3_bucket_versioning.replica,
]
}
resource "aws_s3_bucket" "artifacts" {
bucket = local.artifacts_bucket_name
}
resource "aws_s3_bucket_versioning" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
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_bucket_ownership_controls" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
id = "expire-noncurrent-packages"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = 30
}
}
depends_on = [aws_s3_bucket_versioning.artifacts]
}

View file

@ -0,0 +1,19 @@
# HCP workspace variables for forgejo-prod. Do not commit a real tfvars file.
#
# existing_vpc_id = "<afterhours-shift-manager output vpc_id>"
# existing_public_subnet_ids = ["<public subnet a>", "<public subnet b>"]
#
# ami_id is pinned in variables.tf. Replace that default on purpose when the
# instance should move to a new AL2023 arm64 image. Lookup:
# aws ssm get-parameter \
# --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64 \
# --region us-east-1 --query Parameter.Value --output text
#
# enable_https = false
# enable_schedules = false
#
# Workspace environment variables, never a project variable set:
# TFC_AWS_PROVIDER_AUTH=true
# TFC_AWS_APPLY_ROLE_ARN / TFC_AWS_PLAN_ROLE_ARN
# First apply points those ARNs at hcptf-bootstrap / hcptf-bootstrap-plan.
# After that apply, point them at hcptf-forgejo / hcptf-forgejo-plan.

229
terraform/user_data.sh Normal file
View file

@ -0,0 +1,229 @@
#!/bin/bash
set -euxo pipefail
FORGEJO_VERSION="__FORGEJO_VERSION__"
BACKUP_BUCKET="__BACKUP_BUCKET__"
AWS_REGION="__AWS_REGION__"
dnf install -y git cronie python3
systemctl enable --now crond
useradd --system --shell /bin/bash --home-dir /home/forgejo --create-home forgejo
# Persistent data volume. Wait until the attachment shows up, and never format a disk that already has a filesystem.
root_disk() {
lsblk -no PKNAME "$(findmnt -n -o SOURCE /)" 2>/dev/null || echo xvda
}
data_disk() {
local root name
root="$(root_disk)"
while read -r name; do
if [ -n "$name" ] && [ "$name" != "$root" ]; then
printf '%s\n' "$name"
return 0
fi
done < <(lsblk -dno NAME)
return 1
}
until data_disk >/dev/null; do
echo "Waiting for data volume..."
sleep 5
done
DATA_DEVICE="/dev/$(data_disk)"
if ! blkid "$DATA_DEVICE"; then
mkfs.ext4 -L forgejo-data "$DATA_DEVICE"
fi
mkdir -p /var/lib/forgejo
echo "LABEL=forgejo-data /var/lib/forgejo ext4 defaults,nofail 0 2" >> /etc/fstab
mount -a
mkdir -p /var/lib/forgejo/{data,log}
chown -R forgejo:forgejo /var/lib/forgejo
chmod 750 /var/lib/forgejo
curl -Lo /usr/local/bin/forgejo "https://codeberg.org/forgejo/forgejo/releases/download/v${FORGEJO_VERSION}/forgejo-${FORGEJO_VERSION}-linux-arm64"
chmod +x /usr/local/bin/forgejo
mkdir -p /etc/forgejo
chown root:forgejo /etc/forgejo
chmod 770 /etc/forgejo
cat > /etc/forgejo/app.ini << 'INIEOF'
APP_NAME = Sea Haven Git
[server]
DOMAIN = forgejo.seahaven.com
ROOT_URL = https://forgejo.seahaven.com/
HTTP_PORT = 3000
START_SSH_SERVER = true
SSH_PORT = 2222
SSH_LISTEN_PORT = 2222
LFS_START_SERVER = true
[database]
DB_TYPE = sqlite3
PATH = /var/lib/forgejo/data/forgejo.db
[repository]
ROOT = /var/lib/forgejo/data/repositories
[log]
ROOT_PATH = /var/lib/forgejo/log
[security]
INSTALL_LOCK = true
[service]
DISABLE_REGISTRATION = true
[mirror]
DEFAULT_INTERVAL = 1h
INIEOF
chown root:forgejo /etc/forgejo/app.ini
chmod 660 /etc/forgejo/app.ini
cat > /etc/systemd/system/forgejo.service << 'SVCEOF'
[Unit]
Description=Forgejo
After=network.target
[Service]
Type=simple
User=forgejo
Group=forgejo
WorkingDirectory=/var/lib/forgejo
ExecStart=/usr/local/bin/forgejo web --config /etc/forgejo/app.ini
Restart=always
RestartSec=5
Environment=USER=forgejo HOME=/home/forgejo FORGEJO_WORK_DIR=/var/lib/forgejo
[Install]
WantedBy=multi-user.target
SVCEOF
systemctl daemon-reload
# Restore from the latest S3 dump when the data volume has no database.
if [ ! -f /var/lib/forgejo/data/forgejo.db ]; then
echo "No database on data volume - restoring latest backup from S3"
S3_PREFIX="$(aws ssm get-parameter --name /forgejo/backup-s3-prefix --query Parameter.Value --output text --region "${AWS_REGION}" || echo archive)"
LATEST="$(aws s3 ls "s3://${BACKUP_BUCKET}/${S3_PREFIX}/" --region "${AWS_REGION}" | awk '{print $2}' | sort | tail -1 | tr -d '/')"
if [ -n "$LATEST" ]; then
FILE="$(aws s3 ls "s3://${BACKUP_BUCKET}/${S3_PREFIX}/${LATEST}/" --region "${AWS_REGION}" | awk '{print $4}' | tail -1)"
RESTORE_DIR="$(mktemp -d)"
aws s3 cp "s3://${BACKUP_BUCKET}/${S3_PREFIX}/${LATEST}/${FILE}" "$RESTORE_DIR/dump.tar.gz" --region "${AWS_REGION}"
tar xzf "$RESTORE_DIR/dump.tar.gz" -C "$RESTORE_DIR"
cp -a "$RESTORE_DIR"/data/. /var/lib/forgejo/data/
mkdir -p /var/lib/forgejo/data/repositories
cp -a "$RESTORE_DIR"/repos/. /var/lib/forgejo/data/repositories/
chown -R forgejo:forgejo /var/lib/forgejo
rm -rf "$RESTORE_DIR"
else
echo "No backup found in S3 - starting fresh"
fi
fi
systemctl enable --now forgejo
cat > /usr/local/bin/forgejo-backup.sh << 'BAKEOF'
#!/bin/bash
set -euo pipefail
TIMESTAMP="$(date +%Y-%m-%d)"
S3_PREFIX="$(aws ssm get-parameter --name /forgejo/backup-s3-prefix --query Parameter.Value --output text --region __AWS_REGION__ || echo archive)"
DUMP_DIR="$(mktemp -d)"
chown forgejo:forgejo "$DUMP_DIR"
cd "$DUMP_DIR"
sudo -u forgejo /usr/local/bin/forgejo dump --config /etc/forgejo/app.ini --type tar.gz --file "$DUMP_DIR/forgejo-${TIMESTAMP}.tar.gz"
aws s3 cp "$DUMP_DIR/forgejo-${TIMESTAMP}.tar.gz" "s3://__BACKUP_BUCKET__/${S3_PREFIX}/${TIMESTAMP}/forgejo-${TIMESTAMP}.tar.gz" --region __AWS_REGION__
rm -rf "$DUMP_DIR"
BAKEOF
chmod +x /usr/local/bin/forgejo-backup.sh
echo "0 5 * * * root /usr/local/bin/forgejo-backup.sh >> /var/log/forgejo-backup.log 2>&1" > /etc/cron.d/forgejo-backup
chmod 644 /etc/cron.d/forgejo-backup
cat > /usr/local/bin/forgejo-autodiscover.sh << 'ADEOF'
#!/bin/bash
set -euo pipefail
GH_PAT="$(aws secretsmanager get-secret-value --secret-id forgejo/github-pat --query SecretString --output text --region __AWS_REGION__)"
FORGEJO_TOKEN="$(aws secretsmanager get-secret-value --secret-id forgejo/api-token --query SecretString --output text --region __AWS_REGION__)"
FORGEJO_URL="https://forgejo.seahaven.com/api/v1"
GH_ORG="Sea-Haven-Industries"
gh_repos="$(curl -sf -H "Authorization: token ${GH_PAT}" "https://api.github.com/orgs/${GH_ORG}/repos?per_page=100&type=all" | python3 -c '
import json, sys
for r in json.load(sys.stdin):
print("%s\t%s" % (r["name"], r["archived"]))
')"
forgejo_repos="$(curl -sf -H "Authorization: token ${FORGEJO_TOKEN}" "${FORGEJO_URL}/repos/search?limit=100" | python3 -c '
import json, sys
data = json.load(sys.stdin)
repos = data.get("data", data) if isinstance(data, dict) else data
for r in repos:
print(r["name"])
')"
while IFS=$'\t' read -r name archived; do
if ! echo "$forgejo_repos" | grep -qx "$name"; then
mirror=true
[ "$archived" = "True" ] && mirror=false
echo "$(date -Is) Discovering: $name (mirror=$mirror)"
curl -sf -X POST "${FORGEJO_URL}/repos/migrate" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"clone_addr\": \"https://github.com/${GH_ORG}/${name}.git\",
\"auth_token\": \"${GH_PAT}\",
\"repo_name\": \"${name}\",
\"repo_owner\": \"adam\",
\"service\": \"github\",
\"mirror\": ${mirror},
\"issues\": true,
\"labels\": true,
\"milestones\": true,
\"pull_requests\": true,
\"releases\": true,
\"wiki\": true
}" > /dev/null
fi
done <<< "$gh_repos"
ADEOF
chmod +x /usr/local/bin/forgejo-autodiscover.sh
cat > /usr/local/bin/forgejo-refresh-tokens.sh << 'RTEOF'
#!/bin/bash
set -euo pipefail
GH_PAT="$(aws secretsmanager get-secret-value --secret-id forgejo/github-pat --query SecretString --output text --region __AWS_REGION__)"
FORGEJO_TOKEN="$(aws secretsmanager get-secret-value --secret-id forgejo/api-token --query SecretString --output text --region __AWS_REGION__)"
FORGEJO_URL="https://forgejo.seahaven.com/api/v1"
REPO_ROOT="/var/lib/forgejo/data/repositories/adam"
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0=safe.directory
export GIT_CONFIG_VALUE_0='*'
mirrors="$(curl -sf -H "Authorization: token ${FORGEJO_TOKEN}" "${FORGEJO_URL}/repos/search?limit=100" | python3 -c '
import json, sys
data = json.load(sys.stdin)
repos = data.get("data", data) if isinstance(data, dict) else data
for r in repos:
if r.get("mirror", False):
print(r["name"])
')"
while read -r repo_name; do
[ -z "$repo_name" ] && continue
repo_dir="${REPO_ROOT}/${repo_name}.git"
if [ -d "$repo_dir" ]; then
new_url="https://${GH_PAT}@github.com/Sea-Haven-Industries/${repo_name}.git"
git -C "$repo_dir" remote set-url origin "$new_url" 2>/dev/null && echo "$(date -Is) Refreshed: ${repo_name}"
fi
done <<< "$mirrors"
RTEOF
chmod +x /usr/local/bin/forgejo-refresh-tokens.sh
printf '%s\n' '0 * * * * root /usr/local/bin/forgejo-autodiscover.sh >> /var/log/forgejo-autodiscover.log 2>&1' > /etc/cron.d/forgejo-autodiscover
printf '%s\n' '30 4 * * * root /usr/local/bin/forgejo-refresh-tokens.sh >> /var/log/forgejo-refresh-tokens.log 2>&1' > /etc/cron.d/forgejo-refresh-tokens
chmod 644 /etc/cron.d/forgejo-autodiscover /etc/cron.d/forgejo-refresh-tokens

64
terraform/variables.tf Normal file
View file

@ -0,0 +1,64 @@
variable "aws_region" {
type = string
description = "Primary region. The replica bucket provider is fixed to us-west-2."
default = "us-east-1"
validation {
condition = var.aws_region == "us-east-1"
error_message = "Forgejo primary region is us-east-1."
}
}
variable "ami_id" {
type = string
description = "Pinned Amazon Linux 2023 arm64 AMI. Do not switch this to most_recent. Changing it replaces the instance; the data volume is reattached."
default = "ami-0eb45f74aa8a20238"
validation {
condition = can(regex("^ami-[0-9a-f]+$", var.ami_id))
error_message = "ami_id must be an AMI id."
}
}
variable "forgejo_version" {
type = string
description = "Forgejo release installed by user data. Changing it replaces the instance."
default = "10.0.1"
validation {
condition = can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+$", var.forgejo_version))
error_message = "forgejo_version must be a dotted numeric version."
}
}
variable "existing_vpc_id" {
type = string
description = "After Hours VPC in seahaven-prod (10.70.0.0/16). Set from the afterhours-shift-manager output vpc_id. HCP workspace variable."
validation {
condition = can(regex("^vpc-[0-9a-f]+$", var.existing_vpc_id))
error_message = "existing_vpc_id must be a VPC id."
}
}
variable "existing_public_subnet_ids" {
type = list(string)
description = "Public subnet IDs in existing_vpc_id, at least two AZs. The instance and its data volume use the first subnet's AZ. Set from the afterhours-shift-manager output public_subnet_ids."
validation {
condition = length(var.existing_public_subnet_ids) >= 2
error_message = "ALB requires at least two public subnets."
}
}
variable "enable_https" {
type = bool
description = "Forward the ALB on HTTPS. Leave false until the ACM DNS validation record exists in the mgmt seahaven.com zone and the certificate is Issued."
default = false
}
variable "enable_schedules" {
type = bool
description = "Enable backup-verification schedules and alarms. Leave false until cutover so a disabled checker does not page site-alerts."
default = false
}

26
terraform/versions.tf Normal file
View file

@ -0,0 +1,26 @@
terraform {
required_version = ">= 1.14.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.64"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.7"
}
external = {
source = "hashicorp/external"
version = "~> 2.3"
}
}
cloud {
organization = "seahaven"
workspaces {
name = "forgejo-prod"
}
}
}