diff --git a/.gitea/workflows/build-deploy.yml b/.gitea/workflows/build-deploy.yml new file mode 100644 index 0000000..ab7d86e --- /dev/null +++ b/.gitea/workflows/build-deploy.yml @@ -0,0 +1,144 @@ +name: Build and Deploy API Server + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + +env: + ARTIFACT_DIR: /opt/api-artifacts + GO_VERSION: '1.24' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Get commit info + id: commit + run: | + echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + echo "branch=$(git rev-parse --abbrev-ref HEAD)" >> $GITHUB_OUTPUT + echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v ./... + + - name: Build binary + run: | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags="-s -w -X main.Version=${{ steps.commit.outputs.short_sha }} -X main.BuildTime=${{ steps.commit.outputs.date }}" \ + -o api-server \ + . + + - name: Create build metadata + run: | + cat > build-info.json << EOF + { + "commit": "${{ steps.commit.outputs.sha }}", + "short_commit": "${{ steps.commit.outputs.short_sha }}", + "branch": "${{ steps.commit.outputs.branch }}", + "build_date": "${{ steps.commit.outputs.date }}", + "go_version": "${{ env.GO_VERSION }}" + } + EOF + + - name: Package artifact + run: | + mkdir -p artifact-package + cp api-server artifact-package/ + cp -r migrations artifact-package/ + cp build-info.json artifact-package/ + tar -czf api-server-${{ steps.commit.outputs.sha }}.tar.gz -C artifact-package . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: api-server-${{ steps.commit.outputs.sha }} + path: api-server-${{ steps.commit.outputs.sha }}.tar.gz + retention-days: 30 + + deploy: + needs: build + runs-on: self-hosted + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + + steps: + - name: Checkout deployment scripts + uses: actions/checkout@v4 + with: + sparse-checkout: | + deployment/scripts + + - name: Get commit SHA + id: commit + run: echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: api-server-${{ steps.commit.outputs.sha }} + + - name: Extract and deploy + run: | + BUILD_DIR="${{ env.ARTIFACT_DIR }}/builds/${{ steps.commit.outputs.sha }}" + mkdir -p "$BUILD_DIR" + + tar -xzf api-server-${{ steps.commit.outputs.sha }}.tar.gz -C "$BUILD_DIR" + + chmod +x "$BUILD_DIR/api-server" + + if [ -f "${{ env.ARTIFACT_DIR }}/config/.env.production" ]; then + cp "${{ env.ARTIFACT_DIR }}/config/.env.production" "$BUILD_DIR/.env.production" + fi + + - name: Run deployment script + run: | + cd ${{ env.ARTIFACT_DIR }}/scripts + ./deploy.sh ${{ steps.commit.outputs.sha }} + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + AUTO_MIGRATE: "false" + PORT: "8080" + + - name: Health check + run: | + ${{ env.ARTIFACT_DIR }}/scripts/health-check.sh + + - name: Notify deployment status + if: always() + run: | + if [ $? -eq 0 ]; then + echo "Deployment successful: ${{ steps.commit.outputs.sha }}" + else + echo "Deployment failed, rollback initiated" + ${{ env.ARTIFACT_DIR }}/scripts/rollback.sh + fi diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 0000000..8b6514d --- /dev/null +++ b/deployment/README.md @@ -0,0 +1,290 @@ +# MemberSyncPro Deployment + +Simple deployment workflow using Ansible + Gitea Actions + Blue-Green deployment. + +## Overview + +``` +┌──────────────┐ +│ 1. Ansible │ Run once to set up infrastructure +│ Setup │ (Gitea, PostgreSQL, Runners, Scripts) +└──────┬───────┘ + │ +┌──────▼───────┐ +│ 2. Push Code │ Push to Gitea triggers automatic build +│ to Gitea │ and staging +└──────┬───────┘ + │ +┌──────▼───────┐ +│ 3. Promote │ Manual promotion to production +│ to Prod │ (sudo promote.sh) +└──────────────┘ +``` + +## One-Time Setup + +### 1. Prepare Ansible Inventory + +```bash +cd deployment/ansible +cp hosts.ini.example hosts.ini +``` + +Edit `hosts.ini`: +```ini +[git] +your-vps-ip ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/your-key.pem ansible_python_interpreter=/usr/bin/python3 +``` + +### 2. Set Database Passwords + +```bash +export POSTGRES_ADMIN_PASSWORD="your-strong-password" +export API_SERVER_DB_PASSWORD="your-api-db-password" +``` + +### 3. Run Ansible Playbook + +```bash +cd deployment/ansible + +# Test connection +ansible -i hosts.ini git -m ping + +# Deploy everything +ansible-playbook -i hosts.ini site.yml +``` + +This sets up: +- Gitea server (accessible at `http://your-vps-ip`) +- PostgreSQL databases (gitea + apiserver) +- 5 Gitea Actions runners +- Nginx reverse proxy +- API server deployment infrastructure at `/opt/api-artifacts/` +- Systemd service for the API server + +### 4. Create Gitea Repository + +1. Visit `http://your-vps-ip` +2. Create admin account (first-time setup) +3. Create repository: `membersyncpro` + +### 5. Add Git Remote + +```bash +git remote add production http://your-vps-ip/your-username/membersyncpro.git +``` + +## Daily Deployment Workflow + +### Deploy New Version + +```bash +# 1. Push code +git push production main + +# 2. Wait for build (check Gitea → Actions) +# - Builds binary +# - Runs tests +# - Stages to inactive slot (blue/green) + +# 3. SSH to VPS and promote +ssh ubuntu@your-vps-ip +sudo /opt/api-artifacts/scripts/promote.sh +# Type 'yes' to confirm + +# 4. Verify +curl http://localhost:8080/hello +curl http://localhost:8080/version +``` + +### Rollback + +If something goes wrong after promotion: + +```bash +ssh ubuntu@your-vps-ip +sudo /opt/api-artifacts/scripts/rollback.sh +``` + +Instantly reverts to the previous deployment. + +## VPS Directory Structure + +``` +/opt/api-artifacts/ +├── builds/ +│ ├── abc123.../ # Previous build +│ └── def456.../ # Current build +├── blue -> builds/abc123/ # Previous deployment +├── green -> builds/def456/ # Current deployment +├── current -> green # Active (systemd uses this) +├── scripts/ +│ ├── deploy.sh # Auto-called by CI +│ ├── promote.sh # Manual promotion +│ ├── rollback.sh # Emergency rollback +│ ├── health-check.sh # Health validation +│ └── migrate.sh # Database migrations +└── config/ + └── .env.production # Environment variables +``` + +## Common Tasks + +### View Service Logs + +```bash +# Follow logs +journalctl -u api-server -f + +# Last 100 lines +journalctl -u api-server -n 100 +``` + +### Check Active Deployment + +```bash +# See which slot is active +readlink /opt/api-artifacts/current + +# Full build path +readlink -f /opt/api-artifacts/current +``` + +### Check Runners + +```bash +# All runners +systemctl status gitea-runner-{1..5} + +# Individual runner logs +journalctl -u gitea-runner-1 -f +``` + +### List All Builds + +```bash +ls -lh /opt/api-artifacts/builds/ +``` + +### Clean Old Builds + +```bash +# Keep only last 10 builds +cd /opt/api-artifacts/builds +ls -t | tail -n +11 | xargs -I {} sudo rm -rf {} +``` + +## Configuration + +### Environment Variables (on VPS) + +Edit `/opt/api-artifacts/config/.env.production`: + +```bash +DATABASE_URL=postgresql://apiserver:PASSWORD@localhost:5432/apiserver?sslmode=disable +AUTO_MIGRATE=false +PORT=8080 +GIN_MODE=release +``` + +### Ansible Variables + +Edit `deployment/ansible/site.yml`: + +```yaml +vars: + create_separate_apiserver_db: true # Separate vs shared DB + runner_count: 5 # Number of runners + gitea_version: "1.23.3" # Gitea version +``` + +## Blue-Green Deployment Flow + +1. **Current state:** `current -> blue` (active) +2. **New push:** Build creates `builds/new456/` +3. **Staging:** `green -> builds/new456/` (inactive) +4. **Migrations:** Run on green slot +5. **Promotion:** `current -> green` (switch!) +6. **Restart:** Systemd loads from current +7. **Rollback available:** Blue slot still has previous build + +## Troubleshooting + +### Build Not Triggering + +```bash +# Check runners +systemctl status gitea-runner-1 + +# Runner logs +journalctl -u gitea-runner-1 -f +``` + +### Deployment Fails + +```bash +# Check deploy script logs +journalctl -u api-server -n 50 + +# Verify permissions +ls -la /opt/api-artifacts/ + +# Check database connection +cat /opt/api-artifacts/config/.env.production +``` + +### Service Won't Start + +```bash +# Service logs +journalctl -u api-server -n 50 + +# Verify binary exists +ls -la /opt/api-artifacts/current/api-server + +# Check symlinks +readlink /opt/api-artifacts/current +readlink /opt/api-artifacts/blue +readlink /opt/api-artifacts/green +``` + +## Files Reference + +- `ansible/site.yml` - Complete infrastructure setup playbook +- `ansible/hosts.ini` - Your VPS inventory +- `scripts/` - Deployment scripts (copied to VPS by Ansible) +- `systemd/api-server.service` - Service configuration +- `.gitea/workflows/build-deploy.yml` - CI/CD workflow + +## Ansible Playbook Details + +The `site.yml` playbook handles: + +1. **Package installation:** Git, Nginx, PostgreSQL, Docker +2. **Gitea setup:** Binary download, database, systemd service +3. **Database provisioning:** Admin user, API server user, databases +4. **Runner deployment:** 5 self-hosted runners with auto-registration +5. **API infrastructure:** User, directories, scripts, systemd service +6. **Nginx configuration:** Reverse proxy for Gitea +7. **Firewall:** UFW with SSH, HTTP, PostgreSQL ports + +Run it multiple times safely (idempotent). + +## Security Notes + +- Change default passwords (use environment variables) +- Use SSH keys for VPS access +- Keep `.env.production` file permissions at 600 +- Review UFW firewall rules +- Consider adding SSL/TLS with Let's Encrypt +- PostgreSQL is exposed on 0.0.0.0 - restrict if not needed externally + +## Next Steps + +After basic deployment: +1. Add SSL/TLS certificates (Let's Encrypt) +2. Set up monitoring (Prometheus/Grafana) +3. Configure alerting for deployment failures +4. Automate PostgreSQL backups +5. Add cron job to clean old builds diff --git a/deployment/ansible/.gitignore b/deployment/ansible/.gitignore new file mode 100644 index 0000000..070b406 --- /dev/null +++ b/deployment/ansible/.gitignore @@ -0,0 +1,43 @@ +# Ansible +*.retry +.ansible/ +/tmp/ansible_fact_cache/ + +# Secrets and sensitive data +group_vars/*/vault.yml +host_vars/*/vault.yml +*.secret +*.key +*.pem +secrets/ + +# Environment specific +.env +.env.* + +# Inventory files (use hosts.ini.example as template) +hosts.ini +inventory.ini + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Backup files +*~ +*.bak +*.backup + +# Collections +collections/ansible_collections/ diff --git a/deployment/ansible/README.md b/deployment/ansible/README.md new file mode 100644 index 0000000..3486a1b --- /dev/null +++ b/deployment/ansible/README.md @@ -0,0 +1,68 @@ +# Ansible Infrastructure Setup + +This playbook sets up the complete Gitea + API server infrastructure on a fresh Ubuntu 24.04 VPS. + +## Quick Start + +```bash +# 1. Copy and edit inventory +cp hosts.ini.example hosts.ini +# Edit hosts.ini with your VPS details + +# 2. Set passwords +export POSTGRES_ADMIN_PASSWORD="your-strong-password" +export API_SERVER_DB_PASSWORD="your-api-db-password" + +# 3. Run playbook +ansible-playbook -i hosts.ini site.yml +``` + +## What This Deploys + +### Infrastructure +- Gitea server (Git hosting + Actions) +- PostgreSQL (separate databases for Gitea and API) +- 5 Gitea Actions runners (self-hosted) +- Nginx reverse proxy +- UFW firewall + +### API Server Deployment System +- `/opt/api-artifacts/` directory structure +- Blue-green deployment scripts +- Systemd service configuration +- Environment file template + +### Database Setup +- `gitea` database (for Gitea) +- `apiserver` database (for your application) +- `pgadmin` user (admin access) +- `apiserver` user (application access) + +## Configuration + +Edit `site.yml` to customize: + +```yaml +vars: + create_separate_apiserver_db: true # false to share gitea DB + runner_count: 5 # Number of runners + gitea_version: "1.23.3" # Gitea version +``` + +## Inventory File + +Create `hosts.ini`: + +```ini +[git] +your-vps-ip ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/key.pem ansible_python_interpreter=/usr/bin/python3 +``` + +## After Ansible Runs + +1. Access Gitea at `http://your-vps-ip` +2. Create admin account +3. Create repository +4. Push code to trigger builds + +See main deployment README for full workflow. diff --git a/deployment/ansible/ansible.cfg b/deployment/ansible/ansible.cfg new file mode 100644 index 0000000..05b026b --- /dev/null +++ b/deployment/ansible/ansible.cfg @@ -0,0 +1,10 @@ +[defaults] +inventory = ./hosts.ini +host_key_checking = False +stdout_callback = ansible.builtin.default +result_format = yaml +interpreter_python = auto_silent +timeout = 30 + +[ssh_connection] +pipelining = True diff --git a/deployment/ansible/hosts.ini.example b/deployment/ansible/hosts.ini.example new file mode 100644 index 0000000..aada228 --- /dev/null +++ b/deployment/ansible/hosts.ini.example @@ -0,0 +1,17 @@ +# Ansible Inventory for Gitea + API Server Deployment +# Copy this file to hosts.ini and update with your server details + +[git] +# Replace with your server's IP or hostname +your-server-ip-here ansible_user=root ansible_python_interpreter=/usr/bin/python3 + +# Examples: +# +# Using IP address with root user: +# 192.168.1.100 ansible_user=root ansible_python_interpreter=/usr/bin/python3 +# +# Using hostname with ubuntu user and SSH key: +# myserver.example.com ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_rsa ansible_python_interpreter=/usr/bin/python3 +# +# Using IP with custom SSH port: +# 192.168.1.100 ansible_user=ubuntu ansible_port=2222 ansible_python_interpreter=/usr/bin/python3 diff --git a/deployment/ansible/requirements.yml b/deployment/ansible/requirements.yml new file mode 100644 index 0000000..85e904e --- /dev/null +++ b/deployment/ansible/requirements.yml @@ -0,0 +1,2 @@ +collections: + - name: community.postgresql diff --git a/deployment/ansible/site.yml b/deployment/ansible/site.yml new file mode 100644 index 0000000..02c2d2d --- /dev/null +++ b/deployment/ansible/site.yml @@ -0,0 +1,522 @@ +--- +- name: Install Gitea (Ubuntu 24.04 minimal) + hosts: git + become: true + + vars: + gitea_version: "1.23.3" + gitea_arch: "linux-amd64" + gitea_bin: /usr/local/bin/gitea + + gitea_user: git + gitea_home: /var/lib/gitea + + gitea_http_addr: "0.0.0.0" # Listen on all interfaces + gitea_http_port: 3000 + + gitea_domain: "{{ ansible_host }}" # Use the server's IP address + gitea_root_url: "http://{{ ansible_host }}" # HTTP with IP and port + + gitea_internal_token: "5982634986925861987263497269412869416237419264" # Change to a secure random token + + postgres_db: gitea + postgres_user: gitea + postgres_password: "12409768234691236" + + # PostgreSQL admin user for debugging and management + postgres_admin_user: pgadmin + postgres_admin_password: "{{ lookup('env', 'POSTGRES_ADMIN_PASSWORD') | default('ChangeMe_SecurePassword_2025', true) }}" + + # Golang API Server database user + api_server_db_user: apiserver + api_server_db_password: "{{ lookup('env', 'API_SERVER_DB_PASSWORD') | default('ChangeMe_ApiServer_2025', true) }}" + + # NEW: Option to create separate database for API server + create_separate_apiserver_db: true # Set to false to share gitea database + + # Gitea Runner configuration + runner_version: "0.2.11" + runner_count: 5 # Number of runners to deploy + runner_base_dir: /var/lib/gitea-runner + runner_token: "CHANGE_ME_TO_REGISTRATION_TOKEN" # Get from Gitea admin panel + + tasks: + - name: Install packages + ansible.builtin.apt: + update_cache: true + name: + - ca-certificates + - curl + - git + - nginx + - ufw + - postgresql + - postgresql-contrib + - python3-psycopg # Important on Ubuntu 24.04 + - docker.io + state: present + + - name: Start and enable Docker + ansible.builtin.service: + name: docker + state: started + enabled: true + + - name: Create git system user + ansible.builtin.user: + name: "{{ gitea_user }}" + system: true + create_home: true + home: "{{ gitea_home }}" + shell: /usr/sbin/nologin + groups: docker + append: true + + - name: Create Gitea directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ gitea_user }}" + group: "{{ gitea_user }}" + mode: "0750" + loop: + - /etc/gitea + - "{{ gitea_home }}" + - "{{ gitea_home }}/custom" + - "{{ gitea_home }}/data" + - "{{ gitea_home }}/log" + - "{{ gitea_home }}/repositories" + + - name: Download gitea binary + ansible.builtin.get_url: + url: "https://dl.gitea.com/gitea/{{ gitea_version }}/gitea-{{ gitea_version }}-{{ gitea_arch }}" + dest: "{{ gitea_bin }}" + mode: "0755" + + - name: Ensure postgres running + ansible.builtin.service: + name: postgresql + state: started + enabled: true + + - name: Create postgres user + become_user: postgres + community.postgresql.postgresql_user: + name: "{{ postgres_user }}" + password: "{{ postgres_password }}" + + - name: Create postgres database + become_user: postgres + community.postgresql.postgresql_db: + name: "{{ postgres_db }}" + owner: "{{ postgres_user }}" + + - name: Create PostgreSQL admin user for production debugging + become_user: postgres + community.postgresql.postgresql_user: + name: "{{ postgres_admin_user }}" + password: "{{ postgres_admin_password }}" + role_attr_flags: SUPERUSER,CREATEDB,CREATEROLE,REPLICATION,LOGIN + comment: "Admin user for debugging and managing databases, users, and roles" + + - name: Create PostgreSQL user for golang-api-server + become_user: postgres + community.postgresql.postgresql_user: + name: "{{ api_server_db_user }}" + password: "{{ api_server_db_password }}" + role_attr_flags: LOGIN + comment: "Database user for golang-api-server application" + + - name: Create separate API server database + become_user: postgres + community.postgresql.postgresql_db: + name: apiserver + owner: "{{ api_server_db_user }}" + when: create_separate_apiserver_db + + - name: Grant privileges to api-server user on their database + become_user: postgres + community.postgresql.postgresql_privs: + database: "{{ 'apiserver' if create_separate_apiserver_db else postgres_db }}" + state: present + privs: ALL + type: database + role: "{{ api_server_db_user }}" + + - name: Configure PostgreSQL to listen on all interfaces + ansible.builtin.lineinfile: + path: /etc/postgresql/16/main/postgresql.conf + regexp: '^#?listen_addresses\s*=' + line: "listen_addresses = '*'" + backup: yes + notify: restart postgresql + + - name: Allow remote connections to PostgreSQL + ansible.builtin.lineinfile: + path: /etc/postgresql/16/main/pg_hba.conf + line: "host all all 0.0.0.0/0 scram-sha-256" + insertafter: EOF + backup: yes + notify: restart postgresql + + - name: Write Gitea config + ansible.builtin.copy: + dest: /etc/gitea/app.ini + owner: "{{ gitea_user }}" + group: "{{ gitea_user }}" + mode: "0640" + content: | + APP_NAME = Gitea + RUN_USER = {{ gitea_user }} + RUN_MODE = prod + WORK_PATH = {{ gitea_home }} + + [server] + DOMAIN = {{ gitea_domain }} + ROOT_URL = {{ gitea_root_url }} + HTTP_ADDR = {{ gitea_http_addr }} + HTTP_PORT = {{ gitea_http_port }} + START_SSH_SERVER = false + DISABLE_SSH = false + APP_DATA_PATH = {{ gitea_home }}/data + + [database] + DB_TYPE = postgres + HOST = 127.0.0.1:5432 + NAME = {{ postgres_db }} + USER = {{ postgres_user }} + PASSWD = {{ postgres_password }} + SSL_MODE = disable + + [security] + INSTALL_LOCK = true + INTERNAL_TOKEN = {{ gitea_internal_token}} + + [actions] + ENABLED = true + + [log] + MODE = file + LEVEL = info + ROOT_PATH = {{ gitea_home }}/log + + - name: Install systemd unit + ansible.builtin.copy: + dest: /etc/systemd/system/gitea.service + mode: "0644" + content: | + [Unit] + Description=Gitea + After=network.target postgresql.service + Wants=postgresql.service + + [Service] + Type=simple + User={{ gitea_user }} + Group={{ gitea_user }} + WorkingDirectory={{ gitea_home }} + Environment=HOME={{ gitea_home }} + ExecStart={{ gitea_bin }} web --config /etc/gitea/app.ini + Restart=always + RestartSec=2s + LimitNOFILE=1048576 + + [Install] + WantedBy=multi-user.target + + - name: Start gitea + ansible.builtin.systemd: + daemon_reload: true + name: gitea + state: started + enabled: true + + # Gitea Runner Installation + - name: Delete all runners from Gitea database directly + become_user: postgres + community.postgresql.postgresql_query: + db: "{{ postgres_db }}" + query: "DELETE FROM action_runner;" + ignore_errors: true + + - name: Stop all existing runner services + ansible.builtin.systemd: + name: "gitea-runner-{{ item }}" + state: stopped + enabled: false + loop: "{{ range(1, 21) | list }}" # Check up to 20 possible runners + ignore_errors: true + failed_when: false + + - name: Remove all existing runner systemd units + ansible.builtin.file: + path: "/etc/systemd/system/gitea-runner-{{ item }}.service" + state: absent + loop: "{{ range(1, 21) | list }}" + ignore_errors: true + + - name: Reload systemd after removing runner units + ansible.builtin.systemd: + daemon_reload: true + + - name: Remove all existing runner directories + ansible.builtin.file: + path: "{{ runner_base_dir }}" + state: absent + + - name: Recreate base runner directory + ansible.builtin.file: + path: "{{ runner_base_dir }}" + state: directory + owner: "{{ gitea_user }}" + group: "{{ gitea_user }}" + mode: "0750" + + - name: Download Gitea runner binary + ansible.builtin.get_url: + url: "https://dl.gitea.com/act_runner/{{ runner_version }}/act_runner-{{ runner_version }}-linux-amd64" + dest: /usr/local/bin/act_runner + mode: "0755" + + - name: Create runner directories + ansible.builtin.file: + path: "{{ runner_base_dir }}/runner-{{ item }}" + state: directory + owner: "{{ gitea_user }}" + group: "{{ gitea_user }}" + mode: "0750" + loop: "{{ range(1, runner_count + 1) | list }}" + + - name: Generate runner config + ansible.builtin.shell: | + /usr/local/bin/act_runner generate-config > {{ runner_base_dir }}/runner-{{ item }}/config.yaml + args: + creates: "{{ runner_base_dir }}/runner-{{ item }}/config.yaml" + loop: "{{ range(1, runner_count + 1) | list }}" + + - name: Set runner config ownership + ansible.builtin.file: + path: "{{ runner_base_dir }}/runner-{{ item }}/config.yaml" + owner: "{{ gitea_user }}" + group: "{{ gitea_user }}" + mode: "0640" + loop: "{{ range(1, runner_count + 1) | list }}" + + - name: Generate runner registration tokens + ansible.builtin.shell: | + sudo -u {{ gitea_user }} {{ gitea_bin }} --config /etc/gitea/app.ini actions generate-runner-token + register: runner_tokens + loop: "{{ range(1, runner_count + 1) | list }}" + + - name: Register runners with generated tokens + become: true + become_user: "{{ gitea_user }}" + ansible.builtin.shell: | + cd {{ runner_base_dir }}/runner-{{ item.item }} && \ + /usr/local/bin/act_runner register \ + --instance {{ gitea_root_url }} \ + --token {{ item.stdout | trim }} \ + --config {{ runner_base_dir }}/runner-{{ item.item }}/config.yaml \ + --name runner-{{ item.item }} \ + --no-interactive + loop: "{{ runner_tokens.results }}" + args: + creates: "{{ runner_base_dir }}/runner-{{ item.item }}/.runner" + + - name: Create runner systemd units + ansible.builtin.copy: + dest: "/etc/systemd/system/gitea-runner-{{ item }}.service" + mode: "0644" + content: | + [Unit] + Description=Gitea Runner {{ item }} + After=network.target gitea.service + Wants=gitea.service + + [Service] + Type=simple + User={{ gitea_user }} + Group={{ gitea_user }} + WorkingDirectory={{ runner_base_dir }}/runner-{{ item }} + ExecStart=/usr/local/bin/act_runner daemon --config {{ runner_base_dir }}/runner-{{ item }}/config.yaml + Restart=always + RestartSec=10s + + [Install] + WantedBy=multi-user.target + loop: "{{ range(1, runner_count + 1) | list }}" + + - name: Enable and start runner services + ansible.builtin.systemd: + daemon_reload: true + name: "gitea-runner-{{ item }}" + enabled: true + loop: "{{ range(1, runner_count + 1) | list }}" + + - name: Configure nginx reverse proxy (HTTP only for now) + ansible.builtin.copy: + dest: /etc/nginx/sites-available/gitea + mode: "0644" + content: | + server { + listen 80; + server_name {{ ansible_host }}; + + location / { + proxy_pass http://127.0.0.1:{{ gitea_http_port }}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + + - name: Enable nginx site + ansible.builtin.file: + src: /etc/nginx/sites-available/gitea + dest: /etc/nginx/sites-enabled/gitea + state: link + force: true + + - name: Disable default nginx site + ansible.builtin.file: + path: /etc/nginx/sites-enabled/default + state: absent + + - name: Test and reload nginx + ansible.builtin.shell: | + nginx -t && systemctl reload nginx + args: + executable: /bin/bash + + - name: Allow firewall (SSH + HTTP) + ansible.builtin.ufw: + rule: allow + name: "{{ item }}" + loop: + - "OpenSSH" + - "Nginx Full" + + - name: Allow PostgreSQL port + ansible.builtin.ufw: + rule: allow + port: 5432 + + - name: Enable UFW + ansible.builtin.ufw: + state: enabled + policy: deny + + # ========================================== + # API SERVER DEPLOYMENT INFRASTRUCTURE + # ========================================== + - name: Create apiserver system user + ansible.builtin.user: + name: apiserver + system: true + create_home: true + home: /opt/api-artifacts + shell: /bin/bash + groups: docker + append: true + + - name: Create deployment directory structure + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: apiserver + group: apiserver + mode: "0755" + loop: + - /opt/api-artifacts + - /opt/api-artifacts/builds + - /opt/api-artifacts/scripts + - /opt/api-artifacts/config + + - name: Copy deployment scripts from local repository + ansible.builtin.copy: + src: "{{ playbook_dir }}/../scripts/{{ item }}" + dest: /opt/api-artifacts/scripts/{{ item }} + owner: apiserver + group: apiserver + mode: "0755" + loop: + - deploy.sh + - promote.sh + - rollback.sh + - health-check.sh + - migrate.sh + + - name: Create production environment file + ansible.builtin.copy: + dest: /opt/api-artifacts/config/.env.production + owner: apiserver + group: apiserver + mode: "0600" + content: | + DATABASE_URL=postgresql://{{ api_server_db_user }}:{{ api_server_db_password }}@localhost:5432/{{ 'apiserver' if create_separate_apiserver_db else postgres_db }}?sslmode=disable + AUTO_MIGRATE=false + PORT=8080 + GIN_MODE=release + + - name: Copy systemd service file + ansible.builtin.copy: + src: "{{ playbook_dir }}/../systemd/api-server.service" + dest: /etc/systemd/system/api-server.service + owner: root + group: root + mode: "0644" + register: systemd_service + + - name: Reload systemd daemon + ansible.builtin.systemd: + daemon_reload: true + when: systemd_service.changed + + - name: Enable api-server service (but don't start - no binary yet) + ansible.builtin.systemd: + name: api-server.service + enabled: true + failed_when: false + + - name: Install golang-migrate CLI + ansible.builtin.shell: | + curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-amd64.tar.gz | tar xvz + mv migrate /usr/local/bin/ + chmod +x /usr/local/bin/migrate + args: + creates: /usr/local/bin/migrate + + - name: Display setup summary + ansible.builtin.debug: + msg: + - "============================================" + - "Gitea + API Server Infrastructure Complete!" + - "============================================" + - "" + - "Gitea:" + - " URL: {{ gitea_root_url }}" + - " Runners: {{ runner_count }} runners active" + - "" + - "API Server Deployment:" + - " Artifact directory: /opt/api-artifacts/" + - " Database: {{ 'apiserver' if create_separate_apiserver_db else postgres_db }}" + - " Database user: {{ api_server_db_user }}" + - " Systemd service: api-server.service (enabled)" + - "" + - "Next Steps:" + - "1. Create repository in Gitea and push your golang-api-server code" + - "2. Push will trigger Gitea Actions build automatically" + - "3. After build completes, SSH to server and promote:" + - " sudo /opt/api-artifacts/scripts/promote.sh" + - "" + - "Management Commands:" + - " - View builds: ls /opt/api-artifacts/builds/" + - " - Check logs: journalctl -u api-server -f" + - " - Rollback: sudo /opt/api-artifacts/scripts/rollback.sh" + + handlers: + - name: restart postgresql + ansible.builtin.service: + name: postgresql + state: restarted diff --git a/deployment/scripts/deploy.sh b/deployment/scripts/deploy.sh new file mode 100755 index 0000000..7b73795 --- /dev/null +++ b/deployment/scripts/deploy.sh @@ -0,0 +1,74 @@ +#!/bin/bash +set -euo pipefail + +ARTIFACT_DIR="/opt/api-artifacts" +BUILDS_DIR="$ARTIFACT_DIR/builds" +SCRIPTS_DIR="$ARTIFACT_DIR/scripts" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +if [ $# -ne 1 ]; then + log_error "Usage: $0 " + exit 1 +fi + +COMMIT_HASH="$1" +BUILD_PATH="$BUILDS_DIR/$COMMIT_HASH" + +if [ ! -d "$BUILD_PATH" ]; then + log_error "Build not found: $BUILD_PATH" + exit 1 +fi + +if [ ! -f "$BUILD_PATH/api-server" ]; then + log_error "Binary not found in build: $BUILD_PATH/api-server" + exit 1 +fi + +log_info "Starting deployment for commit: $COMMIT_HASH" + +CURRENT_LINK="$ARTIFACT_DIR/current" +BLUE_LINK="$ARTIFACT_DIR/blue" +GREEN_LINK="$ARTIFACT_DIR/green" + +if [ -L "$CURRENT_LINK" ]; then + CURRENT_TARGET=$(readlink "$CURRENT_LINK") + if [ "$CURRENT_TARGET" = "blue" ]; then + TARGET_SLOT="green" + INACTIVE_LINK="$GREEN_LINK" + else + TARGET_SLOT="blue" + INACTIVE_LINK="$BLUE_LINK" + fi +else + TARGET_SLOT="blue" + INACTIVE_LINK="$BLUE_LINK" +fi + +log_info "Deploying to slot: $TARGET_SLOT" + +ln -sfn "$BUILD_PATH" "$INACTIVE_LINK" + +log_info "Symlink updated: $INACTIVE_LINK -> $BUILD_PATH" + +log_info "Running database migrations..." +if ! "$SCRIPTS_DIR/migrate.sh" "$TARGET_SLOT"; then + log_error "Migration failed, aborting deployment" + exit 1 +fi + +log_info "Deployment staged to $TARGET_SLOT slot" +log_info "Build ready at: $BUILD_PATH" +log_info "" +log_info "To promote this deployment to production, run:" +log_info " sudo $SCRIPTS_DIR/promote.sh" +log_info "" +log_info "To test before promoting:" +log_info " Check $INACTIVE_LINK for manual verification" diff --git a/deployment/scripts/health-check.sh b/deployment/scripts/health-check.sh new file mode 100755 index 0000000..fb6eb6a --- /dev/null +++ b/deployment/scripts/health-check.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail + +HEALTH_ENDPOINT="${HEALTH_ENDPOINT:-http://localhost:8080/hello}" +MAX_RETRIES="${MAX_RETRIES:-10}" +RETRY_DELAY="${RETRY_DELAY:-3}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +if ! systemctl is-active --quiet api-server.service; then + log_error "Service is not running" + exit 1 +fi + +for i in $(seq 1 $MAX_RETRIES); do + log_info "Health check attempt $i/$MAX_RETRIES..." + + if response=$(curl -sf -m 5 "$HEALTH_ENDPOINT" 2>/dev/null); then + log_info "Health check passed!" + echo "$response" | head -c 200 + exit 0 + fi + + if [ $i -lt $MAX_RETRIES ]; then + sleep $RETRY_DELAY + fi +done + +log_error "Health check failed after $MAX_RETRIES attempts" +exit 1 diff --git a/deployment/scripts/migrate.sh b/deployment/scripts/migrate.sh new file mode 100755 index 0000000..653fe38 --- /dev/null +++ b/deployment/scripts/migrate.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail + +ARTIFACT_DIR="/opt/api-artifacts" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +SLOT="${1:-blue}" +SLOT_LINK="$ARTIFACT_DIR/$SLOT" + +if [ ! -L "$SLOT_LINK" ]; then + log_error "Slot not found: $SLOT" + exit 1 +fi + +BUILD_PATH=$(readlink -f "$SLOT_LINK") +MIGRATIONS_DIR="$BUILD_PATH/migrations" + +if [ ! -d "$MIGRATIONS_DIR" ]; then + log_error "Migrations directory not found: $MIGRATIONS_DIR" + exit 1 +fi + +if [ -f "$BUILD_PATH/.env.production" ]; then + source "$BUILD_PATH/.env.production" +fi + +if [ -z "${DATABASE_URL:-}" ]; then + log_error "DATABASE_URL not set" + exit 1 +fi + +log_info "Running migrations from: $MIGRATIONS_DIR" + +cd "$BUILD_PATH" +export AUTO_MIGRATE=true +export PORT=0 + +timeout 30s ./api-server 2>&1 | grep -i "migration" || true + +log_info "Migrations completed" diff --git a/deployment/scripts/promote.sh b/deployment/scripts/promote.sh new file mode 100755 index 0000000..600db04 --- /dev/null +++ b/deployment/scripts/promote.sh @@ -0,0 +1,64 @@ +#!/bin/bash +set -euo pipefail + +ARTIFACT_DIR="/opt/api-artifacts" +CURRENT_LINK="$ARTIFACT_DIR/current" +BLUE_LINK="$ARTIFACT_DIR/blue" +GREEN_LINK="$ARTIFACT_DIR/green" +SERVICE_NAME="api-server.service" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +if [ -L "$CURRENT_LINK" ]; then + CURRENT_TARGET=$(readlink "$CURRENT_LINK") + if [ "$CURRENT_TARGET" = "blue" ]; then + NEW_TARGET="green" + else + NEW_TARGET="blue" + fi +else + NEW_TARGET="blue" +fi + +NEW_TARGET_LINK="$ARTIFACT_DIR/$NEW_TARGET" + +if [ ! -L "$NEW_TARGET_LINK" ]; then + log_error "Target deployment not found: $NEW_TARGET_LINK" + log_error "Run deploy.sh first" + exit 1 +fi + +NEW_BUILD=$(readlink "$NEW_TARGET_LINK") +log_info "Promoting $NEW_TARGET to production" +log_info "New build: $NEW_BUILD" + +read -p "Continue with promotion? (yes/no): " -r +if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then + log_warn "Promotion cancelled" + exit 0 +fi + +log_info "Switching current symlink to $NEW_TARGET" +ln -sfn "$NEW_TARGET" "$CURRENT_LINK" + +log_info "Reloading systemd service..." +systemctl reload-or-restart "$SERVICE_NAME" + +sleep 5 + +log_info "Running health check..." +if "$ARTIFACT_DIR/scripts/health-check.sh"; then + log_info "Promotion successful!" + log_info "Active deployment: $NEW_TARGET ($NEW_BUILD)" +else + log_error "Health check failed after promotion!" + log_warn "Run rollback.sh to revert" + exit 1 +fi diff --git a/deployment/scripts/rollback.sh b/deployment/scripts/rollback.sh new file mode 100755 index 0000000..f946de9 --- /dev/null +++ b/deployment/scripts/rollback.sh @@ -0,0 +1,62 @@ +#!/bin/bash +set -euo pipefail + +ARTIFACT_DIR="/opt/api-artifacts" +CURRENT_LINK="$ARTIFACT_DIR/current" +SERVICE_NAME="api-server.service" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +if [ ! -L "$CURRENT_LINK" ]; then + log_error "No current deployment found" + exit 1 +fi + +CURRENT_TARGET=$(readlink "$CURRENT_LINK") + +if [ "$CURRENT_TARGET" = "blue" ]; then + PREVIOUS_SLOT="green" +else + PREVIOUS_SLOT="blue" +fi + +PREVIOUS_LINK="$ARTIFACT_DIR/$PREVIOUS_SLOT" + +if [ ! -L "$PREVIOUS_LINK" ]; then + log_error "No previous deployment found to rollback to" + exit 1 +fi + +PREVIOUS_BUILD=$(readlink "$PREVIOUS_LINK") + +log_warn "ROLLBACK: Reverting from $CURRENT_TARGET to $PREVIOUS_SLOT" +log_info "Previous build: $PREVIOUS_BUILD" + +read -p "Continue with rollback? (yes/no): " -r +if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then + log_warn "Rollback cancelled" + exit 0 +fi + +log_info "Switching current symlink to $PREVIOUS_SLOT" +ln -sfn "$PREVIOUS_SLOT" "$CURRENT_LINK" + +log_info "Reloading systemd service..." +systemctl reload-or-restart "$SERVICE_NAME" + +sleep 5 + +if "$ARTIFACT_DIR/scripts/health-check.sh"; then + log_info "Rollback successful!" + log_info "Active deployment: $PREVIOUS_SLOT ($PREVIOUS_BUILD)" +else + log_error "Health check failed after rollback - manual intervention required" + exit 1 +fi diff --git a/deployment/systemd/api-server.service b/deployment/systemd/api-server.service new file mode 100644 index 0000000..500292e --- /dev/null +++ b/deployment/systemd/api-server.service @@ -0,0 +1,39 @@ +[Unit] +Description=Golang API Server +After=network.target postgresql.service +Wants=postgresql.service + +[Service] +Type=simple +User=apiserver +Group=apiserver +WorkingDirectory=/opt/api-artifacts/current + +ExecStart=/opt/api-artifacts/current/api-server + +EnvironmentFile=/opt/api-artifacts/current/.env.production + +Restart=always +RestartSec=10 +StartLimitInterval=300 +StartLimitBurst=5 + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/api-artifacts + +LimitNOFILE=65535 +LimitNPROC=4096 + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=api-server + +KillMode=mixed +KillSignal=SIGTERM +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/main.go b/main.go index ab0aed4..2ddaefc 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,11 @@ import ( _ "github.com/lib/pq" ) -var db *sql.DB +var ( + db *sql.DB + Version string = "dev" + BuildTime string = "unknown" +) func initDB() { var err error @@ -103,6 +107,12 @@ func main() { r := gin.Default() r.GET("/hello", helloWorld) + r.GET("/version", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "version": Version, + "build_time": BuildTime, + }) + }) port := os.Getenv("PORT") if port == "" {