--- - name: Deploy Docker Image to Application Stack hosts: "{{ deployment_hosts | default('production') }}" gather_facts: yes become: no vars: # Application code directory (where docker-compose files are located) application_code_dest: "/home/deploy/michaelschiemer/current" application_compose_suffix: >- {%- if deployment_environment == 'staging' -%} staging.yml {%- else -%} production.yml {%- endif -%} # Image to deploy (can be overridden via -e image_tag=...) image_tag: "{{ image_tag | default('latest') }}" docker_registry: "{{ docker_registry | default('registry.michaelschiemer.de') }}" app_name_default: "framework" # Deployment environment (staging or production) deployment_environment: "{{ deployment_environment | default('production') }}" tasks: - name: Check if vault file exists locally stat: path: "{{ playbook_dir }}/../secrets/{{ deployment_environment }}.vault.yml" delegate_to: localhost register: vault_file_stat become: no - name: Load secrets from vault file if exists include_vars: file: "{{ playbook_dir }}/../secrets/{{ deployment_environment }}.vault.yml" when: vault_file_stat.stat.exists no_log: yes ignore_errors: yes delegate_to: localhost become: no - name: Set app_name from provided value or default ansible.builtin.set_fact: app_name: "{{ app_name if (app_name is defined and app_name != '') else app_name_default }}" - name: Extract registry URL from docker-compose file (for image deployment) ansible.builtin.shell: | cd {{ application_code_dest }} grep -h "image:" docker-compose.base.yml docker-compose.{{ application_compose_suffix }} 2>/dev/null | \ grep -E "{{ app_name }}" | head -1 | \ sed -E 's/.*image:\s*([^\/]+).*/\1/' | \ sed -E 's/\/.*$//' || echo "localhost:5000" register: compose_registry_url changed_when: false failed_when: false - name: Set local registry (where containers expect the image) ansible.builtin.set_fact: local_registry: "{{ (compose_registry_url.stdout | trim) if (compose_registry_url.stdout | trim != '') else 'localhost:5000' }}" - name: Set source registry (where workflow pushes the image) ansible.builtin.set_fact: source_registry: "{{ docker_registry }}" - name: Set deploy_image from source registry (for pulling) ansible.builtin.set_fact: deploy_image: "{{ source_registry }}/{{ app_name }}:{{ image_tag }}" - name: Set local_image (where containers expect the image) ansible.builtin.set_fact: local_image: "{{ local_registry }}/{{ app_name }}:{{ image_tag }}" - name: Set database and MinIO variables from vault or defaults ansible.builtin.set_fact: db_username: "{{ db_username | default(vault_db_user | default('postgres')) }}" db_password: "{{ db_password | default(vault_db_password | default('')) }}" minio_root_user: "{{ minio_root_user | default(vault_minio_root_user | default('minioadmin')) }}" minio_root_password: "{{ minio_root_password | default(vault_minio_root_password | default('')) }}" secrets_dir: "{{ secrets_dir | default('./secrets') }}" git_repository_url: "{{ git_repository_url | default(vault_git_repository_url | default('https://git.michaelschiemer.de/michael/michaelschiemer.git')) }}" git_branch: >- {%- if deployment_environment == 'staging' -%} staging {%- else -%} main {%- endif -%} git_token: "{{ git_token | default(vault_git_token | default('')) }}" git_username: "{{ git_username | default(vault_git_username | default('')) }}" git_password: "{{ git_password | default(vault_git_password | default('')) }}" no_log: yes - name: Determine Docker registry password from vault or extra vars ansible.builtin.set_fact: registry_password: >- {%- if docker_registry_password is defined and docker_registry_password | string | trim != '' -%} {{ docker_registry_password }} {%- elif vault_docker_registry_password is defined and vault_docker_registry_password | string | trim != '' -%} {{ vault_docker_registry_password }} {%- else -%} {{ '' }} {%- endif -%} no_log: yes - name: Check if registry is accessible ansible.builtin.uri: url: "https://{{ docker_registry }}/v2/" method: GET status_code: [200, 401] timeout: 5 validate_certs: no register: registry_check ignore_errors: yes delegate_to: "{{ inventory_hostname }}" become: no - name: Set registry accessible flag ansible.builtin.set_fact: registry_accessible: "{{ 'true' if (registry_check.status is defined and registry_check.status | int in [200, 401]) else 'false' }}" - name: Login to Docker registry community.docker.docker_login: registry_url: "{{ docker_registry }}" username: "{{ docker_registry_username | default('admin') }}" password: "{{ registry_password }}" when: - registry_password | string | trim != '' - registry_accessible == 'true' no_log: yes ignore_errors: yes register: docker_login_result - name: Display image pull information ansible.builtin.debug: msg: - "Attempting to pull image: {{ deploy_image }}" - "Source registry: {{ source_registry }}" - "Local registry: {{ local_registry }}" - "Registry accessible: {{ registry_accessible | default('unknown') }}" when: registry_accessible is defined and registry_accessible == 'true' - name: Check if image already exists locally ansible.builtin.shell: | docker images --format "{{ '{{' }}.Repository{{ '}}' }}:{{ '{{' }}.Tag{{ '}}' }}" | grep -E "^{{ deploy_image | regex_escape }}$" || echo "NOT_FOUND" register: image_exists_before_pull when: registry_accessible is defined and registry_accessible == 'true' changed_when: false failed_when: false - name: Display image existence check ansible.builtin.debug: msg: - "Image exists before pull: {{ image_exists_before_pull.stdout | default('unknown') }}" - "Will pull: {{ 'YES' if (image_exists_before_pull.stdout | default('') == 'NOT_FOUND') else 'NO (already exists)' }}" when: registry_accessible is defined and registry_accessible == 'true' - name: Pull Docker image from registry using shell command ansible.builtin.shell: | docker pull {{ deploy_image }} 2>&1 when: - registry_accessible is defined and registry_accessible == 'true' register: image_pull_result ignore_errors: yes failed_when: false changed_when: image_pull_result.rc == 0 - name: Display pull result ansible.builtin.debug: msg: - "Pull command exit code: {{ image_pull_result.rc | default('unknown') }}" - "Pull stdout: {{ image_pull_result.stdout | default('none') }}" - "Pull stderr: {{ image_pull_result.stderr | default('none') }}" - "Pull succeeded: {{ 'YES' if (image_pull_result.rc | default(1) == 0) else 'NO' }}" when: registry_accessible is defined and registry_accessible == 'true' - name: Verify image exists locally after pull community.docker.docker_image_info: name: "{{ deploy_image }}" register: image_info when: registry_accessible is defined and registry_accessible == 'true' ignore_errors: yes failed_when: false - name: Check if image exists by inspecting docker images ansible.builtin.shell: | docker images --format "{{ '{{' }}.Repository{{ '}}' }}:{{ '{{' }}.Tag{{ '}}' }}" | grep -E "^{{ deploy_image | regex_escape }}$" || echo "NOT_FOUND" register: image_check when: registry_accessible is defined and registry_accessible == 'true' changed_when: false failed_when: false - name: Display image verification results ansible.builtin.debug: msg: - "Image info result: {{ image_info | default('not executed') }}" - "Image check result: {{ image_check.stdout | default('not executed') }}" - "Image exists: {{ 'YES' if (image_check.stdout | default('') != 'NOT_FOUND' and image_check.stdout | default('') != '') else 'NO' }}" when: registry_accessible is defined and registry_accessible == 'true' - name: Fail if image was not pulled successfully ansible.builtin.fail: msg: | Failed to pull image {{ deploy_image }} from registry. The image does not exist locally after pull attempt. Pull command result: - Exit code: {{ image_pull_result.rc | default('unknown') }} - Stdout: {{ image_pull_result.stdout | default('none') }} - Stderr: {{ image_pull_result.stderr | default('none') }} Image check result: {{ image_check.stdout | default('unknown') }} Please check: 1. Does the image exist in {{ source_registry }}? 2. Are registry credentials correct? 3. Is the registry accessible? 4. Check the pull command output above for specific error messages. when: - registry_accessible is defined and registry_accessible == 'true' - (image_check.stdout | default('') == 'NOT_FOUND' or image_check.stdout | default('') == '') - name: Tag image for local registry (if source and local registry differ) community.docker.docker_image: name: "{{ deploy_image }}" repository: "{{ local_image }}" tag: "{{ image_tag }}" source: local when: - source_registry != local_registry - image_check.stdout is defined - image_check.stdout != 'NOT_FOUND' - image_check.stdout != '' register: image_tag_result - name: Push image to local registry (if source and local registry differ) community.docker.docker_image: name: "{{ local_image }}" push: true source: local when: - source_registry != local_registry - image_tag_result.changed | default(false) ignore_errors: yes failed_when: false - name: Update docker-compose file with new image tag ansible.builtin.replace: path: "{{ application_code_dest }}/docker-compose.{{ application_compose_suffix }}" regexp: '^(\s+image:\s+)({{ local_registry }}/{{ app_name }}:)(.*)$' replace: '\1\2{{ image_tag }}' register: compose_update_result failed_when: false changed_when: compose_update_result.changed | default(false) - name: Update docker-compose file with new image (alternative pattern - any registry) ansible.builtin.replace: path: "{{ application_code_dest }}/docker-compose.{{ application_compose_suffix }}" regexp: '^(\s+image:\s+)([^\/]+\/{{ app_name }}:)(.*)$' replace: '\1{{ local_registry }}/{{ app_name }}:{{ image_tag }}' register: compose_update_alt when: compose_update_result.changed == false failed_when: false changed_when: compose_update_alt.changed | default(false) # Ensure PostgreSQL Staging Stack is running (creates postgres-staging-internal network) - name: Check if PostgreSQL Staging Stack directory exists ansible.builtin.stat: path: "{{ stacks_base_path | default('/home/deploy/deployment/stacks') }}/postgresql-staging/docker-compose.yml" register: postgres_staging_compose_exists changed_when: false when: deployment_environment | default('') == 'staging' - name: Check if PostgreSQL Staging Stack is running ansible.builtin.shell: | cd {{ stacks_base_path | default('/home/deploy/deployment/stacks') }}/postgresql-staging docker compose ps --format json 2>/dev/null | grep -q '"State":"running"' && echo "RUNNING" || echo "NOT_RUNNING" register: postgres_staging_status changed_when: false failed_when: false when: - deployment_environment | default('') == 'staging' - postgres_staging_compose_exists.stat.exists | default(false) | bool - name: Start PostgreSQL Staging Stack if not running ansible.builtin.shell: | cd {{ stacks_base_path | default('/home/deploy/deployment/stacks') }}/postgresql-staging docker compose up -d register: postgres_staging_start changed_when: postgres_staging_start.rc == 0 when: - deployment_environment | default('') == 'staging' - postgres_staging_compose_exists.stat.exists | default(false) | bool - postgres_staging_status.stdout | default('') == 'NOT_RUNNING' - name: Wait for PostgreSQL Staging Stack to be ready ansible.builtin.wait_for: timeout: 30 delay: 2 when: - deployment_environment | default('') == 'staging' - postgres_staging_start.changed | default(false) | bool # Extract and create external networks (fallback if PostgreSQL Stack doesn't create them) - name: Extract external networks from docker-compose files ansible.builtin.shell: | cd {{ application_code_dest }} grep -h "external:" docker-compose.base.yml docker-compose.{{ application_compose_suffix }} 2>/dev/null | \ grep -B 5 "external: true" | \ grep -E "^\s+[a-zA-Z0-9_-]+:" | \ sed 's/://' | \ sed 's/^[[:space:]]*//' | \ sort -u || echo "" register: external_networks_raw changed_when: false failed_when: false - name: "Extract external network names (with name field)" ansible.builtin.shell: | cd {{ application_code_dest }} grep -A 2 "external: true" docker-compose.base.yml docker-compose.{{ application_compose_suffix }} 2>/dev/null | \ grep "name:" | \ sed 's/.*name:[[:space:]]*//' | \ sed 's/"//g' | \ sort -u || echo "" register: external_network_names changed_when: false failed_when: false - name: Set list of external networks to create ansible.builtin.set_fact: external_networks_to_create: >- {%- set networks_from_keys = external_networks_raw.stdout | trim | split('\n') | select('match', '.+') | list -%} {%- set networks_from_names = external_network_names.stdout | trim | split('\n') | select('match', '.+') | list -%} {%- set all_networks = (networks_from_keys + networks_from_names) | unique | list -%} {%- set default_networks = ['traefik-public', 'app-internal'] -%} {%- set final_networks = (all_networks + default_networks) | unique | list -%} {{ final_networks }} # Create external networks (fallback if they weren't created by their respective stacks) # Note: This is a fallback - ideally networks are created by their stacks (e.g., postgres-staging-internal by PostgreSQL Staging Stack) - name: Ensure Docker networks exist community.docker.docker_network: name: "{{ item }}" state: present driver: bridge loop: "{{ external_networks_to_create | default(['traefik-public', 'app-internal']) }}" ignore_errors: yes - name: Check if .env file exists stat: path: "{{ application_code_dest }}/.env" register: env_file_exists - name: Create minimal .env file if it doesn't exist copy: dest: "{{ application_code_dest }}/.env" content: | # Minimal .env file for Docker Compose # This file should be properly configured by the application setup playbook DB_USERNAME={{ db_username | default('postgres') }} DB_PASSWORD={{ db_password | default('') }} MINIO_ROOT_USER={{ minio_root_user | default('minioadmin') }} MINIO_ROOT_PASSWORD={{ minio_root_password | default('') }} SECRETS_DIR={{ secrets_dir | default('./secrets') }} GIT_REPOSITORY_URL={{ git_repository_url | default('') }} GIT_TOKEN={{ git_token | default('') }} GIT_USERNAME={{ git_username | default('') }} GIT_PASSWORD={{ git_password | default('') }} owner: "{{ ansible_user }}" group: "{{ ansible_user }}" mode: '0600' when: not env_file_exists.stat.exists become: yes - name: Check if Docker daemon.json exists stat: path: /etc/docker/daemon.json register: docker_daemon_json become: yes - name: Read existing Docker daemon.json slurp: src: /etc/docker/daemon.json register: docker_daemon_config when: docker_daemon_json.stat.exists become: yes changed_when: false - name: Set Docker daemon configuration with insecure registry set_fact: docker_daemon_config_dict: "{{ docker_daemon_config.content | b64decode | from_json if (docker_daemon_json.stat.exists and docker_daemon_config.content is defined) else {} }}" - name: Build insecure registries list set_fact: insecure_registries_list: >- {%- set existing = docker_daemon_config_dict.get('insecure-registries', []) | list -%} {%- set needed_registries = [local_registry] | list -%} {%- set all_registries = (existing + needed_registries) | unique | list -%} {{ all_registries }} - name: Merge insecure registry into Docker daemon config set_fact: docker_daemon_config_merged: "{{ docker_daemon_config_dict | combine({'insecure-registries': insecure_registries_list}) }}" - name: Update Docker daemon.json with insecure registry copy: dest: /etc/docker/daemon.json content: "{{ docker_daemon_config_merged | to_json(indent=2) }}" mode: '0644' when: docker_daemon_config_merged != docker_daemon_config_dict become: yes register: docker_daemon_updated - name: Restart Docker daemon if configuration changed systemd: name: docker state: restarted when: docker_daemon_updated.changed | default(false) become: yes ignore_errors: yes - name: Wait for Docker daemon to be ready shell: docker ps > /dev/null 2>&1 register: docker_ready until: docker_ready.rc == 0 retries: 30 delay: 2 when: docker_daemon_updated.changed | default(false) ignore_errors: yes changed_when: false - name: Set list of registries to login to (source registry for pulling, local registry for pushing) ansible.builtin.set_fact: registries_to_login: >- {%- if source_registry is defined and local_registry is defined -%} {%- if source_registry != local_registry -%} {%- set reg_list = [source_registry, local_registry] -%} {%- else -%} {%- set reg_list = [local_registry] -%} {%- endif -%} {%- set final_list = reg_list | unique | list -%} {{ final_list }} {%- else -%} {%- set default_list = [docker_registry | default('localhost:5000')] -%} {{ default_list }} {%- endif -%} - name: Login to all Docker registries before compose up community.docker.docker_login: registry_url: "{{ item }}" username: "{{ docker_registry_username | default('admin') }}" password: "{{ registry_password }}" when: - registry_password | string | trim != '' - registry_accessible == 'true' loop: "{{ registries_to_login | default([docker_registry]) }}" no_log: yes register: docker_login_results failed_when: false - name: Display login results ansible.builtin.debug: msg: "Docker login to {{ item.item }}: {% if item.failed %}FAILED ({{ item.msg | default('unknown error') }}){% else %}SUCCESS{% endif %}" when: - registry_password | string | trim != '' - registry_accessible == 'true' loop: "{{ docker_login_results.results | default([]) }}" loop_control: label: "{{ item.item }}" - name: Deploy application stack with new image shell: | cd {{ application_code_dest }} docker compose -f docker-compose.base.yml -f docker-compose.{{ application_compose_suffix }} up -d --pull missing --force-recreate --remove-orphans register: compose_deploy_result changed_when: true environment: DB_USERNAME: "{{ db_username | default('postgres') }}" DB_PASSWORD: "{{ db_password | default('') }}" MINIO_ROOT_USER: "{{ minio_root_user | default('minioadmin') }}" MINIO_ROOT_PASSWORD: "{{ minio_root_password | default('') }}" SECRETS_DIR: "{{ secrets_dir | default('./secrets') }}" GIT_REPOSITORY_URL: "{{ git_repository_url | default('') }}" GIT_BRANCH: "{{ git_branch | default('main') }}" GIT_TOKEN: "{{ git_token | default('') }}" GIT_USERNAME: "{{ git_username | default('') }}" GIT_PASSWORD: "{{ git_password | default('') }}" - name: Wait for containers to start ansible.builtin.pause: seconds: 15 - name: Check container status shell: | cd {{ application_code_dest }} docker compose -f docker-compose.base.yml -f docker-compose.{{ application_compose_suffix }} ps register: container_status changed_when: false - name: Display deployment summary ansible.builtin.debug: msg: | ======================================== Image Deployment Summary ======================================== Image: {{ deploy_image }} Tag: {{ image_tag }} Environment: {{ deployment_environment }} Stack: {{ application_code_dest }} Status: SUCCESS ======================================== Container Status: {{ container_status.stdout | default('Not available') }} ========================================