# 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) 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=30 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) }}" # === NEW (surgical): requeue attempt counter from environment, default 0 === 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 (path typo?)" 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 }} (chatty)" 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 ansible.builtin.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 }}" # === 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: _lines: >- {{ (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('')) }} _rc_is_3: "{{ (nb_preflight.rc | default(1)) | int == 3 }}" ansible.builtin.set_fact: cloud_fail_line: "{{ _cloud_fail_line }}" cloud_bad: "{{ _rc_is_3 or (( _cloud_fail_line | length ) > 0) }}" - 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 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*=') | 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" 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 and show 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 value: '{{ nb_upgrade_cmd }}'" - "len={{ nb_upgrade_cmd | length }}" # --- Parse the OK line robustly (token-based) --- - name: "NB preflight | Parse OK line (token)" 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 }}" # --- 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: 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 ansible.builtin.debug: msg: - "NB preflight verdict: {{ 'IP CHANGED (will requeue)' if nb_ip_changed else 'IP SAME (continue)' }}" - "Reason: {{ nb_change_reason }}" # --- 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) - (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" 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" # === 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': ((requeue_attempt | int) + 1) } | 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 - 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 ansible.builtin.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('') }}" - name: "NB preflight | Stop further tasks for this host" when: - nb_script.stat.exists - nb_ip_changed | default(false) - (nb_preflight.rc | default(1)) == 0 # <— extra safety meta: end_host # --- If IP did not change → optional 1s pause, then continue normally --- - name: "NB preflight | Pause 1s" when: - nb_script.stat.exists - not (nb_ip_changed | default(false)) delegate_to: localhost ansible.builtin.pause: seconds: 1 tasks: - name: "⚙️ Start | Dev1 banner → tunnel/auth → Dev2 firmux (telemetry mode)" debug: msg: - "Device: {{ inventory_hostname }}" - "Mode: report-only (soft-fail; publish journals on failures)" # ----------------------- 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 # ---------------------------- Idempotent temp IP on DEV1 ---------------------------- - name: Add temporary IP on DEV1 (tolerate 'File exists') 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(''))) - name: Debug result of adding temp IP to DEV1 ansible.builtin.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 }}" # ---------------------------- 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 DEV2 MAC (if any) ansible.builtin.set_fact: dev2_mac: "{{ (dev2_mac_scan.stdout | default('') ) | trim }}" changed_when: false - 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 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(''))) - 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 }}" - 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" # ---------------------------- 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 # ----------------------- Local tunnel prep ----------------------- - name: Pick a free local TCP port for the tunnel 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: Record chosen port and create control dir delegate_to: localhost shell: "mktemp -d" register: mktemp_dir failed_when: false - 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" # ----------------------- 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) }}" 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 → X.X.X-rYYYY (POSIX tools) 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 }}" - name: Debug Dev1 normalized firmware delegate_to: localhost 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 # ----------------------- 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 post_tasks: - name: "✅ Cleanup start" debug: msg: "Closing tunnel and removing temporary IP" - 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" failed_when: false ignore_errors: true - name: Remove tunnel 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 # --- 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"