ansible-automation/playbooks/find_docker_enroll_portainer.yml
sam 9fa819a10d Remove AGENT_HOST: bind to 0.0.0.0, not the host IP
Setting AGENT_HOST to the host's real IP (e.g. 10.40.40.3) causes the agent
to try binding to that specific address inside the container, which fails with
'cannot assign requested address' because the container only has a Docker
bridge interface.

Without AGENT_HOST the agent binds to 0.0.0.0:9001 and Docker's port mapping
(-p 9001:9001) forwards traffic correctly. The TLSSkipVerify on the Portainer
registration already handles the bridge-IP cert mismatch.

Fixes: portainer_agent restart loop on snap-based Docker hosts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 01:26:33 -07:00

284 lines
9.7 KiB
YAML

---
# 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: Skip hosts that are already managed by Portainer directly
ansible.builtin.meta: end_host
when: portainer_skip_agent | 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"
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: Skip hosts managed by Portainer directly (no agent needed)
ansible.builtin.meta: end_host
when: portainer_skip_agent | 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