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:
parent
e364538206
commit
b9c5cdff12
11 changed files with 486 additions and 0 deletions
6
roles/app_deployer/defaults/main.yml
Normal file
6
roles/app_deployer/defaults/main.yml
Normal 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: ""
|
||||
18
roles/app_deployer/files/app.service.j2
Normal file
18
roles/app_deployer/files/app.service.j2
Normal 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
|
||||
102
roles/app_deployer/files/deploy-app.yml
Normal file
102
roles/app_deployer/files/deploy-app.yml
Normal 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
|
||||
44
roles/app_deployer/files/forgejo-deploy-workflow.yml
Normal file
44
roles/app_deployer/files/forgejo-deploy-workflow.yml
Normal 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"
|
||||
}'
|
||||
52
roles/app_deployer/files/rollback.sh
Normal file
52
roles/app_deployer/files/rollback.sh
Normal 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}"
|
||||
12
roles/app_deployer/files/webhook.service
Normal file
12
roles/app_deployer/files/webhook.service
Normal 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
|
||||
9
roles/app_deployer/handlers/main.yml
Normal file
9
roles/app_deployer/handlers/main.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
- name: reload systemd
|
||||
systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: restart webhook
|
||||
systemd:
|
||||
name: webhook
|
||||
state: restarted
|
||||
158
roles/app_deployer/tasks/main.yml
Normal file
158
roles/app_deployer/tasks/main.yml
Normal 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
|
||||
34
roles/app_deployer/templates/deploy.sh.j2
Normal file
34
roles/app_deployer/templates/deploy.sh.j2
Normal 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."
|
||||
20
roles/app_deployer/templates/hooks.json.j2
Normal file
20
roles/app_deployer/templates/hooks.json.j2
Normal 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
31
roles/app_deployer/templates/validate-deploy-token.sh.j2
Normal file
31
roles/app_deployer/templates/validate-deploy-token.sh.j2
Normal 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"
|
||||
Loading…
Reference in a new issue