From b7c22b2ae0e43b7476e94c54bbf96fd649fa0e66 Mon Sep 17 00:00:00 2001 From: pavel Date: Mon, 19 Jan 2026 19:22:57 +0200 Subject: [PATCH] 1922 --- .../ansible-playbooks/sot-updater-minimum.yml | 1704 ++++++++++++++++ .../sot-updater-upgradecmd.yml | 1776 +++++++++++++---- files/ansible-playbooks/sot-updater.yml | 211 +- 3 files changed, 3179 insertions(+), 512 deletions(-) create mode 100644 files/ansible-playbooks/sot-updater-minimum.yml diff --git a/files/ansible-playbooks/sot-updater-minimum.yml b/files/ansible-playbooks/sot-updater-minimum.yml new file mode 100644 index 0000000..a8fd6a6 --- /dev/null +++ b/files/ansible-playbooks/sot-updater-minimum.yml @@ -0,0 +1,1704 @@ +# sot-updater.yml — Read fw on Dev1 + Dev2, publish to NetBox via Rabbit (telemetry mode, no upgrade scheduling) + +- name: Read fw on Dev1 + Dev2, publish NetBox custom fields (full base, AIRPINGs, soft-fail telemetry, no upgrade scheduling) + hosts: all + gather_facts: no + + vars: + pathprefix: "PATH=/sbin:/usr/sbin:/bin:/usr/bin:$PATH; " + dev1_user: "root" + dev1_pass: "wavewave" + ssh_timeout: 30 + + dev2_host: "192.168.1.1" + dev2_port: 22 + dev2_ssh_user: "root" + dev2_passfiles: [ "basicpass", "basicpass2" ] + + dev2_side_ip: "192.168.1.11/24" + dev1_iface: "br-wan" + arping_iface: "eth0" + dev2_side_ip_addr: "{{ dev2_side_ip.split('/')[0] }}" + + ssh_opts_common: >- + -o PreferredAuthentications=password + -o PubkeyAuthentication=no + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o NumberOfPasswordPrompts=1 + -o ConnectTimeout=15 + + rmq_host: "{{ lookup('env','RMQ_HOST') | default('10.210.12.2', true) }}" + rmq_port: "{{ lookup('env','RMQ_PORT') | default('15672', true) }}" + rmq_user: "{{ lookup('env','RMQ_USER') | default('admin', true) }}" + rmq_pass: "{{ lookup('env','RMQ_PASS') | default('change_me', true) }}" + rmq_vhost: "{{ lookup('env','RMQ_VHOST') | default('app', true) }}" + rmq_exchange: "{{ lookup('env','RMQ_EXCHANGE') | default('controls', true) }}" + control_queue: "{{ lookup('env','CONTROLQUEUE') | default('queue_controls', true) }}" + + # --- Retry attempt tracking (default 0 if missing) --- + requeue_attempt: "{{ (lookup('env','REQUEUE_ATTEMPT') | default('0', true)) | int }}" + + pre_tasks: + - name: "NB preflight | Verify script exists" + delegate_to: localhost + ansible.builtin.stat: + path: "/opt/containers/ansible-worker/app/nb_onedevice_update.py" + register: nb_script + + - name: "NB preflight | Abort softly if script missing" + when: not nb_script.stat.exists + delegate_to: localhost + ansible.builtin.debug: + msg: + - "NB preflight skipped: /opt/containers/ansible-worker/app/nb_onedevice_update.py not found." + - "Tip: adjust chdir/path or script name." + + - name: "NB preflight | Run nb_onedevice_update.py for {{ inventory_hostname }}" + when: nb_script.stat.exists + delegate_to: localhost + environment: + PYTHONUNBUFFERED: "1" + args: + chdir: "/opt/containers/ansible-worker/app" + executable: /bin/bash + shell: | + set -o pipefail + python3 -u nb_onedevice_update.py "{{ inventory_hostname }}" --chatty 2>&1 + register: nb_preflight + changed_when: false + failed_when: false + + - name: "NB preflight | Show results" + when: nb_script.stat.exists + delegate_to: localhost + debug: + msg: + - "rc={{ nb_preflight.rc }}" + - "stdout_lines:" + - "{{ (nb_preflight.stdout_lines | default([''])) }}" + - "stderr_lines:" + - "{{ (nb_preflight.stderr_lines | default([''])) }}" + - "raw stdout (joined): {{ nb_preflight.stdout | default('') | trim }}" + + - name: "NB preflight | Detect cloud failure" + when: nb_script.stat.exists + delegate_to: localhost + vars: + _out: "{{ (nb_preflight.stdout | default('') | regex_replace('\r','')) }}" + _lines: >- + {{ _out.split('\n') | map('trim') | list }} + _cloud_fail_line: >- + {{ (_lines | select('match', '^FAIL\\s+Cloud\\b') | list | last | default('')) }} + _error_line: >- + {{ (_lines | select('match', '^FAIL\\s+') | list | last | default('')) }} + _rc_is_3: "{{ (nb_preflight.rc | default(1)) | int == 3 }}" + _cloud_error_type: >- + {%- if _error_line is search('network error') + and _error_line is search('500 error responses') -%} + cloud_http_500 + {%- elif (_cloud_fail_line | length) > 0 -%} + cloud_logic + {%- elif _rc_is_3 -%} + cloud_other + {%- else -%} + none + {%- endif -%} + _cloud_retryable: >- + {{ _cloud_error_type not in ['cloud_http_500'] }} + set_fact: + cloud_fail_line: "{{ _cloud_fail_line }}" + cloud_bad: "{{ _rc_is_3 or (( _cloud_fail_line | length ) > 0) }}" + cloud_error_type: "{{ _cloud_error_type }}" + cloud_retryable: "{{ _cloud_retryable }}" + + # --- Extract upgrade_cmd line if any --- + - name: "NB preflight | Extract upgrade_cmd line" + when: nb_script.stat.exists + delegate_to: localhost + set_fact: + nb_upgrade_line: >- + {{ + ( + nb_preflight.stdout_lines | default([]) | map('regex_replace','\r','') | map('trim') + | select('match', '^NB:\\s*upgrade_cmd\\s*=') + | list | first + ) | default('') + }} + + - name: "NB preflight | Parse upgrade_cmd value" + when: nb_upgrade_line | length > 0 + delegate_to: localhost + shell: | + printf '%s\n' "{{ nb_upgrade_line }}" | awk -F'=' '{print $2}' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' + args: { executable: /bin/bash } + register: up_cmd_sh + changed_when: false + + - name: "NB preflight | Set parsed upgrade_cmd" + when: nb_upgrade_line | length > 0 + delegate_to: localhost + set_fact: + nb_upgrade_cmd: "{{ up_cmd_sh.stdout | default('') | trim }}" + + - name: "NB preflight | Debug parsed upgrade_cmd" + when: nb_upgrade_line | length > 0 + delegate_to: localhost + debug: + msg: + - "upgrade_cmd='{{ nb_upgrade_cmd }}' (len={{ nb_upgrade_cmd|length }})" + + - name: "NB preflight | Parse OK line" + when: nb_script.stat.exists + delegate_to: localhost + vars: + nb_lines: >- + {{ (nb_preflight.stdout | default('') | regex_replace('\r','')) | split('\n') | map('trim') | list }} + nb_ok_line: >- + {{ (nb_lines | select('match', '^OK\\s+') | list | last | default('')) }} + nb_tokens: >- + {{ (nb_ok_line | regex_replace('^OK\\s+', '')).split() }} + nb_kv: >- + {{ + dict( + nb_tokens + | select('match', '^[a-zA-Z_]+=') + | map('split', '=', 1) + | map('list') + ) + }} + set_fact: + nb_ok: "{{ (nb_preflight.rc|default(1)) == 0 and (nb_ok_line|length)>0 }}" + nb_dev: "{{ (nb_tokens|first|default('')) if (nb_tokens|length>0) else '' }}" + nb_ip: "{{ nb_kv.get('ip','') }}" + nb_fw: "{{ nb_kv.get('fw','') }}" + nb_node: "{{ nb_kv.get('node','') }}" + nb_sector: "{{ nb_kv.get('sector','') }}" + nb_small: "{{ nb_kv.get('small','') }}" + nb_ok_line: "{{ nb_ok_line }}" + + - name: "NB preflight | Detect IP change" + when: nb_script.stat.exists + delegate_to: localhost + vars: + out: "{{ (nb_preflight.stdout | default('') | regex_replace('\r','')) }}" + reason: >- + {%- if 'IP: moving ' in out -%}moving + {%- elif 'IP: create new ' in out -%}create new + {%- elif 'IP: pruning stale ' in out -%}pruning stale + {%- else -%}none{%- endif -%} + set_fact: + nb_ip_changed: "{{ reason != 'none' }}" + nb_change_reason: "{{ reason }}" + + - name: "NB preflight | Verdict" + when: nb_script.stat.exists + delegate_to: localhost + debug: + msg: + - "Preflight verdict:" + - "Cloud fail: {{ cloud_bad|default(false) }}" + - "IP changed: {{ nb_ip_changed|default(false) }}" + - "Reason: {{ nb_change_reason|default('none') }}" + + - name: "NB preflight | Compute next_attempt" + delegate_to: localhost + set_fact: + next_attempt: "{{ (requeue_attempt | int) + 1 }}" + + # --- Requeue control: only 3 attempts, 300s apart --- + - name: "NB preflight | Publish delayed requeue (300s (10s)) if needed" + when: + - nb_script.stat.exists + - (nb_ip_changed | default(false)) or ( + (cloud_bad | default(false)) and + (cloud_error_type | default('none')) != 'cloud_http_500' + ) + - (requeue_attempt | int) < 3 + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/app/deviceconfig.delayed/publish" + method: POST + user: "admin" + password: "change_me" + force_basic_auth: true + status_code: 200 + return_content: yes + headers: { content-type: "application/json" } + body_format: json + body: + properties: + content_type: "application/json" + headers: { x-delay: 900000 } + routing_key: "deviceconfig" + payload: "{{ { + 'inscope_device': (ansible_hostname | default(inventory_hostname)), + 'task_name': 'sot-updater', + 'attempt': next_attempt + } | to_json }}" + payload_encoding: "string" + register: rmq_requeue + changed_when: false + failed_when: false + + - name: "NB preflight | Log requeue publish response" + when: + - nb_script.stat.exists + - ((cloud_bad | default(false)) or (nb_ip_changed | default(false))) + - (requeue_attempt | int) < 3 + delegate_to: localhost + debug: + msg: + - "Requeue published (attempt {{ (requeue_attempt | int) + 1 }})" + - "HTTP: {{ rmq_requeue.status|default('n/a') }}" + - "JSON: {{ rmq_requeue.json|default('') }}" + + - name: "NB preflight | Stop host after requeue" + when: + - nb_script.stat.exists + - ( + (nb_ip_changed | default(false)) or + ( + (cloud_bad | default(false)) and + (cloud_error_type | default('none')) != 'cloud_http_500' + ) + ) + meta: end_host + + + - name: "NB preflight | Pause 1s if OK" + when: + - nb_script.stat.exists + - not ((cloud_bad | default(false)) or (nb_ip_changed | default(false))) + delegate_to: localhost + pause: + seconds: 1 + + tasks: + - name: "⚙️ Start | Dev1 banner → tunnel/auth → Dev2 firmux (telemetry mode)" + debug: + msg: + - "Device: {{ inventory_hostname }}" + - "Mode: report-only (soft-fail telemetry)" + + # ----------------------- Scan status tracking (journal finalization) ----------------------- + - name: Init scan status tracking + delegate_to: localhost + set_fact: + scan_ok: [] + scan_fail: [] + changed_when: false + + # ----------------------- Temp IP on DEV1 ----------------------- + - name: Add temporary IP on DEV1 + raw: "{{ pathprefix }} ip a add {{ dev2_side_ip }} dev {{ dev1_iface }}" + register: add_ip + failed_when: false + ignore_errors: true + + - name: Add temporary IP on DEV1 (tolerate 'File exists') + raw: > + {{ pathprefix }} + ip a add {{ dev2_side_ip }} dev {{ dev1_iface }} + register: add_ip + changed_when: add_ip.rc == 0 + failed_when: > + add_ip.rc != 0 + and ('File exists' not in (add_ip.stdout|default(''))) + and ('File exists' not in (add_ip.stderr|default(''))) + + - name: Debug temp IP result + debug: + msg: + - "add_ip.rc={{ add_ip.rc|default('') }}" + - "add_ip.out={{ add_ip.stdout|default('')|trim }}" + - "add_ip.err={{ add_ip.stderr|default('')|trim }}" + + - name: Discover DEV2 MAC via bridge fdb + raw: > + {{ pathprefix }} + bridge fdb show {{ dev1_iface }} | grep eth0 | grep -v permanent | grep master | awk '{print $1}' | head -n1 + register: dev2_mac_scan + changed_when: false + failed_when: false + + - name: Capture discovered MAC + set_fact: + dev2_mac: "{{ dev2_mac_scan.stdout|default('')|trim }}" + + - name: Clear existing ARP entry + raw: > + {{ pathprefix }} + ip neigh del {{ dev2_host }} dev {{ dev1_iface }} 2>/dev/null || true + changed_when: false + failed_when: false + + - name: Add static ARP if MAC found + when: dev2_mac|length > 0 + raw: > + {{ pathprefix }} + ip neigh add {{ dev2_host }} lladdr {{ dev2_mac }} dev {{ dev1_iface }} nud permanent + register: dev2_arp_add + changed_when: dev2_arp_add.rc == 0 + failed_when: > + dev2_arp_add.rc != 0 + and ('File exists' not in (dev2_arp_add.stdout|default(''))) + and ('File exists' not in (dev2_arp_add.stderr|default(''))) + + - name: Debug ARP summary + debug: + msg: + - "dev2_mac={{ dev2_mac|default('unset') }}" + - "arp_add.rc={{ dev2_arp_add.rc|default('') }}" + - "arp_add.out={{ dev2_arp_add.stdout|default('')|trim }}" + - "arp_add.err={{ dev2_arp_add.stderr|default('')|trim }}" + + - name: Note skipping static ARP + when: dev2_mac|length == 0 + debug: + msg: "No MAC discovered; skipping static ARP add." + + - name: Refresh ARP + raw: "{{ pathprefix }} arping -U -I {{ arping_iface }} {{ dev2_side_ip_addr }} -c 3" + failed_when: false + ignore_errors: true + + # ----------------------- Tunnel setup ----------------------- + - name: Pick free local port + delegate_to: localhost + shell: | + for i in $(seq 1 50); do + p="$(shuf -i 20000-39999 -n 1)" + ss -ltn | awk '{print $4}' | grep -qE "(:|\.)${p}$" || { echo $p; exit 0; } + done + exit 1 + register: pick_port + failed_when: false + + - name: Create control dir + delegate_to: localhost + shell: "mktemp -d" + register: mktemp_dir + failed_when: false + + - name: Set tunnel facts + delegate_to: localhost + set_fact: + _local_port: "{{ pick_port.stdout|default('')|trim }}" + _ctrl_dir: "{{ mktemp_dir.stdout|default('')|trim }}" + _ctrl_sock: "{{ mktemp_dir.stdout|default('')|trim }}/ssh_tunnel_ctl" + + - name: Start SSH tunnel + delegate_to: localhost + shell: | + set -e + USER="{{ dev1_user }}" + HOST="{{ ansible_host|default(inventory_hostname) }}" + sshpass -p '{{ dev1_pass }}' ssh -f -N {{ ssh_opts_common }} \ + -M -S "{{ _ctrl_sock }}" \ + -L "127.0.0.1:{{ _local_port }}:{{ dev2_host }}:{{ dev2_port }}" \ + "${USER}@${HOST}" + args: { executable: /bin/bash } + failed_when: false + ignore_errors: true + + - name: Probe tunnel + delegate_to: localhost + shell: "nc -z -w5 127.0.0.1 {{ _local_port }}" + register: nc_probe + failed_when: false + ignore_errors: true + + - name: Set tunnel_ok + delegate_to: localhost + set_fact: + tunnel_ok: "{{ nc_probe.rc == 0 }}" + + # ----------------------- Dev1 banner ----------------------- + - name: Dev1 | Probe banner + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "cat /etc/banner | grep -i rev | head -n1" + register: dev1_banner + failed_when: false + ignore_errors: true + + - name: Dev1 | Normalize banner + delegate_to: localhost + shell: | + printf '%s\n' "{{ dev1_banner.stdout|trim }}" \ + | awk -F '|' '{print $1}' \ + | sed -E 's/[[:space:]]+rev[[:space:]]+/-r/' \ + | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+-r[0-9]+' || true + register: dev1_fw_clean_cmd + changed_when: false + + - name: Dev1 | Set final fw string + delegate_to: localhost + set_fact: + dev1_fw_clean: "{{ dev1_fw_clean_cmd.stdout|trim }}" + + # ----------------------- Scan tracking: Dev1 fw_version ----------------------- + - name: Scan tracking | Dev1 fw_version outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + (['fw_version'] if ((dev1_fw_clean | default('') | length) > 0) else []) }}" + scan_fail: "{{ scan_fail + (['fw_version=unavailable'] if ((dev1_fw_clean | default('') | length) == 0) else []) }}" + changed_when: false + + - name: Publish Dev1 fw_version + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'fw_version', + 'task_result': (dev1_fw_clean if (dev1_fw_clean|length>0) else 'unavailable') + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Dev1 SSID count (jq-based, schema-aware) ----------------------- + - name: Dev1 | SSID profiles summary via jq (AP radios only) + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "jq -r ' + [ + .wireless.radios + | to_entries[] + | select(.value.mode == \"ap\") + | (.value.vaps // [])[ ] + | select(has(\"ssid\")) + | {ssid:.ssid, enabled:(.enabled // false)} + ] + | unique_by(.ssid, .enabled) + | { + e: (map(select(.enabled==true)) | length), + d: (map(select(.enabled==false)) | length) + } + | \"\\(.e + .d) (\\(.e)e \\(.d)d)\" + ' /tmp/config.json 2>/dev/null || echo '0 (0e 0d)'" + register: ssid_profiles_summary_raw + changed_when: false + failed_when: false + ignore_errors: true + + + - name: Dev1 | Normalize SSID profiles summary + delegate_to: localhost + set_fact: + multissidfix_value: "{{ (ssid_profiles_summary_raw.stdout | default('0 (0e 0d)')) | trim }}" + + # ----------------------- Scan tracking: Dev1 multissidfix ----------------------- + - name: Scan tracking | Dev1 multissidfix outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + ['multissidfix'] }}" + changed_when: false + + - name: Dev1 | multissidfix debug + delegate_to: localhost + when: multissidfix_value is defined + debug: + msg: "multissidfix_value={{ multissidfix_value }}" + + - name: Publish Dev1 multissidfix custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'multissidfix', + 'task_result': multissidfix_value + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Dev1 scroll24.sh check & publish ----------------------- + - name: Dev1 | Check scroll24.sh presence + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "test -f /root/scroll24.sh && echo present || echo missing" + register: scroll24_present_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Set scroll24 presence fact + delegate_to: localhost + set_fact: + scroll24_present: "{{ (scroll24_present_raw.stdout | default('') | trim) == 'present' }}" + + - name: Dev1 | Read scroll24.sh version line (may be empty -> v1) + when: scroll24_present | default(false) + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "grep -i 'scroll24_version' /root/scroll24.sh 2>/dev/null | head -n1 || true" + register: scroll24_ver_line + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Normalize scroll24 version + when: scroll24_present | default(false) + delegate_to: localhost + set_fact: + scroll24_ver_clean: >- + {{ + ( + scroll24_ver_line.stdout | default('') | trim + ) + | regex_replace('^.*=', '') + | trim + if (scroll24_ver_line is defined + and (scroll24_ver_line.stdout | default('') | trim | length) > 0) + else 'v1' + }} + + - name: Dev1 | Read scroll24 crontab entry (first match) + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "crontab -l 2>/dev/null | grep 'scroll24.sh' | head -n1 || true" + register: scroll24_cron_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Normalize scroll24 cron line + delegate_to: localhost + set_fact: + scroll24_cron_line: "{{ scroll24_cron_raw.stdout | default('') | trim }}" + + - name: Dev1 | Derive scroll24 schedule summary + delegate_to: localhost + shell: | + line="{{ scroll24_cron_line | default('') }}" + + # No crontab hit or no scroll24.sh line + if [ -z "$line" ]; then + echo nocron + exit 0 + fi + + # Extract first 5 cron fields + min="$(echo "$line" | awk '{print $1}')" + hour="$(echo "$line" | awk '{print $2}')" + + # Pattern 1: */N * * * * -> every N minutes + case "$min" in + '*/'*) + n="${min#*/}" + if [ -n "$n" ]; then + echo "${n}m" + exit 0 + fi + ;; + esac + + # Pattern 2: M */H * * * -> every H hours (we ignore the minute) + case "$hour" in + '*/'*) + h="${hour#*/}" + if [ -n "$h" ]; then + echo "${h}h" + exit 0 + fi + ;; + esac + + # Fallback + echo custom + args: + executable: /bin/bash + register: scroll24_sched_raw + changed_when: false + failed_when: false + + - name: Dev1 | Set scroll24 schedule fact + delegate_to: localhost + set_fact: + scroll24_schedule: "{{ scroll24_sched_raw.stdout | default('') | trim }}" + + - name: Dev1 | Compute scroll24 value + delegate_to: localhost + set_fact: + scroll24_value: >- + {%- if not (scroll24_present | default(false)) -%} + missing + {%- elif scroll24_schedule in ['nocron', ''] -%} + {{ scroll24_ver_clean | default('v1') }}_nocron + {%- elif scroll24_schedule == 'custom' -%} + {{ scroll24_ver_clean | default('v1') }}_custom + {%- else -%} + {{ scroll24_ver_clean | default('v1') }}_{{ scroll24_schedule }} + {%- endif -%} + + # ----------------------- Scan tracking: Dev1 scroll24 ----------------------- + - name: Scan tracking | Dev1 scroll24 outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + ['scroll24'] }}" + changed_when: false + + - name: Dev1 | scroll24 debug + delegate_to: localhost + debug: + msg: + - "scroll24_present={{ scroll24_present | default(false) }}" + - "scroll24_ver_clean={{ scroll24_ver_clean | default('n/a') }}" + - "scroll24_cron_line={{ scroll24_cron_line | default('') }}" + - "scroll24_schedule={{ scroll24_schedule | default('n/a') }}" + - "scroll24_value={{ scroll24_value | default('n/a') }}" + + - name: Publish Dev1 scroll24 custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'scroll24', + 'task_result': scroll24_value + } | to_json }}" + payload_encoding: "string" + changed_when: false + + + # ----------------------- Dev1 wifidebug.sh check & publish ----------------------- + - name: Dev1 | Check wifidebug.sh presence + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "test -f /root/wifidebug.sh && echo present || echo missing" + register: wd_present + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Set presence fact + delegate_to: localhost + set_fact: + wifidebug_present: "{{ (wd_present.stdout | default('') | trim) == 'present' }}" + + - name: Dev1 | Read wifidebug.sh version (SCRIPTVERSION) + when: wifidebug_present | default(false) + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "grep -m1 'SCRIPTVERSION' /root/wifidebug.sh 2>/dev/null | awk -F'=' '{print \$2}' | tr -d '[:space:]' | sed -E 's/^v?([0-9].*)$/\1/'" + register: wd_version_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Normalize wifidebug version + delegate_to: localhost + set_fact: + wd_version_clean: "{{ wd_version_raw.stdout | default('') | trim }}" + + - name: Dev1 | Compute wifidebug value + delegate_to: localhost + set_fact: + wifidebug_value: >- + {%- if not (wifidebug_present | default(false)) -%}missing + {%- elif (wd_version_clean | default('') | length) > 0 -%}v{{ wd_version_clean }} + {%- else -%}n/a{%- endif -%} + + # ----------------------- Scan tracking: Dev1 wifidebug ----------------------- + - name: Scan tracking | Dev1 wifidebug outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + ['wifidebug'] }}" + changed_when: false + + - name: Publish Dev1 wifidebug custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'wifidebug', + 'task_result': wifidebug_value + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Dev1 fw_printenv size → cpe_bootenv ----------------------- + - name: Dev1 | Read fw_printenv size (line count) + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "fw_printenv 2>/dev/null | wc -l" + args: + executable: /bin/bash + register: dev1_bootenv_wc + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Normalize cpe_bootenv value + delegate_to: localhost + set_fact: + cpe_bootenv_value: >- + {{ + 'unavailable' + if ( + (dev1_bootenv_wc.rc | default(1)) != 0 or + (dev1_bootenv_wc.stdout | default('') | trim) in ['', '0'] + ) + else (dev1_bootenv_wc.stdout | default('') | trim) + }} + + # ----------------------- Scan tracking: Dev1 cpe_bootenv ----------------------- + - name: Scan tracking | Dev1 cpe_bootenv outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + (['cpe_bootenv'] if ((cpe_bootenv_value | default('unavailable')) != 'unavailable') else []) }}" + scan_fail: "{{ scan_fail + (['cpe_bootenv=unavailable'] if ((cpe_bootenv_value | default('unavailable')) == 'unavailable') else []) }}" + changed_when: false + + - name: Publish Dev1 cpe_bootenv custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'cpe_bootenv', + 'task_result': cpe_bootenv_value + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Dev1 hostname (for Dev2 mismatch check) ----------------------- + - name: Dev1 | Read kernel hostname + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "cat /proc/sys/kernel/hostname 2>/dev/null || hostname 2>/dev/null || echo ''" + args: + executable: /bin/bash + register: dev1_hostname_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Normalize hostname + delegate_to: localhost + set_fact: + dev1_hostname: >- + {{ + (dev1_hostname_raw.stdout | default('') | trim) + if ((dev1_hostname_raw.stdout | default('') | trim | length) > 0) + else (inventory_hostname | default('')) + }} + + # ----------------------- Dev2 auth + firmux ----------------------- + - name: Try DEV2 login + when: tunnel_ok|default(false) + delegate_to: localhost + shell: | + PORT="{{ _local_port }}" + # First try: basicpass + if timeout {{ ssh_timeout }}s sshpass -f basicpass ssh {{ ssh_opts_common }} \ + -o ConnectionAttempts=1 -o ConnectTimeout={{ ssh_timeout }} \ + -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" "echo ok" >/dev/null 2>&1; then + echo basicpass + exit 0 + fi + + # Second try: basicpass2 + if timeout {{ ssh_timeout }}s sshpass -f basicpass2 ssh {{ ssh_opts_common }} \ + -o ConnectionAttempts=1 -o ConnectTimeout={{ ssh_timeout }} \ + -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" "echo ok" >/dev/null 2>&1; then + echo basicpass2 + exit 0 + fi + + # Both failed + echo NONE + exit 1 + args: + executable: /bin/bash + register: dev2_auth + failed_when: false + ignore_errors: true + + - name: Set dev2_passfile_used + delegate_to: localhost + set_fact: + dev2_passfile_used: "{{ (dev2_auth.rc == 0)|ternary(dev2_auth.stdout|trim,'NONE') }}" + + - name: Dev2 | Auth debug + delegate_to: localhost + debug: + msg: + - "dev2_auth.rc={{ dev2_auth.rc | default('n/a') }}" + - "dev2_auth.stdout={{ dev2_auth.stdout | default('') | trim }}" + - "dev2_passfile_used={{ dev2_passfile_used }}" + + - name: Dev2 | Read firmux (tunnel) + when: + - tunnel_ok | default(false) + - dev2_passfile_used != 'NONE' + delegate_to: localhost + shell: | + PORT="{{ _local_port }}" + sshpass -f "{{ dev2_passfile_used }}" ssh {{ ssh_opts_common }} \ + -o ConnectTimeout={{ ssh_timeout }} \ + -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ + "cat /usr/lib/release/firmux 2>/dev/null || true" + args: + executable: /bin/bash + register: dev2_firmux + changed_when: false + failed_when: false + ignore_errors: true + + # ------ LLDP candidate IP when Dev2 login failed (tunnel up but Dev2 unreachable) ------ + - name: Dev1 | Try LLDP-derived Dev2 IP (auth to Dev2 failed) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + delegate_to: localhost + shell: | + DIGITS="{{ inventory_hostname | regex_replace('[^0-9]', '') }}" + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "cat /var/run/lldp_server.json 2>/dev/null | grep ${DIGITS} -A 10 | grep address | grep -vE 'subtype|ipv6' | awk -F'\"' '{ print \$4 }' | head -n1" + register: lldp_ip_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Show LLDP candidate IP (Dev2, after SSH auth failure) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + delegate_to: localhost + debug: + msg: + - >- + LLDP-derived Dev2 IP (raw best-effort): + {{ + (lldp_ip_raw.stdout | default('') | trim) + if ((lldp_ip_raw.stdout | default('') | trim | length) > 0) + else '' + }} + - >- + LLDP 10.0.0.0/8 usable: + {{ + ((lldp_ip_raw.stdout | default('') | trim) is match('^10\.')) + | ternary('yes (10/8)', 'no (not 10/8) or none') + }} + + - name: Set LLDP IP fact (10.0.0.0/8 only) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + delegate_to: localhost + set_fact: + lldp_dev2_ip_raw: "{{ lldp_ip_raw.stdout | default('') | trim }}" + lldp_dev2_ip: >- + {{ + (lldp_ip_raw.stdout | default('') | trim) + if ((lldp_ip_raw.stdout | default('') | trim) is match('^10\.')) + else '' + }} + + - name: Try DEV2 login via direct LLDP IP + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + delegate_to: localhost + shell: | + for f in basicpass basicpass2; do + if timeout 20s sshpass -f "$f" ssh {{ ssh_opts_common }} \ + -o ConnectionAttempts=1 -o ConnectTimeout=10 \ + "{{ dev2_ssh_user }}@{{ lldp_dev2_ip }}" "echo ok" >/dev/null 2>&1; then + echo "$f"; exit 0 + fi + done + exit 1 + register: dev2_auth_lldp + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | LLDP path selection debug + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + delegate_to: localhost + debug: + msg: + - "LLDP candidate IP={{ lldp_dev2_ip }}" + - "LLDP auth rc={{ dev2_auth_lldp.rc | default('n/a') }}" + - "passfile (if any) will follow in next step" + + - name: Set dev2_passfile_used_lldp + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + delegate_to: localhost + set_fact: + dev2_passfile_used_lldp: >- + {{ + ((dev2_auth_lldp.rc | default(1)) == 0) + | ternary(dev2_auth_lldp.stdout | default('') | trim, 'NONE') + }} + + - name: Dev2 | LLDP passfile decision + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + delegate_to: localhost + debug: + msg: + - "Using passfile for LLDP path: {{ dev2_passfile_used_lldp }}" + + - name: Dev2 | Check TCP/22 reachability (LLDP) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + delegate_to: localhost + shell: "nc -z -w3 {{ lldp_dev2_ip }} 22" + register: lldp_ssh_tcp + changed_when: false + failed_when: false + ignore_errors: true + + # ------ LLDP IPv6 fallback (Dev1 → Dev2 on link-local) ------ + - name: Dev1 | Try LLDP-derived Dev2 IPv6 (auth to Dev2 failed, IPv4 LLDP unusable) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + delegate_to: localhost + shell: | + DIGITS="{{ inventory_hostname | regex_replace('[^0-9]', '') }}" + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "cat /var/run/lldp_server.json 2>/dev/null | grep ${DIGITS} -A 15 | grep 'address_ipv6' | awk -F'\\\"' '{ print \\$4 }' | head -n1" + args: + executable: /bin/bash + register: lldp_ip6_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Set LLDP IPv6 fact + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + delegate_to: localhost + set_fact: + lldp_dev2_ip6: "{{ lldp_ip6_raw.stdout | default('') | trim }}" + + - name: Dev1 | Show LLDP IPv6 candidate + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + delegate_to: localhost + debug: + msg: + - "LLDP-derived Dev2 IPv6 (best-effort): {{ lldp_dev2_ip6 | default('') }}" + + - name: Dev1 | Copy Dev2 passfiles for IPv6 LLDP use + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + copy: + src: "{{ item }}" + dest: "/tmp/{{ item }}" + mode: "0600" + loop: "{{ dev2_passfiles }}" + ignore_errors: true + + - name: Dev1 | Try DEV2 login via LLDP IPv6 (nested SSH via Dev1) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + delegate_to: localhost + shell: | + IP6="{{ lldp_dev2_ip6 }}" + if [ -z "$IP6" ]; then + exit 1 + fi + + for f in {{ dev2_passfiles | join(' ') }}; do + if sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "timeout {{ ssh_timeout }}s sshpass -f '/tmp/${f}' ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout={{ ssh_timeout }} \ + '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]' 'echo ok' " \ + >/dev/null 2>&1; then + echo "$f" + exit 0 + fi + done + exit 1 + args: + executable: /bin/bash + register: dev2_auth_lldp6 + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | LLDP IPv6 path debug + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + delegate_to: localhost + debug: + msg: + - "LLDP IPv6 candidate={{ lldp_dev2_ip6 | default('') }}" + - "LLDP IPv6 auth rc={{ dev2_auth_lldp6.rc | default('n/a') }}" + + - name: Set dev2_passfile_used_lldp6 + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + delegate_to: localhost + set_fact: + dev2_passfile_used_lldp6: >- + {{ + ((dev2_auth_lldp6.rc | default(1)) == 0) + | ternary(dev2_auth_lldp6.stdout | default('') | trim, 'NONE') + }} + + + - name: Indoor | Compute resolved IP for NetBox indoor_ip + delegate_to: localhost + set_fact: + indoor_conn_ip: >- + {%- if (tunnel_ok | default(false)) and (dev2_passfile_used | default('NONE')) != 'NONE' -%} + {{ dev2_host }} + {%- elif (dev2_passfile_used_lldp is defined) + and (dev2_passfile_used_lldp | default('NONE')) != 'NONE' + and (lldp_dev2_ip | default('') | length) > 0 -%} + {{ lldp_dev2_ip }} + {%- elif (dev2_passfile_used_lldp6 is defined) + and (dev2_passfile_used_lldp6 | default('NONE')) != 'NONE' + and (lldp_dev2_ip6 | default('') | length) > 0 -%} + {{ lldp_dev2_ip6 }} + {%- else -%} + na + {%- endif -%} + changed_when: false + + - name: Publish Dev2 indoor_ip custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'indoor_ip', + 'task_result': indoor_conn_ip + } | to_json }}" + payload_encoding: "string" + changed_when: false + + + - name: Dev2 | IPv6 LLDP passfile decision + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + delegate_to: localhost + debug: + msg: + - "Using passfile for LLDP IPv6 path: {{ dev2_passfile_used_lldp6 | default('NONE') }}" + + - name: Dev2 | Read firmux (LLDP IPv6 via Dev1) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + - dev2_passfile_used_lldp6 is defined + - dev2_passfile_used_lldp6 != 'NONE' + delegate_to: localhost + shell: | + IP6="{{ lldp_dev2_ip6 }}" + F="{{ dev2_passfile_used_lldp6 }}" + if [ -z "$IP6" ] || [ -z "$F" ] || [ "$F" = "NONE" ]; then + exit 0 + fi + + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "sshpass -f '/tmp/${F}' ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout={{ ssh_timeout }} \ + '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]' \ + 'cat /usr/lib/release/firmux 2>/dev/null || true'" + args: + executable: /bin/bash + register: dev2_firmux_lldp6 + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Read firmux (direct LLDP IP) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + - dev2_passfile_used_lldp != 'NONE' + delegate_to: localhost + shell: | + sshpass -f "{{ dev2_passfile_used_lldp }}" ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout=15 \ + "{{ dev2_ssh_user }}@{{ lldp_dev2_ip }}" \ + "cat /usr/lib/release/firmux 2>/dev/null || true" + register: dev2_firmux_lldp + failed_when: false + ignore_errors: true + + - name: Dev2 | Choose firmux output (tunnel, IPv4 LLDP, then IPv6 LLDP) + delegate_to: localhost + set_fact: + dev2_firmux_text: >- + {{ + (dev2_firmux.stdout | default('') | trim) + if ((dev2_firmux.stdout | default('') | trim | length) > 0) + else ( + (dev2_firmux_lldp.stdout | default('') | trim) + if ( + (dev2_firmux_lldp is defined) and + ((dev2_firmux_lldp.stdout | default('') | trim | length) > 0) + ) + else (dev2_firmux_lldp6.stdout | default('') | trim) + ) + }} + + - name: Dev2 | firmux selection debug + delegate_to: localhost + debug: + msg: + - "dev2_firmux_text='{{ dev2_firmux_text | default('') | regex_replace('\n','\\n') }}'" + - "indoor_fw_norm='{{ indoor_fw_norm | default('') }}'" + + - name: Dev2 | Extract firmware version line + delegate_to: localhost + run_once: true + set_fact: + indoor_fw_norm: >- + {{ + ( + (dev2_firmux_text | default('') | regex_replace('\r',''))|split('\n') + | map('trim') | select('truthy') | list + ) | select('match','(?i).*\\brev\\s*[0-9]+.*') + | list | first | default( + ((dev2_firmux_text | default('') | regex_replace('\r','')) + | split('\n')|map('trim')|select('truthy')|list|first|default('')) + ) | trim + }} + + - name: Dev2 | firmux selection debug + delegate_to: localhost + debug: + msg: + - "dev2_firmux_text='{{ dev2_firmux_text | default('') | regex_replace('\n','\\n') }}'" + - "indoor_fw_norm='{{ indoor_fw_norm | default('') }}'" + + # ----------------------- Scan tracking: Dev2 indoor_fwver ----------------------- + - name: Scan tracking | Dev2 indoor_fwver outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + (['indoor_fwver'] if ((indoor_fw_norm | default('') | length) > 0) else []) }}" + scan_fail: "{{ scan_fail + (['indoor_fwver=unavailable'] if ((indoor_fw_norm | default('') | length) == 0) else []) }}" + changed_when: false + + - name: Publish Dev2 indoor_fwver + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'indoor_fwver', + 'task_result': (indoor_fw_norm if indoor_fw_norm|length>0 else 'unavailable') + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Dev2 fw_printenv size → indoor_bootenv ----------------------- + - name: Dev2 | Read fw_printenv size (tunnel) + when: + - tunnel_ok | default(false) + - dev2_passfile_used != 'NONE' + delegate_to: localhost + shell: | + PORT="{{ _local_port }}" + sshpass -f "{{ dev2_passfile_used }}" ssh {{ ssh_opts_common }} \ + -o ConnectTimeout={{ ssh_timeout }} \ + -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ + "fw_printenv 2>/dev/null | wc -l" + args: + executable: /bin/bash + register: dev2_bootenv_wc_tunnel + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Read fw_printenv size (LLDP) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + - dev2_passfile_used_lldp is defined + - dev2_passfile_used_lldp != 'NONE' + delegate_to: localhost + shell: | + sshpass -f "{{ dev2_passfile_used_lldp }}" ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout=15 \ + "{{ dev2_ssh_user }}@{{ lldp_dev2_ip }}" \ + "fw_printenv 2>/dev/null | wc -l" + args: + executable: /bin/bash + register: dev2_bootenv_wc_lldp + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Read fw_printenv size (LLDP IPv6 via Dev1) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + - dev2_passfile_used_lldp6 is defined + - dev2_passfile_used_lldp6 != 'NONE' + delegate_to: localhost + shell: | + IP6="{{ lldp_dev2_ip6 }}" + F="{{ dev2_passfile_used_lldp6 }}" + if [ -z "$IP6" ] || [ -z "$F" ] || [ "$F" = "NONE" ]; then + exit 0 + fi + + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "sshpass -f '/tmp/${F}' ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout={{ ssh_timeout }} \ + '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]' \ + 'fw_printenv 2>/dev/null | wc -l'" + args: + executable: /bin/bash + register: dev2_bootenv_wc_lldp6 + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Select fw_printenv size output + delegate_to: localhost + set_fact: + indoor_bootenv_raw: >- + {{ + (dev2_bootenv_wc_tunnel.stdout | default('') | trim) + if ( + (dev2_bootenv_wc_tunnel is defined) and + ((dev2_bootenv_wc_tunnel.rc | default(1)) == 0) and + ((dev2_bootenv_wc_tunnel.stdout | default('') | trim | length) > 0) + ) + else ( + (dev2_bootenv_wc_lldp.stdout | default('') | trim) + if ( + (dev2_bootenv_wc_lldp is defined) and + ((dev2_bootenv_wc_lldp.rc | default(1)) == 0) and + ((dev2_bootenv_wc_lldp.stdout | default('') | trim | length) > 0) + ) + else ( + (dev2_bootenv_wc_lldp6.stdout | default('') | trim) + if ( + (dev2_bootenv_wc_lldp6 is defined) and + ((dev2_bootenv_wc_lldp6.rc | default(1)) == 0) and + ((dev2_bootenv_wc_lldp6.stdout | default('') | trim | length) > 0) + ) + else '' + ) + ) + }} + + - name: Dev2 | Normalize indoor_bootenv value + delegate_to: localhost + set_fact: + indoor_bootenv_value: >- + {{ + 'unavailable' + if (indoor_bootenv_raw | default('') | trim) in ['', '0'] + else (indoor_bootenv_raw | default('') | trim) + }} + + # ----------------------- Scan tracking: Dev2 indoor_bootenv ----------------------- + - name: Scan tracking | Dev2 indoor_bootenv outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + (['indoor_bootenv'] if ((indoor_bootenv_value | default('unavailable')) != 'unavailable') else []) }}" + scan_fail: "{{ scan_fail + (['indoor_bootenv=unavailable'] if ((indoor_bootenv_value | default('unavailable')) == 'unavailable') else []) }}" + changed_when: false + + - name: Publish Dev2 indoor_bootenv custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'indoor_bootenv', + 'task_result': indoor_bootenv_value + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Dev2 hostname check + publish ----------------------- + - name: Dev2 | Read hostname (tunnel) + when: + - tunnel_ok | default(false) + - dev2_passfile_used != 'NONE' + delegate_to: localhost + shell: | + PORT="{{ _local_port }}" + sshpass -f "{{ dev2_passfile_used }}" ssh {{ ssh_opts_common }} \ + -o ConnectTimeout={{ ssh_timeout }} \ + -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ + "cat /proc/sys/kernel/hostname 2>/dev/null || hostname 2>/dev/null || echo ''" + args: + executable: /bin/bash + register: dev2_hostname_tunnel + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Read hostname (LLDP IPv4) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + - dev2_passfile_used_lldp is defined + - dev2_passfile_used_lldp != 'NONE' + delegate_to: localhost + shell: | + sshpass -f "{{ dev2_passfile_used_lldp }}" ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout=15 \ + "{{ dev2_ssh_user }}@{{ lldp_dev2_ip }}" \ + "cat /proc/sys/kernel/hostname 2>/dev/null || hostname 2>/dev/null || echo ''" + args: + executable: /bin/bash + register: dev2_hostname_lldp + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Read hostname (LLDP IPv6 via Dev1) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + - dev2_passfile_used_lldp6 is defined + - dev2_passfile_used_lldp6 != 'NONE' + delegate_to: localhost + shell: | + IP6="{{ lldp_dev2_ip6 }}" + F="{{ dev2_passfile_used_lldp6 }}" + if [ -z "$IP6" ] || [ -z "$F" ] || [ "$F" = "NONE" ]; then + exit 0 + fi + + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "sshpass -f '/tmp/${F}' ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout={{ ssh_timeout }} \ + '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]' \ + 'cat /proc/sys/kernel/hostname 2>/dev/null || hostname 2>/dev/null || echo '''" + args: + executable: /bin/bash + register: dev2_hostname_lldp6 + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Select hostname output + delegate_to: localhost + set_fact: + indoor_hostname_raw: >- + {{ + (dev2_hostname_tunnel.stdout | default('') | trim) + if ( + (dev2_hostname_tunnel is defined) and + ((dev2_hostname_tunnel.stdout | default('') | trim | length) > 0) + ) + else ( + (dev2_hostname_lldp.stdout | default('') | trim) + if ( + (dev2_hostname_lldp is defined) and + ((dev2_hostname_lldp.stdout | default('') | trim | length) > 0) + ) + else ( + (dev2_hostname_lldp6.stdout | default('') | trim) + if ( + (dev2_hostname_lldp6 is defined) and + ((dev2_hostname_lldp6.stdout | default('') | trim | length) > 0) + ) + else '' + ) + ) + }} + + - name: Dev2 | Normalize indoor_hostname + delegate_to: localhost + set_fact: + indoor_hostname_value: >- + {{ + 'unavailable' + if (indoor_hostname_raw | default('') | trim) == '' + else ( + ( + '!' ~ (indoor_hostname_raw | default('') | trim) + ) + if ( + (dev1_hostname | default(inventory_hostname)) + != (indoor_hostname_raw | default('') | trim) + ) + else (indoor_hostname_raw | default('') | trim) + ) + }} + + # ----------------------- Scan tracking: Dev2 indoor_hostname ----------------------- + - name: Scan tracking | Dev2 indoor_hostname outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + (['indoor_hostname'] if ((indoor_hostname_value | default('unavailable')) != 'unavailable') else []) }}" + scan_fail: "{{ scan_fail + (['indoor_hostname=unavailable'] if ((indoor_hostname_value | default('unavailable')) == 'unavailable') else []) }}" + changed_when: false + + - name: Dev2 | indoor_hostname debug + delegate_to: localhost + debug: + msg: + - "dev1_hostname={{ dev1_hostname | default('n/a') }}" + - "indoor_hostname_raw={{ indoor_hostname_raw | default('n/a') }}" + - "indoor_hostname_value={{ indoor_hostname_value | default('n/a') }}" + + - name: Publish Dev2 indoor_hostname custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'indoor_hostname', + 'task_result': indoor_hostname_value + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Final journal: scan status ----------------------- + - name: Journal final scan status (success / partial / fail) + delegate_to: localhost + vars: + _ok_n: "{{ (scan_ok | default([])) | length }}" + _fail_n: "{{ (scan_fail | default([])) | length }}" + _reason: "{{ (scan_fail | default([])) | join('; ') }}" + _msg: >- + {%- if (_fail_n | int) == 0 -%} + scan was successful + {%- elif (_ok_n | int) == 0 -%} + scanning failed — {{ _reason }} + {%- else -%} + scan was completed partially — {{ _reason }} + {%- endif -%} + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost | urlencode }}/{{ rmq_exchange | urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname | default(inventory_hostname)), + 'task_name': 'journal_add', + 'task_result': _msg + } | to_json }}" + payload_encoding: "string" + changed_when: false + + post_tasks: + - name: "✅ Cleanup start" + debug: + msg: "Closing tunnel and removing temporary IP" + + - name: Refresh ARP (cleanup) + raw: "{{ pathprefix }} arping -U -I {{ arping_iface }} {{ dev2_side_ip_addr }} -c 3" + failed_when: false + ignore_errors: true + + - name: Close SSH ControlMaster + delegate_to: localhost + shell: "ssh -S '{{ _ctrl_sock|default('/dev/null') }}' -O exit 2>/dev/null || true" + failed_when: false + ignore_errors: true + + - name: Remove control dir + delegate_to: localhost + file: + path: "{{ _ctrl_dir|default('/tmp/none') }}" + state: absent + ignore_errors: true + + - name: Remove temporary IP + raw: "{{ pathprefix }} ip a del {{ dev2_side_ip }} dev {{ dev1_iface }}" + failed_when: false + ignore_errors: true + + - name: "✅ Completed | Device processed" + debug: + msg: + - "Device: {{ inventory_hostname }}" + - "Status: DONE (telemetry only)" diff --git a/files/ansible-playbooks/sot-updater-upgradecmd.yml b/files/ansible-playbooks/sot-updater-upgradecmd.yml index 4f191ef..fb775fd 100644 --- a/files/ansible-playbooks/sot-updater-upgradecmd.yml +++ b/files/ansible-playbooks/sot-updater-upgradecmd.yml @@ -1,6 +1,6 @@ -# sot-updater.yml — Read fw on Dev1 + Dev2, publish to NetBox via Rabbit (telemetry mode) +# sot-updater.yml — Read fw on Dev1 + Dev2, publish to NetBox via Rabbit (telemetry mode, no upgrade scheduling) -- name: Read fw on Dev1 + Dev2, publish NetBox custom fields (full base, AIRPINGs, soft-fail telemetry) +- name: Read fw on Dev1 + Dev2, publish NetBox custom fields (full base, AIRPINGs, soft-fail telemetry, no upgrade scheduling) hosts: all gather_facts: no @@ -26,7 +26,7 @@ -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o NumberOfPasswordPrompts=1 - -o ConnectTimeout=30 + -o ConnectTimeout=15 rmq_host: "{{ lookup('env','RMQ_HOST') | default('10.210.12.2', true) }}" rmq_port: "{{ lookup('env','RMQ_PORT') | default('15672', true) }}" @@ -36,7 +36,7 @@ rmq_exchange: "{{ lookup('env','RMQ_EXCHANGE') | default('controls', true) }}" control_queue: "{{ lookup('env','CONTROLQUEUE') | default('queue_controls', true) }}" - # === NEW (surgical): requeue attempt counter from environment, default 0 === + # --- Retry attempt tracking (default 0 if missing) --- requeue_attempt: "{{ (lookup('env','REQUEUE_ATTEMPT') | default('0', true)) | int }}" pre_tasks: @@ -46,7 +46,7 @@ path: "/opt/containers/ansible-worker/app/nb_onedevice_update.py" register: nb_script - - name: "NB preflight | Abort softly if script missing (path typo?)" + - name: "NB preflight | Abort softly if script missing" when: not nb_script.stat.exists delegate_to: localhost ansible.builtin.debug: @@ -54,7 +54,7 @@ - "NB preflight skipped: /opt/containers/ansible-worker/app/nb_onedevice_update.py not found." - "Tip: adjust chdir/path or script name." - - name: "NB preflight | Run nb_onedevice_update.py for {{ inventory_hostname }} (chatty)" + - name: "NB preflight | Run nb_onedevice_update.py for {{ inventory_hostname }}" when: nb_script.stat.exists delegate_to: localhost environment: @@ -72,7 +72,7 @@ - name: "NB preflight | Show results" when: nb_script.stat.exists delegate_to: localhost - ansible.builtin.debug: + debug: msg: - "rc={{ nb_preflight.rc }}" - "stdout_lines:" @@ -81,94 +81,61 @@ - "{{ (nb_preflight.stderr_lines | default([''])) }}" - "raw stdout (joined): {{ nb_preflight.stdout | default('') | trim }}" - # === HARD STOP ON CLOUD FAILURE (immediately after preflight) === - - name: "NB preflight | Detect cloud failure (rc==3 OR 'FAIL Cloud' line)" + - name: "NB preflight | Detect cloud failure" when: nb_script.stat.exists delegate_to: localhost vars: + _out: "{{ (nb_preflight.stdout | default('') | regex_replace('\r','')) }}" _lines: >- - {{ (nb_preflight.stdout | default('') | regex_replace('\r','')) - | split('\n') | map('trim') | list }} + {{ _out.split('\n') | map('trim') | list }} _cloud_fail_line: >- {{ (_lines | select('match', '^FAIL\\s+Cloud\\b') | list | last | default('')) }} + _error_line: >- + {{ (_lines | select('match', '^FAIL\\s+') | list | last | default('')) }} _rc_is_3: "{{ (nb_preflight.rc | default(1)) | int == 3 }}" - ansible.builtin.set_fact: + _cloud_error_type: >- + {%- if _error_line is search('network error') + and _error_line is search('500 error responses') -%} + cloud_http_500 + {%- elif (_cloud_fail_line | length) > 0 -%} + cloud_logic + {%- elif _rc_is_3 -%} + cloud_other + {%- else -%} + none + {%- endif -%} + _cloud_retryable: >- + {{ _cloud_error_type not in ['cloud_http_500'] }} + set_fact: cloud_fail_line: "{{ _cloud_fail_line }}" cloud_bad: "{{ _rc_is_3 or (( _cloud_fail_line | length ) > 0) }}" + cloud_error_type: "{{ _cloud_error_type }}" + cloud_retryable: "{{ _cloud_retryable }}" - - name: "NB preflight | Journal + STOP (cloud unavailable)" - when: - - nb_script.stat.exists - - cloud_bad | default(false) - delegate_to: localhost - ansible.builtin.uri: - url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost | urlencode }}/{{ rmq_exchange | urlencode }}/publish" - method: POST - user: "{{ rmq_user }}" - password: "{{ rmq_pass }}" - force_basic_auth: true - status_code: 200 - headers: - content-type: "application/json" - body_format: json - body: - properties: - content_type: "application/json" - routing_key: "{{ control_queue }}" - payload: "{{ { - 'inscope_device': (ansible_hostname | default(inventory_hostname)), - 'task_name': 'journal_add', - 'task_result': ( - 'preflight abort: cloud unavailable; closing without requeue. ' - ~ (cloud_fail_line | default('')) - ~ ' rc=' ~ ((nb_preflight.rc | default('')) | string) - ) - } | to_json }}" - payload_encoding: "string" - register: rmq_cloud_unavail - changed_when: (rmq_cloud_unavail.json is defined) and (rmq_cloud_unavail.json.routed | default(false) | bool) - failed_when: false - - - name: "NB preflight | Stop host due to cloud unavailability (no requeue)" - when: - - nb_script.stat.exists - - cloud_bad | default(false) - ansible.builtin.meta: end_host - # === END HARD STOP === - - # --- Debug-only: capture the exact upgrade_cmd line and stop the play --- - - name: "NB preflight | Extract exact upgrade_cmd line" + # --- Extract upgrade_cmd line if any --- + - name: "NB preflight | Extract upgrade_cmd line" when: nb_script.stat.exists delegate_to: localhost - ansible.builtin.set_fact: + set_fact: nb_upgrade_line: >- {{ ( nb_preflight.stdout_lines | default([]) | map('regex_replace','\r','') | map('trim') - | select('match', '^NB:\s*upgrade_cmd\s*=') + | select('match', '^NB:\\s*upgrade_cmd\\s*=') | list | first ) | default('') }} - - name: "NB preflight | Show captured upgrade_cmd line" - when: nb_script.stat.exists - delegate_to: localhost - ansible.builtin.debug: - msg: - - "upgrade_cmd line (exact): {{ nb_upgrade_line | default('') }}" - - "found? {{ (nb_upgrade_line | length) > 0 }}" - - - name: "NB preflight | Parse value after '=' via awk" + - name: "NB preflight | Parse upgrade_cmd value" when: nb_upgrade_line | length > 0 delegate_to: localhost shell: | printf '%s\n' "{{ nb_upgrade_line }}" | awk -F'=' '{print $2}' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' - args: - executable: /bin/bash + args: { executable: /bin/bash } register: up_cmd_sh changed_when: false - - name: "NB preflight | Set and show parsed upgrade_cmd" + - name: "NB preflight | Set parsed upgrade_cmd" when: nb_upgrade_line | length > 0 delegate_to: localhost set_fact: @@ -179,11 +146,9 @@ delegate_to: localhost debug: msg: - - "upgrade_cmd value: '{{ nb_upgrade_cmd }}'" - - "len={{ nb_upgrade_cmd | length }}" + - "upgrade_cmd='{{ nb_upgrade_cmd }}' (len={{ nb_upgrade_cmd|length }})" - # --- Parse the OK line robustly (token-based) --- - - name: "NB preflight | Parse OK line (token)" + - name: "NB preflight | Parse OK line" when: nb_script.stat.exists delegate_to: localhost vars: @@ -197,23 +162,22 @@ {{ dict( nb_tokens - | select('match', '^[a-zA-Z_]+=') + | select('match', '^[a-zA-Z_]+=') | map('split', '=', 1) | map('list') ) }} set_fact: - nb_ok: "{{ (nb_preflight.rc | default(1)) == 0 and (nb_ok_line | length) > 0 }}" - nb_dev: "{{ (nb_tokens | first | default('')) if (nb_tokens|length>0) else '' }}" - nb_ip: "{{ nb_kv.get('ip', '') }}" - nb_fw: "{{ nb_kv.get('fw', '') }}" - nb_node: "{{ nb_kv.get('node', '') }}" - nb_sector: "{{ nb_kv.get('sector', '') }}" - nb_small: "{{ nb_kv.get('small', '') }}" + nb_ok: "{{ (nb_preflight.rc|default(1)) == 0 and (nb_ok_line|length)>0 }}" + nb_dev: "{{ (nb_tokens|first|default('')) if (nb_tokens|length>0) else '' }}" + nb_ip: "{{ nb_kv.get('ip','') }}" + nb_fw: "{{ nb_kv.get('fw','') }}" + nb_node: "{{ nb_kv.get('node','') }}" + nb_sector: "{{ nb_kv.get('sector','') }}" + nb_small: "{{ nb_kv.get('small','') }}" nb_ok_line: "{{ nb_ok_line }}" - # --- Detect "cloud vs NetBox (before update) was different" (regex-free, robust) - - name: "NB preflight | Detect whether IP changed (pre-update)" + - name: "NB preflight | Detect IP change" when: nb_script.stat.exists delegate_to: localhost vars: @@ -230,18 +194,27 @@ - name: "NB preflight | Verdict" when: nb_script.stat.exists delegate_to: localhost - ansible.builtin.debug: + debug: msg: - - "NB preflight verdict: {{ 'IP CHANGED (will requeue)' if nb_ip_changed else 'IP SAME (continue)' }}" - - "Reason: {{ nb_change_reason }}" + - "Preflight verdict:" + - "Cloud fail: {{ cloud_bad|default(false) }}" + - "IP changed: {{ nb_ip_changed|default(false) }}" + - "Reason: {{ nb_change_reason|default('none') }}" - # --- If IP changed → publish a 3s delayed 'sot-updater' and stop this host --- - - name: "NB preflight | Publish delayed requeue (3s) and stop" + - name: "NB preflight | Compute next_attempt" + delegate_to: localhost + set_fact: + next_attempt: "{{ (requeue_attempt | int) + 1 }}" + + # --- Requeue control: only 3 attempts, 300s apart --- + - name: "NB preflight | Publish delayed requeue (300s (10s)) if needed" when: - nb_script.stat.exists - - nb_ip_changed | default(false) - - (nb_preflight.rc | default(1)) == 0 # <— extra safety - - (requeue_attempt | int) < 3 # <— NEW: limit to 3 tries + - (nb_ip_changed | default(false)) or ( + (cloud_bad | default(false)) and + (cloud_error_type | default('none')) != 'cloud_http_500' + ) + - (requeue_attempt | int) < 3 delegate_to: localhost uri: url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/app/deviceconfig.delayed/publish" @@ -251,20 +224,17 @@ force_basic_auth: true status_code: 200 return_content: yes - headers: - content-type: "application/json" + headers: { content-type: "application/json" } body_format: json body: properties: content_type: "application/json" - # === NEW (surgical): 20s delay instead of 3s === - headers: { x-delay: 20000 } + headers: { x-delay: 900000 } routing_key: "deviceconfig" - # === NEW (surgical): include attempt counter in payload === payload: "{{ { 'inscope_device': (ansible_hostname | default(inventory_hostname)), 'task_name': 'sot-updater', - 'attempt': ((requeue_attempt | int) + 1) + 'attempt': next_attempt } | to_json }}" payload_encoding: "string" register: rmq_requeue @@ -274,31 +244,33 @@ - name: "NB preflight | Log requeue publish response" when: - nb_script.stat.exists - - nb_ip_changed | default(false) - - (nb_preflight.rc | default(1)) == 0 # <— extra safety - - (requeue_attempt | int) < 3 # <— NEW: only if we actually published + - ((cloud_bad | default(false)) or (nb_ip_changed | default(false))) + - (requeue_attempt | int) < 3 delegate_to: localhost - ansible.builtin.debug: + debug: msg: - - "RMQ publish URL: http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/app/deviceconfig.delayed/publish" - - "HTTP status: {{ rmq_requeue.status | default('unknown') }}" - - "Parsed JSON: {{ rmq_requeue.json | default('') }}" - - "Raw content: {{ rmq_requeue.content | default('') }}" + - "Requeue published (attempt {{ (requeue_attempt | int) + 1 }})" + - "HTTP: {{ rmq_requeue.status|default('n/a') }}" + - "JSON: {{ rmq_requeue.json|default('') }}" - - name: "NB preflight | Stop further tasks for this host" + - name: "NB preflight | Stop host after requeue" when: - nb_script.stat.exists - - nb_ip_changed | default(false) - - (nb_preflight.rc | default(1)) == 0 # <— extra safety + - ( + (nb_ip_changed | default(false)) or + ( + (cloud_bad | default(false)) and + (cloud_error_type | default('none')) != 'cloud_http_500' + ) + ) meta: end_host - # --- If IP did not change → optional 1s pause, then continue normally --- - - name: "NB preflight | Pause 1s" + - name: "NB preflight | Pause 1s if OK" when: - nb_script.stat.exists - - not (nb_ip_changed | default(false)) + - not ((cloud_bad | default(false)) or (nb_ip_changed | default(false))) delegate_to: localhost - ansible.builtin.pause: + pause: seconds: 1 tasks: @@ -306,7 +278,15 @@ debug: msg: - "Device: {{ inventory_hostname }}" - - "Mode: report-only (soft-fail; publish journals on failures)" + - "Mode: report-only (soft-fail telemetry)" + + # ----------------------- Scan status tracking (journal finalization) ----------------------- + - name: Init scan status tracking + delegate_to: localhost + set_fact: + scan_ok: [] + scan_fail: [] + changed_when: false # ----------------------- Temp IP on DEV1 ----------------------- - name: Add temporary IP on DEV1 @@ -315,85 +295,75 @@ failed_when: false ignore_errors: true - # ---------------------------- Idempotent temp IP on DEV1 ---------------------------- - name: Add temporary IP on DEV1 (tolerate 'File exists') - ansible.builtin.raw: > + raw: > {{ pathprefix }} ip a add {{ dev2_side_ip }} dev {{ dev1_iface }} register: add_ip changed_when: add_ip.rc == 0 failed_when: > add_ip.rc != 0 - and ('File exists' not in (add_ip.stdout | default(''))) - and ('File exists' not in (add_ip.stderr | default(''))) + and ('File exists' not in (add_ip.stdout|default(''))) + and ('File exists' not in (add_ip.stderr|default(''))) - - name: Debug result of adding temp IP to DEV1 - ansible.builtin.debug: + - name: Debug temp IP result + debug: msg: - - "add_ip.rc={{ add_ip.rc | default('') }}" - - "add_ip.stdout={{ (add_ip.stdout | default('')) | trim }}" - - "add_ip.stderr={{ (add_ip.stderr | default('')) | trim }}" + - "add_ip.rc={{ add_ip.rc|default('') }}" + - "add_ip.out={{ add_ip.stdout|default('')|trim }}" + - "add_ip.err={{ add_ip.stderr|default('')|trim }}" - # ---------------------------- Discover MAC via bridge FDB and add static ARP ---------------------------- - - name: Discover DEV2 MAC via bridge fdb on DEV1 (best-effort) - ansible.builtin.raw: > + - name: Discover DEV2 MAC via bridge fdb + raw: > {{ pathprefix }} bridge fdb show {{ dev1_iface }} | grep eth0 | grep -v permanent | grep master | awk '{print $1}' | head -n1 register: dev2_mac_scan changed_when: false failed_when: false - - name: Capture discovered DEV2 MAC (if any) - ansible.builtin.set_fact: - dev2_mac: "{{ (dev2_mac_scan.stdout | default('') ) | trim }}" - changed_when: false + - name: Capture discovered MAC + set_fact: + dev2_mac: "{{ dev2_mac_scan.stdout|default('')|trim }}" - - name: Clear existing ARP entry for DEV2 on DEV1 (best-effort) - ansible.builtin.raw: > + - name: Clear existing ARP entry + raw: > {{ pathprefix }} ip neigh del {{ dev2_host }} dev {{ dev1_iface }} 2>/dev/null || true - register: dev2_arp_del changed_when: false failed_when: false - - name: Add static ARP entry on DEV1 (locks DEV2 IP → discovered MAC) - when: dev2_mac is defined and dev2_mac | length > 0 - ansible.builtin.raw: > + - name: Add static ARP if MAC found + when: dev2_mac|length > 0 + raw: > {{ pathprefix }} ip neigh add {{ dev2_host }} lladdr {{ dev2_mac }} dev {{ dev1_iface }} nud permanent register: dev2_arp_add changed_when: dev2_arp_add.rc == 0 failed_when: > dev2_arp_add.rc != 0 - and ('File exists' not in (dev2_arp_add.stdout | default(''))) - and ('File exists' not in (dev2_arp_add.stderr | default(''))) + and ('File exists' not in (dev2_arp_add.stdout|default(''))) + and ('File exists' not in (dev2_arp_add.stderr|default(''))) - - name: Debug ARP action summary on DEV1 - ansible.builtin.debug: + - name: Debug ARP summary + debug: msg: - - "dev2_mac={{ dev2_mac | default('UNSET') }}" - - "arp_add.rc={{ dev2_arp_add.rc | default('') }}" - - "arp_add.out={{ (dev2_arp_add.stdout | default('')) | trim }}" - - "arp_add.err={{ (dev2_arp_add.stderr | default('')) | trim }}" + - "dev2_mac={{ dev2_mac|default('unset') }}" + - "arp_add.rc={{ dev2_arp_add.rc|default('') }}" + - "arp_add.out={{ dev2_arp_add.stdout|default('')|trim }}" + - "arp_add.err={{ dev2_arp_add.stderr|default('')|trim }}" - - name: Note skipping static ARP add (no MAC discovered) - when: dev2_mac is not defined or dev2_mac | length == 0 - ansible.builtin.debug: - msg: "No suitable dynamic MAC found via bridge fdb; skipping static ARP add on DEV1" + - name: Note skipping static ARP + when: dev2_mac|length == 0 + debug: + msg: "No MAC discovered; skipping static ARP add." - # ---------------------------- ARP refresh ---------------------------- - - name: Refresh ARP #1 - ansible.builtin.raw: "{{ pathprefix }} arping -U -I {{ arping_iface }} {{ dev2_side_ip_addr }} -c 3" - failed_when: false - ignore_errors: true - - - name: Refresh ARP #1 + - name: Refresh ARP raw: "{{ pathprefix }} arping -U -I {{ arping_iface }} {{ dev2_side_ip_addr }} -c 3" failed_when: false ignore_errors: true - # ----------------------- Local tunnel prep ----------------------- - - name: Pick a free local TCP port for the tunnel + # ----------------------- Tunnel setup ----------------------- + - name: Pick free local port delegate_to: localhost shell: | for i in $(seq 1 50); do @@ -404,32 +374,25 @@ register: pick_port failed_when: false - - name: Record chosen port and create control dir + - name: Create control dir delegate_to: localhost shell: "mktemp -d" register: mktemp_dir failed_when: false - - name: Set facts for tunnel paths + - name: Set tunnel facts delegate_to: localhost set_fact: - _local_port: "{{ (pick_port.stdout | default('') | trim) }}" - _ctrl_dir: "{{ (mktemp_dir.stdout | default('') | trim) }}" - _ctrl_sock: "{{ (mktemp_dir.stdout | default('') | trim) }}/ssh_tunnel_ctl" + _local_port: "{{ pick_port.stdout|default('')|trim }}" + _ctrl_dir: "{{ mktemp_dir.stdout|default('')|trim }}" + _ctrl_sock: "{{ mktemp_dir.stdout|default('')|trim }}/ssh_tunnel_ctl" - # ----------------------- AIRPING #2 ----------------------- - - name: Refresh ARP #2 - raw: "{{ pathprefix }} arping -U -I {{ arping_iface }} {{ dev2_side_ip_addr }} -c 3" - failed_when: false - ignore_errors: true - - # ----------------------- Start tunnel ----------------------- - - name: Start SSH tunnel via DEV1 + - name: Start SSH tunnel delegate_to: localhost shell: | set -e USER="{{ dev1_user }}" - HOST="{{ ansible_host | default(inventory_hostname) }}" + HOST="{{ ansible_host|default(inventory_hostname) }}" sshpass -p '{{ dev1_pass }}' ssh -f -N {{ ssh_opts_common }} \ -M -S "{{ _ctrl_sock }}" \ -L "127.0.0.1:{{ _local_port }}:{{ dev2_host }}:{{ dev2_port }}" \ @@ -450,21 +413,21 @@ set_fact: tunnel_ok: "{{ nc_probe.rc == 0 }}" - # ----------------------- DEV1 banner ----------------------- + # ----------------------- Dev1 banner ----------------------- - name: Dev1 | Probe banner delegate_to: localhost shell: | sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ - "{{ dev1_user }}@{{ ansible_host | default(inventory_hostname) }}" \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ "cat /etc/banner | grep -i rev | head -n1" register: dev1_banner failed_when: false ignore_errors: true - - name: Dev1 | Normalize banner → X.X.X-rYYYY (POSIX tools) + - name: Dev1 | Normalize banner delegate_to: localhost shell: | - printf '%s\n' "{{ dev1_banner.stdout | trim }}" \ + printf '%s\n' "{{ dev1_banner.stdout|trim }}" \ | awk -F '|' '{print $1}' \ | sed -E 's/[[:space:]]+rev[[:space:]]+/-r/' \ | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+-r[0-9]+' || true @@ -474,17 +437,20 @@ - name: Dev1 | Set final fw string delegate_to: localhost set_fact: - dev1_fw_clean: "{{ dev1_fw_clean_cmd.stdout | trim }}" + dev1_fw_clean: "{{ dev1_fw_clean_cmd.stdout|trim }}" - - name: Debug Dev1 normalized firmware + # ----------------------- Scan tracking: Dev1 fw_version ----------------------- + - name: Scan tracking | Dev1 fw_version outcome delegate_to: localhost - debug: - msg: "Dev1 fw_version {{ dev1_fw_clean | default('N/A') }}" + set_fact: + scan_ok: "{{ scan_ok + (['fw_version'] if ((dev1_fw_clean | default('') | length) > 0) else []) }}" + scan_fail: "{{ scan_fail + (['fw_version=unavailable'] if ((dev1_fw_clean | default('') | length) == 0) else []) }}" + changed_when: false - name: Publish Dev1 fw_version delegate_to: localhost uri: - url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost | urlencode }}/{{ rmq_exchange | urlencode }}/publish" + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" method: POST user: "{{ rmq_user }}" password: "{{ rmq_pass }}" @@ -496,7 +462,7 @@ properties: { content_type: "application/json" } routing_key: "{{ control_queue }}" payload: "{{ { - 'inscope_device': (ansible_hostname | default(inventory_hostname)), + 'inscope_device': (ansible_hostname|default(inventory_hostname)), 'task_name': 'custom_field_set', 'task_add1': 'fw_version', 'task_result': (dev1_fw_clean if (dev1_fw_clean|length>0) else 'unavailable') @@ -504,19 +470,420 @@ payload_encoding: "string" changed_when: false - # ----------------------- DEV2 auth ----------------------- - - name: Try DEV2 login - when: tunnel_ok | default(false) + # ----------------------- Dev1 SSID count (jq-based, schema-aware) ----------------------- + - name: Dev1 | SSID profiles summary via jq (AP radios only) delegate_to: localhost shell: | - for f in basicpass basicpass2; do - PORT="{{ _local_port }}" - if sshpass -f "$f" ssh -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ - -p "$PORT" root@127.0.0.1 "echo ok" >/dev/null 2>&1; then - echo "$f"; exit 0 - fi - done + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "jq -r ' + [ + .wireless.radios + | to_entries[] + | select(.value.mode == \"ap\") + | (.value.vaps // [])[ ] + | select(has(\"ssid\")) + | {ssid:.ssid, enabled:(.enabled // false)} + ] + | unique_by(.ssid, .enabled) + | { + e: (map(select(.enabled==true)) | length), + d: (map(select(.enabled==false)) | length) + } + | \"\\(.e + .d) (\\(.e)e \\(.d)d)\" + ' /tmp/config.json 2>/dev/null || echo '0 (0e 0d)'" + register: ssid_profiles_summary_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Normalize SSID profiles summary + delegate_to: localhost + set_fact: + multissidfix_value: "{{ (ssid_profiles_summary_raw.stdout | default('0 (0e 0d)')) | trim }}" + + # ----------------------- Scan tracking: Dev1 multissidfix ----------------------- + - name: Scan tracking | Dev1 multissidfix outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + ['multissidfix'] }}" + changed_when: false + + - name: Dev1 | multissidfix debug + delegate_to: localhost + when: multissidfix_value is defined + debug: + msg: "multissidfix_value={{ multissidfix_value }}" + + - name: Publish Dev1 multissidfix custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'multissidfix', + 'task_result': multissidfix_value + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Dev1 scroll24.sh check & publish ----------------------- + - name: Dev1 | Check scroll24.sh presence + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "test -f /root/scroll24.sh && echo present || echo missing" + register: scroll24_present_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Set scroll24 presence fact + delegate_to: localhost + set_fact: + scroll24_present: "{{ (scroll24_present_raw.stdout | default('') | trim) == 'present' }}" + + - name: Dev1 | Read scroll24.sh version line (may be empty -> v1) + when: scroll24_present | default(false) + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "grep -i 'scroll24_version' /root/scroll24.sh 2>/dev/null | head -n1 || true" + register: scroll24_ver_line + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Normalize scroll24 version + when: scroll24_present | default(false) + delegate_to: localhost + set_fact: + scroll24_ver_clean: >- + {{ + ( + scroll24_ver_line.stdout | default('') | trim + ) + | regex_replace('^.*=', '') + | trim + if (scroll24_ver_line is defined + and (scroll24_ver_line.stdout | default('') | trim | length) > 0) + else 'v1' + }} + + - name: Dev1 | Read scroll24 crontab entry (first match) + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "crontab -l 2>/dev/null | grep 'scroll24.sh' | head -n1 || true" + register: scroll24_cron_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Normalize scroll24 cron line + delegate_to: localhost + set_fact: + scroll24_cron_line: "{{ scroll24_cron_raw.stdout | default('') | trim }}" + + - name: Dev1 | Derive scroll24 schedule summary + delegate_to: localhost + shell: | + line="{{ scroll24_cron_line | default('') }}" + + # No crontab hit or no scroll24.sh line + if [ -z "$line" ]; then + echo nocron + exit 0 + fi + + # Extract first 5 cron fields + min="$(echo "$line" | awk '{print $1}')" + hour="$(echo "$line" | awk '{print $2}')" + + # Pattern 1: */N * * * * -> every N minutes + case "$min" in + '*/'*) + n="${min#*/}" + if [ -n "$n" ]; then + echo "${n}m" + exit 0 + fi + ;; + esac + + # Pattern 2: M */H * * * -> every H hours (we ignore the minute) + case "$hour" in + '*/'*) + h="${hour#*/}" + if [ -n "$h" ]; then + echo "${h}h" + exit 0 + fi + ;; + esac + + # Fallback + echo custom + args: + executable: /bin/bash + register: scroll24_sched_raw + changed_when: false + failed_when: false + + - name: Dev1 | Set scroll24 schedule fact + delegate_to: localhost + set_fact: + scroll24_schedule: "{{ scroll24_sched_raw.stdout | default('') | trim }}" + + - name: Dev1 | Compute scroll24 value + delegate_to: localhost + set_fact: + scroll24_value: >- + {%- if not (scroll24_present | default(false)) -%} + missing + {%- elif scroll24_schedule in ['nocron', ''] -%} + {{ scroll24_ver_clean | default('v1') }}_nocron + {%- elif scroll24_schedule == 'custom' -%} + {{ scroll24_ver_clean | default('v1') }}_custom + {%- else -%} + {{ scroll24_ver_clean | default('v1') }}_{{ scroll24_schedule }} + {%- endif -%} + + # ----------------------- Scan tracking: Dev1 scroll24 ----------------------- + - name: Scan tracking | Dev1 scroll24 outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + ['scroll24'] }}" + changed_when: false + + - name: Dev1 | scroll24 debug + delegate_to: localhost + debug: + msg: + - "scroll24_present={{ scroll24_present | default(false) }}" + - "scroll24_ver_clean={{ scroll24_ver_clean | default('n/a') }}" + - "scroll24_cron_line={{ scroll24_cron_line | default('') }}" + - "scroll24_schedule={{ scroll24_schedule | default('n/a') }}" + - "scroll24_value={{ scroll24_value | default('n/a') }}" + + - name: Publish Dev1 scroll24 custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'scroll24', + 'task_result': scroll24_value + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Dev1 wifidebug.sh check & publish ----------------------- + - name: Dev1 | Check wifidebug.sh presence + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "test -f /root/wifidebug.sh && echo present || echo missing" + register: wd_present + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Set presence fact + delegate_to: localhost + set_fact: + wifidebug_present: "{{ (wd_present.stdout | default('') | trim) == 'present' }}" + + - name: Dev1 | Read wifidebug.sh version (SCRIPTVERSION) + when: wifidebug_present | default(false) + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "grep -m1 'SCRIPTVERSION' /root/wifidebug.sh 2>/dev/null | awk -F'=' '{print \$2}' | tr -d '[:space:]' | sed -E 's/^v?([0-9].*)$/\1/'" + register: wd_version_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Normalize wifidebug version + delegate_to: localhost + set_fact: + wd_version_clean: "{{ wd_version_raw.stdout | default('') | trim }}" + + - name: Dev1 | Compute wifidebug value + delegate_to: localhost + set_fact: + wifidebug_value: >- + {%- if not (wifidebug_present | default(false)) -%}missing + {%- elif (wd_version_clean | default('') | length) > 0 -%}v{{ wd_version_clean }} + {%- else -%}n/a{%- endif -%} + + # ----------------------- Scan tracking: Dev1 wifidebug ----------------------- + - name: Scan tracking | Dev1 wifidebug outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + ['wifidebug'] }}" + changed_when: false + + - name: Publish Dev1 wifidebug custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'wifidebug', + 'task_result': wifidebug_value + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Dev1 fw_printenv size → cpe_bootenv ----------------------- + - name: Dev1 | Read fw_printenv size (line count) + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "fw_printenv 2>/dev/null | wc -l" + args: + executable: /bin/bash + register: dev1_bootenv_wc + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Normalize cpe_bootenv value + delegate_to: localhost + set_fact: + cpe_bootenv_value: >- + {{ + 'unavailable' + if ( + (dev1_bootenv_wc.rc | default(1)) != 0 or + (dev1_bootenv_wc.stdout | default('') | trim) in ['', '0'] + ) + else (dev1_bootenv_wc.stdout | default('') | trim) + }} + + # ----------------------- Scan tracking: Dev1 cpe_bootenv ----------------------- + - name: Scan tracking | Dev1 cpe_bootenv outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + (['cpe_bootenv'] if ((cpe_bootenv_value | default('unavailable')) != 'unavailable') else []) }}" + scan_fail: "{{ scan_fail + (['cpe_bootenv=unavailable'] if ((cpe_bootenv_value | default('unavailable')) == 'unavailable') else []) }}" + changed_when: false + + - name: Publish Dev1 cpe_bootenv custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'cpe_bootenv', + 'task_result': cpe_bootenv_value + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Dev1 hostname (for Dev2 mismatch check) ----------------------- + - name: Dev1 | Read kernel hostname + delegate_to: localhost + shell: | + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "cat /proc/sys/kernel/hostname 2>/dev/null || hostname 2>/dev/null || echo ''" + args: + executable: /bin/bash + register: dev1_hostname_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Normalize hostname + delegate_to: localhost + set_fact: + dev1_hostname: >- + {{ + (dev1_hostname_raw.stdout | default('') | trim) + if ((dev1_hostname_raw.stdout | default('') | trim | length) > 0) + else (inventory_hostname | default('')) + }} + + # ----------------------- Dev2 auth + firmux ----------------------- + - name: Try DEV2 login + when: tunnel_ok|default(false) + delegate_to: localhost + shell: | + PORT="{{ _local_port }}" + # First try: basicpass + if timeout {{ ssh_timeout }}s sshpass -f basicpass ssh {{ ssh_opts_common }} \ + -o ConnectionAttempts=1 -o ConnectTimeout={{ ssh_timeout }} \ + -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" "echo ok" >/dev/null 2>&1; then + echo basicpass + exit 0 + fi + + # Second try: basicpass2 + if timeout {{ ssh_timeout }}s sshpass -f basicpass2 ssh {{ ssh_opts_common }} \ + -o ConnectionAttempts=1 -o ConnectTimeout={{ ssh_timeout }} \ + -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" "echo ok" >/dev/null 2>&1; then + echo basicpass2 + exit 0 + fi + + # Both failed + echo NONE exit 1 + args: + executable: /bin/bash register: dev2_auth failed_when: false ignore_errors: true @@ -524,59 +891,889 @@ - name: Set dev2_passfile_used delegate_to: localhost set_fact: - dev2_passfile_used: "{{ (dev2_auth.rc == 0) | ternary(dev2_auth.stdout | trim, 'NONE') }}" + dev2_passfile_used: "{{ (dev2_auth.rc == 0)|ternary(dev2_auth.stdout|trim,'NONE') }}" - # ----------------------- AIRPING #3 ----------------------- - - name: Refresh ARP #3 - raw: "{{ pathprefix }} arping -U -I {{ arping_iface }} {{ dev2_side_ip_addr }} -c 3" - failed_when: false - ignore_errors: true + - name: Dev2 | Auth debug + delegate_to: localhost + debug: + msg: + - "dev2_auth.rc={{ dev2_auth.rc | default('n/a') }}" + - "dev2_auth.stdout={{ dev2_auth.stdout | default('') | trim }}" + - "dev2_passfile_used={{ dev2_passfile_used }}" - # ----------------------- DEV2 firmux (simplified, literal) ----------------------- - - name: Dev2 | Read /usr/lib/release/firmux - when: tunnel_ok | default(false) and dev2_passfile_used != 'NONE' + - name: Dev2 | Read firmux (tunnel) + when: + - tunnel_ok | default(false) + - dev2_passfile_used != 'NONE' delegate_to: localhost shell: | PORT="{{ _local_port }}" - sshpass -f "{{ dev2_passfile_used }}" ssh \ - -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ - -o ConnectTimeout=15 \ + sshpass -f "{{ dev2_passfile_used }}" ssh {{ ssh_opts_common }} \ + -o ConnectTimeout={{ ssh_timeout }} \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "cat /usr/lib/release/firmux 2>/dev/null || true" + args: + executable: /bin/bash register: dev2_firmux + changed_when: false failed_when: false ignore_errors: true - - name: Dev2 | Extract firmware version line (prefer 'rev', else first non-empty) + # ------ LLDP candidate IP when Dev2 login failed (tunnel up but Dev2 unreachable) ------ + - name: Dev1 | Try LLDP-derived Dev2 IP (auth to Dev2 failed) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + delegate_to: localhost + shell: | + DIGITS="{{ inventory_hostname | regex_replace('[^0-9]', '') }}" + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "cat /var/run/lldp_server.json 2>/dev/null | grep ${DIGITS} -A 10 | grep address | grep -vE 'subtype|ipv6' | awk -F'\"' '{ print \$4 }' | head -n1" + register: lldp_ip_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Show LLDP candidate IP (Dev2, after SSH auth failure) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + delegate_to: localhost + debug: + msg: + - >- + LLDP-derived Dev2 IP (raw best-effort): + {{ + (lldp_ip_raw.stdout | default('') | trim) + if ((lldp_ip_raw.stdout | default('') | trim | length) > 0) + else '' + }} + - >- + LLDP 10.0.0.0/8 usable: + {{ + ((lldp_ip_raw.stdout | default('') | trim) is match('^10\.')) + | ternary('yes (10/8)', 'no (not 10/8) or none') + }} + + - name: Set LLDP IP fact (10.0.0.0/8 only) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + delegate_to: localhost + set_fact: + lldp_dev2_ip_raw: "{{ lldp_ip_raw.stdout | default('') | trim }}" + lldp_dev2_ip: >- + {{ + (lldp_ip_raw.stdout | default('') | trim) + if ((lldp_ip_raw.stdout | default('') | trim) is match('^10\.')) + else '' + }} + + - name: Try DEV2 login via direct LLDP IP + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + delegate_to: localhost + shell: | + for f in basicpass basicpass2; do + if timeout 20s sshpass -f "$f" ssh {{ ssh_opts_common }} \ + -o ConnectionAttempts=1 -o ConnectTimeout=10 \ + "{{ dev2_ssh_user }}@{{ lldp_dev2_ip }}" "echo ok" >/dev/null 2>&1; then + echo "$f"; exit 0 + fi + done + exit 1 + register: dev2_auth_lldp + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | LLDP path selection debug + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + delegate_to: localhost + debug: + msg: + - "LLDP candidate IP={{ lldp_dev2_ip }}" + - "LLDP auth rc={{ dev2_auth_lldp.rc | default('n/a') }}" + - "passfile (if any) will follow in next step" + + - name: Set dev2_passfile_used_lldp + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + delegate_to: localhost + set_fact: + dev2_passfile_used_lldp: >- + {{ + ((dev2_auth_lldp.rc | default(1)) == 0) + | ternary(dev2_auth_lldp.stdout | default('') | trim, 'NONE') + }} + + - name: Dev2 | LLDP passfile decision + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + delegate_to: localhost + debug: + msg: + - "Using passfile for LLDP path: {{ dev2_passfile_used_lldp }}" + + - name: Dev2 | Check TCP/22 reachability (LLDP) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + delegate_to: localhost + shell: "nc -z -w3 {{ lldp_dev2_ip }} 22" + register: lldp_ssh_tcp + changed_when: false + failed_when: false + ignore_errors: true + + # ------ LLDP IPv6 fallback (Dev1 → Dev2 on link-local) ------ + - name: Dev1 | Try LLDP-derived Dev2 IPv6 (auth to Dev2 failed, IPv4 LLDP unusable) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + delegate_to: localhost + shell: | + DIGITS="{{ inventory_hostname | regex_replace('[^0-9]', '') }}" + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "cat /var/run/lldp_server.json 2>/dev/null | grep ${DIGITS} -A 15 | grep 'address_ipv6' | awk -F'\\\"' '{ print \\$4 }' | head -n1" + args: + executable: /bin/bash + register: lldp_ip6_raw + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev1 | Set LLDP IPv6 fact + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + delegate_to: localhost + set_fact: + lldp_dev2_ip6: "{{ lldp_ip6_raw.stdout | default('') | trim }}" + + - name: Dev1 | Show LLDP IPv6 candidate + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + delegate_to: localhost + debug: + msg: + - "LLDP-derived Dev2 IPv6 (best-effort): {{ lldp_dev2_ip6 | default('') }}" + + - name: Dev1 | Copy Dev2 passfiles for IPv6 LLDP use + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + copy: + src: "{{ item }}" + dest: "/tmp/{{ item }}" + mode: "0600" + loop: "{{ dev2_passfiles }}" + ignore_errors: true + + - name: Dev1 | Try DEV2 login via LLDP IPv6 (nested SSH via Dev1) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + delegate_to: localhost + shell: | + IP6="{{ lldp_dev2_ip6 }}" + if [ -z "$IP6" ]; then + exit 1 + fi + + for f in {{ dev2_passfiles | join(' ') }}; do + if sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "timeout {{ ssh_timeout }}s sshpass -f '/tmp/${f}' ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout={{ ssh_timeout }} \ + '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]' 'echo ok' " \ + >/dev/null 2>&1; then + echo "$f" + exit 0 + fi + done + exit 1 + args: + executable: /bin/bash + register: dev2_auth_lldp6 + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | LLDP IPv6 path debug + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + delegate_to: localhost + debug: + msg: + - "LLDP IPv6 candidate={{ lldp_dev2_ip6 | default('') }}" + - "LLDP IPv6 auth rc={{ dev2_auth_lldp6.rc | default('n/a') }}" + + - name: Set dev2_passfile_used_lldp6 + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + delegate_to: localhost + set_fact: + dev2_passfile_used_lldp6: >- + {{ + ((dev2_auth_lldp6.rc | default(1)) == 0) + | ternary(dev2_auth_lldp6.stdout | default('') | trim, 'NONE') + }} + + - name: Dev2 | IPv6 LLDP passfile decision + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + delegate_to: localhost + debug: + msg: + - "Using passfile for LLDP IPv6 path: {{ dev2_passfile_used_lldp6 | default('NONE') }}" + + - name: Dev2 | Read firmux (LLDP IPv6 via Dev1) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + - dev2_passfile_used_lldp6 is defined + - dev2_passfile_used_lldp6 != 'NONE' + delegate_to: localhost + shell: | + IP6="{{ lldp_dev2_ip6 }}" + F="{{ dev2_passfile_used_lldp6 }}" + if [ -z "$IP6" ] || [ -z "$F" ] || [ "$F" = "NONE" ]; then + exit 0 + fi + + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "sshpass -f '/tmp/${F}' ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout={{ ssh_timeout }} \ + '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]' \ + 'cat /usr/lib/release/firmux 2>/dev/null || true'" + args: + executable: /bin/bash + register: dev2_firmux_lldp6 + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Read firmux (direct LLDP IP) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + - dev2_passfile_used_lldp != 'NONE' + delegate_to: localhost + shell: | + sshpass -f "{{ dev2_passfile_used_lldp }}" ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout=15 \ + "{{ dev2_ssh_user }}@{{ lldp_dev2_ip }}" \ + "cat /usr/lib/release/firmux 2>/dev/null || true" + register: dev2_firmux_lldp + failed_when: false + ignore_errors: true + + - name: Dev2 | Choose firmux output (tunnel, IPv4 LLDP, then IPv6 LLDP) + delegate_to: localhost + set_fact: + dev2_firmux_text: >- + {{ + (dev2_firmux.stdout | default('') | trim) + if ((dev2_firmux.stdout | default('') | trim | length) > 0) + else ( + (dev2_firmux_lldp.stdout | default('') | trim) + if ( + (dev2_firmux_lldp is defined) and + ((dev2_firmux_lldp.stdout | default('') | trim | length) > 0) + ) + else (dev2_firmux_lldp6.stdout | default('') | trim) + ) + }} + + - name: Dev2 | firmux selection debug + delegate_to: localhost + debug: + msg: + - "dev2_firmux_text='{{ dev2_firmux_text | default('') | regex_replace('\n','\\n') }}'" + - "indoor_fw_norm='{{ indoor_fw_norm | default('') }}'" + + - name: Dev2 | Extract firmware version line delegate_to: localhost run_once: true set_fact: indoor_fw_norm: >- {{ ( - ( - dev2_firmux.stdout | default('') | regex_replace('\r','') - ) | split('\n') - | map('trim') - | select('truthy') - | list - ) | select('match', '(?i).*\\brev\\s*[0-9]+.*') - | list - | first - | default( - ((dev2_firmux.stdout | default('') | regex_replace('\r','')) - | split('\n') - | map('trim') - | select('truthy') - | list - | first - | default('')) - ) - | trim + (dev2_firmux_text | default('') | regex_replace('\r',''))|split('\n') + | map('trim') | select('truthy') | list + ) | select('match','(?i).*\\brev\\s*[0-9]+.*') + | list | first | default( + ((dev2_firmux_text | default('') | regex_replace('\r','')) + | split('\n')|map('trim')|select('truthy')|list|first|default('')) + ) | trim }} + - name: Dev2 | firmux selection debug + delegate_to: localhost + debug: + msg: + - "dev2_firmux_text='{{ dev2_firmux_text | default('') | regex_replace('\n','\\n') }}'" + - "indoor_fw_norm='{{ indoor_fw_norm | default('') }}'" + + # ----------------------- Scan tracking: Dev2 indoor_fwver ----------------------- + - name: Scan tracking | Dev2 indoor_fwver outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + (['indoor_fwver'] if ((indoor_fw_norm | default('') | length) > 0) else []) }}" + scan_fail: "{{ scan_fail + (['indoor_fwver=unavailable'] if ((indoor_fw_norm | default('') | length) == 0) else []) }}" + changed_when: false + - name: Publish Dev2 indoor_fwver delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'indoor_fwver', + 'task_result': (indoor_fw_norm if indoor_fw_norm|length>0 else 'unavailable') + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Dev2 fw_printenv size → indoor_bootenv ----------------------- + - name: Dev2 | Read fw_printenv size (tunnel) + when: + - tunnel_ok | default(false) + - dev2_passfile_used != 'NONE' + delegate_to: localhost + shell: | + PORT="{{ _local_port }}" + sshpass -f "{{ dev2_passfile_used }}" ssh {{ ssh_opts_common }} \ + -o ConnectTimeout={{ ssh_timeout }} \ + -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ + "fw_printenv 2>/dev/null | wc -l" + args: + executable: /bin/bash + register: dev2_bootenv_wc_tunnel + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Read fw_printenv size (LLDP) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + - dev2_passfile_used_lldp is defined + - dev2_passfile_used_lldp != 'NONE' + delegate_to: localhost + shell: | + sshpass -f "{{ dev2_passfile_used_lldp }}" ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout=15 \ + "{{ dev2_ssh_user }}@{{ lldp_dev2_ip }}" \ + "fw_printenv 2>/dev/null | wc -l" + args: + executable: /bin/bash + register: dev2_bootenv_wc_lldp + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Read fw_printenv size (LLDP IPv6 via Dev1) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + - dev2_passfile_used_lldp6 is defined + - dev2_passfile_used_lldp6 != 'NONE' + delegate_to: localhost + shell: | + IP6="{{ lldp_dev2_ip6 }}" + F="{{ dev2_passfile_used_lldp6 }}" + if [ -z "$IP6" ] || [ -z "$F" ] || [ "$F" = "NONE" ]; then + exit 0 + fi + + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "sshpass -f '/tmp/${F}' ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout={{ ssh_timeout }} \ + '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]' \ + 'fw_printenv 2>/dev/null | wc -l'" + args: + executable: /bin/bash + register: dev2_bootenv_wc_lldp6 + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Select fw_printenv size output + delegate_to: localhost + set_fact: + indoor_bootenv_raw: >- + {{ + (dev2_bootenv_wc_tunnel.stdout | default('') | trim) + if ( + (dev2_bootenv_wc_tunnel is defined) and + ((dev2_bootenv_wc_tunnel.rc | default(1)) == 0) and + ((dev2_bootenv_wc_tunnel.stdout | default('') | trim | length) > 0) + ) + else ( + (dev2_bootenv_wc_lldp.stdout | default('') | trim) + if ( + (dev2_bootenv_wc_lldp is defined) and + ((dev2_bootenv_wc_lldp.rc | default(1)) == 0) and + ((dev2_bootenv_wc_lldp.stdout | default('') | trim | length) > 0) + ) + else ( + (dev2_bootenv_wc_lldp6.stdout | default('') | trim) + if ( + (dev2_bootenv_wc_lldp6 is defined) and + ((dev2_bootenv_wc_lldp6.rc | default(1)) == 0) and + ((dev2_bootenv_wc_lldp6.stdout | default('') | trim | length) > 0) + ) + else '' + ) + ) + }} + + - name: Dev2 | Normalize indoor_bootenv value + delegate_to: localhost + set_fact: + indoor_bootenv_value: >- + {{ + 'unavailable' + if (indoor_bootenv_raw | default('') | trim) in ['', '0'] + else (indoor_bootenv_raw | default('') | trim) + }} + + # ----------------------- Scan tracking: Dev2 indoor_bootenv ----------------------- + - name: Scan tracking | Dev2 indoor_bootenv outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + (['indoor_bootenv'] if ((indoor_bootenv_value | default('unavailable')) != 'unavailable') else []) }}" + scan_fail: "{{ scan_fail + (['indoor_bootenv=unavailable'] if ((indoor_bootenv_value | default('unavailable')) == 'unavailable') else []) }}" + changed_when: false + + - name: Publish Dev2 indoor_bootenv custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'indoor_bootenv', + 'task_result': indoor_bootenv_value + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Dev2 hostname check + publish ----------------------- + - name: Dev2 | Read hostname (tunnel) + when: + - tunnel_ok | default(false) + - dev2_passfile_used != 'NONE' + delegate_to: localhost + shell: | + PORT="{{ _local_port }}" + sshpass -f "{{ dev2_passfile_used }}" ssh {{ ssh_opts_common }} \ + -o ConnectTimeout={{ ssh_timeout }} \ + -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ + "cat /proc/sys/kernel/hostname 2>/dev/null || hostname 2>/dev/null || echo ''" + args: + executable: /bin/bash + register: dev2_hostname_tunnel + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Read hostname (LLDP IPv4) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - lldp_dev2_ip | length > 0 + - dev2_passfile_used_lldp is defined + - dev2_passfile_used_lldp != 'NONE' + delegate_to: localhost + shell: | + sshpass -f "{{ dev2_passfile_used_lldp }}" ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout=15 \ + "{{ dev2_ssh_user }}@{{ lldp_dev2_ip }}" \ + "cat /proc/sys/kernel/hostname 2>/dev/null || hostname 2>/dev/null || echo ''" + args: + executable: /bin/bash + register: dev2_hostname_lldp + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Read hostname (LLDP IPv6 via Dev1) + when: + - tunnel_ok | default(false) + - dev2_passfile_used == 'NONE' + - (dev2_passfile_used_lldp is not defined or dev2_passfile_used_lldp == 'NONE') + - lldp_dev2_ip6 is defined + - lldp_dev2_ip6 | length > 0 + - dev2_passfile_used_lldp6 is defined + - dev2_passfile_used_lldp6 != 'NONE' + delegate_to: localhost + shell: | + IP6="{{ lldp_dev2_ip6 }}" + F="{{ dev2_passfile_used_lldp6 }}" + if [ -z "$IP6" ] || [ -z "$F" ] || [ "$F" = "NONE" ]; then + exit 0 + fi + + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "sshpass -f '/tmp/${F}' ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout={{ ssh_timeout }} \ + '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]' \ + 'cat /proc/sys/kernel/hostname 2>/dev/null || hostname 2>/dev/null || echo '''" + args: + executable: /bin/bash + register: dev2_hostname_lldp6 + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Select hostname output + delegate_to: localhost + set_fact: + indoor_hostname_raw: >- + {{ + (dev2_hostname_tunnel.stdout | default('') | trim) + if ( + (dev2_hostname_tunnel is defined) and + ((dev2_hostname_tunnel.stdout | default('') | trim | length) > 0) + ) + else ( + (dev2_hostname_lldp.stdout | default('') | trim) + if ( + (dev2_hostname_lldp is defined) and + ((dev2_hostname_lldp.stdout | default('') | trim | length) > 0) + ) + else ( + (dev2_hostname_lldp6.stdout | default('') | trim) + if ( + (dev2_hostname_lldp6 is defined) and + ((dev2_hostname_lldp6.stdout | default('') | trim | length) > 0) + ) + else '' + ) + ) + }} + + - name: Dev2 | Normalize indoor_hostname + delegate_to: localhost + set_fact: + indoor_hostname_value: >- + {{ + 'unavailable' + if (indoor_hostname_raw | default('') | trim) == '' + else ( + ( + '!' ~ (indoor_hostname_raw | default('') | trim) + ) + if ( + (dev1_hostname | default(inventory_hostname)) + != (indoor_hostname_raw | default('') | trim) + ) + else (indoor_hostname_raw | default('') | trim) + ) + }} + + # ----------------------- Scan tracking: Dev2 indoor_hostname ----------------------- + - name: Scan tracking | Dev2 indoor_hostname outcome + delegate_to: localhost + set_fact: + scan_ok: "{{ scan_ok + (['indoor_hostname'] if ((indoor_hostname_value | default('unavailable')) != 'unavailable') else []) }}" + scan_fail: "{{ scan_fail + (['indoor_hostname=unavailable'] if ((indoor_hostname_value | default('unavailable')) == 'unavailable') else []) }}" + changed_when: false + + - name: Dev2 | indoor_hostname debug + delegate_to: localhost + debug: + msg: + - "dev1_hostname={{ dev1_hostname | default('n/a') }}" + - "indoor_hostname_raw={{ indoor_hostname_raw | default('n/a') }}" + - "indoor_hostname_value={{ indoor_hostname_value | default('n/a') }}" + + - name: Publish Dev2 indoor_hostname custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'indoor_hostname', + 'task_result': indoor_hostname_value + } | to_json }}" + payload_encoding: "string" + changed_when: false + + - name: Indoor | Normalize Dev2 passfile facts (avoid undefined) + delegate_to: localhost + set_fact: + dev2_passfile_used_lldp: "{{ dev2_passfile_used_lldp | default('NONE') }}" + dev2_passfile_used_lldp6: "{{ dev2_passfile_used_lldp6 | default('NONE') }}" + changed_when: false + + + # ----------------------- Dev2 indoor_ip resolution (ppp0/eth0.4000 preferred) ----------------------- + - name: Indoor | Compute fallback connected IP (as used for Dev2 reachability) + delegate_to: localhost + set_fact: + indoor_conn_ip_fallback: >- + {%- if (tunnel_ok | default(false)) and (dev2_passfile_used | default('NONE')) != 'NONE' -%} + {{ dev2_host }} + {%- elif (dev2_passfile_used_lldp is defined) + and (dev2_passfile_used_lldp | default('NONE')) != 'NONE' + and (lldp_dev2_ip | default('') | length) > 0 -%} + {{ lldp_dev2_ip }} + {%- elif (dev2_passfile_used_lldp6 is defined) + and (dev2_passfile_used_lldp6 | default('NONE')) != 'NONE' + and (lldp_dev2_ip6 | default('') | length) > 0 -%} + {{ lldp_dev2_ip6 }} + {%- else -%} + na + {%- endif -%} + changed_when: false + + - name: Dev2 | Read ppp0 10/8 IPv4 (preferred) + when: + - (dev2_passfile_used | default('NONE')) != 'NONE' + or ((dev2_passfile_used_lldp | default('NONE')) != 'NONE') + or ((dev2_passfile_used_lldp6 | default('NONE')) != 'NONE') + delegate_to: localhost + shell: | + set -o pipefail + + # Choose connection method (tunnel > LLDP IPv4 > LLDP IPv6) + if [ "{{ dev2_passfile_used | default('NONE') }}" != "NONE" ]; then + PORT="{{ _local_port }}" + sshpass -f "{{ dev2_passfile_used }}" ssh {{ ssh_opts_common }} \ + -o ConnectTimeout={{ ssh_timeout }} \ + -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ + "ip -o -4 addr show dev ppp0 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | grep -m1 '^10\\.' || true" + exit 0 + fi + + if [ "{{ dev2_passfile_used_lldp | default('NONE') }}" != "NONE" ] && [ -n "{{ lldp_dev2_ip | default('') }}" ]; then + sshpass -f "{{ dev2_passfile_used_lldp }}" ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout=15 \ + "{{ dev2_ssh_user }}@{{ lldp_dev2_ip }}" \ + "ip -o -4 addr show dev ppp0 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | grep -m1 '^10\\.' || true" + exit 0 + fi + + if [ "{{ dev2_passfile_used_lldp6 | default('NONE') }}" != "NONE" ] && [ -n "{{ lldp_dev2_ip6 | default('') }}" ]; then + IP6="{{ lldp_dev2_ip6 }}" + F="{{ dev2_passfile_used_lldp6 }}" + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "sshpass -f '/tmp/${F}' ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout={{ ssh_timeout }} \ + '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]' \ + \"ip -o -4 addr show dev ppp0 2>/dev/null | awk '{print \\\\\$4}' | cut -d/ -f1 | grep -m1 '^10\\\\.' || true\"" + exit 0 + fi + + exit 0 + args: + executable: /bin/bash + register: dev2_ppp0_ip + changed_when: false + failed_when: false + ignore_errors: true + + - name: Dev2 | Read eth0.4000 10/8 IPv4 (only if ppp0 not usable) + when: + - (dev2_ppp0_ip.stdout | default('') | trim) == '' + - (dev2_passfile_used | default('NONE')) != 'NONE' + or ((dev2_passfile_used_lldp | default('NONE')) != 'NONE') + or ((dev2_passfile_used_lldp6 | default('NONE')) != 'NONE') + delegate_to: localhost + shell: | + set -o pipefail + + # Choose connection method (tunnel > LLDP IPv4 > LLDP IPv6) + if [ "{{ dev2_passfile_used | default('NONE') }}" != "NONE" ]; then + PORT="{{ _local_port }}" + sshpass -f "{{ dev2_passfile_used }}" ssh {{ ssh_opts_common }} \ + -o ConnectTimeout={{ ssh_timeout }} \ + -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ + "ip -o -4 addr show dev eth0.4000 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | grep -m1 '^10\\.' || true" + exit 0 + fi + + if [ "{{ dev2_passfile_used_lldp | default('NONE') }}" != "NONE" ] && [ -n "{{ lldp_dev2_ip | default('') }}" ]; then + sshpass -f "{{ dev2_passfile_used_lldp }}" ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout=15 \ + "{{ dev2_ssh_user }}@{{ lldp_dev2_ip }}" \ + "ip -o -4 addr show dev eth0.4000 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | grep -m1 '^10\\.' || true" + exit 0 + fi + + if [ "{{ dev2_passfile_used_lldp6 | default('NONE') }}" != "NONE" ] && [ -n "{{ lldp_dev2_ip6 | default('') }}" ]; then + IP6="{{ lldp_dev2_ip6 }}" + F="{{ dev2_passfile_used_lldp6 }}" + sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ + "sshpass -f '/tmp/${F}' ssh \ + -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ + -o ConnectTimeout={{ ssh_timeout }} \ + '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]' \ + \"ip -o -4 addr show dev eth0.4000 2>/dev/null | awk '{print \\\\\$4}' | cut -d/ -f1 | grep -m1 '^10\\\\.' || true\"" + exit 0 + fi + + exit 0 + args: + executable: /bin/bash + register: dev2_eth04000_ip + changed_when: false + failed_when: false + ignore_errors: true + + - name: Indoor | Select indoor_ip (ppp0 > eth0.4000 > fallback) + delegate_to: localhost + set_fact: + indoor_ip_value: >- + {%- if (dev2_ppp0_ip.stdout | default('') | trim) is match('^10\.') -%} + {{ dev2_ppp0_ip.stdout | trim }} + {%- elif (dev2_eth04000_ip.stdout | default('') | trim) is match('^10\.') -%} + {{ dev2_eth04000_ip.stdout | trim }} + {%- else -%} + {{ indoor_conn_ip_fallback | default('na') | trim }} + {%- endif -%} + changed_when: false + + - name: Dev2 | indoor_ip debug + delegate_to: localhost + debug: + msg: + - "ppp0_ip={{ dev2_ppp0_ip.stdout | default('') | trim }}" + - "eth0.4000_ip={{ dev2_eth04000_ip.stdout | default('') | trim }}" + - "fallback_ip={{ indoor_conn_ip_fallback | default('') | trim }}" + - "selected indoor_ip={{ indoor_ip_value | default('') | trim }}" + + - name: Publish Dev2 indoor_ip custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'indoor_ip', + 'task_result': (indoor_ip_value | default('na') | trim) + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------- Final journal: scan status ----------------------- + - name: Journal final scan status (success / partial / fail) + delegate_to: localhost + vars: + _ok_n: "{{ (scan_ok | default([])) | length }}" + _fail_n: "{{ (scan_fail | default([])) | length }}" + _reason: "{{ (scan_fail | default([])) | join('; ') }}" + _msg: >- + {%- if (_fail_n | int) == 0 -%} + scan was successful + {%- elif (_ok_n | int) == 0 -%} + scanning failed — {{ _reason }} + {%- else -%} + scan was completed partially — {{ _reason }} + {%- endif -%} uri: url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost | urlencode }}/{{ rmq_exchange | urlencode }}/publish" method: POST @@ -591,9 +1788,8 @@ routing_key: "{{ control_queue }}" payload: "{{ { 'inscope_device': (ansible_hostname | default(inventory_hostname)), - 'task_name': 'custom_field_set', - 'task_add1': 'indoor_fwver', - 'task_result': (indoor_fw_norm if indoor_fw_norm|length>0 else 'unavailable') + 'task_name': 'journal_add', + 'task_result': _msg } | to_json }}" payload_encoding: "string" changed_when: false @@ -603,150 +1799,34 @@ debug: msg: "Closing tunnel and removing temporary IP" - - name: Refresh ARP #4 + - name: Refresh ARP (cleanup) + ignore_unreachable: true raw: "{{ pathprefix }} arping -U -I {{ arping_iface }} {{ dev2_side_ip_addr }} -c 3" failed_when: false ignore_errors: true + - name: Close SSH ControlMaster delegate_to: localhost - shell: "ssh -S '{{ _ctrl_sock | default('/dev/null') }}' -O exit 2>/dev/null || true" + shell: "ssh -S '{{ _ctrl_sock|default('/dev/null') }}' -O exit 2>/dev/null || true" failed_when: false ignore_errors: true - - name: Remove tunnel control dir + - name: Remove control dir delegate_to: localhost file: - path: "{{ _ctrl_dir | default('/tmp/none') }}" + path: "{{ _ctrl_dir|default('/tmp/none') }}" state: absent ignore_errors: true - name: Remove temporary IP + ignore_unreachable: true raw: "{{ pathprefix }} ip a del {{ dev2_side_ip }} dev {{ dev1_iface }}" failed_when: false ignore_errors: true - # --- Determine success of Dev1 & Dev2 reads (controller-side heuristics) --- - - name: Post | Derive success flags for Dev1/Dev2 reads - delegate_to: localhost - ansible.builtin.set_fact: - dev1_read_ok: "{{ (dev1_fw_clean | default('') | trim) | length > 0 }}" - dev2_read_ok: "{{ (dev2_firmux is defined) and ((dev2_firmux.rc | default(1)) == 0) and ((dev2_firmux.stdout | default('') | trim) | length > 0) }}" - - - name: Post | Debug success flags - delegate_to: localhost - ansible.builtin.debug: - msg: - - "dev1_read_ok={{ dev1_read_ok | default(false) }}" - - "dev2_read_ok={{ dev2_read_ok | default(false) }}" - - "nb_upgrade_cmd='{{ (nb_upgrade_cmd | default('')) }}' (len={{ (nb_upgrade_cmd | default('')) | length }})" - - # --- If both reads OK and upgrade_cmd looks valid, publish journal + schedule upgrade --- - - name: Post | Build journal payload for planned upgrade - when: - - (dev1_read_ok | default(false)) and (dev2_read_ok | default(false)) - - (nb_upgrade_cmd | default('')) | length > 10 - delegate_to: localhost - ansible.builtin.set_fact: - upgrade_journal_payload: - inscope_device: "{{ ansible_hostname | default(inventory_hostname) }}" - task_name: "journal_add" - task_result: >- - device is configured to be upgraded with {{ nb_upgrade_cmd }}. requesting the upgrade - - - name: Post | Publish journal to controls - when: upgrade_journal_payload is defined - delegate_to: localhost - ansible.builtin.uri: - url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost | urlencode }}/{{ rmq_exchange | urlencode }}/publish" - method: POST - user: "{{ rmq_user }}" - password: "{{ rmq_pass }}" - force_basic_auth: true - status_code: 200 - headers: { content-type: "application/json" } - body_format: json - body: - properties: { content_type: "application/json" } - routing_key: "{{ control_queue }}" - payload: "{{ upgrade_journal_payload | to_json }}" - payload_encoding: "string" - register: rmq_journal_upgrade - changed_when: (rmq_journal_upgrade.json is defined) and (rmq_journal_upgrade.json.routed | default(false) | bool) - failed_when: false - - - name: Post | Schedule upgrade task via delayed exchange (10s) - when: - - (dev1_read_ok | default(false)) and (dev2_read_ok | default(false)) - - (nb_upgrade_cmd | default('')) | length > 10 - delegate_to: localhost - ansible.builtin.uri: - url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/app/deviceconfig.delayed/publish" - method: POST - user: "admin" - password: "change_me" - force_basic_auth: true - status_code: 200 - return_content: yes - headers: - content-type: "application/json" - body_format: json - body: - properties: - content_type: "application/json" - headers: { x-delay: 300000 } - routing_key: "deviceconfig" - payload: "{{ {'inscope_device': (ansible_hostname | default(inventory_hostname)), 'task_name': (nb_upgrade_cmd | default('')) } | to_json }}" - payload_encoding: "string" - register: rmq_schedule_upgrade - changed_when: false - failed_when: false - - # === NEW: Tag only when scheduling path ran (success gate same as scheduling) === - - name: Post | Build tag payload (auto-indoor-upgrade) - when: - - (dev1_read_ok | default(false)) and (dev2_read_ok | default(false)) - - (nb_upgrade_cmd | default('')) | length > 10 - - rmq_schedule_upgrade is defined - delegate_to: localhost - ansible.builtin.set_fact: - tag_add_payload: - inscope_device: "{{ ansible_hostname | default(inventory_hostname) }}" - task_name: "tag_add" - task_add1: "auto-indoor-upgrade" - - - name: Post | Publish add-tag to controls - when: tag_add_payload is defined - delegate_to: localhost - ansible.builtin.uri: - url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost | urlencode }}/{{ rmq_exchange | urlencode }}/publish" - method: POST - user: "{{ rmq_user }}" - password: "{{ rmq_pass }}" - force_basic_auth: true - status_code: 200 - headers: { content-type: "application/json" } - body_format: json - body: - properties: { content_type: "application/json" } - routing_key: "{{ control_queue }}" - payload: "{{ tag_add_payload | to_json }}" - payload_encoding: "string" - register: rmq_tag_add - changed_when: (rmq_tag_add.json is defined) and (rmq_tag_add.json.routed | default(false) | bool) - - - name: Post | Log upgrade scheduling response - when: rmq_schedule_upgrade is defined - delegate_to: localhost - ansible.builtin.debug: - msg: - - "Upgrade scheduled to deviceconfig.delayed in 10s" - - "HTTP status: {{ rmq_schedule_upgrade.status | default('unknown') }}" - - "Parsed JSON: {{ rmq_schedule_upgrade.json | default('') }}" - - "Raw content: {{ rmq_schedule_upgrade.content | default('') }}" - - name: "✅ Completed | Device processed" debug: msg: - "Device: {{ inventory_hostname }}" - - "Status: DONE" + - "Status: DONE (telemetry only)" diff --git a/files/ansible-playbooks/sot-updater.yml b/files/ansible-playbooks/sot-updater.yml index 579284c..a8fd6a6 100644 --- a/files/ansible-playbooks/sot-updater.yml +++ b/files/ansible-playbooks/sot-updater.yml @@ -265,6 +265,7 @@ ) meta: end_host + - name: "NB preflight | Pause 1s if OK" when: - nb_script.stat.exists @@ -497,6 +498,7 @@ failed_when: false ignore_errors: true + - name: Dev1 | Normalize SSID profiles summary delegate_to: localhost set_fact: @@ -703,6 +705,7 @@ payload_encoding: "string" changed_when: false + # ----------------------- Dev1 wifidebug.sh check & publish ----------------------- - name: Dev1 | Check wifidebug.sh presence delegate_to: localhost @@ -1151,6 +1154,50 @@ | ternary(dev2_auth_lldp6.stdout | default('') | trim, 'NONE') }} + + - name: Indoor | Compute resolved IP for NetBox indoor_ip + delegate_to: localhost + set_fact: + indoor_conn_ip: >- + {%- if (tunnel_ok | default(false)) and (dev2_passfile_used | default('NONE')) != 'NONE' -%} + {{ dev2_host }} + {%- elif (dev2_passfile_used_lldp is defined) + and (dev2_passfile_used_lldp | default('NONE')) != 'NONE' + and (lldp_dev2_ip | default('') | length) > 0 -%} + {{ lldp_dev2_ip }} + {%- elif (dev2_passfile_used_lldp6 is defined) + and (dev2_passfile_used_lldp6 | default('NONE')) != 'NONE' + and (lldp_dev2_ip6 | default('') | length) > 0 -%} + {{ lldp_dev2_ip6 }} + {%- else -%} + na + {%- endif -%} + changed_when: false + + - name: Publish Dev2 indoor_ip custom field + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" + method: POST + user: "{{ rmq_user }}" + password: "{{ rmq_pass }}" + force_basic_auth: true + status_code: 200 + headers: { content-type: "application/json" } + body_format: json + body: + properties: { content_type: "application/json" } + routing_key: "{{ control_queue }}" + payload: "{{ { + 'inscope_device': (ansible_hostname|default(inventory_hostname)), + 'task_name': 'custom_field_set', + 'task_add1': 'indoor_ip', + 'task_result': indoor_conn_ip + } | to_json }}" + payload_encoding: "string" + changed_when: false + + - name: Dev2 | IPv6 LLDP passfile decision when: - tunnel_ok | default(false) @@ -1587,170 +1634,6 @@ payload_encoding: "string" changed_when: false - # ----------------------- Dev2 indoor_ip resolution (ppp0/eth0.4000 preferred) ----------------------- - - name: Indoor | Compute fallback connected IP (as used for Dev2 reachability) - delegate_to: localhost - set_fact: - indoor_conn_ip_fallback: >- - {%- if (tunnel_ok | default(false)) and (dev2_passfile_used | default('NONE')) != 'NONE' -%} - {{ dev2_host }} - {%- elif (dev2_passfile_used_lldp is defined) - and (dev2_passfile_used_lldp | default('NONE')) != 'NONE' - and (lldp_dev2_ip | default('') | length) > 0 -%} - {{ lldp_dev2_ip }} - {%- elif (dev2_passfile_used_lldp6 is defined) - and (dev2_passfile_used_lldp6 | default('NONE')) != 'NONE' - and (lldp_dev2_ip6 | default('') | length) > 0 -%} - {{ lldp_dev2_ip6 }} - {%- else -%} - na - {%- endif -%} - changed_when: false - - - name: Dev2 | Read ppp0 10/8 IPv4 (preferred) - when: - - (dev2_passfile_used | default('NONE')) != 'NONE' - or ((dev2_passfile_used_lldp | default('NONE')) != 'NONE') - or ((dev2_passfile_used_lldp6 | default('NONE')) != 'NONE') - delegate_to: localhost - shell: | - set -o pipefail - - # Choose connection method (tunnel > LLDP IPv4 > LLDP IPv6) - if [ "{{ dev2_passfile_used | default('NONE') }}" != "NONE" ]; then - PORT="{{ _local_port }}" - sshpass -f "{{ dev2_passfile_used }}" ssh {{ ssh_opts_common }} \ - -o ConnectTimeout={{ ssh_timeout }} \ - -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ - "ip -o -4 addr show dev ppp0 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | grep -m1 '^10\\.' || true" - exit 0 - fi - - if [ "{{ dev2_passfile_used_lldp | default('NONE') }}" != "NONE" ] && [ -n "{{ lldp_dev2_ip | default('') }}" ]; then - sshpass -f "{{ dev2_passfile_used_lldp }}" ssh \ - -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ - -o ConnectTimeout=15 \ - "{{ dev2_ssh_user }}@{{ lldp_dev2_ip }}" \ - "ip -o -4 addr show dev ppp0 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | grep -m1 '^10\\.' || true" - exit 0 - fi - - if [ "{{ dev2_passfile_used_lldp6 | default('NONE') }}" != "NONE" ] && [ -n "{{ lldp_dev2_ip6 | default('') }}" ]; then - IP6="{{ lldp_dev2_ip6 }}" - F="{{ dev2_passfile_used_lldp6 }}" - sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ - "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ - "sshpass -f '/tmp/${F}' ssh \ - -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ - -o ConnectTimeout={{ ssh_timeout }} \ - '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]' \ - \"ip -o -4 addr show dev ppp0 2>/dev/null | awk '{print \\\\\$4}' | cut -d/ -f1 | grep -m1 '^10\\\\.' || true\"" - exit 0 - fi - - exit 0 - args: - executable: /bin/bash - register: dev2_ppp0_ip - changed_when: false - failed_when: false - ignore_errors: true - - - name: Dev2 | Read eth0.4000 10/8 IPv4 (only if ppp0 not usable) - when: - - (dev2_ppp0_ip.stdout | default('') | trim) == '' - - (dev2_passfile_used | default('NONE')) != 'NONE' - or ((dev2_passfile_used_lldp | default('NONE')) != 'NONE') - or ((dev2_passfile_used_lldp6 | default('NONE')) != 'NONE') - delegate_to: localhost - shell: | - set -o pipefail - - # Choose connection method (tunnel > LLDP IPv4 > LLDP IPv6) - if [ "{{ dev2_passfile_used | default('NONE') }}" != "NONE" ]; then - PORT="{{ _local_port }}" - sshpass -f "{{ dev2_passfile_used }}" ssh {{ ssh_opts_common }} \ - -o ConnectTimeout={{ ssh_timeout }} \ - -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ - "ip -o -4 addr show dev eth0.4000 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | grep -m1 '^10\\.' || true" - exit 0 - fi - - if [ "{{ dev2_passfile_used_lldp | default('NONE') }}" != "NONE" ] && [ -n "{{ lldp_dev2_ip | default('') }}" ]; then - sshpass -f "{{ dev2_passfile_used_lldp }}" ssh \ - -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ - -o ConnectTimeout=15 \ - "{{ dev2_ssh_user }}@{{ lldp_dev2_ip }}" \ - "ip -o -4 addr show dev eth0.4000 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | grep -m1 '^10\\.' || true" - exit 0 - fi - - if [ "{{ dev2_passfile_used_lldp6 | default('NONE') }}" != "NONE" ] && [ -n "{{ lldp_dev2_ip6 | default('') }}" ]; then - IP6="{{ lldp_dev2_ip6 }}" - F="{{ dev2_passfile_used_lldp6 }}" - sshpass -p '{{ dev1_pass }}' ssh {{ ssh_opts_common }} \ - "{{ dev1_user }}@{{ ansible_host|default(inventory_hostname) }}" \ - "sshpass -f '/tmp/${F}' ssh \ - -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ - -o ConnectTimeout={{ ssh_timeout }} \ - '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]' \ - \"ip -o -4 addr show dev eth0.4000 2>/dev/null | awk '{print \\\\\$4}' | cut -d/ -f1 | grep -m1 '^10\\\\.' || true\"" - exit 0 - fi - - exit 0 - args: - executable: /bin/bash - register: dev2_eth04000_ip - changed_when: false - failed_when: false - ignore_errors: true - - - name: Indoor | Select indoor_ip (ppp0 > eth0.4000 > fallback) - delegate_to: localhost - set_fact: - indoor_ip_value: >- - {%- if (dev2_ppp0_ip.stdout | default('') | trim) is match('^10\.') -%} - {{ dev2_ppp0_ip.stdout | trim }} - {%- elif (dev2_eth04000_ip.stdout | default('') | trim) is match('^10\.') -%} - {{ dev2_eth04000_ip.stdout | trim }} - {%- else -%} - {{ indoor_conn_ip_fallback | default('na') | trim }} - {%- endif -%} - changed_when: false - - - name: Dev2 | indoor_ip debug - delegate_to: localhost - debug: - msg: - - "ppp0_ip={{ dev2_ppp0_ip.stdout | default('') | trim }}" - - "eth0.4000_ip={{ dev2_eth04000_ip.stdout | default('') | trim }}" - - "fallback_ip={{ indoor_conn_ip_fallback | default('') | trim }}" - - "selected indoor_ip={{ indoor_ip_value | default('') | trim }}" - - - name: Publish Dev2 indoor_ip custom field - delegate_to: localhost - uri: - url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost|urlencode }}/{{ rmq_exchange|urlencode }}/publish" - method: POST - user: "{{ rmq_user }}" - password: "{{ rmq_pass }}" - force_basic_auth: true - status_code: 200 - headers: { content-type: "application/json" } - body_format: json - body: - properties: { content_type: "application/json" } - routing_key: "{{ control_queue }}" - payload: "{{ { - 'inscope_device': (ansible_hostname|default(inventory_hostname)), - 'task_name': 'custom_field_set', - 'task_add1': 'indoor_ip', - 'task_result': (indoor_ip_value | default('na') | trim) - } | to_json }}" - payload_encoding: "string" - changed_when: false - # ----------------------- Final journal: scan status ----------------------- - name: Journal final scan status (success / partial / fail) delegate_to: localhost