550 lines
18 KiB
YAML
550 lines
18 KiB
YAML
---
|
|
# Rsync-based Deployment Playbook with Releases/Symlink Pattern
|
|
# Implements production-ready deployment with zero-downtime and rollback support
|
|
# No GitHub dependency - deploys directly from local machine
|
|
#
|
|
# Usage:
|
|
# ansible-playbook -i inventories/production/hosts.yml playbooks/deploy-rsync-based.yml
|
|
# ansible-playbook -i inventories/production/hosts.yml playbooks/deploy-rsync-based.yml --extra-vars "release_tag=v1.0.0"
|
|
|
|
- name: Deploy Custom PHP Framework (Rsync-based with Releases)
|
|
hosts: web_servers
|
|
become: true
|
|
|
|
vars:
|
|
# Application configuration
|
|
app_name: michaelschiemer
|
|
app_user: deploy
|
|
app_group: deploy
|
|
|
|
# Deployment paths
|
|
app_base_path: "/home/{{ app_user }}/{{ app_name }}"
|
|
releases_path: "{{ app_base_path }}/releases"
|
|
shared_path: "{{ app_base_path }}/shared"
|
|
current_path: "{{ app_base_path }}/current"
|
|
|
|
# Local source directory (project root on your machine)
|
|
local_project_path: "{{ playbook_dir }}/../../.."
|
|
|
|
# Release configuration
|
|
release_timestamp: "{{ ansible_date_time.epoch }}"
|
|
# Note: effective_release_tag is set in pre_tasks based on Git tags
|
|
release_name: "{{ effective_release_tag | default(release_tag | default(release_timestamp)) }}"
|
|
release_path: "{{ releases_path }}/{{ release_name }}"
|
|
|
|
# Deployment settings
|
|
keep_releases: 5
|
|
composer_install_flags: "--no-dev --optimize-autoloader --no-interaction"
|
|
|
|
# Shared directories and files
|
|
# Shared directories that need symlinks
|
|
# NOTE: storage/logs, storage/cache, storage/uploads are handled by Docker volumes
|
|
shared_dirs:
|
|
- storage/sessions
|
|
- public/uploads
|
|
|
|
shared_files:
|
|
- .env.production
|
|
|
|
# Rsync exclusions
|
|
rsync_excludes:
|
|
- .git/
|
|
- .github/
|
|
- node_modules/
|
|
- .env
|
|
- .env.local
|
|
- .env.development
|
|
- storage/
|
|
- public/uploads/
|
|
- tests/
|
|
- .idea/
|
|
- .vscode/
|
|
- "*.log"
|
|
- .DS_Store
|
|
- deployment/
|
|
- database.sqlite
|
|
- "*.cache"
|
|
- .php-cs-fixer.cache
|
|
- var/cache/
|
|
- var/logs/
|
|
- "*.php85/"
|
|
- src/**/*.php85/
|
|
|
|
pre_tasks:
|
|
# Git Tag Detection and Validation
|
|
- name: Get current Git tag (if release_tag not specified)
|
|
local_action:
|
|
module: command
|
|
cmd: git describe --tags --exact-match
|
|
chdir: "{{ local_project_path }}"
|
|
register: git_current_tag
|
|
become: false
|
|
ignore_errors: yes
|
|
when: release_tag is not defined
|
|
|
|
- name: Get current Git commit hash
|
|
local_action:
|
|
module: command
|
|
cmd: git rev-parse --short HEAD
|
|
chdir: "{{ local_project_path }}"
|
|
register: git_commit_hash
|
|
become: false
|
|
|
|
- name: Set release_name from Git tag or timestamp
|
|
set_fact:
|
|
effective_release_tag: "{{ release_tag | default(git_current_tag.stdout if (git_current_tag is defined and git_current_tag.rc == 0) else release_timestamp) }}"
|
|
git_hash: "{{ git_commit_hash.stdout }}"
|
|
|
|
- name: Display deployment information
|
|
debug:
|
|
msg:
|
|
- "=========================================="
|
|
- "Deployment Information"
|
|
- "=========================================="
|
|
- "Release: {{ effective_release_tag }}"
|
|
- "Git Hash: {{ git_hash }}"
|
|
- "Source: {{ local_project_path }}"
|
|
- "Target: {{ ansible_host }}"
|
|
- "=========================================="
|
|
|
|
- name: Install Composer dependencies locally before deployment
|
|
local_action:
|
|
module: command
|
|
cmd: composer install {{ composer_install_flags }}
|
|
chdir: "{{ local_project_path }}"
|
|
become: false
|
|
|
|
- name: Build NPM assets locally before deployment
|
|
local_action:
|
|
module: command
|
|
cmd: npm run build
|
|
chdir: "{{ local_project_path }}"
|
|
become: false
|
|
- name: Check if deployment lock exists
|
|
stat:
|
|
path: "{{ app_base_path }}/.deploy.lock"
|
|
register: deploy_lock
|
|
|
|
- name: Remove stale deployment lock if force flag is set
|
|
file:
|
|
path: "{{ app_base_path }}/.deploy.lock"
|
|
state: absent
|
|
when: deploy_lock.stat.exists and (force_deploy | default(false))
|
|
|
|
- name: Fail if deployment is already in progress (without force)
|
|
fail:
|
|
msg: "Deployment already in progress. Lock file exists: {{ app_base_path }}/.deploy.lock. Use --extra-vars 'force_deploy=true' to override."
|
|
when: deploy_lock.stat.exists and not (force_deploy | default(false))
|
|
|
|
- name: Create deployment lock
|
|
file:
|
|
path: "{{ app_base_path }}/.deploy.lock"
|
|
state: touch
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
mode: '0644'
|
|
|
|
- name: Log deployment start
|
|
lineinfile:
|
|
path: "{{ app_base_path }}/deploy.log"
|
|
line: "[{{ ansible_date_time.iso8601 }}] Deployment started - Release: {{ release_name }} - User: {{ ansible_user_id }}"
|
|
create: yes
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
|
|
tasks:
|
|
# ==========================================
|
|
# 1. Directory Structure Setup
|
|
# ==========================================
|
|
|
|
- name: Create base application directory
|
|
file:
|
|
path: "{{ app_base_path }}"
|
|
state: directory
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
mode: '0755'
|
|
|
|
- name: Create releases directory
|
|
file:
|
|
path: "{{ releases_path }}"
|
|
state: directory
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
mode: '0755'
|
|
|
|
- name: Create shared directory
|
|
file:
|
|
path: "{{ shared_path }}"
|
|
state: directory
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
mode: '0755'
|
|
|
|
- name: Create shared subdirectories
|
|
file:
|
|
path: "{{ shared_path }}/{{ item }}"
|
|
state: directory
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
mode: '0755'
|
|
loop: "{{ shared_dirs }}"
|
|
|
|
# ==========================================
|
|
# 2. Rsync Application Code to New Release
|
|
# ==========================================
|
|
|
|
- name: Remove old release directory if exists (prevent permission issues)
|
|
file:
|
|
path: "{{ release_path }}"
|
|
state: absent
|
|
|
|
- name: Create new release directory
|
|
file:
|
|
path: "{{ release_path }}"
|
|
state: directory
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
mode: '0755'
|
|
|
|
- name: Temporarily rename .dockerignore to prevent rsync -F from reading it
|
|
command: mv {{ local_project_path }}/.dockerignore {{ local_project_path }}/.dockerignore.bak
|
|
delegate_to: localhost
|
|
become: false
|
|
ignore_errors: yes
|
|
|
|
- name: Sync application code to new release via rsync (raw command to avoid -F flag)
|
|
command: >
|
|
rsync --delay-updates --compress --delete-after --archive --rsh='ssh -i {{ ansible_ssh_private_key_file }} -o StrictHostKeyChecking=no' --no-g --no-o
|
|
{% for exclude in rsync_excludes %}--exclude='{{ exclude }}' {% endfor %}
|
|
{{ local_project_path }}/ {{ app_user }}@{{ ansible_host }}:{{ release_path }}/
|
|
delegate_to: localhost
|
|
become: false
|
|
|
|
- name: Restore .dockerignore after rsync
|
|
command: mv {{ local_project_path }}/.dockerignore.bak {{ local_project_path }}/.dockerignore
|
|
delegate_to: localhost
|
|
become: false
|
|
ignore_errors: yes
|
|
|
|
- name: Set correct ownership for release
|
|
file:
|
|
path: "{{ release_path }}"
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
recurse: yes
|
|
|
|
- name: Get local git commit hash (if available)
|
|
command: git rev-parse HEAD
|
|
args:
|
|
chdir: "{{ local_project_path }}"
|
|
register: commit_hash
|
|
delegate_to: localhost
|
|
become: false
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Log release and commit information
|
|
lineinfile:
|
|
path: "{{ app_base_path }}/deploy.log"
|
|
line: "[{{ ansible_date_time.iso8601 }}] Release: {{ effective_release_tag }} | Git Hash: {{ git_hash | default('N/A') }} | Commit: {{ commit_hash.stdout | default('N/A') }}"
|
|
when: commit_hash.rc == 0
|
|
|
|
# ==========================================
|
|
# 3. Shared Files/Directories Symlinks
|
|
# ==========================================
|
|
|
|
- name: Remove shared directories from release (they will be symlinked)
|
|
file:
|
|
path: "{{ release_path }}/{{ item }}"
|
|
state: absent
|
|
loop: "{{ shared_dirs }}"
|
|
|
|
- name: Create parent directories for symlinks
|
|
file:
|
|
path: "{{ release_path }}/{{ item | dirname }}"
|
|
state: directory
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
mode: '0755'
|
|
loop: "{{ shared_dirs }}"
|
|
# Skip if dirname is current directory ('.')
|
|
when: (item | dirname) != '.'
|
|
|
|
- name: Create symlinks for shared directories
|
|
file:
|
|
src: "{{ shared_path }}/{{ item }}"
|
|
dest: "{{ release_path }}/{{ item }}"
|
|
state: link
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
force: yes
|
|
loop: "{{ shared_dirs }}"
|
|
|
|
- name: Remove .env.production from release (will be symlinked)
|
|
file:
|
|
path: "{{ release_path }}/.env.production"
|
|
state: absent
|
|
|
|
- name: Create symlink for .env.production
|
|
file:
|
|
src: "{{ shared_path }}/.env.production"
|
|
dest: "{{ release_path }}/.env.production"
|
|
state: link
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
force: yes
|
|
|
|
- name: Create .env symlink with relative path to shared .env.production for Docker container access
|
|
file:
|
|
src: "../../shared/.env.production"
|
|
dest: "{{ release_path }}/.env"
|
|
state: link
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
force: yes
|
|
|
|
# ==========================================
|
|
# 4. Dependencies Installation
|
|
# ==========================================
|
|
|
|
# Composer dependencies and NPM assets are already built locally and rsync'd
|
|
# No need to run composer install or npm build on the server
|
|
|
|
# ==========================================
|
|
# 5. File Permissions
|
|
# ==========================================
|
|
|
|
- name: Make console script executable
|
|
file:
|
|
path: "{{ release_path }}/console.php"
|
|
mode: '0755'
|
|
ignore_errors: yes
|
|
|
|
# ==========================================
|
|
# 6. Database Migrations (Optional)
|
|
# ==========================================
|
|
|
|
- name: Run database migrations
|
|
command: php console.php db:migrate --no-interaction
|
|
args:
|
|
chdir: "{{ release_path }}"
|
|
become_user: "{{ app_user }}"
|
|
when: run_migrations | default(false) | bool
|
|
register: migrations_result
|
|
|
|
- name: Log migration result
|
|
lineinfile:
|
|
path: "{{ app_base_path }}/deploy.log"
|
|
line: "[{{ ansible_date_time.iso8601 }}] Migrations: {{ migrations_result.stdout | default('skipped') }}"
|
|
when: run_migrations | default(false) | bool
|
|
|
|
# ==========================================
|
|
# 7. Prepare for Deployment
|
|
# ==========================================
|
|
|
|
- name: Get current release (before switch)
|
|
stat:
|
|
path: "{{ current_path }}"
|
|
register: current_release_before
|
|
|
|
- name: Stop existing Docker containers (if any)
|
|
command: docker compose down
|
|
args:
|
|
chdir: "{{ current_path }}"
|
|
become_user: "{{ app_user }}"
|
|
when: current_release_before.stat.exists
|
|
ignore_errors: yes
|
|
|
|
# ==========================================
|
|
# 8. Symlink Switch (Zero-Downtime)
|
|
# ==========================================
|
|
|
|
- name: Store previous release path for rollback
|
|
set_fact:
|
|
previous_release: "{{ current_release_before.stat.lnk_source | default('none') }}"
|
|
|
|
- name: Switch current symlink to new release (atomic operation)
|
|
file:
|
|
src: "{{ release_path }}"
|
|
dest: "{{ current_path }}"
|
|
state: link
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
force: yes
|
|
|
|
- name: Log symlink switch
|
|
lineinfile:
|
|
path: "{{ app_base_path }}/deploy.log"
|
|
line: "[{{ ansible_date_time.iso8601 }}] Symlink switched: {{ current_path }} -> {{ release_path }}"
|
|
|
|
# ==========================================
|
|
# 8.5. SSL Certificate Setup
|
|
# ==========================================
|
|
|
|
- name: Create SSL directory in release
|
|
file:
|
|
path: "{{ release_path }}/ssl"
|
|
state: directory
|
|
owner: "{{ app_user }}"
|
|
group: "{{ app_group }}"
|
|
mode: '0755'
|
|
|
|
- name: Copy SSL certificates from certbot to release (if they exist)
|
|
shell: |
|
|
if docker ps | grep -q certbot; then
|
|
docker cp certbot:/etc/letsencrypt/archive/michaelschiemer.de/fullchain1.pem {{ release_path }}/ssl/fullchain.pem 2>/dev/null || true
|
|
docker cp certbot:/etc/letsencrypt/archive/michaelschiemer.de/privkey1.pem {{ release_path }}/ssl/privkey.pem 2>/dev/null || true
|
|
chown {{ app_user }}:{{ app_group }} {{ release_path }}/ssl/*.pem 2>/dev/null || true
|
|
fi
|
|
args:
|
|
chdir: "{{ current_path }}"
|
|
ignore_errors: yes
|
|
|
|
# ==========================================
|
|
# 9. Start Docker Containers
|
|
# ==========================================
|
|
|
|
- name: Start Docker containers with new release
|
|
command: docker compose -f docker-compose.yml -f docker-compose.production.yml up -d --build
|
|
args:
|
|
chdir: "{{ current_path }}"
|
|
become_user: "{{ app_user }}"
|
|
|
|
- name: Wait for containers to be ready
|
|
pause:
|
|
seconds: 15
|
|
|
|
# ==========================================
|
|
# 10. Health Checks
|
|
# ==========================================
|
|
|
|
- name: Wait for application to be ready
|
|
pause:
|
|
seconds: 10
|
|
|
|
- name: Health check - Nginx ping endpoint (HTTPS)
|
|
uri:
|
|
url: "https://{{ ansible_host }}/ping"
|
|
method: GET
|
|
return_content: yes
|
|
status_code: 200
|
|
validate_certs: no
|
|
follow_redirects: none
|
|
register: health_check
|
|
retries: 3
|
|
delay: 5
|
|
until: health_check.status == 200
|
|
ignore_errors: yes
|
|
|
|
- name: Log health check result
|
|
lineinfile:
|
|
path: "{{ app_base_path }}/deploy.log"
|
|
line: "[{{ ansible_date_time.iso8601 }}] Health check: {{ health_check.status | default('FAILED') }}"
|
|
|
|
- name: Rollback on health check failure
|
|
block:
|
|
- name: Stop failed release containers
|
|
command: docker compose down
|
|
args:
|
|
chdir: "{{ current_path }}"
|
|
become_user: "{{ app_user }}"
|
|
|
|
- name: Switch symlink back to previous release
|
|
file:
|
|
src: "{{ previous_release }}"
|
|
dest: "{{ current_path }}"
|
|
state: link
|
|
force: yes
|
|
when: previous_release != 'none'
|
|
|
|
- name: Start previous release containers
|
|
command: docker compose -f docker-compose.yml -f docker-compose.production.yml up -d
|
|
args:
|
|
chdir: "{{ current_path }}"
|
|
become_user: "{{ app_user }}"
|
|
when: previous_release != 'none'
|
|
|
|
- name: Remove failed release
|
|
file:
|
|
path: "{{ release_path }}"
|
|
state: absent
|
|
|
|
- name: Log rollback
|
|
lineinfile:
|
|
path: "{{ app_base_path }}/deploy.log"
|
|
line: "[{{ ansible_date_time.iso8601 }}] ROLLBACK: Health check failed, reverted to {{ previous_release }}"
|
|
|
|
- name: Fail deployment
|
|
fail:
|
|
msg: "Deployment failed - health check returned {{ health_check.status }}. Rolled back to previous release."
|
|
when: health_check.status != 200
|
|
|
|
# ==========================================
|
|
# 11. Cleanup Old Releases
|
|
# ==========================================
|
|
|
|
- name: Get list of all releases
|
|
find:
|
|
paths: "{{ releases_path }}"
|
|
file_type: directory
|
|
register: all_releases
|
|
|
|
- name: Sort releases by creation time
|
|
set_fact:
|
|
sorted_releases: "{{ all_releases.files | sort(attribute='ctime', reverse=true) }}"
|
|
|
|
- name: Remove old releases (keep last {{ keep_releases }})
|
|
file:
|
|
path: "{{ item.path }}"
|
|
state: absent
|
|
loop: "{{ sorted_releases[keep_releases:] }}"
|
|
when: sorted_releases | length > keep_releases
|
|
|
|
- name: Log cleanup
|
|
lineinfile:
|
|
path: "{{ app_base_path }}/deploy.log"
|
|
line: "[{{ ansible_date_time.iso8601 }}] Cleanup: Kept {{ [sorted_releases | length, keep_releases] | min }} releases, removed {{ [sorted_releases | length - keep_releases, 0] | max }}"
|
|
|
|
post_tasks:
|
|
- name: Cleanup and logging
|
|
block:
|
|
- name: Remove deployment lock
|
|
file:
|
|
path: "{{ app_base_path }}/.deploy.lock"
|
|
state: absent
|
|
|
|
- name: Log deployment completion
|
|
lineinfile:
|
|
path: "{{ app_base_path }}/deploy.log"
|
|
line: "[{{ ansible_date_time.iso8601 }}] Deployment completed successfully - Release: {{ release_name }}"
|
|
|
|
- name: Display deployment summary
|
|
debug:
|
|
msg:
|
|
- "=========================================="
|
|
- "Deployment Summary"
|
|
- "=========================================="
|
|
- "Release: {{ release_name }}"
|
|
- "Commit: {{ commit_hash.stdout | default('N/A') }}"
|
|
- "Path: {{ release_path }}"
|
|
- "Current: {{ current_path }}"
|
|
- "Health Check: {{ health_check.status | default('N/A') }}"
|
|
- "Previous Release: {{ previous_release }}"
|
|
- "=========================================="
|
|
|
|
rescue:
|
|
- name: Remove deployment lock on failure
|
|
file:
|
|
path: "{{ app_base_path }}/.deploy.lock"
|
|
state: absent
|
|
|
|
- name: Log deployment failure
|
|
lineinfile:
|
|
path: "{{ app_base_path }}/deploy.log"
|
|
line: "[{{ ansible_date_time.iso8601 }}] DEPLOYMENT FAILED - Release: {{ release_name }}"
|
|
|
|
- name: Fail with error message
|
|
fail:
|
|
msg: "Deployment failed. Check {{ app_base_path }}/deploy.log for details."
|