From 26c32763cc4c76fb6d259bb65e4ebd764939a8da Mon Sep 17 00:00:00 2001 From: hiperman Date: Mon, 26 Jan 2026 23:24:37 -0500 Subject: [PATCH] Initial commit --- README.md | 415 ++++++++++++++++++++++++++++++++++++++ defaults/main.yaml | 40 ++++ meta/main.yml | 6 + tasks/backup.yaml | 97 +++++++++ tasks/deploy.yaml | 39 ++++ tasks/health_check.yaml | 49 +++++ tasks/main.yaml | 14 ++ tasks/manage_compose.yaml | 14 ++ tasks/restore.yaml | 120 +++++++++++ tasks/setup.yaml | 111 ++++++++++ tasks/update.yaml | 31 +++ 11 files changed, 936 insertions(+) create mode 100644 README.md create mode 100644 defaults/main.yaml create mode 100644 meta/main.yml create mode 100644 tasks/backup.yaml create mode 100644 tasks/deploy.yaml create mode 100644 tasks/health_check.yaml create mode 100644 tasks/main.yaml create mode 100644 tasks/manage_compose.yaml create mode 100644 tasks/restore.yaml create mode 100644 tasks/setup.yaml create mode 100644 tasks/update.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..43ecb03 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/defaults/main.yaml b/defaults/main.yaml new file mode 100644 index 0000000..7e9d352 --- /dev/null +++ b/defaults/main.yaml @@ -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" diff --git a/meta/main.yml b/meta/main.yml new file mode 100644 index 0000000..bfb2610 --- /dev/null +++ b/meta/main.yml @@ -0,0 +1,6 @@ +--- +galaxy_info: + author: Patrick Jaroszewski + description: Role for deploying Docker Compose apps + license: MIT + min_ansible_version: "2.14" diff --git a/tasks/backup.yaml b/tasks/backup.yaml new file mode 100644 index 0000000..43d99b4 --- /dev/null +++ b/tasks/backup.yaml @@ -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 diff --git a/tasks/deploy.yaml b/tasks/deploy.yaml new file mode 100644 index 0000000..45cf045 --- /dev/null +++ b/tasks/deploy.yaml @@ -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] \ No newline at end of file diff --git a/tasks/health_check.yaml b/tasks/health_check.yaml new file mode 100644 index 0000000..d9a4ee3 --- /dev/null +++ b/tasks/health_check.yaml @@ -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 \ No newline at end of file diff --git a/tasks/main.yaml b/tasks/main.yaml new file mode 100644 index 0000000..3ba0cb5 --- /dev/null +++ b/tasks/main.yaml @@ -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] diff --git a/tasks/manage_compose.yaml b/tasks/manage_compose.yaml new file mode 100644 index 0000000..dcc5ff4 --- /dev/null +++ b/tasks/manage_compose.yaml @@ -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') }}" \ No newline at end of file diff --git a/tasks/restore.yaml b/tasks/restore.yaml new file mode 100644 index 0000000..c25be2c --- /dev/null +++ b/tasks/restore.yaml @@ -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] \ No newline at end of file diff --git a/tasks/setup.yaml b/tasks/setup.yaml new file mode 100644 index 0000000..61db2d5 --- /dev/null +++ b/tasks/setup.yaml @@ -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] \ No newline at end of file diff --git a/tasks/update.yaml b/tasks/update.yaml new file mode 100644 index 0000000..6e31fb2 --- /dev/null +++ b/tasks/update.yaml @@ -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] \ No newline at end of file