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
This commit is contained in:
Jeremie Fraeys 2026-02-21 18:31:12 -05:00
parent e364538206
commit b9c5cdff12
No known key found for this signature in database
11 changed files with 486 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,52 @@
#!/bin/bash
# Rollback script - revert to previous version of an app
# Usage: rollback.sh <app_name> [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 <app_name> [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}"

View file

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

View file

@ -0,0 +1,9 @@
---
- name: reload systemd
systemd:
daemon_reload: true
- name: restart webhook
systemd:
name: webhook
state: restarted

View file

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

View file

@ -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 <app> <version> <env>"
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."

View file

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

View file

@ -0,0 +1,31 @@
#!/bin/bash
# Timing-safe token validation wrapper for deployment webhook
# Usage: validate-deploy-token.sh <token> <app> <version> <env>
# 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 <token> <app> <version> <env>" >&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"