--- # find_docker_enroll_portainer.yml # # Discovers which hosts in the inventory are running the Docker engine, # deploys the Portainer Agent container on each Docker host, registers the # host as a new environment in Portainer, and writes a local report. # # Required variables (set in group_vars/all.yml or Semaphore extra-vars): # portainer_url - e.g. http://10.40.40.2:9000 # portainer_api_token - Portainer API token (Settings → Users → API key) # portainer_agent_port - defaults to 9001 # # Usage: # ansible-playbook -i semaphore/inventory/hosts.yml \ # playbooks/find_docker_enroll_portainer.yml # # From Semaphore: point at this playbook, select the hosts.yml inventory, # and add portainer_api_token as an extra-var or in group_vars/all.yml. - name: Discover Docker hosts and collect facts hosts: all gather_facts: false ignore_unreachable: true tasks: - name: Test SSH connectivity ansible.builtin.wait_for_connection: timeout: 10 register: ssh_check become: false ignore_errors: true - name: Gather minimal facts for reachable hosts ansible.builtin.setup: gather_subset: - "!all" - network - distribution when: ssh_check is succeeded ignore_errors: true - name: Check if Docker binary is present ansible.builtin.command: which docker register: docker_which changed_when: false failed_when: false when: ssh_check is succeeded - name: Check if Docker daemon is responding ansible.builtin.command: docker info --format '{% raw %}{{json .ServerVersion}}{% endraw %}' register: docker_info changed_when: false failed_when: false become: true when: - ssh_check is succeeded - docker_which.rc is defined - docker_which.rc == 0 - name: Record Docker status as host fact ansible.builtin.set_fact: docker_running: >- {{ docker_which.rc is defined and docker_which.rc == 0 and docker_info.rc is defined and docker_info.rc == 0 }} docker_version: >- {{ (docker_info.stdout | default('""') | from_json) if (docker_info.rc is defined and docker_info.rc == 0) else 'not running' }} when: ssh_check is succeeded - name: Mark unreachable hosts ansible.builtin.set_fact: docker_running: false ssh_reachable: false when: ssh_check is failed or ssh_check is skipped # --------------------------------------------------------------------------- # Play 2: Deploy Portainer Agent on Docker hosts # --------------------------------------------------------------------------- - name: Deploy Portainer Agent on Docker hosts hosts: all gather_facts: false ignore_unreachable: true become: true vars: portainer_agent_port: "{{ portainer_agent_port | default(9001) }}" tasks: - name: Skip hosts without Docker ansible.builtin.meta: end_host when: not (docker_running | default(false)) - name: Check if portainer_agent container already exists ansible.builtin.command: > docker ps -a --filter name=portainer_agent --format "{% raw %}{{.Status}}{% endraw %}" register: agent_status changed_when: false failed_when: false - name: Pull Portainer Agent image community.docker.docker_image: name: portainer/agent tag: latest source: pull when: "'Up' not in (agent_status.stdout | default(''))" - name: Deploy Portainer Agent container community.docker.docker_container: name: portainer_agent image: portainer/agent:latest state: started restart_policy: always ports: - "{{ portainer_agent_port }}:9001" volumes: - /var/run/docker.sock:/var/run/docker.sock env: AGENT_PORT: "9001" # Ensures the agent's self-signed TLS cert covers the host's real IP, # not just the Docker bridge (172.17.0.x) IP. AGENT_HOST: "{{ ansible_host }}" when: "'Up' not in (agent_status.stdout | default(''))" register: agent_deployed ignore_errors: true - name: Wait for Portainer Agent to be ready ansible.builtin.wait_for: port: "{{ portainer_agent_port }}" host: "127.0.0.1" delay: 3 timeout: 30 when: agent_deployed is changed # --------------------------------------------------------------------------- # Play 3: Register Docker hosts in Portainer # --------------------------------------------------------------------------- # Runs the Portainer API calls FROM each remote host (not delegate_to localhost) # to avoid privilege escalation issues on the Semaphore runner. # validate_certs: false needed for Portainer's self-signed cert. - name: Register Docker hosts in Portainer hosts: all gather_facts: false ignore_unreachable: true vars: portainer_agent_port: "{{ portainer_agent_port | default(9001) }}" tasks: - name: Skip hosts without Docker ansible.builtin.meta: end_host when: not (docker_running | default(false)) - name: Check if endpoint already exists in Portainer ansible.builtin.uri: url: "{{ portainer_url }}/api/endpoints" method: GET headers: X-API-Key: "{{ portainer_api_token }}" return_content: true status_code: 200 validate_certs: false register: existing_endpoints ignore_errors: true - name: Determine if this host is already enrolled ansible.builtin.set_fact: already_enrolled: >- {{ (existing_endpoints.json | default([])) | selectattr('Name', 'equalto', inventory_hostname) | list | length > 0 }} - name: Register host as Portainer Agent endpoint ansible.builtin.uri: url: "{{ portainer_url }}/api/endpoints" method: POST headers: X-API-Key: "{{ portainer_api_token }}" body_format: form-multipart body: Name: "{{ inventory_hostname }}" EndpointCreationType: "2" URL: "tcp://{{ ansible_host }}:{{ portainer_agent_port }}" TLS: "true" TLSSkipVerify: "true" TLSSkipClientVerify: "true" status_code: [200, 201] return_content: true validate_certs: false register: portainer_enroll when: not already_enrolled ignore_errors: true - name: Store enrollment result ansible.builtin.set_fact: portainer_endpoint_id: >- {{ (portainer_enroll.json.Id | string) if (portainer_enroll is not skipped and portainer_enroll is not failed and portainer_enroll.json is defined) else ( (existing_endpoints.json | default([])) | selectattr('Name', 'equalto', inventory_hostname) | map(attribute='Id') | list | first | default('unknown') | string ) }} portainer_enrolled_now: "{{ portainer_enroll is changed and portainer_enroll is not failed }}" # --------------------------------------------------------------------------- # Play 4: Print discovery report to Semaphore output # --------------------------------------------------------------------------- # Runs on all hosts but only executes once (run_once: true per task). # Uses debug instead of file I/O to avoid any privilege issues on the runner. - name: Print Docker host discovery report hosts: all gather_facts: false tasks: - name: Collect discovery results ansible.builtin.set_fact: docker_hosts_found: >- {{ hostvars | dict2items | selectattr('value.docker_running', 'defined') | selectattr('value.docker_running', 'equalto', true) | map(attribute='key') | list | sort }} unreachable_hosts: >- {{ hostvars | dict2items | selectattr('value.ssh_reachable', 'defined') | selectattr('value.ssh_reachable', 'equalto', false) | map(attribute='key') | list | sort }} no_docker_hosts: >- {{ hostvars | dict2items | selectattr('value.docker_running', 'defined') | selectattr('value.docker_running', 'equalto', false) | rejectattr('value.ssh_reachable', 'equalto', false) | map(attribute='key') | list | sort }} run_once: true - name: Print discovery report ansible.builtin.debug: msg: | ============================================================ Docker Host Discovery Report — {{ portainer_url }} ============================================================ DOCKER FOUND ({{ docker_hosts_found | length }}): {% for h in docker_hosts_found %} {{ h }} ({{ hostvars[h].ansible_host | default('?') }}) Docker: {{ hostvars[h].docker_version | default('?') }} Portainer: ID={{ hostvars[h].portainer_endpoint_id | default('n/a') }} enrolled_now={{ hostvars[h].portainer_enrolled_now | default(false) }} {% endfor %} NO DOCKER ({{ no_docker_hosts | length }}): {% for h in no_docker_hosts %} {{ h }} ({{ hostvars[h].ansible_host | default('?') }}) {% endfor %} UNREACHABLE ({{ unreachable_hosts | length }}): {% for h in unreachable_hosts %} {{ h }} ({{ hostvars[h].ansible_host | default('?') }}) {% endfor %} ============================================================ run_once: true