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>
284 lines
9.7 KiB
YAML
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
|