From 3a911df088e86912feac401c9e883055952c98b4 Mon Sep 17 00:00:00 2001 From: pavel Date: Mon, 19 Jan 2026 19:29:47 +0200 Subject: [PATCH] 1929 --- .../sot-updater-upgradecmd.yml | 1876 ++++------------- files/ansible-playbooks/sot-updater.yml | 221 +- 2 files changed, 572 insertions(+), 1525 deletions(-) diff --git a/files/ansible-playbooks/sot-updater-upgradecmd.yml b/files/ansible-playbooks/sot-updater-upgradecmd.yml index fb775fd..4f191ef 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, no upgrade scheduling) +# sot-updater.yml — Read fw on Dev1 + Dev2, publish to NetBox via Rabbit (telemetry mode) -- name: Read fw on Dev1 + Dev2, publish NetBox custom fields (full base, AIRPINGs, soft-fail telemetry, no upgrade scheduling) +- name: Read fw on Dev1 + Dev2, publish NetBox custom fields (full base, AIRPINGs, soft-fail telemetry) hosts: all gather_facts: no @@ -26,7 +26,7 @@ -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o NumberOfPasswordPrompts=1 - -o ConnectTimeout=15 + -o ConnectTimeout=30 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) }}" - # --- Retry attempt tracking (default 0 if missing) --- + # === NEW (surgical): requeue attempt counter from environment, default 0 === 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" + - name: "NB preflight | Abort softly if script missing (path typo?)" 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 }}" + - name: "NB preflight | Run nb_onedevice_update.py for {{ inventory_hostname }} (chatty)" 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 - debug: + ansible.builtin.debug: msg: - "rc={{ nb_preflight.rc }}" - "stdout_lines:" @@ -81,61 +81,94 @@ - "{{ (nb_preflight.stderr_lines | default([''])) }}" - "raw stdout (joined): {{ nb_preflight.stdout | default('') | trim }}" - - name: "NB preflight | Detect cloud failure" + # === HARD STOP ON CLOUD FAILURE (immediately after preflight) === + - name: "NB preflight | Detect cloud failure (rc==3 OR 'FAIL Cloud' line)" when: nb_script.stat.exists delegate_to: localhost vars: - _out: "{{ (nb_preflight.stdout | default('') | regex_replace('\r','')) }}" _lines: >- - {{ _out.split('\n') | map('trim') | list }} + {{ (nb_preflight.stdout | default('') | regex_replace('\r','')) + | 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: + ansible.builtin.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" + - 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" when: nb_script.stat.exists delegate_to: localhost - set_fact: + ansible.builtin.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 | Parse upgrade_cmd value" + - 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" 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 parsed upgrade_cmd" + - name: "NB preflight | Set and show parsed upgrade_cmd" when: nb_upgrade_line | length > 0 delegate_to: localhost set_fact: @@ -146,9 +179,11 @@ delegate_to: localhost debug: msg: - - "upgrade_cmd='{{ nb_upgrade_cmd }}' (len={{ nb_upgrade_cmd|length }})" + - "upgrade_cmd value: '{{ nb_upgrade_cmd }}'" + - "len={{ nb_upgrade_cmd | length }}" - - name: "NB preflight | Parse OK line" + # --- Parse the OK line robustly (token-based) --- + - name: "NB preflight | Parse OK line (token)" when: nb_script.stat.exists delegate_to: localhost vars: @@ -162,22 +197,23 @@ {{ 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 }}" - - name: "NB preflight | Detect IP change" + # --- Detect "cloud vs NetBox (before update) was different" (regex-free, robust) + - name: "NB preflight | Detect whether IP changed (pre-update)" when: nb_script.stat.exists delegate_to: localhost vars: @@ -194,27 +230,18 @@ - name: "NB preflight | Verdict" when: nb_script.stat.exists delegate_to: localhost - debug: + ansible.builtin.debug: msg: - - "Preflight verdict:" - - "Cloud fail: {{ cloud_bad|default(false) }}" - - "IP changed: {{ nb_ip_changed|default(false) }}" - - "Reason: {{ nb_change_reason|default('none') }}" + - "NB preflight verdict: {{ 'IP CHANGED (will requeue)' if nb_ip_changed else 'IP SAME (continue)' }}" + - "Reason: {{ nb_change_reason }}" - - 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" + # --- If IP changed → publish a 3s delayed 'sot-updater' and stop this host --- + - name: "NB preflight | Publish delayed requeue (3s) and stop" 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 + - nb_ip_changed | default(false) + - (nb_preflight.rc | default(1)) == 0 # <— extra safety + - (requeue_attempt | int) < 3 # <— NEW: limit to 3 tries delegate_to: localhost uri: url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/app/deviceconfig.delayed/publish" @@ -224,17 +251,20 @@ 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" - headers: { x-delay: 900000 } + # === NEW (surgical): 20s delay instead of 3s === + headers: { x-delay: 20000 } routing_key: "deviceconfig" + # === NEW (surgical): include attempt counter in payload === payload: "{{ { 'inscope_device': (ansible_hostname | default(inventory_hostname)), 'task_name': 'sot-updater', - 'attempt': next_attempt + 'attempt': ((requeue_attempt | int) + 1) } | to_json }}" payload_encoding: "string" register: rmq_requeue @@ -244,33 +274,31 @@ - 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 + - nb_ip_changed | default(false) + - (nb_preflight.rc | default(1)) == 0 # <— extra safety + - (requeue_attempt | int) < 3 # <— NEW: only if we actually published delegate_to: localhost - debug: + ansible.builtin.debug: msg: - - "Requeue published (attempt {{ (requeue_attempt | int) + 1 }})" - - "HTTP: {{ rmq_requeue.status|default('n/a') }}" - - "JSON: {{ rmq_requeue.json|default('') }}" + - "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('') }}" - - name: "NB preflight | Stop host after requeue" + - name: "NB preflight | Stop further tasks for this host" when: - nb_script.stat.exists - - ( - (nb_ip_changed | default(false)) or - ( - (cloud_bad | default(false)) and - (cloud_error_type | default('none')) != 'cloud_http_500' - ) - ) + - nb_ip_changed | default(false) + - (nb_preflight.rc | default(1)) == 0 # <— extra safety meta: end_host - - name: "NB preflight | Pause 1s if OK" + # --- If IP did not change → optional 1s pause, then continue normally --- + - name: "NB preflight | Pause 1s" when: - nb_script.stat.exists - - not ((cloud_bad | default(false)) or (nb_ip_changed | default(false))) + - not (nb_ip_changed | default(false)) delegate_to: localhost - pause: + ansible.builtin.pause: seconds: 1 tasks: @@ -278,15 +306,7 @@ 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 + - "Mode: report-only (soft-fail; publish journals on failures)" # ----------------------- Temp IP on DEV1 ----------------------- - name: Add temporary IP on DEV1 @@ -295,75 +315,85 @@ failed_when: false ignore_errors: true + # ---------------------------- Idempotent temp IP on DEV1 ---------------------------- - name: Add temporary IP on DEV1 (tolerate 'File exists') - raw: > + ansible.builtin.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 temp IP result - debug: + - name: Debug result of adding temp IP to DEV1 + ansible.builtin.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 }}" + - "add_ip.rc={{ add_ip.rc | default('') }}" + - "add_ip.stdout={{ (add_ip.stdout | default('')) | trim }}" + - "add_ip.stderr={{ (add_ip.stderr | default('')) | trim }}" - - name: Discover DEV2 MAC via bridge fdb - raw: > + # ---------------------------- Discover MAC via bridge FDB and add static ARP ---------------------------- + - name: Discover DEV2 MAC via bridge fdb on DEV1 (best-effort) + ansible.builtin.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: Capture discovered DEV2 MAC (if any) + ansible.builtin.set_fact: + dev2_mac: "{{ (dev2_mac_scan.stdout | default('') ) | trim }}" + changed_when: false - - name: Clear existing ARP entry - raw: > + - name: Clear existing ARP entry for DEV2 on DEV1 (best-effort) + ansible.builtin.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 if MAC found - when: dev2_mac|length > 0 - raw: > + - 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: > {{ 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 summary - debug: + - name: Debug ARP action summary on DEV1 + ansible.builtin.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 - when: dev2_mac|length == 0 - debug: - msg: "No MAC discovered; skipping static ARP add." + - 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: Refresh ARP + # ---------------------------- 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 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 + # ----------------------- Local tunnel prep ----------------------- + - name: Pick a free local TCP port for the tunnel delegate_to: localhost shell: | for i in $(seq 1 50); do @@ -374,25 +404,32 @@ register: pick_port failed_when: false - - name: Create control dir + - name: Record chosen port and create control dir delegate_to: localhost shell: "mktemp -d" register: mktemp_dir failed_when: false - - name: Set tunnel facts + - name: Set facts for tunnel paths 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" - - name: Start SSH tunnel + # ----------------------- 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 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 }}" \ @@ -413,21 +450,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 + - name: Dev1 | Normalize banner → X.X.X-rYYYY (POSIX tools) 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 @@ -437,1343 +474,15 @@ - 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 }}" - # ----------------------- Scan tracking: Dev1 fw_version ----------------------- - - name: Scan tracking | Dev1 fw_version outcome + - name: Debug Dev1 normalized firmware 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 + debug: + msg: "Dev1 fw_version {{ dev1_fw_clean | default('N/A') }}" - 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: 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 - - - 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 @@ -1788,8 +497,103 @@ routing_key: "{{ control_queue }}" payload: "{{ { 'inscope_device': (ansible_hostname | default(inventory_hostname)), - 'task_name': 'journal_add', - 'task_result': _msg + '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 + + # ----------------------- DEV2 auth ----------------------- + - name: Try DEV2 login + when: tunnel_ok | default(false) + 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 + exit 1 + 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') }}" + + # ----------------------- 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 + + # ----------------------- DEV2 firmux (simplified, literal) ----------------------- + - name: Dev2 | Read /usr/lib/release/firmux + when: tunnel_ok | default(false) and 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 \ + -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ + "cat /usr/lib/release/firmux 2>/dev/null || true" + register: dev2_firmux + failed_when: false + ignore_errors: true + + - name: Dev2 | Extract firmware version line (prefer 'rev', else first non-empty) + 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 + }} + + - 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 @@ -1799,34 +603,150 @@ debug: msg: "Closing tunnel and removing temporary IP" - - name: Refresh ARP (cleanup) - ignore_unreachable: true + - name: Refresh ARP #4 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 control dir + - name: Remove tunnel 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 (telemetry only)" + - "Status: DONE" diff --git a/files/ansible-playbooks/sot-updater.yml b/files/ansible-playbooks/sot-updater.yml index a8fd6a6..5c07d08 100644 --- a/files/ansible-playbooks/sot-updater.yml +++ b/files/ansible-playbooks/sot-updater.yml @@ -265,7 +265,6 @@ ) meta: end_host - - name: "NB preflight | Pause 1s if OK" when: - nb_script.stat.exists @@ -498,7 +497,6 @@ failed_when: false ignore_errors: true - - name: Dev1 | Normalize SSID profiles summary delegate_to: localhost set_fact: @@ -705,7 +703,6 @@ payload_encoding: "string" changed_when: false - # ----------------------- Dev1 wifidebug.sh check & publish ----------------------- - name: Dev1 | Check wifidebug.sh presence delegate_to: localhost @@ -1154,50 +1151,6 @@ | 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) @@ -1634,6 +1587,178 @@ 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 @@ -1675,6 +1800,7 @@ msg: "Closing tunnel and removing temporary IP" - 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 @@ -1693,6 +1819,7 @@ 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