From 530bd7210effa3f098aa4dd11f19db652416f218 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 29 Sep 2026 18:36:17 -0400 Subject: [PATCH] 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. --- .github/workflows/ci.yaml | 46 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/deploy.yaml | 7 +- .github/workflows/labeler.yml | 2 +- .gitignore | 4 + README.md | 39 +- terraform/.terraform.lock.hcl | 68 ++ terraform/alarms.tf | 41 ++ terraform/alb.tf | 117 ++++ terraform/build_lambda.sh | 25 + terraform/build_lambda_external.sh | 22 + terraform/data.tf | 33 + terraform/ec2.tf | 148 +++++ terraform/hcp_iam.tf | 804 ++++++++++++++++++++++++ terraform/iam.tf | 429 +++++++++++++ terraform/lambda.tf | 105 ++++ terraform/locals.tf | 58 ++ terraform/outputs.tf | 46 ++ terraform/providers.tf | 26 + terraform/s3.tf | 246 ++++++++ terraform/terraform.tfvars.example | 19 + terraform/user_data.sh | 229 +++++++ terraform/variables.tf | 64 ++ terraform/versions.tf | 26 + 24 files changed, 2588 insertions(+), 18 deletions(-) create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/alarms.tf create mode 100644 terraform/alb.tf create mode 100644 terraform/build_lambda.sh create mode 100644 terraform/build_lambda_external.sh create mode 100644 terraform/data.tf create mode 100644 terraform/ec2.tf create mode 100644 terraform/hcp_iam.tf create mode 100644 terraform/iam.tf create mode 100644 terraform/lambda.tf create mode 100644 terraform/locals.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/providers.tf create mode 100644 terraform/s3.tf create mode 100644 terraform/terraform.tfvars.example create mode 100644 terraform/user_data.sh create mode 100644 terraform/variables.tf create mode 100644 terraform/versions.tf diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 13a16fd..dc96a86 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 09793af..bdd6bf0 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -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 diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index bd6faaa..ba7c134 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -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 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 90b2c46..03d2c5b 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -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 diff --git a/.gitignore b/.gitignore index 9804997..a3b11cd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ cdk.out/ *.js.map docs/*.pdf __pycache__/ +.terraform/ +terraform/build/ +*.tfstate +*.tfstate.* diff --git a/README.md b/README.md index 8288b4b..25bff69 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..dc87726 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -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", + ] +} diff --git a/terraform/alarms.tf b/terraform/alarms.tf new file mode 100644 index 0000000..7714a5d --- /dev/null +++ b/terraform/alarms.tf @@ -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] +} diff --git a/terraform/alb.tf b/terraform/alb.tf new file mode 100644 index 0000000..cf4a3e6 --- /dev/null +++ b/terraform/alb.tf @@ -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 + } +} diff --git a/terraform/build_lambda.sh b/terraform/build_lambda.sh new file mode 100644 index 0000000..2252159 --- /dev/null +++ b/terraform/build_lambda.sh @@ -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 {} + diff --git a/terraform/build_lambda_external.sh b/terraform/build_lambda_external.sh new file mode 100644 index 0000000..d6c9b7f --- /dev/null +++ b/terraform/build_lambda_external.sh @@ -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}" diff --git a/terraform/data.tf b/terraform/data.tf new file mode 100644 index 0000000..d0bb9a1 --- /dev/null +++ b/terraform/data.tf @@ -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." + } +} diff --git a/terraform/ec2.tf b/terraform/ec2.tf new file mode 100644 index 0000000..2771077 --- /dev/null +++ b/terraform/ec2.tf @@ -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" + } + } + } +} diff --git a/terraform/hcp_iam.tf b/terraform/hcp_iam.tf new file mode 100644 index 0000000..a64145a --- /dev/null +++ b/terraform/hcp_iam.tf @@ -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", + ] +} diff --git a/terraform/iam.tf b/terraform/iam.tf new file mode 100644 index 0000000..e81f19f --- /dev/null +++ b/terraform/iam.tf @@ -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 +} diff --git a/terraform/lambda.tf b/terraform/lambda.tf new file mode 100644 index 0000000..09ab52c --- /dev/null +++ b/terraform/lambda.tf @@ -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 +} diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 0000000..21bdbac --- /dev/null +++ b/terraform/locals.tf @@ -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, + ) +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..4f2e718 --- /dev/null +++ b/terraform/outputs.tf @@ -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 +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..188079e --- /dev/null +++ b/terraform/providers.tf @@ -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 + } + } +} diff --git a/terraform/s3.tf b/terraform/s3.tf new file mode 100644 index 0000000..376b27e --- /dev/null +++ b/terraform/s3.tf @@ -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] +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..c9762ba --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,19 @@ +# HCP workspace variables for forgejo-prod. Do not commit a real tfvars file. +# +# existing_vpc_id = "" +# existing_public_subnet_ids = ["", ""] +# +# 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. diff --git a/terraform/user_data.sh b/terraform/user_data.sh new file mode 100644 index 0000000..6bf7dc2 --- /dev/null +++ b/terraform/user_data.sh @@ -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 diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..bc4a583 --- /dev/null +++ b/terraform/variables.tf @@ -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 +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..5e2e8ec --- /dev/null +++ b/terraform/versions.tf @@ -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" + } + } +}