Initial commit
This commit is contained in:
415
README.md
Normal file
415
README.md
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
# Docker Compose App Role
|
||||||
|
|
||||||
|
A flexible Ansible role for deploying and managing Docker Compose applications with features including backup/restore and healthchecks.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This role provides a standardized way to deploy containerized applications using Docker Compose. It handles directory creation, template rendering, and container lifecycle management.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Template-based deployment** with Jinja2 templating
|
||||||
|
- **Dual backup system** (controller + remote host)
|
||||||
|
- **Health checks** and deployment verification
|
||||||
|
- **Multiple deployment modes** (template, file, or inline content)
|
||||||
|
- **Flexible directory management**
|
||||||
|
- **Configurable backup retention policies**
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Create a New Role
|
||||||
|
|
||||||
|
Use the provided script to create a new application skeleton role:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./create_app_role.sh my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
```
|
||||||
|
roles/my-app/
|
||||||
|
├── defaults/main.yml # Default variables
|
||||||
|
├── meta/main.yml # Role metadata and dependencies
|
||||||
|
└── templates/
|
||||||
|
└── compose.yml.j2 # Docker Compose template
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Your Application
|
||||||
|
|
||||||
|
Edit `roles/my-app/defaults/main.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
app_role_name: my-app
|
||||||
|
|
||||||
|
my_app_container_name: "{{ app_name | default('my-app') }}"
|
||||||
|
my_app_container_version: latest
|
||||||
|
my_app_restart_policy: "{{ app_restart_policy }}"
|
||||||
|
my_app_http_port: 8080
|
||||||
|
my_app_data_path: "{{ app_dir }}/data"
|
||||||
|
my_app_config_path: "{{ app_dir }}/config"
|
||||||
|
|
||||||
|
# Optional: directories to create
|
||||||
|
app_subdirectories:
|
||||||
|
- "{{ my_app_data_path }}"
|
||||||
|
- "{{ my_app_config_path }}"
|
||||||
|
|
||||||
|
# Optional: backup configuration
|
||||||
|
app_backup_subdirectories:
|
||||||
|
- "{{ my_app_data_path }}"
|
||||||
|
- "{{ my_app_config_path }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Docker Compose Template
|
||||||
|
|
||||||
|
Edit `roles/my-app/templates/compose.yml.j2`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
services:
|
||||||
|
my-app:
|
||||||
|
image: "my-app:{{ my_app_container_version }}"
|
||||||
|
container_name: "{{ my_app_container_name }}"
|
||||||
|
restart: "{{ my_app_restart_policy }}"
|
||||||
|
ports:
|
||||||
|
- "{{ my_app_http_port }}:8080"
|
||||||
|
volumes:
|
||||||
|
- "{{ my_app_data_path }}:/data"
|
||||||
|
- "{{ my_app_config_path }}:/config"
|
||||||
|
environment:
|
||||||
|
- TZ={{ app_timezone | default('UTC') }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Deploy Your Application
|
||||||
|
|
||||||
|
Add to your playbook:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
- name: Deploy my application
|
||||||
|
hosts: localhost
|
||||||
|
roles:
|
||||||
|
- role: my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Backup and Restore
|
||||||
|
|
||||||
|
#### Create a New Backup
|
||||||
|
|
||||||
|
Create an on-demand backup using tags:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup a specific application
|
||||||
|
ansible-playbook --tags backup playbook.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure backup retention and options in your role's meta vars:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# In roles/my-app/meta/main.yml
|
||||||
|
dependencies:
|
||||||
|
- role: docker_compose_app
|
||||||
|
vars:
|
||||||
|
app_backup_subdirectories:
|
||||||
|
- "{{ my_app_data_path }}"
|
||||||
|
- "{{ my_app_config_path }}"
|
||||||
|
app_backup_retention_days_controller: 90 # Keep backups for 90 days
|
||||||
|
app_backup_retention_days_remote: 7 # Keep remote backups for 7 days
|
||||||
|
app_backup_stop_services: true # Stop containers during backup
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Automated Backups
|
||||||
|
|
||||||
|
For scheduled backups:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run daily backup at 2 AM - only runs backup tasks
|
||||||
|
0 2 * * * ansible-playbook -i inventory --tags backup my-playbook.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tag-Based Execution
|
||||||
|
|
||||||
|
The role supports selective execution using tags:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Setup only - doesn't start the containers
|
||||||
|
ansible-playbook --tags setup playbook.yml
|
||||||
|
|
||||||
|
# Full deployment - setup + start containers
|
||||||
|
ansible-playbook --tags deploy playbook.yml
|
||||||
|
|
||||||
|
# Restore and deploy
|
||||||
|
ansible-playbook --tags restore,deploy playbook.yml
|
||||||
|
|
||||||
|
# Backup only
|
||||||
|
ansible-playbook --tags backup playbook.yml
|
||||||
|
|
||||||
|
# Update only
|
||||||
|
ansible-playbook --tags update playbook.yml
|
||||||
|
|
||||||
|
# Backup then update
|
||||||
|
ansible-playbook --tags backup,update playbook.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Restore from Backup
|
||||||
|
|
||||||
|
Restore from the latest backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Setup directories, restore from backup, then deploy
|
||||||
|
ansible-playbook --tags setup,restore,deploy playbook.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure restore options in your role's meta vars:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# In roles/my-app/meta/main.yml
|
||||||
|
dependencies:
|
||||||
|
- role: docker_compose_app
|
||||||
|
vars:
|
||||||
|
app_restore_source: controller # or 'remote'
|
||||||
|
app_restore_max_age_days: 30 # Only restore backups newer than 30 days
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore from a specific archive by setting the variable at runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ansible-playbook --tags restore playbook.yml \
|
||||||
|
-e app_restore_archive=/path/to/specific/backup-20240315-120000.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
Configure health monitoring:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- role: my-app
|
||||||
|
vars:
|
||||||
|
app_name: my-app
|
||||||
|
app_health_check: true
|
||||||
|
app_health_check_method: http
|
||||||
|
app_health_check_url: "http://localhost:8080/health"
|
||||||
|
app_health_check_retries: 30
|
||||||
|
app_health_check_delay: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
|
||||||
|
Update application containers using tags:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update a specific application
|
||||||
|
ansible-playbook --tags update playbook.yml
|
||||||
|
|
||||||
|
# Update all applications in a playbook
|
||||||
|
ansible-playbook --tags update site.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update process:**
|
||||||
|
1. Stops the application
|
||||||
|
2. Pulls latest images for all services
|
||||||
|
3. Recreates containers with new images
|
||||||
|
4. Runs health checks (if enabled)
|
||||||
|
|
||||||
|
|
||||||
|
### Rollbacks
|
||||||
|
|
||||||
|
Rollback using the restore functionality:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First, create a backup then update
|
||||||
|
ansible-playbook --tags backup,update playbook.yml
|
||||||
|
|
||||||
|
# If update fails, rollback from backup
|
||||||
|
ansible-playbook --tags restore playbook.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Notes:**
|
||||||
|
- Updates do NOT create automatic backups - create backups manually beforehand
|
||||||
|
- Rollbacks must be triggered manually using the restore functionality
|
||||||
|
- Using `latest` tags prevents effective rollbacks since the old image is overwritten
|
||||||
|
- For production, use specific version tags instead of `latest`
|
||||||
|
|
||||||
|
|
||||||
|
### Multiple Instances
|
||||||
|
|
||||||
|
Deploy multiple instances of the same application:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- role: jellyfin
|
||||||
|
vars:
|
||||||
|
app_name: jellyfin-movies
|
||||||
|
jellyfin_http_port: 8096
|
||||||
|
|
||||||
|
- role: jellyfin
|
||||||
|
vars:
|
||||||
|
app_name: jellyfin-tv
|
||||||
|
jellyfin_http_port: 8097
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Custom Templates and Files
|
||||||
|
|
||||||
|
#### Override template location:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- role: my-app
|
||||||
|
vars:
|
||||||
|
app_compose_template: /path/to/custom/compose.yml.j2
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Use a static compose file:
|
||||||
|
|
||||||
|
If you have an existing `docker-compose.yml` file that doesn't need templating:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- role: my-app
|
||||||
|
vars:
|
||||||
|
app_name: my-app
|
||||||
|
app_compose_file: /path/to/existing/docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
The file will be copied as-is to the application directory without Jinja2 processing.
|
||||||
|
|
||||||
|
#### Use inline content:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- role: docker_compose_app
|
||||||
|
vars:
|
||||||
|
app_name: simple-app
|
||||||
|
app_role_name: simple-app
|
||||||
|
app_compose_content: |
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
### Core Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `app_name` | Required | Unique application instance name |
|
||||||
|
| `role_name` | Required | Role name for template paths |
|
||||||
|
| `app_dir` | `{{ host_root_path }}/{{ app_name }}` | Application directory |
|
||||||
|
| `app_uid` | `{{ ansible_facts.user_uid }}` | File ownership UID |
|
||||||
|
| `app_gid` | `{{ ansible_facts.user_gid }}` | File ownership GID |
|
||||||
|
| `app_permission_mode` | `"0640"` | File permission mode |
|
||||||
|
|
||||||
|
### Deployment Options
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `app_compose_template` | `{{ app_templates_path }}/compose.yml.j2` | Template file path |
|
||||||
|
| `app_compose_file` | - | Static compose file path |
|
||||||
|
| `app_compose_content` | - | Inline compose content |
|
||||||
|
| `app_compose_validate` | `true` | Validate compose syntax |
|
||||||
|
| `app_compose_pull` | `policy` | Image pull strategy |
|
||||||
|
| `app_compose_recreate` | `auto` | Container recreate strategy |
|
||||||
|
|
||||||
|
### Backup Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `app_backup_subdirectories` | `[]` | Specific directories to backup |
|
||||||
|
| `app_backup_stop_services` | `true` | Stop containers during backup |
|
||||||
|
| `app_backup_retention_days_controller` | `90` | Controller backup retention |
|
||||||
|
| `app_backup_retention_days_remote` | `7` | Remote backup retention |
|
||||||
|
|
||||||
|
### Restore Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `app_restore_source` | `controller` | Restore source (`controller`/`remote`) |
|
||||||
|
| `app_restore_archive` | - | Specific archive file to restore (overrides latest) |
|
||||||
|
| `app_restore_max_age_days` | `30` | Maximum backup age for restore (when finding latest) |
|
||||||
|
|
||||||
|
### Health Check Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `app_health_check` | `true` | Enable health checks |
|
||||||
|
| `app_health_check_method` | `docker` | Check method (`docker`/`http`) |
|
||||||
|
| `app_health_check_url` | - | HTTP endpoint for health check |
|
||||||
|
| `app_health_check_retries` | `30` | Number of check retries |
|
||||||
|
| `app_health_check_delay` | `10` | Delay between checks (seconds) |
|
||||||
|
| `app_health_check_status_codes` | `[200, 201, 202]` | Valid HTTP status codes |
|
||||||
|
|
||||||
|
### Directory Management
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `app_subdirectories` | `[]` | Directories to create in app_dir |
|
||||||
|
| `app_extra_templates` | `[]` | Additional templates to render |
|
||||||
|
|
||||||
|
Example `app_extra_templates`:
|
||||||
|
```yaml
|
||||||
|
app_extra_templates:
|
||||||
|
- src: config.json.j2
|
||||||
|
dest: "{{ app_dir }}/config/app.json"
|
||||||
|
- src: env.j2
|
||||||
|
dest: "{{ app_dir }}/.env"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use semantic versioning** for container tags instead of `latest`
|
||||||
|
2. **Test restore procedures** regularly to ensure backups are valid
|
||||||
|
3. **Use specific directories** for backups instead of entire app directory
|
||||||
|
4. **Monitor disk usage** on backup storage locations
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The role is organized into logical task files for selective execution:
|
||||||
|
|
||||||
|
```
|
||||||
|
├── setup.yaml # Validation, directories, and templates
|
||||||
|
├── restore.yaml # Backup restoration logic
|
||||||
|
├── deploy.yaml # Docker Compose deployment and startup
|
||||||
|
├── health_check.yaml # Health monitoring and verification
|
||||||
|
├── backup.yaml # Backup creation and management
|
||||||
|
├── update.yaml # Container update and image pulling
|
||||||
|
└── manage_compose.yaml # Docker Compose lifecycle management
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task Execution Flow
|
||||||
|
|
||||||
|
1. **Setup** (`setup.yaml`) - [setup, deploy]
|
||||||
|
- Docker/Compose validation
|
||||||
|
- Variable validation and security checks
|
||||||
|
- Directory creation
|
||||||
|
- Additional template deployment
|
||||||
|
|
||||||
|
2. **Restore** (`restore.yaml`) - [restore, never]
|
||||||
|
- Backup file selection and validation
|
||||||
|
- Service stopping
|
||||||
|
- Archive extraction
|
||||||
|
- Service restart
|
||||||
|
|
||||||
|
3. **Deploy** (`deploy.yaml`) - [deploy]
|
||||||
|
- Docker Compose file creation
|
||||||
|
- Container startup and management
|
||||||
|
|
||||||
|
4. **Health Check** (`health_check.yaml`) - [deploy, healthcheck]
|
||||||
|
- Service health verification
|
||||||
|
- Endpoint monitoring
|
||||||
|
|
||||||
|
5. **Backup** (`backup.yaml`) - [backup, never]
|
||||||
|
- Service stopping (optional)
|
||||||
|
- Archive creation
|
||||||
|
- Backup copying and cleanup
|
||||||
|
- Service restart
|
||||||
|
|
||||||
|
6. **Update** (`update.yaml`) - [update, never]
|
||||||
|
- Service stopping
|
||||||
|
- Image pulling
|
||||||
|
- Container recreation
|
||||||
|
- Health verification
|
||||||
|
|
||||||
|
Each task file is designed to be idempotent and can be run multiple times safely.
|
||||||
40
defaults/main.yaml
Normal file
40
defaults/main.yaml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
app_dir: "{{ host_root_path }}/{{ app_name }}"
|
||||||
|
app_uid: "{{ ansible_facts.user_uid }}"
|
||||||
|
app_gid: "{{ ansible_facts.user_gid }}"
|
||||||
|
app_permission_mode: "0640"
|
||||||
|
app_restart_policy: unless-stopped
|
||||||
|
|
||||||
|
app_backup_path: "{{ backup_path }}/{{ app_name }}"
|
||||||
|
app_backup_path_remote: "{{ app_dir }}/backups"
|
||||||
|
app_backup: false
|
||||||
|
app_backup_subdirectories: []
|
||||||
|
app_backup_stop_services: true
|
||||||
|
app_backup_retention_days_controller: 90
|
||||||
|
app_backup_retention_days_remote: 7
|
||||||
|
app_restore: false
|
||||||
|
app_restore_source: controller
|
||||||
|
app_restore_max_age_days: 30
|
||||||
|
app_compose_dest: "{{ app_dir }}/docker-compose.yml"
|
||||||
|
|
||||||
|
app_compose_validate: true
|
||||||
|
app_compose_pull: policy
|
||||||
|
app_compose_recreate: auto
|
||||||
|
app_compose_start: true
|
||||||
|
|
||||||
|
app_health_check: true
|
||||||
|
app_health_check_method: docker
|
||||||
|
app_health_check_retries: 30
|
||||||
|
app_health_check_delay: 10
|
||||||
|
app_health_check_status_codes: [200, 201, 202]
|
||||||
|
|
||||||
|
app_templates_path: "{{ app_roles_path + '/' + app_role_name }}/templates"
|
||||||
|
app_compose_template: "{{ app_templates_path }}/compose.yml.j2"
|
||||||
|
|
||||||
|
app_subdirectories: []
|
||||||
|
# - config
|
||||||
|
# - data
|
||||||
|
|
||||||
|
app_extra_templates: []
|
||||||
|
# - src: path/to/template/config.json.j2
|
||||||
|
# dest: "{{ app_dir }}/config.json"
|
||||||
6
meta/main.yml
Normal file
6
meta/main.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
galaxy_info:
|
||||||
|
author: Patrick Jaroszewski
|
||||||
|
description: Role for deploying Docker Compose apps
|
||||||
|
license: MIT
|
||||||
|
min_ansible_version: "2.14"
|
||||||
97
tasks/backup.yaml
Normal file
97
tasks/backup.yaml
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
- name: "{{ app_name }} - Backup application data"
|
||||||
|
block:
|
||||||
|
- name: Starting backup
|
||||||
|
debug:
|
||||||
|
msg: |
|
||||||
|
Starting backup of {{ app_name }}
|
||||||
|
Backing up dirs: {{ app_backup_subdirectories }}
|
||||||
|
|
||||||
|
- name: Validate required backup variables
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: "Required variable {{ item }} is not defined"
|
||||||
|
when: vars[item] is not defined
|
||||||
|
loop:
|
||||||
|
- app_name
|
||||||
|
- app_dir
|
||||||
|
- backup_path
|
||||||
|
|
||||||
|
- name: Set backup source paths
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_backup_sources: "{{ app_backup_subdirectories | default([app_dir + '/*']) }}"
|
||||||
|
|
||||||
|
- name: Create local backup directory on controller
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ app_backup_path }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0750"
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Create remote backup directory on host
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ app_backup_path_remote | default(app_dir + '/backups') }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_uid | default(omit) }}"
|
||||||
|
group: "{{ app_gid | default(omit) }}"
|
||||||
|
mode: "{{ app_permission_mode | default('0755') }}"
|
||||||
|
|
||||||
|
- name: Stop application for backup
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ app_dir }}"
|
||||||
|
state: stopped
|
||||||
|
when: app_backup_stop_services | default(true)
|
||||||
|
|
||||||
|
- name: Create timestamped backup
|
||||||
|
ansible.builtin.archive:
|
||||||
|
path: "{{ _backup_sources }}"
|
||||||
|
dest: "{{ app_backup_path_remote | default(app_dir + '/backups') }}/{{ app_name }}-{{ ansible_date_time.iso8601_basic_short }}.tar.gz"
|
||||||
|
format: gz
|
||||||
|
owner: "{{ app_uid | default(omit) }}"
|
||||||
|
group: "{{ app_gid | default(omit) }}"
|
||||||
|
mode: "{{ app_permission_mode | default('0644') }}"
|
||||||
|
register: remote_backup
|
||||||
|
|
||||||
|
- name: Copy backup to controller
|
||||||
|
ansible.builtin.fetch:
|
||||||
|
src: "{{ remote_backup.dest }}"
|
||||||
|
dest: "{{ app_backup_path }}/{{ app_name }}-{{ ansible_date_time.iso8601_basic_short }}.tar.gz"
|
||||||
|
flat: true
|
||||||
|
when: remote_backup is succeeded
|
||||||
|
|
||||||
|
- name: Clean old backups on controller
|
||||||
|
ansible.builtin.find:
|
||||||
|
paths: "{{ app_backup_path }}"
|
||||||
|
patterns: "{{ app_name }}-*.tar.gz"
|
||||||
|
age: "{{ app_backup_retention_days_controller | default(90) }}d"
|
||||||
|
register: old_backups_controller
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Remove old backups from controller
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: absent
|
||||||
|
loop: "{{ old_backups_controller.files | default([]) }}"
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Clean old backups on remote host
|
||||||
|
ansible.builtin.find:
|
||||||
|
paths: "{{ app_backup_path_remote | default(app_dir + '/backups') }}"
|
||||||
|
patterns: "{{ app_name }}-*.tar.gz"
|
||||||
|
age: "{{ app_backup_retention_days_remote | default(7) }}d"
|
||||||
|
register: old_backups_remote
|
||||||
|
|
||||||
|
- name: Remove old backups from remote host
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: absent
|
||||||
|
loop: "{{ old_backups_remote.files | default([]) }}"
|
||||||
|
|
||||||
|
always:
|
||||||
|
- name: Restart application after backup
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ app_dir }}"
|
||||||
|
state: present
|
||||||
|
when: app_backup_stop_services | default(true)
|
||||||
|
|
||||||
|
tags: [backup]
|
||||||
|
when: app_backup | bool
|
||||||
39
tasks/deploy.yaml
Normal file
39
tasks/deploy.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
- name: "{{ app_name }} - Deploy Docker Compose configuration"
|
||||||
|
block:
|
||||||
|
- name: Copy Docker Compose template
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ app_compose_template }}"
|
||||||
|
dest: "{{ app_compose_dest }}"
|
||||||
|
mode: "{{ app_permission_mode }}"
|
||||||
|
owner: "{{ app_uid }}"
|
||||||
|
group: "{{ app_gid }}"
|
||||||
|
validate: "{{ 'docker compose -f %s config -q' if app_compose_validate else omit }}"
|
||||||
|
when: _compose_type == 'template'
|
||||||
|
register: compose_file
|
||||||
|
|
||||||
|
- name: Copy Docker Compose file
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ app_compose_file }}"
|
||||||
|
dest: "{{ app_compose_dest }}"
|
||||||
|
mode: "{{ app_permission_mode }}"
|
||||||
|
owner: "{{ app_uid }}"
|
||||||
|
group: "{{ app_gid }}"
|
||||||
|
validate: "{{ 'docker compose -f %s config -q' if app_compose_validate else omit }}"
|
||||||
|
when: _compose_type == 'file'
|
||||||
|
register: compose_file
|
||||||
|
|
||||||
|
- name: Copy Docker Compose content from YAML string
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ app_compose_content }}"
|
||||||
|
dest: "{{ app_compose_dest }}"
|
||||||
|
mode: "{{ app_permission_mode }}"
|
||||||
|
owner: "{{ app_uid }}"
|
||||||
|
group: "{{ app_gid }}"
|
||||||
|
validate: "{{ 'docker compose -f %s config -q' if app_compose_validate else omit }}"
|
||||||
|
when: _compose_type == 'content'
|
||||||
|
register: compose_file
|
||||||
|
|
||||||
|
- ansible.builtin.include_tasks: manage_compose.yaml
|
||||||
|
when: app_compose_start | default(true)
|
||||||
|
tags: [deploy]
|
||||||
49
tasks/health_check.yaml
Normal file
49
tasks/health_check.yaml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
- name: Wait for containers to be healthy
|
||||||
|
community.docker.docker_container_info:
|
||||||
|
name: "{{ app_name }}"
|
||||||
|
register: container_info
|
||||||
|
until: container_info.container.State.Health.Status | default('healthy') == 'healthy'
|
||||||
|
retries: "{{ app_health_check_retries | default(30) }}"
|
||||||
|
delay: "{{ app_health_check_delay | default(10) }}"
|
||||||
|
when: app_health_check | default(true) and app_health_check_method | default('docker') == 'docker'
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: Verify application HTTP endpoint
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "{{ app_health_check_url }}"
|
||||||
|
method: GET
|
||||||
|
status_code: "{{ app_health_check_status_codes | default([200, 201, 202]) }}"
|
||||||
|
register: http_health_check
|
||||||
|
until: http_health_check.status in (app_health_check_status_codes | default([200, 201, 202]))
|
||||||
|
retries: "{{ app_health_check_retries | default(30) }}"
|
||||||
|
delay: "{{ app_health_check_delay | default(10) }}"
|
||||||
|
when: app_health_check | default(true) and app_health_check_method | default('docker') == 'http' and app_health_check_url is defined
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: Check if all containers are running
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: docker compose ps --services --filter status=running
|
||||||
|
chdir: "{{ app_dir }}"
|
||||||
|
register: running_services
|
||||||
|
when: app_health_check | default(true)
|
||||||
|
|
||||||
|
- name: Check total services
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: docker compose ps --services
|
||||||
|
chdir: "{{ app_dir }}"
|
||||||
|
register: total_services
|
||||||
|
when: app_health_check | default(true)
|
||||||
|
|
||||||
|
- name: Verify deployment success
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- running_services.stdout_lines | length == total_services.stdout_lines | length
|
||||||
|
fail_msg: "Not all containers are running. Running: {{ running_services.stdout_lines | length }}, Total: {{ total_services.stdout_lines | length }}"
|
||||||
|
success_msg: "All containers are running successfully."
|
||||||
|
when: app_health_check | default(true) and total_services.stdout_lines is defined
|
||||||
|
|
||||||
|
- name: Log deployment status
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "Deployment {{ 'successful' if (running_services.stdout_lines | length == total_services.stdout_lines | length) else 'failed' }}"
|
||||||
|
when: app_health_check | default(true) and total_services.stdout_lines is defined
|
||||||
14
tasks/main.yaml
Normal file
14
tasks/main.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
- ansible.builtin.include_tasks: setup.yaml
|
||||||
|
tags: [setup, deploy]
|
||||||
|
- ansible.builtin.include_tasks: restore.yaml
|
||||||
|
tags: [restore, never]
|
||||||
|
- ansible.builtin.include_tasks: deploy.yaml
|
||||||
|
tags: [deploy]
|
||||||
|
- ansible.builtin.include_tasks: health_check.yaml
|
||||||
|
tags: [deploy, healthcheck]
|
||||||
|
- ansible.builtin.include_tasks: backup.yaml
|
||||||
|
tags: [backup, never]
|
||||||
|
- ansible.builtin.include_tasks: update.yaml
|
||||||
|
tags: [update, never]
|
||||||
14
tasks/manage_compose.yaml
Normal file
14
tasks/manage_compose.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
- name: "{{ app_name }} - Stop Docker Compose stack"
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ app_dir }}"
|
||||||
|
state: stopped
|
||||||
|
remove_orphans: true
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: "{{ app_name }} - Start Docker Compose stack"
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ app_dir }}"
|
||||||
|
state: present
|
||||||
|
pull: "{{ app_compose_pull | default('policy') }}"
|
||||||
|
recreate: "{{ app_compose_recreate | default('auto') }}"
|
||||||
120
tasks/restore.yaml
Normal file
120
tasks/restore.yaml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
---
|
||||||
|
- name: "{{ app_name }} - Restore application data"
|
||||||
|
block:
|
||||||
|
- name: Set specific archive if provided
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_restore_backup: "{{ app_restore_archive }}"
|
||||||
|
when: app_restore_archive is defined
|
||||||
|
|
||||||
|
- name: Verify specific archive exists (controller)
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ _restore_backup }}"
|
||||||
|
delegate_to: localhost
|
||||||
|
register: specific_archive_stat
|
||||||
|
when: app_restore_archive is defined and app_restore_source | default('controller') == 'controller'
|
||||||
|
|
||||||
|
- name: Verify specific archive exists (remote)
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ _restore_backup }}"
|
||||||
|
register: specific_archive_stat
|
||||||
|
when: app_restore_archive is defined and app_restore_source | default('controller') == 'remote'
|
||||||
|
|
||||||
|
- name: Fail if specific archive doesn't exist
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: "Specified restore archive does not exist: {{ _restore_backup }}"
|
||||||
|
when: app_restore_archive is defined and not specific_archive_stat.stat.exists
|
||||||
|
|
||||||
|
- name: Find latest backup on controller
|
||||||
|
ansible.builtin.find:
|
||||||
|
paths: "{{ app_backup_path }}"
|
||||||
|
patterns: "{{ app_name }}-*.tar.gz"
|
||||||
|
age: "-{{ app_restore_max_age_days | default(30) }}d"
|
||||||
|
delegate_to: localhost
|
||||||
|
register: controller_backups
|
||||||
|
when: app_restore_source | default('controller') == 'controller' and app_restore_archive is not defined
|
||||||
|
|
||||||
|
- name: Find latest backup on remote host
|
||||||
|
ansible.builtin.find:
|
||||||
|
paths: "{{ app_backup_path_remote | default(app_dir + '/backups') }}"
|
||||||
|
patterns: "{{ app_name }}-*.tar.gz"
|
||||||
|
age: "-{{ app_restore_max_age_days | default(30) }}d"
|
||||||
|
register: remote_backups
|
||||||
|
when: app_restore_source | default('controller') == 'remote' and app_restore_archive is not defined
|
||||||
|
|
||||||
|
- name: Set latest backup file (controller)
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_restore_backup: "{{ (controller_backups.files | sort(attribute='mtime') | last).path }}"
|
||||||
|
when: app_restore_source | default('controller') == 'controller' and controller_backups.files | length > 0 and app_restore_archive is not defined
|
||||||
|
|
||||||
|
- name: Set latest backup file (remote)
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_restore_backup: "{{ (remote_backups.files | sort(attribute='mtime') | last).path }}"
|
||||||
|
when: app_restore_source | default('controller') == 'remote' and remote_backups.files | length > 0 and app_restore_archive is not defined
|
||||||
|
|
||||||
|
- name: Copy backup from controller to remote if needed
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ _restore_backup }}"
|
||||||
|
dest: "{{ app_dir }}/restore_backup.tar.gz"
|
||||||
|
owner: "{{ app_uid | default(omit) }}"
|
||||||
|
group: "{{ app_gid | default(omit) }}"
|
||||||
|
mode: "{{ app_permission_mode | default('0644') }}"
|
||||||
|
when: app_restore_source | default('controller') == 'controller' and _restore_backup is defined
|
||||||
|
register: copied_backup
|
||||||
|
|
||||||
|
- name: Set restore path for remote backup
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_restore_path: "{{ _restore_backup if app_restore_source | default('controller') == 'remote' else copied_backup.dest }}"
|
||||||
|
when: _restore_backup is defined
|
||||||
|
|
||||||
|
- name: Stop application for restore
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ app_dir }}"
|
||||||
|
state: stopped
|
||||||
|
when: _restore_path is defined
|
||||||
|
|
||||||
|
- name: Debug directory permissions before deletion
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
ls -la "{{ item }}"
|
||||||
|
ls -la "{{ item | dirname }}"
|
||||||
|
whoami
|
||||||
|
id
|
||||||
|
loop: "{{ app_backup_subdirectories | default([]) }}"
|
||||||
|
when: _restore_path is defined
|
||||||
|
become: true
|
||||||
|
register: debug_perms
|
||||||
|
|
||||||
|
- name: Show debug output
|
||||||
|
ansible.builtin.debug:
|
||||||
|
var: debug_perms
|
||||||
|
|
||||||
|
- name: Remove existing backup subdirectories for clean restore
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: absent
|
||||||
|
loop: "{{ app_backup_subdirectories | default([]) }}"
|
||||||
|
when: _restore_path is defined
|
||||||
|
become: true
|
||||||
|
|
||||||
|
- name: Extract backup to application directory
|
||||||
|
ansible.builtin.unarchive:
|
||||||
|
src: "{{ _restore_path }}"
|
||||||
|
dest: "{{ app_dir }}"
|
||||||
|
owner: "{{ app_uid | default(omit) }}"
|
||||||
|
group: "{{ app_gid | default(omit) }}"
|
||||||
|
remote_src: true
|
||||||
|
when: _restore_path is defined
|
||||||
|
|
||||||
|
- name: Clean up copied backup file
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ app_dir }}/restore_backup.tar.gz"
|
||||||
|
state: absent
|
||||||
|
when: app_restore_source | default('controller') == 'controller' and copied_backup is defined
|
||||||
|
|
||||||
|
always:
|
||||||
|
- name: Start application after restore
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ app_dir }}"
|
||||||
|
state: present
|
||||||
|
when: _restore_path is defined
|
||||||
|
|
||||||
|
tags: [restore]
|
||||||
111
tasks/setup.yaml
Normal file
111
tasks/setup.yaml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
- name: "{{ app_name }} - Validate deployment requirements"
|
||||||
|
block:
|
||||||
|
- name: Verify Docker is available
|
||||||
|
ansible.builtin.command: docker --version
|
||||||
|
register: docker_check
|
||||||
|
failed_when: docker_check.rc != 0
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Verify Docker Compose is available
|
||||||
|
ansible.builtin.command: docker compose version
|
||||||
|
register: compose_check
|
||||||
|
failed_when: compose_check.rc != 0
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Validate required variables
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- app_name is defined
|
||||||
|
- app_name | length > 0
|
||||||
|
- app_name is match('^[a-zA-Z0-9_-]+$')
|
||||||
|
- host_root_path is defined
|
||||||
|
- host_root_path | length > 0
|
||||||
|
- host_root_path is match('^/[a-zA-Z0-9/_-]*$')
|
||||||
|
fail_msg: "Validation failed for {{ app_name | default('undefined') }}: Required variables missing or contain invalid characters. app_name and host_root_path must be defined and contain only alphanumeric, underscore, hyphen, and slash characters."
|
||||||
|
|
||||||
|
- name: Set compose source type
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_compose_type: >-
|
||||||
|
{{
|
||||||
|
'template' if app_compose_template is not none else
|
||||||
|
'file' if app_compose_file is not none else
|
||||||
|
'content' if app_compose_content is not none else
|
||||||
|
'none'
|
||||||
|
}}
|
||||||
|
|
||||||
|
- name: Validate compose source
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- _compose_type != 'none'
|
||||||
|
fail_msg: "No Docker Compose source specified for {{ app_name }}: Must define one of app_compose_template, app_compose_file, or app_compose_content"
|
||||||
|
|
||||||
|
- name: Validate directory paths
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- app_dir is match('^/[a-zA-Z0-9/_-]*$')
|
||||||
|
- not (app_dir is match('.*\.\..*'))
|
||||||
|
fail_msg: "Security validation failed for {{ app_name }}: app_dir '{{ app_dir }}' contains invalid characters or path traversal sequences"
|
||||||
|
|
||||||
|
- name: Check if compose template exists
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ app_compose_template }}"
|
||||||
|
register: _compose_template_stat
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
when: app_compose_template is defined and app_compose_template | length > 0
|
||||||
|
|
||||||
|
- name: Assert compose template exists
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- _compose_template_stat.stat.exists
|
||||||
|
fail_msg: "Template file not found for {{ app_name }}: '{{ app_compose_template }}' does not exist"
|
||||||
|
when: app_compose_template is defined and app_compose_template | length > 0
|
||||||
|
|
||||||
|
- name: Check if compose file exists
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ app_compose_file }}"
|
||||||
|
register: _compose_file_stat
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
when: app_compose_file is defined and app_compose_file
|
||||||
|
|
||||||
|
- name: Assert compose file exists
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- _compose_file_stat.stat.exists
|
||||||
|
fail_msg: "Docker Compose file not found for {{ app_name }}: '{{ app_compose_file }}' does not exist"
|
||||||
|
when: app_compose_file is defined and app_compose_file
|
||||||
|
tags: [setup, deploy]
|
||||||
|
|
||||||
|
- name: "{{ app_name }} - Create application directories"
|
||||||
|
block:
|
||||||
|
- name: Create application directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ app_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_uid }}"
|
||||||
|
group: "{{ app_gid }}"
|
||||||
|
mode: "{{ app_permission_mode }}"
|
||||||
|
|
||||||
|
- name: Create application subdirectories
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_uid }}"
|
||||||
|
group: "{{ app_gid }}"
|
||||||
|
mode: "{{ app_permission_mode }}"
|
||||||
|
loop: "{{ app_subdirectories }}"
|
||||||
|
tags: [setup, deploy]
|
||||||
|
|
||||||
|
- name: "{{ app_name }} - Deploy additional templates"
|
||||||
|
block:
|
||||||
|
- name: Copy over additional templates
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ item.src }}"
|
||||||
|
dest: "{{ item.dest }}"
|
||||||
|
owner: "{{ app_uid }}"
|
||||||
|
group: "{{ app_gid }}"
|
||||||
|
mode: "{{ app_permission_mode }}"
|
||||||
|
loop: "{{ app_extra_templates }}"
|
||||||
|
tags: [setup, deploy]
|
||||||
31
tasks/update.yaml
Normal file
31
tasks/update.yaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
- name: "{{ app_name }} - Update application"
|
||||||
|
block:
|
||||||
|
- name: Stop application for update
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ app_dir }}"
|
||||||
|
state: stopped
|
||||||
|
|
||||||
|
- name: Pull latest container images
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ app_dir }}"
|
||||||
|
pull: always
|
||||||
|
|
||||||
|
- name: Start application with new images
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ app_dir }}"
|
||||||
|
state: present
|
||||||
|
recreate: always
|
||||||
|
register: update_restart
|
||||||
|
|
||||||
|
- name: Verify application health after update
|
||||||
|
ansible.builtin.include_tasks: health_check.yaml
|
||||||
|
when: app_health_check | default(true)
|
||||||
|
|
||||||
|
rescue:
|
||||||
|
- name: Update failed, attempt to restart application
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ app_dir }}"
|
||||||
|
state: present
|
||||||
|
|
||||||
|
tags: [update]
|
||||||
Reference in New Issue
Block a user