forgejo/lib/forgejo-stack.ts
Adam Moussa 5ed1db788e
Add 3-2-1 backup strategy with cross-region and GCS offsite (#5)
* Add 3-2-1 backup strategy with cross-region replication and GCS offsite

Implements a fully compliant 3-2-1 backup architecture:
- Copy 1 (live): Harden existing EBS snapshots to 30-day retention
- Copy 2 (near-site): S3 cross-region replication to us-west-2 with
  Object Lock (governance 90d) and versioning
- Copy 3 (offsite): GCS bucket in dedicated seahaven-backups GCP project
  with 2-year irreversible retention lock

Also adds a verification Lambda that checks all 3 locations daily and
runs monthly restore tests with SQLite integrity checks.

* Enable QEMU in CI for arm64 Lambda Docker builds

* Commit cdk.context.json for CI synth without AWS credentials

Vpc.fromLookup requires cached context to synthesize without
AWS credentials. Required for CI which runs cdk synth without
an OIDC role.

* Fix GCP project ID to sea-haven-backups

* Address code review findings for backup verification

Fix 4 critical issues:
- Add filter/priority/deleteMarkerReplication to S3 CRR rule (deploy would fail without)
- Add stack dependency so replica deploys before main stack
- Fix DB file extension matching (.sqlite3/.sql instead of .db)
- Replace nonexistent `forgejo restore` command with actual restore steps in README

Fix 4 moderate issues:
- Add timeout=10 to Slack webhook urlopen call
- Add filter='data' to tarfile.extract for PEP 706 compliance
- Add explicit ValueError for unknown handler mode
- Use date-scoped S3/GCS prefix instead of unbounded listing

* Fix backup strategy bug findings

* Handle SQL text dumps separately from binary SQLite in restore test

Forgejo dump produces gitea-db.sql as a text SQL dump (XORM export),
not a binary SQLite file. Opening it directly with sqlite3.connect()
throws DatabaseError. Now imports the SQL dump into a temp DB first.

* Fix GCS backup check: align staleness cutoff and add size validation

GCS check used a 72h cutoff but only listed 2 days of prefixes (~48h),
making the staleness check unreachable. Also added 1MB minimum file
size validation to match the S3 check.

* Rename SECRET_ARN env vars to SECRET_NAME to match actual values

* Fix EBS snapshot state check, drop unused GCS write grant and dead lifecycle rule

* Fix restore runbook, DLM snapshot tagging, README cleanup, and gsutil prompt

* Fix restore runbook: trailing-dot cp idiom and Glacier restore step

* Rename GCS service account to match read-only permissions

* Add 4 GiB ephemeral storage to verification Lambda

Monthly restore-test downloads and extracts the full dump tarball
in /tmp. As the dump grows with LFS data, the default 512 MB will
eventually cause ENOSPC failures.

* Replace hardcoded instance ID in README with CloudFormation lookup

The instance ID changes on every instance replacement (version
upgrades, stack updates). Using a dynamic query prevents stale
references and removes a manual update step from the deploy process.

* Read backup S3 prefix from SSM parameter at runtime

Adds /forgejo/backup-s3-prefix SSM parameter (value: archive)
and updates the backup script to fetch it instead of hardcoding
the prefix. Eliminates the manual post-deploy sed step.

* Address cross-review findings for backup verification

- Add size guard before downloading dump in restore test (3.5 GB cap)
- Use paginator for list_objects_v2 in S3 checks and restore test
- Remove unnecessary overrideLogicalId on GcsTransferCredentials secret
- Pass explicit { mode: "daily" } to daily EventBridge rule target
- Add fallback for SSM parameter fetch in backup script
- Export replica bucket ARN/name from replica stack, consume via props

* Add CloudWatch alarm for backup verification Lambda errors

Fires on any Lambda error and on missing data (missed schedule).
Catches silent failures where the Slack notification never fires.

* Add .env to .gitignore

Required by org CI conventions check.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-15 17:59:31 -04:00

474 lines
17 KiB
TypeScript

import * as cdk from "aws-cdk-lib";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as iam from "aws-cdk-lib/aws-iam";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as elbv2_targets from "aws-cdk-lib/aws-elasticloadbalancingv2-targets";
import * as route53 from "aws-cdk-lib/aws-route53";
import * as route53Targets from "aws-cdk-lib/aws-route53-targets";
import * as ssm from "aws-cdk-lib/aws-ssm";
import * as dlm from "aws-cdk-lib/aws-dlm";
import { Construct } from "constructs";
import { BackupVerification } from "./constructs/backup-verification";
const FORGEJO_VERSION = "10.0.1";
interface ForgejoStackProps extends cdk.StackProps {
replicaBucketArn: string;
replicaBucketName: string;
}
export class ForgejoStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: ForgejoStackProps) {
super(scope, id, props);
const vpc = ec2.Vpc.fromLookup(this, "SeaHavenVpc", {
vpcId: "vpc-0d3d4b67bd0cf8a68",
});
const privateSubnet1 = ec2.Subnet.fromSubnetAttributes(
this, "PrivateSubnet1", {
subnetId: "subnet-04e38c507e96f1926",
availabilityZone: "us-east-1a",
}
);
const sg = new ec2.SecurityGroup(this, "SecurityGroup", {
vpc,
securityGroupName: "forgejo",
description: "Forgejo git server - VPC and VPN access",
allowAllOutbound: true,
});
const albSg = ec2.SecurityGroup.fromSecurityGroupId(
this, "AlbSg", "sg-0b0301deed193258a",
{ allowAllOutbound: false }
);
albSg.addEgressRule(sg, ec2.Port.tcp(3000), "Forgejo HTTP");
sg.addIngressRule(albSg, ec2.Port.tcp(3000), "HTTP from ALB");
sg.addIngressRule(ec2.Peer.ipv4("10.20.0.0/16"), ec2.Port.tcp(3000), "HTTP from VPC");
sg.addIngressRule(ec2.Peer.ipv4("10.10.0.0/16"), ec2.Port.tcp(3000), "HTTP from office VPN");
sg.addIngressRule(ec2.Peer.ipv4("10.20.0.0/16"), ec2.Port.tcp(2222), "SSH git from VPC");
sg.addIngressRule(ec2.Peer.ipv4("10.10.0.0/16"), ec2.Port.tcp(2222), "SSH git from office VPN");
const role = new iam.Role(this, "InstanceRole", {
roleName: "forgejo-instance",
assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore"),
],
});
role.addToPolicy(new iam.PolicyStatement({
actions: ["secretsmanager:GetSecretValue"],
resources: [
`arn:aws:secretsmanager:us-east-1:328440206208:secret:forgejo/*`,
],
}));
const backupBucket = new s3.Bucket(this, "BackupBucket", {
bucketName: "forgejo-backups-328440206208",
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
versioned: true,
lifecycleRules: [
{
id: "archive-to-glacier",
prefix: "archive/",
transitions: [
{ storageClass: s3.StorageClass.GLACIER, transitionAfter: cdk.Duration.days(30) },
],
},
{
id: "cleanup-noncurrent-versions",
noncurrentVersionExpiration: cdk.Duration.days(90),
},
],
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
backupBucket.grantReadWrite(role);
const backupS3Prefix = new ssm.StringParameter(this, "BackupS3Prefix", {
parameterName: "/forgejo/backup-s3-prefix",
description: "S3 key prefix for Forgejo backup dumps",
stringValue: "archive",
});
backupS3Prefix.grantRead(role);
const replicaBucketArn = props.replicaBucketArn;
const replicationRole = new iam.Role(this, "ReplicationRole", {
roleName: "forgejo-s3-replication",
assumedBy: new iam.ServicePrincipal("s3.amazonaws.com"),
});
replicationRole.addToPolicy(new iam.PolicyStatement({
actions: [
"s3:GetReplicationConfiguration",
"s3:ListBucket",
],
resources: [backupBucket.bucketArn],
}));
replicationRole.addToPolicy(new iam.PolicyStatement({
actions: [
"s3:GetObjectVersionForReplication",
"s3:GetObjectVersionAcl",
"s3:GetObjectVersionTagging",
],
resources: [`${backupBucket.bucketArn}/*`],
}));
replicationRole.addToPolicy(new iam.PolicyStatement({
actions: [
"s3:ReplicateObject",
"s3:ReplicateDelete",
"s3:ReplicateTags",
],
resources: [`${replicaBucketArn}/*`],
}));
const cfnBucket = backupBucket.node.defaultChild as s3.CfnBucket;
cfnBucket.replicationConfiguration = {
role: replicationRole.roleArn,
rules: [{
id: "replicate-to-west",
status: "Enabled",
priority: 1,
filter: { prefix: "" },
deleteMarkerReplication: { status: "Disabled" },
destination: {
bucket: replicaBucketArn,
storageClass: "STANDARD",
},
}],
};
const gcsTransferUser = new iam.User(this, "GcsTransferUser", {
userName: "forgejo-gcs-transfer",
});
gcsTransferUser.addToPolicy(new iam.PolicyStatement({
actions: ["s3:GetObject", "s3:ListBucket"],
resources: [backupBucket.bucketArn, `${backupBucket.bucketArn}/*`],
}));
const gcsTransferKey = new iam.AccessKey(this, "GcsTransferAccessKey", {
user: gcsTransferUser,
});
const gcsTransferCredentials = new secretsmanager.Secret(this, "GcsTransferCredentials", {
secretName: "forgejo/gcs-transfer-credentials",
secretObjectValue: {
accessKeyId: cdk.SecretValue.unsafePlainText(gcsTransferKey.accessKeyId),
secretAccessKey: gcsTransferKey.secretAccessKey,
},
});
const userData = ec2.UserData.forLinux();
userData.addCommands(
"set -euxo pipefail",
"",
`FORGEJO_VERSION="${FORGEJO_VERSION}"`,
"",
"dnf install -y git cronie",
"systemctl enable --now crond",
"",
"useradd --system --shell /bin/bash --home-dir /home/forgejo --create-home forgejo",
"",
"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",
"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 us-east-1 || 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://forgejo-backups-328440206208/${S3_PREFIX}/${TIMESTAMP}/forgejo-${TIMESTAMP}.tar.gz",
"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 us-east-1)",
"FORGEJO_TOKEN=$(aws secretsmanager get-secret-value --secret-id forgejo/api-token --query SecretString --output text --region us-east-1)",
"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 sys, json",
"for r in json.load(sys.stdin):",
" print(f\\\"{r['name']}\\\\t{r['archived']}\\\")",
"\")",
"",
"forgejo_repos=$(curl -sf -H \"Authorization: token $FORGEJO_TOKEN\" \"$FORGEJO_URL/repos/search?limit=100\" | python3 -c \"",
"import sys, json",
"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 us-east-1)",
"FORGEJO_TOKEN=$(aws secretsmanager get-secret-value --secret-id forgejo/api-token --query SecretString --output text --region us-east-1)",
"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 sys, json",
"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",
);
const instance = new ec2.Instance(this, "Instance", {
instanceName: "forgejo",
vpc,
vpcSubnets: { subnets: [privateSubnet1] },
instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.SMALL),
machineImage: ec2.MachineImage.latestAmazonLinux2023({
cpuType: ec2.AmazonLinuxCpuType.ARM_64,
}),
securityGroup: sg,
role,
userData,
blockDevices: [{
deviceName: "/dev/xvda",
volume: ec2.BlockDeviceVolume.ebs(50, {
volumeType: ec2.EbsDeviceVolumeType.GP3,
encrypted: true,
}),
}],
});
cdk.Tags.of(instance).add("forgejo-backup", "true");
const dlmRole = new iam.Role(this, "DlmRole", {
roleName: "forgejo-dlm",
assumedBy: new iam.ServicePrincipal("dlm.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSDataLifecycleManagerServiceRole"
),
],
});
new dlm.CfnLifecyclePolicy(this, "SnapshotPolicy", {
description: "Nightly EBS snapshots for Forgejo",
state: "ENABLED",
executionRoleArn: dlmRole.roleArn,
policyDetails: {
resourceTypes: ["INSTANCE"],
targetTags: [{ key: "forgejo-backup", value: "true" }],
schedules: [{
name: "forgejo-nightly",
createRule: { interval: 24, intervalUnit: "HOURS", times: ["06:00"] },
retainRule: { count: 30 },
copyTags: true,
tagsToAdd: [{ key: "forgejo-backup", value: "true" }],
}],
},
});
const alb = elbv2.ApplicationLoadBalancer.fromApplicationLoadBalancerAttributes(
this, "Alb", {
loadBalancerArn:
"arn:aws:elasticloadbalancing:us-east-1:328440206208:loadbalancer/app/seahaven-com/222c3257354ab559",
securityGroupId: "sg-0b0301deed193258a",
loadBalancerDnsName: "seahaven-com-1856441924.us-east-1.elb.amazonaws.com",
loadBalancerCanonicalHostedZoneId: "Z35SXDOTRQ7X7K",
}
);
const httpsListener = elbv2.ApplicationListener.fromApplicationListenerAttributes(
this, "HttpsListener", {
listenerArn:
"arn:aws:elasticloadbalancing:us-east-1:328440206208:listener/app/seahaven-com/222c3257354ab559/bab8bcf0da0e2927",
securityGroup: albSg,
}
);
const targetGroup = new elbv2.ApplicationTargetGroup(this, "TargetGroup", {
targetGroupName: "forgejo",
vpc,
port: 3000,
protocol: elbv2.ApplicationProtocol.HTTP,
targetType: elbv2.TargetType.INSTANCE,
healthCheck: {
path: "/",
healthyHttpCodes: "200,302",
},
targets: [new elbv2_targets.InstanceIdTarget(instance.instanceId, 3000)],
});
new elbv2.ApplicationListenerRule(this, "ListenerRule", {
listener: httpsListener,
priority: 4,
conditions: [elbv2.ListenerCondition.hostHeaders(["forgejo.seahaven.com"])],
targetGroups: [targetGroup],
});
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(
this, "SeaHavenZone", {
hostedZoneId: "Z06652411XKH89KTZD3XA",
zoneName: "seahaven.com",
}
);
new route53.ARecord(this, "DnsRecord", {
zone: hostedZone,
recordName: "forgejo",
target: route53.RecordTarget.fromAlias(
new route53Targets.LoadBalancerTarget(alb)
),
});
new BackupVerification(this, "BackupVerification", {
sourceBucket: backupBucket,
replicaBucketName: props.replicaBucketName,
gcsBucket: "forgejo-backups-offsite-seahaven",
gcsSaSecretName: "forgejo/gcs-sa-key",
slackWebhookSecretName: "forgejo/slack-webhook",
});
new cdk.CfnOutput(this, "ForgejoUrl", {
value: "https://forgejo.seahaven.com",
});
new cdk.CfnOutput(this, "InstanceId", {
value: instance.instanceId,
});
new cdk.CfnOutput(this, "PrivateIp", {
value: instance.instancePrivateIp,
});
}
}