feat: complete CI/CD automation and fix deployment issues
Some checks failed
Update Automation Tests / Integration Tests (pull_request) Failing after 40s

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
This commit is contained in:
aviyadeveloper 2026-06-11 17:16:51 +02:00
parent 153bd11b05
commit 890a23e8d5
18 changed files with 332 additions and 50 deletions

27
Makefile Normal file
View File

@ -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

View File

@ -61,7 +61,7 @@ This phase provisions the AWS infrastructure using Terraform.
- ✅ Configure Security Group for EC2 (ports 22, 80, 443) - ✅ Configure Security Group for EC2 (ports 22, 80, 443)
- ✅ Provision EC2 instance (t3.medium, Ubuntu 24.04) with IAM role - ✅ Provision EC2 instance (t3.medium, Ubuntu 24.04) with IAM role
- ✅ Create S3 bucket for backups (with versioning & encryption) - ✅ 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) - ✅ 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 - ✅ 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 - ✅ 512MB upload limit
### 3.4 Testing ✅ ### 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) - ✅ Valid SSL certificate (Let's Encrypt)
- ✅ HTTP → HTTPS redirect working - ✅ HTTP → HTTPS redirect working
- ✅ Gitea web interface accessible and functional - ✅ Gitea web interface accessible and functional

View File

@ -24,6 +24,15 @@
group: ubuntu group: ubuntu
mode: "0644" 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 - name: Fetch database credentials from Secrets Manager
ansible.builtin.shell: | ansible.builtin.shell: |
aws secretsmanager get-secret-value \ aws secretsmanager get-secret-value \
@ -58,6 +67,9 @@
DB_USER={{ db_creds.username }} DB_USER={{ db_creds.username }}
DB_PASSWORD={{ db_creds.password }} DB_PASSWORD={{ db_creds.password }}
DB_NAME={{ db_creds.database }} 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_HOST={{ ses_creds.smtp_host }}
SMTP_PORT={{ ses_creds.smtp_port }} SMTP_PORT={{ ses_creds.smtp_port }}
SMTP_USERNAME={{ ses_creds.smtp_username }} SMTP_USERNAME={{ ses_creds.smtp_username }}
@ -82,3 +94,58 @@
until: result.status == 200 until: result.status == 200
retries: 30 retries: 30
delay: 10 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

View File

@ -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

133
ansible/setup-runner.yml Normal file
View File

@ -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

View File

@ -55,7 +55,7 @@
- name: Check if certificate was obtained - name: Check if certificate was obtained
ansible.builtin.command: 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 register: cert_check
changed_when: false changed_when: false
failed_when: false failed_when: false

View File

@ -16,3 +16,6 @@
- name: Setup cron jobs for automated maintenance - name: Setup cron jobs for automated maintenance
import_playbook: setup-cron.yml import_playbook: setup-cron.yml
- name: Setup Gitea Actions Runner
import_playbook: setup-runner.yml

View File

@ -6,6 +6,11 @@ DB_USER=gitea
DB_PASSWORD=<generated-from-secrets-manager> DB_PASSWORD=<generated-from-secrets-manager>
DB_NAME=gitea DB_NAME=gitea
# Gitea admin credentials (from AWS Secrets Manager)
GITEA_ADMIN_USERNAME=<generated-from-secrets-manager>
GITEA_ADMIN_PASSWORD=<generated-from-secrets-manager>
GITEA_ADMIN_EMAIL=<generated-from-secrets-manager>
# AWS SES SMTP credentials (from AWS Secrets Manager) # AWS SES SMTP credentials (from AWS Secrets Manager)
SMTP_HOST=email-smtp.eu-central-1.amazonaws.com SMTP_HOST=email-smtp.eu-central-1.amazonaws.com
SMTP_PORT=587 SMTP_PORT=587

View File

@ -35,9 +35,12 @@ services:
- GITEA__database__NAME=${DB_NAME} - GITEA__database__NAME=${DB_NAME}
- GITEA__database__USER=${DB_USER} - GITEA__database__USER=${DB_USER}
- GITEA__database__PASSWD=${DB_PASSWORD} - GITEA__database__PASSWD=${DB_PASSWORD}
- GITEA__server__DOMAIN=gitea.poll-streams.com - GITEA__server__DOMAIN=git.poll-streams.com
- GITEA__server__SSH_DOMAIN=gitea.poll-streams.com - GITEA__server__SSH_DOMAIN=git.poll-streams.com
- GITEA__server__ROOT_URL=https://gitea.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: volumes:
- gitea-data:/data - gitea-data:/data
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
@ -79,7 +82,7 @@ services:
- certbot-etc:/etc/letsencrypt - certbot-etc:/etc/letsencrypt
- certbot-var:/var/lib/letsencrypt - certbot-var:/var/lib/letsencrypt
- web-root:/var/www/html - 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: depends_on:
- nginx - nginx

View File

@ -4,7 +4,7 @@
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name gitea.poll-streams.com; server_name git.poll-streams.com;
# Let's Encrypt ACME challenge # Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {

View File

@ -2,7 +2,7 @@
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name gitea.poll-streams.com; server_name git.poll-streams.com;
# Let's Encrypt ACME challenge # Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
@ -19,11 +19,11 @@ server {
server { server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl http2; listen [::]:443 ssl http2;
server_name gitea.poll-streams.com; server_name git.poll-streams.com;
# SSL certificates # SSL certificates
ssl_certificate /etc/letsencrypt/live/gitea.poll-streams.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/git.poll-streams.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gitea.poll-streams.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/git.poll-streams.com/privkey.pem;
# SSL configuration # SSL configuration
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;

26
scripts/empty-s3-bucket.sh Executable file
View File

@ -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"

View File

@ -6,7 +6,7 @@ data "aws_route53_zone" "main" {
resource "aws_route53_record" "gitea" { resource "aws_route53_record" "gitea" {
zone_id = data.aws_route53_zone.main.zone_id zone_id = data.aws_route53_zone.main.zone_id
name = "gitea.poll-streams.com" name = "git.poll-streams.com"
type = "A" type = "A"
ttl = 300 ttl = 300
records = [aws_instance.gitea.public_ip] records = [aws_instance.gitea.public_ip]

View File

@ -36,7 +36,8 @@ resource "aws_iam_role_policy" "secrets_manager_read" {
Effect = "Allow" Effect = "Allow"
Action = [ Action = [
"secretsmanager:GetSecretValue", "secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret" "secretsmanager:DescribeSecret",
"secretsmanager:UpdateSecret"
] ]
Resource = [ Resource = [
aws_secretsmanager_secret.db_credentials.arn, aws_secretsmanager_secret.db_credentials.arn,

View File

@ -26,17 +26,17 @@ output "ssh_connection_command" {
output "ssh_connection_via_domain" { output "ssh_connection_via_domain" {
description = "SSH command using domain name (use after DNS propagates)" 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" { output "gitea_domain" {
description = "Gitea domain name" description = "Gitea domain name"
value = "gitea.poll-streams.com" value = "git.poll-streams.com"
} }
output "gitea_url" { output "gitea_url" {
description = "Gitea URL (will be HTTPS after SSL setup)" 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" { output "db_secret_arn" {

View File

@ -4,10 +4,17 @@ resource "random_password" "db_password" {
special = true 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 # Store credentials in AWS Secrets Manager
resource "aws_secretsmanager_secret" "db_credentials" { resource "aws_secretsmanager_secret" "db_credentials" {
name = "${var.project_name}-db-credentials" name = "${var.project_name}-db-credentials"
description = "PostgreSQL database credentials for Gitea" description = "PostgreSQL database credentials for Gitea"
recovery_window_in_days = 0
tags = { tags = {
Name = "${var.project_name}-db-credentials" Name = "${var.project_name}-db-credentials"
@ -17,10 +24,36 @@ resource "aws_secretsmanager_secret" "db_credentials" {
resource "aws_secretsmanager_secret_version" "db_credentials" { resource "aws_secretsmanager_secret_version" "db_credentials" {
secret_id = aws_secretsmanager_secret.db_credentials.id secret_id = aws_secretsmanager_secret.db_credentials.id
secret_string = jsonencode({ secret_string = jsonencode({
username = "gitea" username = "gitea"
password = random_password.db_password.result password = random_password.db_password.result
database = "gitea" database = "gitea"
host = "postgres" host = "postgres"
port = 5432 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
}) })
} }

View File

@ -42,25 +42,3 @@ resource "aws_iam_user_policy" "ses_smtp_user_policy" {
resource "aws_iam_access_key" "ses_smtp_access_key" { resource "aws_iam_access_key" "ses_smtp_access_key" {
user = aws_iam_user.ses_smtp_user.name 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
})
}

View File

@ -1,7 +1,7 @@
# S3 Bucket for Backups # S3 Bucket for Backups
resource "aws_s3_bucket" "backups" { resource "aws_s3_bucket" "backups" {
bucket = "${var.project_name}-backups" bucket = "${var.project_name}-backups"
force_destroy = true
tags = { tags = {
Name = "${var.project_name}-backups" Name = "${var.project_name}-backups"
} }