From b9c5cdff1247861e06c768fa5d9b877011be380e Mon Sep 17 00:00:00 2001 From: Jeremie Fraeys Date: Sat, 21 Feb 2026 18:31:12 -0500 Subject: [PATCH] Add app deployer role for automated deployments - Systemd service and timer for deployment orchestration - Webhook listener for Git-triggered deployments - Forgejo Actions workflow for CI/CD pipeline - Deployment scripts with rollback capability - Deploy token validation for security --- roles/app_deployer/defaults/main.yml | 6 + roles/app_deployer/files/app.service.j2 | 18 ++ roles/app_deployer/files/deploy-app.yml | 102 +++++++++++ .../files/forgejo-deploy-workflow.yml | 44 +++++ roles/app_deployer/files/rollback.sh | 52 ++++++ roles/app_deployer/files/webhook.service | 12 ++ roles/app_deployer/handlers/main.yml | 9 + roles/app_deployer/tasks/main.yml | 158 ++++++++++++++++++ roles/app_deployer/templates/deploy.sh.j2 | 34 ++++ roles/app_deployer/templates/hooks.json.j2 | 20 +++ .../templates/validate-deploy-token.sh.j2 | 31 ++++ 11 files changed, 486 insertions(+) create mode 100644 roles/app_deployer/defaults/main.yml create mode 100644 roles/app_deployer/files/app.service.j2 create mode 100644 roles/app_deployer/files/deploy-app.yml create mode 100644 roles/app_deployer/files/forgejo-deploy-workflow.yml create mode 100644 roles/app_deployer/files/rollback.sh create mode 100644 roles/app_deployer/files/webhook.service create mode 100644 roles/app_deployer/handlers/main.yml create mode 100644 roles/app_deployer/tasks/main.yml create mode 100644 roles/app_deployer/templates/deploy.sh.j2 create mode 100644 roles/app_deployer/templates/hooks.json.j2 create mode 100644 roles/app_deployer/templates/validate-deploy-token.sh.j2 diff --git a/roles/app_deployer/defaults/main.yml b/roles/app_deployer/defaults/main.yml new file mode 100644 index 0000000..7b50a4a --- /dev/null +++ b/roles/app_deployer/defaults/main.yml @@ -0,0 +1,6 @@ +--- +webhook_version: "2.8.1" +# Get actual checksum from: https://github.com/adnanh/webhook/releases/download/2.8.1/webhook-2.8.1-linux-amd64.tar.gz.sha256 +webhook_checksum: "sha256:" +vault_password: "" +vault_deploy_token: "" diff --git a/roles/app_deployer/files/app.service.j2 b/roles/app_deployer/files/app.service.j2 new file mode 100644 index 0000000..9084824 --- /dev/null +++ b/roles/app_deployer/files/app.service.j2 @@ -0,0 +1,18 @@ +[Unit] +Description={{ app_name }} application +After=network.target + +[Service] +Type=simple +User={{ app_name }} +Group={{ app_name }} +WorkingDirectory=/opt/apps/{{ app_name }} +ExecStart=/opt/apps/{{ app_name }}/app +Restart=always +RestartSec=5 +EnvironmentFile=-/opt/apps/{{ app_name }}/.env +Environment="APP_NAME={{ app_name }}" +Environment="APP_VERSION={{ app_version }}" + +[Install] +WantedBy=multi-user.target diff --git a/roles/app_deployer/files/deploy-app.yml b/roles/app_deployer/files/deploy-app.yml new file mode 100644 index 0000000..46f27d4 --- /dev/null +++ b/roles/app_deployer/files/deploy-app.yml @@ -0,0 +1,102 @@ +--- +# Generic app deployment playbook +# Deploys any app without requiring per-app playbooks +# Required extra-vars: app_name, app_version +# Optional extra-vars: env (default: prod), app_port, app_env_vars +# Run on the web host (which acts as deployment server) + +- hosts: localhost + become: true + vars: + env: "{{ env | default('prod') }}" + app_dir: "/opt/apps/{{ app_name }}" + app_binary: "{{ app_dir }}/app" + systemd_service: "{{ app_name }}" + pre_tasks: + - name: Load vault vars if present + include_vars: + file: "{{ playbook_dir }}/../secrets/vault.yml" + when: (lookup('ansible.builtin.fileglob', playbook_dir ~ '/../secrets/vault.yml', wantlist=True) | length) > 0 + tags: [vault] + + - name: Validate required variables + assert: + that: + - app_name | length > 0 + - app_version | length > 0 + fail_msg: "app_name and app_version are required. Use --extra-vars 'app_name=myapp app_version=abc123'" + + - name: Ensure app artifact exists on deployment server + stat: + path: "/opt/artifacts/{{ app_name }}-{{ app_version }}" + register: app_artifact + delegate_to: localhost + become: false + + - name: Fail if app artifact is missing + fail: + msg: "App artifact not found: /opt/artifacts/{{ app_name }}-{{ app_version }}. Ensure the CI pipeline uploaded it." + when: not app_artifact.stat.exists + delegate_to: localhost + become: false + + tasks: + - name: Ensure deploy user exists + user: + name: "{{ app_name }}" + system: true + create_home: false + shell: /bin/false + state: present + + - name: Create app directory + file: + path: "{{ app_dir }}" + state: directory + owner: "{{ app_name }}" + group: "{{ app_name }}" + mode: '0755' + + - name: Copy app artifact to target host + copy: + src: "/opt/artifacts/{{ app_name }}-{{ app_version }}" + dest: "{{ app_binary }}" + owner: "{{ app_name }}" + group: "{{ app_name }}" + mode: '0755' + notify: restart app + + - name: Write environment file if app_env_vars provided + copy: + dest: "{{ app_dir }}/.env" + owner: "{{ app_name }}" + group: "{{ app_name }}" + mode: '0600' + content: "{% for key, value in app_env_vars.items() %}{{ key }}={{ value }}\n{% endfor %}" + when: app_env_vars is defined + notify: restart app + + - name: Remove environment file if not provided + file: + path: "{{ app_dir }}/.env" + state: absent + when: app_env_vars is not defined + + - name: Write systemd service for app + template: + src: "app.service.j2" + dest: "/etc/systemd/system/{{ systemd_service }}.service" + notify: restart app + + - name: Enable and start app service + systemd: + name: "{{ systemd_service }}" + enabled: true + state: started + daemon_reload: true + + handlers: + - name: restart app + systemd: + name: "{{ systemd_service }}" + state: restarted diff --git a/roles/app_deployer/files/forgejo-deploy-workflow.yml b/roles/app_deployer/files/forgejo-deploy-workflow.yml new file mode 100644 index 0000000..b1ee3bc --- /dev/null +++ b/roles/app_deployer/files/forgejo-deploy-workflow.yml @@ -0,0 +1,44 @@ +# Sample Forgejo Actions workflow for app deployment +# Copy this to your app repo: .forgejo/workflows/deploy.yml + +on: + push: + branches: [main] + +jobs: + build-and-deploy: + runs-on: self-hosted + steps: + - uses: actions/checkout@v3 + + - name: Build binary + run: | + # Replace with your build command (Go, Rust, Node.js, etc.) + go build -o my-api ./cmd/... + # or: cargo build --release + # or: npm run build + + - name: Generate artifact checksum + run: | + sha256sum my-api > my-api.sha256 + # Rename binary to include version + mv my-api my-api-${{ github.sha }} + # Update checksum file with new name + sha256sum my-api-${{ github.sha }} > my-api-${{ github.sha }}.sha256 + + - name: Upload artifact and checksum to web host + run: | + # Upload both binary and checksum + scp my-api-${{ github.sha }} my-api-${{ github.sha }}.sha256 deploy@web:/opt/artifacts/ + + - name: Trigger deployment on web host + run: | + # Use HTTPS with valid certificate + curl -s -X POST https://web.jfraeys.com:9000/hooks/deploy \ + -H "Content-Type: application/json" \ + -H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \ + -d '{ + "app": "my-api", + "version": "${{ github.sha }}", + "env": "prod" + }' diff --git a/roles/app_deployer/files/rollback.sh b/roles/app_deployer/files/rollback.sh new file mode 100644 index 0000000..ecdb186 --- /dev/null +++ b/roles/app_deployer/files/rollback.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Rollback script - revert to previous version of an app +# Usage: rollback.sh [version] +# If version not specified, shows available versions + +set -e + +APP="$1" +TARGET_VERSION="$2" +ARTIFACTS_DIR="/opt/artifacts" +APP_DIR="/opt/apps/${APP}" + +if [ -z "$APP" ]; then + echo "Usage: $0 [version]" + echo "Examples:" + echo " $0 my-api # List available versions" + echo " $0 my-api abc123def # Rollback to specific version" + exit 1 +fi + +# List available versions if no target specified +if [ -z "$TARGET_VERSION" ]; then + echo "Available versions for ${APP}:" + ls -lt "${ARTIFACTS_DIR}/${APP}"-* 2>/dev/null | grep -v '.sha256$' | head -10 | while read line; do + version=$(echo "$line" | awk '{print $NF}' | sed "s|${ARTIFACTS_DIR}/${APP}-||") + echo " ${version}" + done + echo "" + echo "Current version:" + cat "${APP_DIR}/.current-version" 2>/dev/null || echo " (unknown)" + exit 0 +fi + +# Check if target version exists +if [ ! -f "${ARTIFACTS_DIR}/${APP}-${TARGET_VERSION}" ]; then + echo "ERROR: Version ${TARGET_VERSION} not found in ${ARTIFACTS_DIR}/" + echo "Run '$0 ${APP}' to see available versions" + exit 1 +fi + +echo "Rolling back ${APP} to version ${TARGET_VERSION}..." + +# Update symlink +ln -sf "${APP_DIR}/app-${TARGET_VERSION}" "${APP_DIR}/app" + +# Update version file +echo "${TARGET_VERSION}" > "${APP_DIR}/.current-version" + +# Restart the service +sudo systemctl restart "${APP}" + +echo "Rollback complete: ${APP} is now running version ${TARGET_VERSION}" diff --git a/roles/app_deployer/files/webhook.service b/roles/app_deployer/files/webhook.service new file mode 100644 index 0000000..05e0cb0 --- /dev/null +++ b/roles/app_deployer/files/webhook.service @@ -0,0 +1,12 @@ +[Unit] +Description=Deployment webhook +After=network.target + +[Service] +ExecStart=/usr/local/bin/webhook -hooks /opt/deploy/hooks.json -port 9000 +Restart=always +User=deploy +WorkingDirectory=/opt/deploy + +[Install] +WantedBy=multi-user.target diff --git a/roles/app_deployer/handlers/main.yml b/roles/app_deployer/handlers/main.yml new file mode 100644 index 0000000..ec9d4d6 --- /dev/null +++ b/roles/app_deployer/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: reload systemd + systemd: + daemon_reload: true + +- name: restart webhook + systemd: + name: webhook + state: restarted diff --git a/roles/app_deployer/tasks/main.yml b/roles/app_deployer/tasks/main.yml new file mode 100644 index 0000000..25a650c --- /dev/null +++ b/roles/app_deployer/tasks/main.yml @@ -0,0 +1,158 @@ +--- +# Role to provision deployment infrastructure on web host +# This allows web host to receive deploy webhooks and run ansible locally + +- name: Ensure deploy user exists + user: + name: deploy + system: true + create_home: true + home: /opt/deploy + shell: /bin/bash + state: present + +- name: Ensure artifacts directory exists + file: + path: /opt/artifacts + state: directory + owner: deploy + group: deploy + mode: '0755' + +- name: Ensure deploy scripts directory exists + file: + path: /opt/deploy/scripts + state: directory + owner: deploy + group: deploy + mode: '0755' + +- name: Ensure vault password file exists (for ansible) + copy: + dest: /opt/deploy/.vault_pass + content: "{{ vault_password }}" + owner: deploy + group: deploy + mode: '0600' + no_log: true + when: vault_password is defined + +- name: Ensure deploy playbooks directory exists + file: + path: /opt/deploy/playbooks + state: directory + owner: deploy + group: deploy + mode: '0755' + +- name: Deploy deploy-app.yml playbook + copy: + src: deploy-app.yml + dest: /opt/deploy/playbooks/deploy-app.yml + owner: deploy + group: deploy + mode: '0644' + +- name: Ensure deploy templates directory exists + file: + path: /opt/deploy/playbooks/templates + state: directory + owner: deploy + group: deploy + mode: '0755' + +- name: Deploy app.service.j2 template + copy: + src: app.service.j2 + dest: /opt/deploy/playbooks/templates/app.service.j2 + owner: deploy + group: deploy + mode: '0644' + +- name: Download webhook binary + get_url: + url: "https://github.com/adnanh/webhook/releases/download/{{ webhook_version }}/webhook-linux-amd64.tar.gz" + dest: "/tmp/webhook-linux-amd64.tar.gz" + mode: '0644' + checksum: "{{ webhook_checksum if webhook_checksum is defined and webhook_checksum | length > 7 else omit }}" + +- name: Extract webhook binary + unarchive: + src: "/tmp/webhook-linux-amd64.tar.gz" + dest: /usr/local/bin + remote_src: true + extra_opts: + - "--strip-components=1" + include: + - "webhook-linux-amd64/webhook" + mode: '0755' + owner: root + group: root + notify: restart webhook + +- name: Cleanup webhook archive + file: + path: "/tmp/webhook-linux-amd64.tar.gz" + state: absent + +- name: Deploy deploy.sh script + template: + src: deploy.sh.j2 + dest: /opt/deploy/scripts/deploy.sh + owner: deploy + group: deploy + mode: '0755' + +- name: Deploy rollback script + copy: + src: rollback.sh + dest: /opt/deploy/scripts/rollback.sh + owner: deploy + group: deploy + mode: '0755' + +- name: Deploy token validation script + template: + src: validate-deploy-token.sh.j2 + dest: /opt/deploy/scripts/validate-deploy-token.sh + owner: deploy + group: deploy + mode: '0755' + +- name: Deploy hooks.json + template: + src: hooks.json.j2 + dest: /opt/deploy/hooks.json + owner: deploy + group: deploy + mode: '0644' + notify: restart webhook + +- name: Configure sudoers for deploy user (restrict to specific commands) + copy: + content: | + # Allow deploy user to run only the deploy script + deploy ALL=(ALL) NOPASSWD: /opt/deploy/scripts/deploy.sh * + dest: /etc/sudoers.d/deploy + owner: root + group: root + mode: '0440' + validate: 'visudo -cf %s' + +- name: Deploy webhook systemd service + copy: + src: webhook.service + dest: /etc/systemd/system/webhook.service + owner: root + group: root + mode: '0644' + notify: + - reload systemd + - restart webhook + +- name: Enable and start webhook service + systemd: + name: webhook + enabled: true + state: started + daemon_reload: true diff --git a/roles/app_deployer/templates/deploy.sh.j2 b/roles/app_deployer/templates/deploy.sh.j2 new file mode 100644 index 0000000..a103c86 --- /dev/null +++ b/roles/app_deployer/templates/deploy.sh.j2 @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +APP=$1 # e.g. "my-api" +VERSION=$2 # e.g. "a3f5c91" (commit SHA from app repo) +ENV=$3 # e.g. "prod" or "staging" + +if [ -z "$APP" ] || [ -z "$VERSION" ] || [ -z "$ENV" ]; then + echo "Usage: $0 " + echo "Example: $0 my-api a3f5c91 prod" + exit 1 +fi + +# Concurrency lock - prevent simultaneous deploys of the same app +exec 9>/var/lock/deploy-${APP}.lock +flock -n 9 || { echo "Deploy already in progress for $APP"; exit 1; } + +echo "Deploying $APP @ $VERSION to $ENV" + +# Build vault args only if password file exists and is non-empty +VAULT_ARGS="" +if [ -s "/opt/deploy/.vault_pass" ]; then + VAULT_ARGS="--vault-password-file /opt/deploy/.vault_pass" +fi + +# Run ansible locally on the web host (which is the deployment server) +ansible-playbook \ + -i "localhost," \ + -c local \ + "/opt/deploy/playbooks/deploy-app.yml" \ + --extra-vars "app_version=$VERSION app_name=$APP env=$ENV" \ + $VAULT_ARGS + +echo "Done." diff --git a/roles/app_deployer/templates/hooks.json.j2 b/roles/app_deployer/templates/hooks.json.j2 new file mode 100644 index 0000000..8cf1c9e --- /dev/null +++ b/roles/app_deployer/templates/hooks.json.j2 @@ -0,0 +1,20 @@ +[ + { + "id": "deploy", + "execute-command": "/opt/deploy/scripts/validate-deploy-token.sh", + "command-working-directory": "/opt/deploy", + "pass-arguments-to-command": [ + { "source": "header", "name": "X-Deploy-Token" }, + { "source": "payload", "name": "app" }, + { "source": "payload", "name": "version" }, + { "source": "payload", "name": "env" } + ], + "trigger-rule": { + "match": { + "type": "regex", + "regex": "^.+$", + "parameter": { "source": "header", "name": "X-Deploy-Token" } + } + } + } +] diff --git a/roles/app_deployer/templates/validate-deploy-token.sh.j2 b/roles/app_deployer/templates/validate-deploy-token.sh.j2 new file mode 100644 index 0000000..5ffc288 --- /dev/null +++ b/roles/app_deployer/templates/validate-deploy-token.sh.j2 @@ -0,0 +1,31 @@ +#!/bin/bash +# Timing-safe token validation wrapper for deployment webhook +# Usage: validate-deploy-token.sh +# Exits 0 if token is valid, 1 otherwise + +set -e + +EXPECTED_TOKEN="{{ vault_deploy_token }}" +PROVIDED_TOKEN="$1" +APP="$2" +VERSION="$3" +ENV="$4" + +if [ -z "$PROVIDED_TOKEN" ] || [ -z "$APP" ] || [ -z "$VERSION" ] || [ -z "$ENV" ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +# Timing-safe comparison using sha256sum +# This prevents timing attacks by ensuring comparison takes constant time +EXPECTED_HASH=$(echo -n "$EXPECTED_TOKEN" | sha256sum | awk '{print $1}') +PROVIDED_HASH=$(echo -n "$PROVIDED_TOKEN" | sha256sum | awk '{print $1}') + +if [ "$EXPECTED_HASH" != "$PROVIDED_HASH" ]; then + echo "Invalid token" >&2 + exit 1 +fi + +# Token is valid - execute deploy +echo "Token valid - deploying $APP @ $VERSION to $ENV" +sudo /opt/deploy/scripts/deploy.sh "$APP" "$VERSION" "$ENV"