From 890a23e8d5189c5949dbf1196cda27217d4b72bf Mon Sep 17 00:00:00 2001 From: aviyadeveloper Date: Thu, 11 Jun 2026 17:16:51 +0200 Subject: [PATCH] feat: complete CI/CD automation and fix deployment issues Infrastructure & Permissions: - Set recovery_window_in_days=0 on secrets for immediate deletion on destroy - Add secretsmanager:UpdateSecret permission to EC2 IAM role - Move SES secret definition from ses.tf to secrets.tf for better organization - Create scripts/empty-s3-bucket.sh to handle versioned S3 object deletion - Update Makefile to use S3 cleanup script in full-destroy target Gitea Admin User Automation: - Remove non-functional GITEA_ADMIN_* environment variables from docker-compose.yml - Add CLI-based admin user creation via docker exec in deploy-gitea.yml - Add database update to disable must_change_password requirement - Fix runner token API call to use GET instead of POST Runner Setup Fixes: - Change runner gitea_instance to http://localhost:3000 (was failing with public URL) - Fix registration to work from same host as Gitea Domain Migration: - Change domain from gitea.poll-streams.com to git.poll-streams.com - Update DNS, docker-compose, nginx configs, ansible inventory, and SSL setup - Enables fresh SSL certificate (avoids Let's Encrypt rate limit) All changes enable zero-to-one deployment: make full-destroy && make full-deploy --- Makefile | 27 ++++++ ROADMAP.md | 4 +- ansible/deploy-gitea.yml | 67 ++++++++++++++ ansible/inventory | 10 ++- ansible/setup-runner.yml | 133 ++++++++++++++++++++++++++++ ansible/setup-ssl.yml | 2 +- ansible/site.yml | 3 + docker/.env.example | 5 ++ docker/docker-compose.yml | 11 ++- docker/nginx/conf.d/gitea-init.conf | 2 +- docker/nginx/conf.d/gitea.conf | 8 +- scripts/empty-s3-bucket.sh | 26 ++++++ terraform/dns.tf | 2 +- terraform/iam.tf | 3 +- terraform/outputs.tf | 6 +- terraform/secrets.tf | 47 ++++++++-- terraform/ses.tf | 22 ----- terraform/storage.tf | 4 +- 18 files changed, 332 insertions(+), 50 deletions(-) create mode 100644 Makefile create mode 100644 ansible/setup-runner.yml create mode 100755 scripts/empty-s3-bucket.sh diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dcb6584 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: help full-deploy full-destroy provision configure test + +help: + @echo "Qvest Task - Gitea Deployment" + @echo "" + @echo "Targets:" + @echo " full-deploy - Full deployment (terraform + ansible)" + @echo " full-destroy - Destroy all infrastructure" + @echo " provision - Provision AWS infrastructure only" + @echo " configure - Run ansible configuration only" + @echo " test - Run integration tests" + +provision: + cd terraform && terraform apply -auto-approve + +configure: + cd ansible && ansible-playbook -i inventory site.yml + +test: + ./scripts/test-update.sh + +full-deploy: provision configure + @echo "Deployment complete. Gitea available at https://git.poll-streams.com" + +full-destroy: + @./scripts/empty-s3-bucket.sh + cd terraform && terraform destroy -auto-approve diff --git a/ROADMAP.md b/ROADMAP.md index 166771a..b0adf46 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,7 +61,7 @@ This phase provisions the AWS infrastructure using Terraform. - ✅ Configure Security Group for EC2 (ports 22, 80, 443) - ✅ Provision EC2 instance (t3.medium, Ubuntu 24.04) with IAM role - ✅ Create S3 bucket for backups (with versioning & encryption) -- ✅ Configure Route 53 DNS records (A record: gitea.poll-streams.com → EC2) +- ✅ Configure Route 53 DNS records (A record: git.poll-streams.com → EC2) - ✅ Use official Terraform AWS modules (VPC, Security Group) - ✅ Refactored into separate files: main.tf, vpc.tf, security.tf, compute.tf, storage.tf, iam.tf, dns.tf, outputs.tf @@ -113,7 +113,7 @@ This phase implements the automated, reproducible Gitea installation. - ✅ 512MB upload limit ### 3.4 Testing ✅ -- ✅ HTTPS access verified: https://gitea.poll-streams.com +- ✅ HTTPS access verified: https://git.poll-streams.com - ✅ Valid SSL certificate (Let's Encrypt) - ✅ HTTP → HTTPS redirect working - ✅ Gitea web interface accessible and functional diff --git a/ansible/deploy-gitea.yml b/ansible/deploy-gitea.yml index 0a90c89..25e315c 100644 --- a/ansible/deploy-gitea.yml +++ b/ansible/deploy-gitea.yml @@ -24,6 +24,15 @@ group: ubuntu mode: "0644" + - name: Copy nginx configuration + ansible.builtin.copy: + src: ../docker/nginx/ + dest: /opt/gitea/nginx/ + owner: ubuntu + group: ubuntu + mode: "0644" + directory_mode: "0755" + - name: Fetch database credentials from Secrets Manager ansible.builtin.shell: | aws secretsmanager get-secret-value \ @@ -58,6 +67,9 @@ DB_USER={{ db_creds.username }} DB_PASSWORD={{ db_creds.password }} DB_NAME={{ db_creds.database }} + GITEA_ADMIN_USERNAME={{ db_creds.admin_username }} + GITEA_ADMIN_PASSWORD={{ db_creds.admin_password }} + GITEA_ADMIN_EMAIL={{ db_creds.admin_email }} SMTP_HOST={{ ses_creds.smtp_host }} SMTP_PORT={{ ses_creds.smtp_port }} SMTP_USERNAME={{ ses_creds.smtp_username }} @@ -82,3 +94,58 @@ until: result.status == 200 retries: 30 delay: 10 + + - name: Create Gitea admin user via CLI + ansible.builtin.shell: | + docker exec --user git gitea gitea admin user create \ + --username "{{ db_creds.admin_username }}" \ + --password "{{ db_creds.admin_password }}" \ + --email "{{ db_creds.admin_email }}" \ + --admin \ + --must-change-password=false + register: admin_create + failed_when: + - admin_create.rc != 0 + - "'already exists' not in admin_create.stderr" + changed_when: "'New user' in admin_create.stdout" + + - name: Disable password change requirement + ansible.builtin.shell: | + docker exec gitea-postgres psql -U {{ db_creds.username }} \ + -d {{ db_creds.database }} \ + -c "UPDATE public.user SET must_change_password = false \ + WHERE name = '{{ db_creds.admin_username }}';" + changed_when: true + + - name: Generate Gitea Actions runner registration token + ansible.builtin.uri: + url: http://localhost:3000/api/v1/admin/runners/registration-token + method: GET + user: "{{ db_creds.admin_username }}" + password: "{{ db_creds.admin_password }}" + force_basic_auth: true + status_code: 200 + register: runner_token_response + retries: 5 + delay: 5 + until: runner_token_response.status == 200 + + - name: Update AWS Secrets Manager with runner token + ansible.builtin.shell: | + set -o pipefail + SECRET_JSON=$(aws secretsmanager get-secret-value \ + --secret-id "{{ secret_name }}" \ + --region "{{ aws_region }}" \ + --query SecretString \ + --output text) + + UPDATED_JSON=$(echo "$SECRET_JSON" | jq --arg token "{{ runner_token_response.json.token }}" \ + '.gitea_runner_token = $token') + + aws secretsmanager update-secret \ + --secret-id "{{ secret_name }}" \ + --region "{{ aws_region }}" \ + --secret-string "$UPDATED_JSON" + args: + executable: /bin/bash + changed_when: true diff --git a/ansible/inventory b/ansible/inventory index d6e0cb4..357abc9 100644 --- a/ansible/inventory +++ b/ansible/inventory @@ -1,2 +1,8 @@ -[gitea] -gitea.poll-streams.com ansible_user=ubuntu ansible_ssh_private_key_file=../ssh-keys/qvest-task-key.pem +--- +all: + children: + gitea: + hosts: + git.poll-streams.com: + ansible_user: ubuntu + ansible_ssh_private_key_file: ../ssh-keys/qvest-task-key.pem diff --git a/ansible/setup-runner.yml b/ansible/setup-runner.yml new file mode 100644 index 0000000..760cbfe --- /dev/null +++ b/ansible/setup-runner.yml @@ -0,0 +1,133 @@ +--- +- name: Setup Gitea Actions Runner + hosts: gitea + become: true + vars: + runner_version: "0.2.10" + runner_binary: "/usr/local/bin/act_runner" + runner_count: 2 + gitea_instance: "http://localhost:3000" + secret_name: "qvest-task-db-credentials" + aws_region: "eu-central-1" + # Registration token must be provided via command line or AWS Secrets Manager + # ansible-playbook setup-runner.yml -e "gitea_runner_token=YOUR_TOKEN" + + tasks: + - name: Download act_runner binary + ansible.builtin.get_url: + url: "https://dl.gitea.com/act_runner/{{ runner_version }}/act_runner-{{ runner_version }}-linux-amd64" + dest: "{{ runner_binary }}" + mode: "0755" + + - name: Create runner config directories + ansible.builtin.file: + path: "/etc/act_runner-{{ item }}" + state: directory + mode: "0755" + with_sequence: start=1 end={{ runner_count }} + + - name: Create runner data directories + ansible.builtin.file: + path: "/var/lib/act_runner-{{ item }}" + state: directory + mode: "0755" + with_sequence: start=1 end={{ runner_count }} + + - name: Check if runners are already registered + ansible.builtin.stat: + path: "/etc/act_runner-{{ item }}/.runner" + register: runner_configs + with_sequence: start=1 end={{ runner_count }} + + - name: Fetch Gitea runner token from AWS Secrets Manager + ansible.builtin.shell: | + set -o pipefail + aws secretsmanager get-secret-value \ + --secret-id "{{ secret_name }}" \ + --region "{{ aws_region }}" \ + --query SecretString \ + --output text | jq -r '.gitea_runner_token // empty' + args: + executable: /bin/bash + register: secrets_output + when: + - gitea_runner_token is not defined + - runner_configs.results | selectattr('stat.exists', 'equalto', false) | list | length > 0 + changed_when: false + failed_when: false + + - name: Set runner token from Secrets Manager + ansible.builtin.set_fact: + gitea_runner_token: "{{ secrets_output.stdout }}" + when: + - gitea_runner_token is not defined + - secrets_output.stdout is defined + - secrets_output.stdout | length > 0 + + - name: Register runners with Gitea + ansible.builtin.shell: | + {{ runner_binary }} register \ + --instance {{ gitea_instance }} \ + --token {{ gitea_runner_token }} \ + --name {{ ansible_hostname }}-runner-{{ item }} \ + --no-interactive + args: + chdir: "/etc/act_runner-{{ item }}" + when: + - gitea_runner_token is defined + - gitea_runner_token | length > 0 + - not runner_configs.results[item | int - 1].stat.exists + with_sequence: start=1 end={{ runner_count }} + register: runner_registrations + changed_when: runner_registrations.rc == 0 + + - name: Display registration warning if token not provided + ansible.builtin.debug: + msg: "Runner registration skipped - no token provided. Re-run with -e gitea_runner_token=TOKEN" + when: + - gitea_runner_token is not defined or gitea_runner_token | length == 0 + - runner_configs.results | selectattr('stat.exists', 'equalto', false) | list | length > 0 + + - name: Create systemd services for runners + ansible.builtin.copy: + dest: "/etc/systemd/system/act_runner-{{ item }}.service" + content: | + [Unit] + Description=Gitea Actions Runner {{ item }} + After=network.target docker.service + Requires=docker.service + + [Service] + Type=simple + ExecStart={{ runner_binary }} daemon + WorkingDirectory=/etc/act_runner-{{ item }} + Restart=always + RestartSec=10 + User=root + + [Install] + WantedBy=multi-user.target + mode: "0644" + with_sequence: start=1 end={{ runner_count }} + register: runner_services + notify: Reload systemd daemon + + - name: Enable and start runner services + ansible.builtin.systemd: + name: "act_runner-{{ item }}" + enabled: true + state: started + with_sequence: start=1 end={{ runner_count }} + when: > + runner_configs.results[item | int - 1].stat.exists or + (runner_registrations.results is defined and + runner_registrations.results[item | int - 1].changed | default(false)) + + - name: Display runner status + ansible.builtin.debug: + msg: "Deployed {{ runner_count }} runners. Services: act_runner-1 to act_runner-{{ runner_count }}" + + handlers: + - name: Reload systemd daemon + ansible.builtin.systemd: + daemon_reload: true diff --git a/ansible/setup-ssl.yml b/ansible/setup-ssl.yml index 039c588..1e65ea9 100644 --- a/ansible/setup-ssl.yml +++ b/ansible/setup-ssl.yml @@ -55,7 +55,7 @@ - name: Check if certificate was obtained ansible.builtin.command: - cmd: docker exec gitea-nginx ls /etc/letsencrypt/live/gitea.poll-streams.com/fullchain.pem + cmd: docker exec gitea-nginx ls /etc/letsencrypt/live/git.poll-streams.com/fullchain.pem register: cert_check changed_when: false failed_when: false diff --git a/ansible/site.yml b/ansible/site.yml index 85e5ce5..e3ee36a 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -16,3 +16,6 @@ - name: Setup cron jobs for automated maintenance import_playbook: setup-cron.yml + +- name: Setup Gitea Actions Runner + import_playbook: setup-runner.yml diff --git a/docker/.env.example b/docker/.env.example index 02ae6dd..5e3a2eb 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -6,6 +6,11 @@ DB_USER=gitea DB_PASSWORD= DB_NAME=gitea +# Gitea admin credentials (from AWS Secrets Manager) +GITEA_ADMIN_USERNAME= +GITEA_ADMIN_PASSWORD= +GITEA_ADMIN_EMAIL= + # AWS SES SMTP credentials (from AWS Secrets Manager) SMTP_HOST=email-smtp.eu-central-1.amazonaws.com SMTP_PORT=587 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9678636..18071b4 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -35,9 +35,12 @@ services: - GITEA__database__NAME=${DB_NAME} - GITEA__database__USER=${DB_USER} - GITEA__database__PASSWD=${DB_PASSWORD} - - GITEA__server__DOMAIN=gitea.poll-streams.com - - GITEA__server__SSH_DOMAIN=gitea.poll-streams.com - - GITEA__server__ROOT_URL=https://gitea.poll-streams.com + - GITEA__server__DOMAIN=git.poll-streams.com + - GITEA__server__SSH_DOMAIN=git.poll-streams.com + - GITEA__server__ROOT_URL=https://git.poll-streams.com + - GITEA__security__INSTALL_LOCK=true + - GITEA__service__DISABLE_REGISTRATION=true + - GITEA__actions__ENABLED=true volumes: - gitea-data:/data - /etc/timezone:/etc/timezone:ro @@ -79,7 +82,7 @@ services: - certbot-etc:/etc/letsencrypt - certbot-var:/var/lib/letsencrypt - web-root:/var/www/html - command: certonly --webroot --webroot-path=/var/www/html --email admin@poll-streams.com --agree-tos --no-eff-email --force-renewal -d gitea.poll-streams.com + command: certonly --webroot --webroot-path=/var/www/html --email admin@poll-streams.com --agree-tos --no-eff-email --force-renewal -d git.poll-streams.com depends_on: - nginx diff --git a/docker/nginx/conf.d/gitea-init.conf b/docker/nginx/conf.d/gitea-init.conf index a3ac49f..aa4951b 100644 --- a/docker/nginx/conf.d/gitea-init.conf +++ b/docker/nginx/conf.d/gitea-init.conf @@ -4,7 +4,7 @@ server { listen 80; listen [::]:80; - server_name gitea.poll-streams.com; + server_name git.poll-streams.com; # Let's Encrypt ACME challenge location /.well-known/acme-challenge/ { diff --git a/docker/nginx/conf.d/gitea.conf b/docker/nginx/conf.d/gitea.conf index a703775..8c5cdb8 100644 --- a/docker/nginx/conf.d/gitea.conf +++ b/docker/nginx/conf.d/gitea.conf @@ -2,7 +2,7 @@ server { listen 80; listen [::]:80; - server_name gitea.poll-streams.com; + server_name git.poll-streams.com; # Let's Encrypt ACME challenge location /.well-known/acme-challenge/ { @@ -19,11 +19,11 @@ server { server { listen 443 ssl http2; listen [::]:443 ssl http2; - server_name gitea.poll-streams.com; + server_name git.poll-streams.com; # SSL certificates - ssl_certificate /etc/letsencrypt/live/gitea.poll-streams.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/gitea.poll-streams.com/privkey.pem; + ssl_certificate /etc/letsencrypt/live/git.poll-streams.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/git.poll-streams.com/privkey.pem; # SSL configuration ssl_protocols TLSv1.2 TLSv1.3; diff --git a/scripts/empty-s3-bucket.sh b/scripts/empty-s3-bucket.sh new file mode 100755 index 0000000..9f6799a --- /dev/null +++ b/scripts/empty-s3-bucket.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -e + +BUCKET_NAME="${1:-qvest-task-backups}" + +echo "Emptying S3 bucket: $BUCKET_NAME" + +# Delete all object versions +aws s3api list-object-versions --bucket "$BUCKET_NAME" --output text \ + --query 'Versions[].[Key,VersionId]' 2>/dev/null | \ + while read -r key version; do + if [ -n "$key" ]; then + aws s3api delete-object --bucket "$BUCKET_NAME" --key "$key" --version-id "$version" >/dev/null 2>&1 + fi + done || true + +# Delete all delete markers +aws s3api list-object-versions --bucket "$BUCKET_NAME" --output text \ + --query 'DeleteMarkers[].[Key,VersionId]' 2>/dev/null | \ + while read -r key version; do + if [ -n "$key" ]; then + aws s3api delete-object --bucket "$BUCKET_NAME" --key "$key" --version-id "$version" >/dev/null 2>&1 + fi + done || true + +echo "S3 bucket emptied successfully" diff --git a/terraform/dns.tf b/terraform/dns.tf index 9d7bd3d..af1e3f9 100644 --- a/terraform/dns.tf +++ b/terraform/dns.tf @@ -6,7 +6,7 @@ data "aws_route53_zone" "main" { resource "aws_route53_record" "gitea" { zone_id = data.aws_route53_zone.main.zone_id - name = "gitea.poll-streams.com" + name = "git.poll-streams.com" type = "A" ttl = 300 records = [aws_instance.gitea.public_ip] diff --git a/terraform/iam.tf b/terraform/iam.tf index 8b00cb1..e92a9bd 100644 --- a/terraform/iam.tf +++ b/terraform/iam.tf @@ -36,7 +36,8 @@ resource "aws_iam_role_policy" "secrets_manager_read" { Effect = "Allow" Action = [ "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret" + "secretsmanager:DescribeSecret", + "secretsmanager:UpdateSecret" ] Resource = [ aws_secretsmanager_secret.db_credentials.arn, diff --git a/terraform/outputs.tf b/terraform/outputs.tf index aaf60f1..3876507 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -26,17 +26,17 @@ output "ssh_connection_command" { output "ssh_connection_via_domain" { description = "SSH command using domain name (use after DNS propagates)" - value = "ssh -i ${local_file.private_key.filename} -o StrictHostKeyChecking=accept-new ubuntu@gitea.poll-streams.com" + value = "ssh -i ${local_file.private_key.filename} -o StrictHostKeyChecking=accept-new ubuntu@git.poll-streams.com" } output "gitea_domain" { description = "Gitea domain name" - value = "gitea.poll-streams.com" + value = "git.poll-streams.com" } output "gitea_url" { description = "Gitea URL (will be HTTPS after SSL setup)" - value = "https://gitea.poll-streams.com" + value = "https://git.poll-streams.com" } output "db_secret_arn" { diff --git a/terraform/secrets.tf b/terraform/secrets.tf index cea5c82..93c263c 100644 --- a/terraform/secrets.tf +++ b/terraform/secrets.tf @@ -4,10 +4,17 @@ resource "random_password" "db_password" { special = true } +# Generate random password for Gitea admin user +resource "random_password" "gitea_admin_password" { + length = 32 + special = true +} + # Store credentials in AWS Secrets Manager resource "aws_secretsmanager_secret" "db_credentials" { - name = "${var.project_name}-db-credentials" - description = "PostgreSQL database credentials for Gitea" + name = "${var.project_name}-db-credentials" + description = "PostgreSQL database credentials for Gitea" + recovery_window_in_days = 0 tags = { Name = "${var.project_name}-db-credentials" @@ -17,10 +24,36 @@ resource "aws_secretsmanager_secret" "db_credentials" { resource "aws_secretsmanager_secret_version" "db_credentials" { secret_id = aws_secretsmanager_secret.db_credentials.id secret_string = jsonencode({ - username = "gitea" - password = random_password.db_password.result - database = "gitea" - host = "postgres" - port = 5432 + username = "gitea" + password = random_password.db_password.result + database = "gitea" + host = "postgres" + port = 5432 + admin_username = "gitea_admin" + admin_password = random_password.gitea_admin_password.result + admin_email = "admin@poll-streams.com" + gitea_runner_token = "" # Will be auto-generated via API + }) +} + +# Store SMTP credentials in Secrets Manager +resource "aws_secretsmanager_secret" "ses_smtp_credentials" { + name = "${var.project_name}-ses-smtp-credentials" + description = "SMTP credentials for AWS SES" + recovery_window_in_days = 0 + + tags = { + Name = "${var.project_name}-ses-smtp-credentials" + } +} + +resource "aws_secretsmanager_secret_version" "ses_smtp_credentials" { + secret_id = aws_secretsmanager_secret.ses_smtp_credentials.id + secret_string = jsonencode({ + smtp_host = "email-smtp.${var.aws_region}.amazonaws.com" + smtp_port = "587" + smtp_username = aws_iam_access_key.ses_smtp_access_key.id + smtp_password = aws_iam_access_key.ses_smtp_access_key.ses_smtp_password_v4 + alert_email = var.alert_email }) } diff --git a/terraform/ses.tf b/terraform/ses.tf index 0340906..cd1ba71 100644 --- a/terraform/ses.tf +++ b/terraform/ses.tf @@ -42,25 +42,3 @@ resource "aws_iam_user_policy" "ses_smtp_user_policy" { resource "aws_iam_access_key" "ses_smtp_access_key" { user = aws_iam_user.ses_smtp_user.name } - -# Store SMTP credentials in Secrets Manager -resource "aws_secretsmanager_secret" "ses_smtp_credentials" { - name = "${var.project_name}-ses-smtp-credentials" - description = "SMTP credentials for AWS SES" - recovery_window_in_days = 7 - - tags = { - Name = "${var.project_name}-ses-smtp-credentials" - } -} - -resource "aws_secretsmanager_secret_version" "ses_smtp_credentials" { - secret_id = aws_secretsmanager_secret.ses_smtp_credentials.id - secret_string = jsonencode({ - smtp_host = "email-smtp.${var.aws_region}.amazonaws.com" - smtp_port = "587" - smtp_username = aws_iam_access_key.ses_smtp_access_key.id - smtp_password = aws_iam_access_key.ses_smtp_access_key.ses_smtp_password_v4 - alert_email = var.alert_email - }) -} diff --git a/terraform/storage.tf b/terraform/storage.tf index d4bb472..d538934 100644 --- a/terraform/storage.tf +++ b/terraform/storage.tf @@ -1,7 +1,7 @@ # S3 Bucket for Backups resource "aws_s3_bucket" "backups" { - bucket = "${var.project_name}-backups" - + bucket = "${var.project_name}-backups" + force_destroy = true tags = { Name = "${var.project_name}-backups" }