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, }, }); // Preserve the logical ID to avoid CloudFormation replacement const gcsTransferCredentialsResource = gcsTransferCredentials.node.defaultChild as secretsmanager.CfnSecret; gcsTransferCredentialsResource.overrideLogicalId("GcsTransferCredentials"); 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) ), }); const backupVerification = 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, "BackupVerificationFunctionArn", { value: backupVerification.functionArn, }); 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, }); } }