mirror of
https://github.com/Sea-Haven-Industries/forgejo.git
synced 2026-09-30 03:03:11 +00:00
feat(terraform): migrate forgejo to HCP Terraform (PLAT-80) (#100)
* 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. * fix(terraform): restore forgejo.db from the dump tarball Boot restore was copying data/ and repos/ and leaving the sqlite file at the archive root, so a volume restore started Forgejo with no database.
This commit is contained in:
parent
e23afc0693
commit
e4982b87ad
24 changed files with 2607 additions and 18 deletions
46
.github/workflows/ci.yaml
vendored
46
.github/workflows/ci.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
7
.github/workflows/deploy.yaml
vendored
7
.github/workflows/deploy.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -6,3 +6,7 @@ cdk.out/
|
|||
*.js.map
|
||||
docs/*.pdf
|
||||
__pycache__/
|
||||
.terraform/
|
||||
terraform/build/
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
|
|
|
|||
39
README.md
39
README.md
|
|
@ -5,7 +5,31 @@
|
|||

|
||||

|
||||
|
||||
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
68
terraform/.terraform.lock.hcl
generated
Normal 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
41
terraform/alarms.tf
Normal 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
117
terraform/alb.tf
Normal 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
25
terraform/build_lambda.sh
Normal 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 {} +
|
||||
22
terraform/build_lambda_external.sh
Normal file
22
terraform/build_lambda_external.sh
Normal 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
33
terraform/data.tf
Normal 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
148
terraform/ec2.tf
Normal 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
804
terraform/hcp_iam.tf
Normal 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
429
terraform/iam.tf
Normal 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
105
terraform/lambda.tf
Normal 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
58
terraform/locals.tf
Normal 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
46
terraform/outputs.tf
Normal 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
26
terraform/providers.tf
Normal 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
246
terraform/s3.tf
Normal 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]
|
||||
}
|
||||
19
terraform/terraform.tfvars.example
Normal file
19
terraform/terraform.tfvars.example
Normal 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.
|
||||
248
terraform/user_data.sh
Normal file
248
terraform/user_data.sh
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
#!/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"
|
||||
mkdir -p /var/lib/forgejo/data /var/lib/forgejo/custom
|
||||
if [ -d "$RESTORE_DIR/data" ]; then
|
||||
cp -a "$RESTORE_DIR"/data/. /var/lib/forgejo/data/
|
||||
fi
|
||||
rm -rf /var/lib/forgejo/data/repositories
|
||||
mkdir -p /var/lib/forgejo/data/repositories
|
||||
if [ -d "$RESTORE_DIR/repos" ]; then
|
||||
cp -a "$RESTORE_DIR"/repos/. /var/lib/forgejo/data/repositories/
|
||||
fi
|
||||
# Dump sqlite lives at the archive root, not under data/. Copy it last.
|
||||
cp "$RESTORE_DIR/gitea-db.sqlite3" /var/lib/forgejo/data/forgejo.db
|
||||
if [ -d "$RESTORE_DIR/lfs" ]; then
|
||||
mkdir -p /var/lib/forgejo/data/lfs
|
||||
cp -a "$RESTORE_DIR"/lfs/. /var/lib/forgejo/data/lfs/
|
||||
fi
|
||||
if [ -d "$RESTORE_DIR/custom" ]; then
|
||||
cp -a "$RESTORE_DIR"/custom/. /var/lib/forgejo/custom/
|
||||
fi
|
||||
chown -R forgejo:forgejo /var/lib/forgejo
|
||||
rm -rf "$RESTORE_DIR"
|
||||
if [ ! -s /var/lib/forgejo/data/forgejo.db ]; then
|
||||
echo "Restore did not produce /var/lib/forgejo/data/forgejo.db" >&2
|
||||
exit 1
|
||||
fi
|
||||
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
64
terraform/variables.tf
Normal 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
26
terraform/versions.tf
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue