Files
michaelschiemer/deployment/infrastructure/playbooks/deploy-rsync-based.yml
Michael Schiemer 3b623e7afb feat(Deployment): Integrate Ansible deployment via PHP deployment pipeline
- Create AnsibleDeployStage using framework's Process module for secure command execution
- Integrate AnsibleDeployStage into DeploymentPipelineCommands for production deployments
- Add force_deploy flag support in Ansible playbook to override stale locks
- Use PHP deployment module as orchestrator (php console.php deploy:production)
- Fix ErrorAggregationInitializer to use Environment class instead of $_ENV superglobal

Architecture:
- BuildStage → AnsibleDeployStage → HealthCheckStage for production
- Process module provides timeout, error handling, and output capture
- Ansible playbook supports rollback via rollback-git-based.yml
- Zero-downtime deployments with health checks
2025-10-26 14:08:07 +01:00

473 lines
15 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 }}"
release_name: "{{ 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/
pre_tasks:
- 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: Create new release directory
file:
path: "{{ release_path }}"
state: directory
owner: "{{ app_user }}"
group: "{{ app_group }}"
mode: '0755'
- name: Sync application code to new release via rsync
synchronize:
src: "{{ local_project_path }}/"
dest: "{{ release_path }}/"
delete: yes
recursive: yes
rsync_opts: "{{ rsync_excludes | map('regex_replace', '^(.*)$', '--exclude=\\1') | list }}"
private_key: "{{ ansible_ssh_private_key_file }}"
delegate_to: localhost
become: false
- 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 commit hash
lineinfile:
path: "{{ app_base_path }}/deploy.log"
line: "[{{ ansible_date_time.iso8601 }}] Commit: {{ commit_hash.stdout | default('N/A - not a git repository') }}"
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 }}"
# ==========================================
# 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
wait_for:
timeout: 10
delegate_to: localhost
- name: Health check - Summary endpoint
uri:
url: "http://{{ ansible_host }}/health/summary"
method: GET
return_content: yes
status_code: 200
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."