# update-indoor.yml (conservative, minimal fixes) - name: Second-line indoor upgrade via DEV1 → tunnel → DEV2 (non-invasive control path) hosts: all gather_facts: no vars: # Busybox-safe PATH prefix for all remote raw calls on DEV1 pathprefix: "PATH=/sbin:/usr/sbin:/bin:/usr/bin:$PATH; " # DEV1 credentials (stable, like rebootin222) dev1_user: "root" dev1_pass: "wavewave" # Tunnel target DEV2 behind DEV1 dev2_host: "192.168.1.1" dev2_port: 22 # Temp IP we add to DEV1 so it can reach DEV2 dev2_side_ip: "192.168.1.11/24" dev1_iface: "br-wan" # DEV2 behind the tunnel dev2_ssh_user: "root" dev2_passfiles: - "basicpass" - "basicpass2" # SSH options used from controller ssh_opts_common: "-o PreferredAuthentications=password -o PubkeyAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o NumberOfPasswordPrompts=1 -o ConnectTimeout=30" # Image to stage on DEV2 (we validate first; the actual write happens later) image_filename: "fox200-2.2.1-r6801.bin" image_md5: "56b7211709de617e058b98d4204e2562" # Optional SHA256; leave empty to skip SHA256 checks image_sha256: "" dev2_image_dir: "/tmp" dev2_image_path: "{{ dev2_image_dir }}/{{ image_filename }}" # Reboot delay in HOURS (integer). Consumer always passes hours; 0 means immediate (~20s grace). rebootin: "" # ---------------- RabbitMQ journaling (mirrors rebootin222 style) ---------------- 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) }}" afterupgrade_routing_key: "{{ lookup('env','AFTERUP_ROUTING_KEY') | default('deviceconfig', true) }}" pre_tasks: # ------------------------------- Hostname sanity DEV1 ------------------------------- - name: Read DEV1 hostname (busybox-safe) ansible.builtin.raw: > {{ pathprefix }} (cat /proc/sys/kernel/hostname 2>/dev/null || echo "") register: dev1_host_read changed_when: false - name: Debug incoming parameters from consumer and defaults delegate_to: localhost ansible.builtin.debug: msg: - "rebootin={{ rebootin | default('UNSET') }}" - "image_filename={{ image_filename | default('UNSET') }}" - "image_md5={{ image_md5 | default('UNSET') }}" - "image_sha256={{ image_sha256 | default('UNSET') }}" - "afterupgrade_routing_key={{ afterupgrade_routing_key | default('UNSET') }}" - "inventory_hostname={{ inventory_hostname }}" # ====== NEW: pick up force-upgrade flag from CLI and TEMP override ====== - name: Read force-upgrade flag from CLI (supports -e force-upgrade=yes) ansible.builtin.set_fact: force_upgrade_raw: "{{ (vars['force-upgrade'] if ('force-upgrade' in vars) else (force_upgrade | default(''))) | string | trim }}" # >>> TEMPORARY OVERRIDE (REMOVE THIS TASK LATER) <<< - name: TEMPORARILY set force-upgrade to yes (REMOVE BEFORE COMMITTING) ansible.builtin.set_fact: force_upgrade_raw: "no" # <<< END TEMPORARY >>> - name: Normalize force-upgrade flag to boolean ansible.builtin.set_fact: force_upgrade: "{{ (force_upgrade_raw | string | trim) | bool }}" - name: Debug force-upgrade effective delegate_to: localhost ansible.builtin.debug: msg: - "force_upgrade_raw={{ force_upgrade_raw | default('') }}" - "force_upgrade={{ force_upgrade | default(false) }}" - name: Stop early if connected DEV1 hostname != inventory ansible.builtin.meta: end_host when: (dev1_host_read.stdout | trim | length > 0) and ((dev1_host_read.stdout | trim) != (inventory_hostname | string)) tasks: # ---------------------------- 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 on DEV1 ---------------------------- - 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 - 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) 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" # ---------------------------- Local tunnel preparation ---------------------------- - name: Pick a free local TCP port for the tunnel (controller side) delegate_to: localhost ansible.builtin.shell: | set -e pick() { for i in $(seq 1 25); do p="$(shuf -i 20000-39999 -n 1)" if command -v ss >/dev/null 2>&1; then if ! ss -ltn | awk '{print $4}' | grep -qE "(:|\.)${p}$"; then echo "$p"; return 0 fi else if ! nc -z 127.0.0.1 "$p" >/dev/null 2>&1; then echo "$p"; return 0 fi fi done return 1 } pick register: pick_port changed_when: false # (moved up) Stop immediately if no free local port was found - name: Stop if no free local port was found ansible.builtin.meta: end_host when: (pick_port.stdout | trim | length) == 0 # (moved up) Set chosen port and control socket path - name: Record chosen local port and create control dir for SSH ControlMaster delegate_to: localhost ansible.builtin.set_fact: _local_port: "{{ pick_port.stdout | trim }}" _ctrl_dir: "{{ lookup('ansible.builtin.pipe', 'mktemp -d') }}" - name: Build path for SSH ControlMaster socket delegate_to: localhost ansible.builtin.set_fact: _ctrl_sock: "{{ _ctrl_dir }}/ssh_tunnel_ctl" # (moved down) Now it’s safe to reference _local_port/_ctrl_sock - name: Debug picked local port (controller) delegate_to: localhost ansible.builtin.debug: msg: - "picked_local_port={{ _local_port }}" - "ctrl_sock={{ _ctrl_sock }}" - "dev1_host={{ ansible_host | default(inventory_hostname) }}" - "dev2_target={{ dev2_host }}:{{ dev2_port }}" - name: Show current listeners on picked port (ss/lsof) delegate_to: localhost ansible.builtin.shell: | set -e P="{{ _local_port }}" { ss -ltnp 2>/dev/null || true; } | awk -v p=":${P}$" '$0 ~ p' { lsof -nP -iTCP:"${P}" -sTCP:LISTEN 2>/dev/null || true; } register: port_listeners_before changed_when: false failed_when: false - name: Debug listeners on picked port (before starting tunnel) delegate_to: localhost ansible.builtin.debug: msg: - "listeners_before:\n{{ (port_listeners_before.stdout | default('')) | trim }}" - name: Refresh ARP 1 on DEV1’s LAN (send unsolicited ARP from temporary IP) ansible.builtin.raw: arping -U -I eth0 192.168.1.11 -c 3 # ---------------------------- Start SSH local forward via DEV1 ---------------------------- - name: Start SSH ControlMaster and forward 127.0.0.1:local_port → DEV2:22 via DEV1 delegate_to: localhost ansible.builtin.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 register: start_tunnel changed_when: true - name: Debug ControlMaster start result (rc/stdout/stderr) delegate_to: localhost ansible.builtin.debug: msg: - "start_tunnel.rc={{ start_tunnel.rc | default('NA') }}" - "start_tunnel.stdout={{ (start_tunnel.stdout | default('')) | trim }}" - "start_tunnel.stderr={{ (start_tunnel.stderr | default('')) | trim }}" - name: Show who is listening now on the local port (post-start) delegate_to: localhost ansible.builtin.shell: | set -e P="{{ _local_port }}" echo "== ss -ltnp on :${P} ==" { ss -ltnp 2>/dev/null || true; } | awk -v p=":${P}$" '$0 ~ p {print}' echo "== lsof LISTEN on :${P} ==" { lsof -nP -iTCP:"${P}" -sTCP:LISTEN 2>/dev/null || true; } echo "== ps/grep ControlMaster by ControlPath ==" ps -ef | grep -F " -S {{ _ctrl_sock }}" | grep -v grep || true register: port_listeners_after changed_when: false failed_when: false - name: Debug listeners on picked port (after starting tunnel) delegate_to: localhost ansible.builtin.debug: msg: - "{{ (port_listeners_after.stdout | default('')) | trim }}" - name: Wait a moment for tunnel to settle delegate_to: localhost ansible.builtin.wait_for: timeout: 1 changed_when: false - name: Verify ControlMaster is running (ssh -O check) delegate_to: localhost ansible.builtin.shell: | set -e HOST="{{ ansible_host | default(inventory_hostname) }}" ssh -S "{{ _ctrl_sock }}" -O check "{{ dev1_user }}@${HOST}" 2>&1 || true register: tun_check changed_when: false - name: Debug ControlMaster status delegate_to: localhost ansible.builtin.debug: msg: - "tunnel_check.rc={{ tun_check.rc }}" - "tunnel_check.out={{ (tun_check.stdout | default('')) | trim }}" - name: Debug ControlMaster check (full rc/stdout/stderr) delegate_to: localhost ansible.builtin.debug: msg: - "tun_check.rc={{ tun_check.rc | default('NA') }}" - "tun_check.stdout={{ (tun_check.stdout | default('')) | trim }}" - "tun_check.stderr={{ (tun_check.stderr | default('')) | trim }}" # ---------------------------- Controller-side sanity for DEV2 auth ---------------------------- - name: Probe TCP reachability to DEV2 through the tunnel (nc) delegate_to: localhost ansible.builtin.shell: | set -e nc -z -w5 127.0.0.1 "{{ _local_port }}" register: nc_probe changed_when: false ignore_errors: true - name: Debug tunnel reachability result delegate_to: localhost ansible.builtin.debug: msg: - "nc.rc={{ nc_probe.rc }}" - "nc.stdout={{ (nc_probe.stdout | default('')) | trim }}" - "nc.stderr={{ (nc_probe.stderr | default('')) | trim }}" - name: Stop if tunnel TCP probe failed ansible.builtin.meta: end_host when: nc_probe.rc != 0 - name: Show passfiles available on controller (ls) delegate_to: localhost ansible.builtin.shell: | set -e ls -l basicpass basicpass2 2>/dev/null || echo "no passfiles in CWD" register: dev2_ls changed_when: false - name: Debug passfiles presence delegate_to: localhost ansible.builtin.debug: msg: - "{{ (dev2_ls.stdout | default('')) | trim }}" - "{{ (dev2_ls.stderr | default('')) | trim }}" - name: Refresh ARP 2 on DEV1’s LAN (send unsolicited ARP from temporary IP) ansible.builtin.raw: arping -U -I eth0 192.168.1.11 -c 3 # ---------------------------- Single banner probe (kept) ---------------------------- - name: Probe SSH banner through tunnel (pre-auth, quick) delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" ssh -p "$PORT" \ -o PreferredAuthentications=none \ -o PubkeyAuthentication=no \ -o KbdInteractiveAuthentication=no \ -o PasswordAuthentication=no \ -o NumberOfPasswordPrompts=0 \ -o ConnectTimeout=5 \ -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -vvv root@127.0.0.1 true 2>&1 || true register: tunnel_banner_probe changed_when: false failed_when: false - name: Debug SSH preauth probe (first 40 lines) delegate_to: localhost ansible.builtin.debug: msg: "{{ (tunnel_banner_probe.stdout | default('') | split('\n'))[:40] | join('\n') }}" # ---------------------------- Pick DEV2 password for root ---------------------------- - name: Try DEV2 login with 'basicpass' (root) delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f basicpass ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=30 \ -p "$PORT" root@127.0.0.1 echo OK >/dev/null 2>&1 register: dev2_try_basicpass changed_when: false ignore_errors: true - name: Snapshot listeners on local tunnel port (after basicpass try) delegate_to: localhost ansible.builtin.shell: | set -e P="{{ _local_port }}" echo "== ss -ltnp on :${P} ==" { ss -ltnp 2>/dev/null || true; } | awk -v p=":${P}$" '$0 ~ p {print}' echo "== lsof LISTEN on :${P} ==" { lsof -nP -iTCP:"${P}" -sTCP:LISTEN 2>/dev/null || true; } echo "== ps/grep ControlMaster by ControlPath ==" ps -ef | grep -F " -S {{ _ctrl_sock }}" | grep -v grep || true register: listeners_after_basicpass changed_when: false failed_when: false - name: Debug auth try context (basicpass) delegate_to: localhost ansible.builtin.debug: msg: - "auth_try=basicpass rc={{ dev2_try_basicpass.rc | default('NA') }}" - "local_port={{ _local_port }}" - "ctrl_sock={{ _ctrl_sock }}" - "listeners:\n{{ (listeners_after_basicpass.stdout | default('')) | trim }}" - name: Select 'basicpass' if previous login succeeded when: dev2_try_basicpass.rc == 0 delegate_to: localhost ansible.builtin.set_fact: dev2_passfile_used: "basicpass" changed_when: false - name: Try DEV2 login with 'basicpass2' (only if first failed) when: dev2_passfile_used is not defined delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f basicpass2 ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=30 \ -p "$PORT" root@127.0.0.1 echo OK >/dev/null 2>&1 register: dev2_try_basicpass2 changed_when: false ignore_errors: true - name: Snapshot listeners on local tunnel port (after basicpass2 try) delegate_to: localhost ansible.builtin.shell: | set -e P="{{ _local_port }}" echo "== ss -ltnp on :${P} ==" { ss -ltnp 2>/dev/null || true; } | awk -v p=":${P}$" '$0 ~ p {print}' echo "== lsof LISTEN on :${P} ==" { lsof -nP -iTCP:"${P}" -sTCP:LISTEN 2>/dev/null || true; } echo "== ps/grep ControlMaster by ControlPath ==" ps -ef | grep -F " -S {{ _ctrl_sock }}" | grep -v grep || true register: listeners_after_basicpass2 changed_when: false failed_when: false - name: Debug auth try context (basicpass2) delegate_to: localhost ansible.builtin.debug: msg: - "auth_try=basicpass2 rc={{ dev2_try_basicpass2.rc | default('NA') }}" - "local_port={{ _local_port }}" - "ctrl_sock={{ _ctrl_sock }}" - "listeners:\n{{ (listeners_after_basicpass2.stdout | default('')) | trim }}" when: dev2_try_basicpass2 is defined - name: Select 'basicpass2' if second login succeeded when: dev2_passfile_used is not defined and dev2_try_basicpass2.rc == 0 delegate_to: localhost ansible.builtin.set_fact: dev2_passfile_used: "basicpass2" changed_when: false - name: Mark DEV2 auth as NONE if both attempts failed when: dev2_passfile_used is not defined delegate_to: localhost ansible.builtin.set_fact: dev2_passfile_used: "NONE" changed_when: false - name: Debug selected DEV2 passfile delegate_to: localhost ansible.builtin.debug: msg: - "dev2_passfile_used={{ dev2_passfile_used }}" - "try_basicpass.rc={{ (dev2_try_basicpass.rc | default('NA')) }}" - "try_basicpass2.rc={{ (dev2_try_basicpass2.rc | default('SKIPPED')) }}" - name: Read DEV2 hostname via tunnel (busybox-safe) when: dev2_passfile_used != "NONE" delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=30 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "cat /proc/sys/kernel/hostname 2>/dev/null || hostname || echo" args: executable: /bin/bash register: dev2_host_read changed_when: false - name: Normalize hostnames for strict compare (inventory/DEV1/DEV2) ansible.builtin.set_fact: _inv_hn: "{{ (inventory_hostname | string) | trim | regex_replace('\\r+$','') | lower }}" _dev1_hn: "{{ (dev1_host_read.stdout | default('')) | trim | regex_replace('\\r+$','') | lower }}" _dev2_hn: "{{ (dev2_host_read.stdout | default('')) | trim | regex_replace('\\r+$','') | lower }}" - name: Debug normalized hostnames (JSON-escaped) delegate_to: localhost ansible.builtin.debug: msg: - "inv={{ _inv_hn | tojson }}" - "dev1={{ _dev1_hn | tojson }}" - "dev2={{ _dev2_hn | tojson }}" # ---------------- Hostname equality guard (soft-journal and stop) ---------------- - name: Guard DEV2 hostname must equal inventory AND DEV1 (prevents IP churn mistakes) block: - name: Fail if DEV2 hostname differs from inventory/DEV1 ansible.builtin.fail: msg: > Hostname mismatch: DEV2='{{ _dev2_hn }}', inventory='{{ _inv_hn }}', DEV1='{{ _dev1_hn }}' when: (_dev2_hn != _inv_hn) or (_dev2_hn != _dev1_hn) rescue: - name: Initialize journal array for hostname mismatch ansible.builtin.set_fact: _journal: [] _blocked: true _prep_blocked: false delegate_to: localhost - name: Append hostname mismatch info to journal ansible.builtin.set_fact: _journal: "{{ _journal + [ 'Hostname mismatch: DEV2=' ~ _dev2_hn ~ ', inventory=' ~ _inv_hn ~ ', DEV1=' ~ _dev1_hn ] }}" delegate_to: localhost - name: Build control queue payload for indoor aborted journal (hostname check) ansible.builtin.set_fact: journal_indoor_aborted: inscope_device: "{{ ansible_hostname | default(inventory_hostname) }}" task_name: "journal_add" task_result: >- indoor: update aborted with following reason(s): {{ (_journal | default([])) | join('; ') }} delegate_to: localhost - name: Publish indoor aborted journal (hostname mismatch) 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: "{{ journal_indoor_aborted | to_json }}" payload_encoding: "string" register: rmq_journal_indoor_aborted_hn_resp changed_when: (rmq_journal_indoor_aborted_hn_resp.json is defined) and (rmq_journal_indoor_aborted_hn_resp.json.routed | default(false) | bool) failed_when: > (rmq_journal_indoor_aborted_hn_resp.status != 200) or (rmq_journal_indoor_aborted_hn_resp.json is not defined) or (not (rmq_journal_indoor_aborted_hn_resp.json.routed | default(false) | bool)) delegate_to: localhost - name: Stop host after hostname mismatch ansible.builtin.meta: end_host # ---------------------------- SOFT-FAIL JOURNAL INIT + PREP MARKER CHECK ---------------------------- - name: Init soft-fail journal flags ansible.builtin.set_fact: _journal: [] _prep_blocked: false _blocked: false - name: Build specific image marker path on DEV2 (/tmp/prepared_for_) ansible.builtin.set_fact: _marker_specific: "/tmp/prepared_for_{{ image_filename | regex_replace('\\.bin$','') }}" - name: Count existing preparation markers on DEV2 (best-effort) when: dev2_passfile_used != "NONE" delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=10 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "ls /tmp/prepared_for* 2>/dev/null | wc -l" args: executable: /bin/bash register: dev2_prep_count changed_when: false ignore_errors: true - name: Soft-block if preparation markers already present on DEV2 when: dev2_prep_count is defined and (dev2_prep_count.stdout is defined) and ((dev2_prep_count.stdout | trim | int) > 0) ansible.builtin.set_fact: _prep_blocked: true _blocked: true _journal: "{{ _journal + [ 'Preparation markers already present on DEV2 (count=' ~ (dev2_prep_count.stdout | trim) ~ '). Skipping staging/write' ] }}" - name: Refresh ARP 1 on DEV1’s LAN (send unsolicited ARP from temporary IP) ansible.builtin.raw: arping -U -I eth0 192.168.1.11 -c 3 # ---------------------------- DEV2 version firmux primary check ---------------------------- - name: Read DEV2 /usr/lib/release/firmux (if present) when: dev2_passfile_used != "NONE" delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=30 \ -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 ignore_errors: true - name: Debug DEV2 firmux banner (if any) when: dev2_firmux is defined delegate_to: localhost ansible.builtin.debug: msg: "DEV2 firmux={{ (dev2_firmux.stdout | default('')) | trim }}" # ====== INSERTED: two-step normalization preview (rev->r, then space->dash) ====== - name: "Normalize DEV2 firmux (step 1): replace 'rev ' -> 'r' (debug only)" when: dev2_firmux is defined delegate_to: localhost ansible.builtin.set_fact: _firmux_step1: "{{ (dev2_firmux.stdout | default('') | trim) | regex_replace('(?i)rev\\s+','r') }}" changed_when: false - name: "Debug firmux after step 1 (rev->r)" when: _firmux_step1 is defined delegate_to: localhost ansible.builtin.debug: msg: "_firmux_step1={{ _firmux_step1 }}" - name: "Normalize DEV2 firmux (step 2): replace space -> '-' (debug only)" when: _firmux_step1 is defined delegate_to: localhost ansible.builtin.set_fact: _firmux_step2: "{{ _firmux_step1 | replace(' ', '-') }}" changed_when: false - name: "Debug firmux after step 2 (space->dash)" when: _firmux_step2 is defined delegate_to: localhost ansible.builtin.debug: msg: "_firmux_step2={{ _firmux_step2 }}" # ====== END INSERTED ====== # ===================== Early equality/substring check (same logic as checker) ===================== # 1) expected_norm from image_filename (prefer extracted X.Y.Z-rNNNN) - name: Normalize expected target step one compute base string (from image_filename) delegate_to: localhost ansible.builtin.set_fact: expected_norm_step1: "{{ (image_filename | default('') | trim) }}" changed_when: false - name: Extract version core X dot Y dot Z dash rNNNN from image_filename if present delegate_to: localhost ansible.builtin.set_fact: expected_norm_core_list: "{{ (image_filename | default('') | regex_findall('[0-9]+\\.[0-9]+\\.[0-9]+-r[0-9]+')) | default([]) }}" changed_when: false - name: Choose first extracted core if available delegate_to: localhost ansible.builtin.set_fact: expected_norm_core: "{{ (expected_norm_core_list | default([]) | length > 0) | ternary((expected_norm_core_list | first), '') }}" changed_when: false - name: Normalize expected target step two prefer extracted core when available delegate_to: localhost ansible.builtin.set_fact: expected_norm: "{{ (expected_norm_core | default('') | length > 0) | ternary(expected_norm_core, expected_norm_step1) }}" changed_when: false # 2) banner_raw + banner_norm (convert "rev NNNN" → "-rNNNN" only if needed) - name: Capture firmware banner line from DEV2 (raw) delegate_to: localhost ansible.builtin.set_fact: banner_raw: "{{ (dev2_firmux.stdout | default('') | trim) }}" changed_when: false - name: Normalize banner line to X dot Y dot Z dash rNNNN suffix delegate_to: localhost ansible.builtin.set_fact: banner_norm: >- {{ ((banner_raw | lower) is search('-r[0-9]+$')) | ternary( banner_raw, (banner_raw | regex_replace('\s*[Rr][Ee][Vv]\.?\s*([0-9]+)\s*$', '-r\1')) ) }} changed_when: false # 3) evaluate (same equality OR substring, case-insensitive) - name: Evaluate version match using normalized equality or substring delegate_to: localhost ansible.builtin.set_fact: version_match: >- {{ (expected_norm | default('') | length > 0) and ( (banner_norm | default('')) == (expected_norm | default('')) or ((banner_norm | default('') | lower) is search((expected_norm | default('') | lower))) or ((expected_norm | default('') | lower) is search((banner_norm | default('') | lower))) ) }} changed_when: false - name: Debug version compare snapshot (pre-write) delegate_to: localhost ansible.builtin.debug: msg: - "expected_norm={{ expected_norm | default('') }}" - "banner_raw={{ banner_raw | default('') }}" - "banner_norm={{ banner_norm | default('') }}" - "force_upgrade={{ force_upgrade | default(false) }}" - "version_match={{ version_match | default(false) }}" # 4) soft-block when equal (unless forced) + back-fill legacy debug names - name: Soft-block if already on target (skip staging/write unless force-upgrade) when: - not (force_upgrade | default(false) | bool) - version_match | default(false) ansible.builtin.set_fact: _blocked: true _journal: "{{ (_journal | default([])) + [ 'Device already running target image: ' ~ (banner_norm | default('')) ~ ' — skipping write/flip (use force-upgrade=yes to override)' ] }}" expected_fw_core_early: "{{ expected_norm | default('') }}" current_fw_core: "{{ banner_norm | default('') }}" changed_when: false # If we are NOT blocked, still fill the legacy names so your later summary prints them - name: Back-fill compare names for summary (no-op if already set) when: not (_blocked | default(false)) ansible.builtin.set_fact: expected_fw_core_early: "{{ expected_fw_core_early | default(expected_norm | default('')) }}" current_fw_core: "{{ current_fw_core | default(banner_norm | default('')) }}" changed_when: false # ---------------------------- Normalize rebootin early (HOURS) ---------------------------- # Strict: rebootin is integer HOURS only. Always add +20s grace to the schedule. - name: Normalize rebootin (strict hours → base seconds/minutes) delegate_to: localhost ansible.builtin.set_fact: reboot_hours: "{{ (rebootin | default('') | string | trim | int) }}" reboot_seconds: "{{ (rebootin | default('') | string | trim | int) * 3600 }}" reboot_minutes: "{{ (rebootin | default('') | string | trim | int) * 60 }}" reboot_requested: true _reboot_requested: true - name: Compute reboot delay (+20s grace) and mirror underscore vars delegate_to: localhost ansible.builtin.set_fact: reboot_delay_seconds: "{{ (reboot_seconds | int) + 20 }}" reboot_delay_minutes: "{{ (((reboot_seconds | int) + 20) // 60) | int }}" _reboot_seconds: "{{ (reboot_seconds | int) + 20 }}" _reboot_minutes: "{{ (((reboot_seconds | int) + 20) // 60) | int }}" - name: Debug reboot normalization detail delegate_to: localhost ansible.builtin.debug: msg: - "rebootin={{ rebootin | default('UNSET') }}" - "reboot_hours={{ reboot_hours }}" - "reboot_seconds={{ reboot_seconds }}" - "reboot_minutes={{ reboot_minutes }}" - name: Warn if rebootin was not provided by consumer (debug only) when: (rebootin | default('') | string | trim) == '' delegate_to: localhost ansible.builtin.debug: msg: "WARNING: rebootin is empty or missing. Consumer likely did not pass -e rebootin=." - name: Debug reboot plan summary delegate_to: localhost ansible.builtin.debug: msg: - "reboot_requested={{ _reboot_requested | default(false) }}" - "reboot_seconds={{ _reboot_seconds | default(0) }}" - "reboot_minutes={{ _reboot_minutes | default(0) }}" - name: Build human-readable reboot phrase delegate_to: localhost ansible.builtin.set_fact: _reboot_phrase: >- {% if not (_reboot_requested | default(false)) -%} reboot not requested {%- elif (_reboot_seconds | int) <= 20 -%} rebooting now {%- elif (_reboot_seconds | int) >= 3600 -%} reboot scheduled in {{ ((_reboot_seconds | int) // 3600) | int }}h {%- else -%} reboot scheduled in {{ (_reboot_minutes | int) }}m {%- endif %} # ---------------------------- Journal: indoor start (we can proceed) ---------------------------- - name: Build control queue payload for 'indoor start' journal ansible.builtin.set_fact: journal_indoor_start: inscope_device: "{{ ansible_hostname | default(inventory_hostname) }}" task_name: "journal_add" task_result: >- Indoor: Dev2 is reachable, starting update. image={{ image_filename }}, current firmware={{ (dev2_firmux.stdout | default('unknown')) | trim }}, reboot planned in {{ ((_reboot_seconds | int) // 3600) | int }}h when: dev2_passfile_used != "NONE" delegate_to: localhost - name: Publish 'indoor start' journal to control queue 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: "{{ journal_indoor_start | to_json }}" payload_encoding: "string" register: rmq_journal_indoor_start_resp changed_when: (rmq_journal_indoor_start_resp.json is defined) and (rmq_journal_indoor_start_resp.json.routed | default(false) | bool) failed_when: > (rmq_journal_indoor_start_resp.status != 200) or (rmq_journal_indoor_start_resp.json is not defined) or (not (rmq_journal_indoor_start_resp.json.routed | default(false) | bool)) when: journal_indoor_start is defined delegate_to: localhost # ---------------------------- Stage image on DEV2 (no write yet) ---------------------------- - name: Check local presence of image file on controller delegate_to: localhost ansible.builtin.stat: path: "{{ image_filename }}" register: local_img - name: Soft-block if local image is missing when: not local_img.stat.exists ansible.builtin.set_fact: _blocked: true _journal: "{{ _journal + [ 'Local image missing on controller: ' ~ image_filename ] }}" - name: Compute local md5 of the image (controller) when: local_img.stat.exists delegate_to: localhost ansible.builtin.shell: | set -e md5sum "{{ image_filename }}" | awk '{print $1}' register: local_md5 changed_when: false - name: Verify local md5 matches expected when: local_img.stat.exists delegate_to: localhost ansible.builtin.assert: that: - (local_md5.stdout | trim) == image_md5 fail_msg: "Local md5 does not match expected got {{ local_md5.stdout | trim }} expected {{ image_md5 }}" success_msg: "Local md5 matches expected" # Optional SHA256 local - name: Compute local sha256 of the image (if provided) when: local_img.stat.exists and (image_sha256 | default('') | length) > 0 delegate_to: localhost ansible.builtin.shell: | set -e sha256sum "{{ image_filename }}" | awk '{print $1}' register: local_sha256 changed_when: false ignore_errors: true - name: Soft-block if local sha256 mismatch/unavailable when: local_img.stat.exists and (image_sha256 | default('') | length) > 0 and (local_sha256 is not defined or (local_sha256.stdout | trim) != (image_sha256 | trim)) ansible.builtin.set_fact: _prep_blocked: true _blocked: true _journal: "{{ _journal + [ 'Local sha256 mismatch/unavailable: have=' ~ ((local_sha256.stdout | default('NA')) | trim) ~ ' expected=' ~ (image_sha256 | trim) ] }}" - name: Refresh ARP 3 on DEV1’s LAN (send unsolicited ARP from temporary IP) ansible.builtin.raw: arping -U -I eth0 192.168.1.11 -c 3 # fw_printenv health before upload (soft-fail) - name: Read fw_printenv size (line count) on DEV2 (soft health) when: local_img.stat.exists and dev2_passfile_used != "NONE" and not (_blocked | default(false)) delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=10 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "(fw_printenv 2>/dev/null | wc -l) || echo 0" args: executable: /bin/bash register: dev2_fwenv_wc changed_when: false ignore_errors: true - name: Soft-block if fw_printenv too small (<30 lines) when: dev2_fwenv_wc is defined and (dev2_fwenv_wc.stdout is defined) and ((dev2_fwenv_wc.stdout | trim | int) < 30) ansible.builtin.set_fact: _prep_blocked: true _blocked: true _journal: "{{ _journal + [ 'fw_printenv too small on DEV2: ' ~ (dev2_fwenv_wc.stdout | trim) ~ ' lines (<30). Skipping image staging' ] }}" - name: Check existing DEV2 image md5 (NOFILE if missing) when: local_img.stat.exists and dev2_passfile_used != "NONE" and not (_blocked | default(false)) delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=30 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "[ -f '{{ dev2_image_path }}' ] && md5sum '{{ dev2_image_path }}' | awk '{print \$1}' || echo NOFILE" args: executable: /bin/bash register: dev2_md5_before changed_when: false - name: Copy image to DEV2 if missing or md5 mismatch when: local_img.stat.exists and dev2_passfile_used != "NONE" and not (_blocked | default(false)) and ((dev2_md5_before.stdout | trim) != image_md5) delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" scp \ -P "$PORT" \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ "{{ image_filename }}" "{{ dev2_ssh_user }}@127.0.0.1:{{ dev2_image_dir }}/" args: executable: /bin/bash register: scp_push changed_when: true - name: Compute md5 of image on DEV2 after copy (or if already present) when: local_img.stat.exists and dev2_passfile_used != "NONE" and not (_blocked | default(false)) delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=30 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "md5sum '{{ dev2_image_path }}' 2>/dev/null | awk '{print \$1}' || echo NOFILE" args: executable: /bin/bash register: dev2_md5_after changed_when: false - name: Soft-block if DEV2 md5 != expected when: local_img.stat.exists and dev2_passfile_used != "NONE" and not (_blocked | default(false)) and ((dev2_md5_after.stdout | trim) != image_md5) ansible.builtin.set_fact: _prep_blocked: true _blocked: true _journal: "{{ _journal + [ 'Remote md5 mismatch on DEV2: have=' ~ (dev2_md5_after.stdout | trim) ~ ' expected=' ~ image_md5 ~ '. Skipping further prep' ] }}" # Optional SHA256 remote - name: Compute sha256 of image on DEV2 (if provided) when: local_img.stat.exists and dev2_passfile_used != "NONE" and not (_blocked | default(false)) and (image_sha256 | default('') | length) > 0 delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=10 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "sha256sum '{{ dev2_image_path }}' 2>/dev/null | awk '{print \$1}' || echo NOSHA" args: executable: /bin/bash register: dev2_sha256_after changed_when: false ignore_errors: true - name: Soft-block if DEV2 sha256 mismatch/unavailable when: local_img.stat.exists and dev2_passfile_used != "NONE" and not (_blocked | default(false)) and (image_sha256 | default('') | length) > 0 and (dev2_sha256_after is not defined or (dev2_sha256_after.stdout | trim) != (image_sha256 | trim)) ansible.builtin.set_fact: _prep_blocked: true _blocked: true _journal: "{{ _journal + [ 'Remote sha256 mismatch/unavailable on DEV2: have=' ~ ((dev2_sha256_after.stdout | default('NA')) | trim) ~ ' expected=' ~ (image_sha256 | trim) ] }}" - name: Pre-validate image on DEV2 with 'update -c' (non-invasive) when: local_img.stat.exists and dev2_passfile_used != "NONE" and not (_blocked | default(false)) delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=30 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "update -c '{{ dev2_image_path }}' 2>&1 || true" args: executable: /bin/bash register: dev2_update_check changed_when: false - name: Debug output from 'update -c' when: dev2_update_check is defined delegate_to: localhost ansible.builtin.debug: msg: "{{ (dev2_update_check.stdout | default('')) | trim }}" - name: Soft-block if 'update -c' did not return 'valid' when: dev2_update_check is defined and not ((dev2_update_check.stdout | default('') | lower) is search('valid')) ansible.builtin.set_fact: _prep_blocked: true _blocked: true _journal: "{{ _journal + [ 'update -c did not return valid on DEV2; output=' ~ ((dev2_update_check.stdout | default('')) | trim) ] }}" # ---------------------------- Journal: indoor aborted (if any blockers) ---------------------------- - name: Build control queue payload for 'indoor aborted' journal ansible.builtin.set_fact: journal_indoor_aborted: inscope_device: "{{ ansible_hostname | default(inventory_hostname) }}" task_name: "journal_add" task_result: >- indoor: update aborted with following reason(s): {{ (_journal | default([])) | join('; ') }} when: (_blocked | default(false)) delegate_to: localhost - name: Publish 'indoor aborted' journal to control queue 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: "{{ journal_indoor_aborted | to_json }}" payload_encoding: "string" register: rmq_journal_indoor_aborted_resp changed_when: (rmq_journal_indoor_aborted_resp.json is defined) and (rmq_journal_indoor_aborted_resp.json.routed | default(false) | bool) failed_when: > (rmq_journal_indoor_aborted_resp.status != 200) or (rmq_journal_indoor_aborted_resp.json is not defined) or (not (rmq_journal_indoor_aborted_resp.json.routed | default(false) | bool)) when: journal_indoor_aborted is defined delegate_to: localhost - name: Refresh ARP 1 on DEV1’s LAN (send unsolicited ARP from temporary IP) ansible.builtin.raw: arping -U -I eth0 192.168.1.11 -c 3 # ============================ ACTUAL UPGRADE WRITE + BANK FLIP (only if not blocked) ============================ - name: Upgrade write and bank flip on DEV2 (guarded by soft-block) when: not (_blocked | default(false)) block: # --- supervised async write with stall handling --- - name: Launch update -w on DEV2 (async) delegate_to: localhost vars: _write_async_cap: 600 # 10 minutes max runtime (tune if needed) _write_poll: 20 # poll every 20 seconds block: - name: Launch update -w on DEV2 (async) ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=0 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "update -w '{{ dev2_image_path }}'" args: executable: /bin/bash async: "{{ _write_async_cap }}" poll: "{{ _write_poll }}" register: dev2_up_write changed_when: true failed_when: false - name: Evaluate async result ansible.builtin.set_fact: _updatew_stalled: >- {{ (dev2_up_write.msg is defined) and ('did not complete' in dev2_up_write.msg | lower) }} _updatew_success: >- {{ (dev2_up_write.stdout | default('')) is search('update is complete') }} - name: Journal stall if update -w timed out when: _updatew_stalled | bool ansible.builtin.set_fact: journal_updatew_stalled: inscope_device: "{{ ansible_hostname | default(inventory_hostname) }}" task_name: "journal_add" task_result: "update -w has stalled. Rebooting device; please schedule another upgrade." - name: Publish stall journal to control queue when: journal_updatew_stalled 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: "{{ journal_updatew_stalled | to_json }}" payload_encoding: "string" register: rmq_stall_journal_resp changed_when: (rmq_stall_journal_resp.json is defined) and (rmq_stall_journal_resp.json.routed | default(false) | bool) failed_when: false - name: Schedule immediate reboot (20s delay) after stall when: _updatew_stalled | bool delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=10 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "nohup /sbin/reboot -d 20 >/dev/null 2>&1 &" args: executable: /bin/bash changed_when: true ignore_errors: true - name: Stop play if update -w stalled when: _updatew_stalled | bool ansible.builtin.meta: end_play - name: Fail play if update -w completed but invalid output when: (not _updatew_stalled) and (not _updatew_success) ansible.builtin.fail: msg: "update -w finished but did not report 'update is complete'" # --- continue normal path if not stalled --- - name: Read current active partition on DEV2 (before flip) when: not _updatew_stalled | bool delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=10 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "fw_printenv active | awk -F= '/^active=/{print \$2}'" args: executable: /bin/bash register: dev2_active_before changed_when: false failed_when: (dev2_active_before.stdout | trim) not in ['1','2'] - name: Determine new active value for DEV2 (flip 1↔2) when: not _updatew_stalled | bool and dev2_active_before.stdout is defined ansible.builtin.set_fact: dev2_new_active: "{{ '1' if (dev2_active_before.stdout | trim) == '2' else '2' }}" - name: Set fw_setenv active={{ dev2_new_active }} on DEV2 when: not _updatew_stalled | bool and dev2_new_active is defined delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=10 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "fw_setenv active {{ dev2_new_active }}" args: executable: /bin/bash register: dev2_setenv_out changed_when: true - name: Verify active partition flipped on DEV2 (read back) when: not _updatew_stalled | bool and dev2_new_active is defined delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=10 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "fw_printenv active | awk -F= '/^active=/{print \$2}'" args: executable: /bin/bash register: dev2_active_after changed_when: false failed_when: (dev2_active_after.stdout | trim) != (dev2_new_active | string) - name: Create specific prep marker on DEV2 for this image when: not _updatew_stalled | bool delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=10 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "touch '{{ _marker_specific }}'" args: executable: /bin/bash register: dev2_marker_write changed_when: true ignore_errors: true # ---------------------------- Reboot scheduling (normalized) ---------------------------- - name: Schedule DEV2 reboot after computed delay (seconds) when: _reboot_requested and (_reboot_minutes | int) >= 0 and dev2_passfile_used != "NONE" and not (_blocked | default(false)) delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" SECS="{{ _reboot_seconds | int }}" CMD='/sbin/reboot -d '"${SECS}"' >/dev/null 2>&1 &' sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=10 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" "${CMD}" args: executable: /bin/bash register: dev2_reboot_sched changed_when: true ignore_errors: true - name: Build 'indoor-restart-scheduled' tag payload ansible.builtin.set_fact: tag_restart_sched_payload: inscope_device: "{{ ansible_hostname | default(inventory_hostname) }}" task_name: "tag_add" task_result: "indoor-restart-scheduled" when: _reboot_requested and (_reboot_minutes | int) >= 0 and dev2_passfile_used != "NONE" and not (_blocked | default(false)) delegate_to: localhost - name: Publish 'indoor-restart-scheduled' tag to control queue 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_restart_sched_payload | to_json }}" payload_encoding: "string" register: rmq_tag_restart_sched_resp changed_when: (rmq_tag_restart_sched_resp.json is defined) and (rmq_tag_restart_sched_resp.json.routed | default(false) | bool) failed_when: > (rmq_tag_restart_sched_resp.status != 200) or (rmq_tag_restart_sched_resp.json is not defined) or (not (rmq_tag_restart_sched_resp.json.routed | default(false) | bool)) when: tag_restart_sched_payload is defined delegate_to: localhost - name: Note reboot was requested but value is invalid (format warning) when: (rebootin | default('') | string | trim | length) > 0 and not _reboot_requested ansible.builtin.debug: msg: "Reboot requested but value '{{ rebootin | string | trim }}' is invalid; not applied" # ---------------------------- Journal: indoor updated and reboot schedule ---------------------------- - name: Compute write success flag (_write_success) ansible.builtin.set_fact: _write_success: "{{ (dev2_up_write.stdout | default('')) is search('update is complete') if (dev2_up_write is defined) else false }}" when: not (_blocked | default(false)) delegate_to: localhost - name: Debug write result and active banks (quick summary) when: not (_blocked | default(false)) delegate_to: localhost ansible.builtin.debug: msg: - "write_success={{ _write_success | default(false) }}" - "active_before={{ (dev2_active_before.stdout | default('NA')) | trim }}" - "active_after={{ (dev2_active_after.stdout | default('NA')) | trim }}" - name: Build 'indoor updated' journal payload text ansible.builtin.set_fact: journal_indoor_updated: inscope_device: "{{ ansible_hostname | default(inventory_hostname) }}" task_name: "journal_add" task_result: >- Indoor: Dev2 prepared for update, {{ _reboot_phrase }}. write_done={{ _write_success }}, active_before={{ (dev2_active_before.stdout | default('NA')) | trim }}, active_after={{ (dev2_active_after.stdout | default('NA')) | trim }} when: not (_blocked | default(false)) and (_write_success | bool) delegate_to: localhost - name: Publish 'indoor updated' journal to control queue 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: "{{ journal_indoor_updated | to_json }}" payload_encoding: "string" register: rmq_journal_indoor_updated_resp changed_when: (rmq_journal_indoor_updated_resp.json is defined) and (rmq_journal_indoor_updated_resp.json.routed | default(false) | bool) failed_when: > (rmq_journal_indoor_updated_resp.status != 200) or (rmq_journal_indoor_updated_resp.json is not defined) or (not (rmq_journal_indoor_updated_resp.json.routed | default(false) | bool)) when: journal_indoor_updated is defined delegate_to: localhost # ──────────────────────────────── Derive expected version for the checker (strict) ──────────────────────────────── - name: Init after-upgrade scheduling vars (attempt=1, delay=reboot + 6m) ansible.builtin.set_fact: au_attempt: 1 au_max_attempts: 3 au_delay_sec: "{{ (reboot_seconds | int) + 360 }}" when: not (_blocked | default(false)) and (_write_success | bool) delegate_to: localhost - name: Generate correlation ID and UTC timestamp (for after-upgrade tracking) ansible.builtin.set_fact: au_correlation_id: "{{ lookup('pipe', 'date +%s%N | sha1sum | cut -c1-12') }}" au_original_emitted_at: "{{ lookup('pipe', 'date -u +%FT%TZ') }}" when: au_delay_sec is defined delegate_to: localhost - name: Derive expected target_version from image filename (strict X.Y.Z-rNNNN) delegate_to: localhost ansible.builtin.set_fact: expected_fw_core: "{{ (image_filename | regex_search('[0-9]+\\.[0-9]+\\.[0-9]+-r[0-9]+')) | default('', true) }}" - name: Debug derived expected target_version for checker delegate_to: localhost ansible.builtin.debug: msg: - "image_filename={{ image_filename }}" - "expected_fw_core={{ expected_fw_core }}" when: expected_fw_core is defined - name: Build after-upgrade check payload (attempt 1) ansible.builtin.set_fact: afterupgrade_payload: task_name: "afterupgrade_indoor_check" inscope_device: "{{ ansible_hostname | default(inventory_hostname) }}" target_version_full: "{{ image_filename }}" target_version: "{{ expected_fw_core }}" attempt: "{{ au_attempt }}" max_attempts: "{{ au_max_attempts }}" current_delay_sec: "{{ au_delay_sec }}" correlation_id: "{{ au_correlation_id }}" original_emitted_at: "{{ au_original_emitted_at }}" schema_version: 1 when: au_delay_sec is defined delegate_to: localhost - name: Debug after-upgrade plan (routing + delay + version) ansible.builtin.debug: msg: - "routing_key={{ afterupgrade_routing_key }}" - "x-delay(ms)={{ (au_delay_sec | int) * 1000 }}" - "target_version={{ afterupgrade_payload.target_version | default('NA') }}" when: afterupgrade_payload is defined delegate_to: localhost - name: Debug after-upgrade payload JSON (exactly what will be sent) delegate_to: localhost ansible.builtin.debug: msg: "{{ afterupgrade_payload | to_json }}" when: afterupgrade_payload is defined - name: Publish delayed after-upgrade check (headers.x-delay) to holding exchange ansible.builtin.uri: url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost | urlencode }}/{{ 'deviceconfig.delayed' | 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" headers: x-delay: "{{ (au_delay_sec | int) * 1000 }}" routing_key: "{{ afterupgrade_routing_key }}" payload: "{{ afterupgrade_payload | to_json }}" payload_encoding: "string" register: rmq_afterupgrade_resp changed_when: (rmq_afterupgrade_resp.json is defined) and (rmq_afterupgrade_resp.json.routed | default(false) | bool) failed_when: > (rmq_afterupgrade_resp.status != 200) or (rmq_afterupgrade_resp.json is not defined) when: afterupgrade_payload is defined delegate_to: localhost # ---------------------------- Final operator summary (one-liners) ---------------------------- - name: Summary key outcomes (one-liners) delegate_to: localhost ansible.builtin.debug: msg: - "dev2_passfile_used={{ dev2_passfile_used }}" - "dev2_firmux={{ (dev2_firmux.stdout | default('')) | trim }}" - "expected_fw_core_early={{ expected_fw_core_early | default('') }}" - "current_fw_core={{ current_fw_core | default('') }}" - "force_upgrade={{ force_upgrade | default(false) }}" - "local_image_present={{ local_img.stat.exists | default(false) }}" - "local_md5={{ (local_md5.stdout | default('NA')) | trim }}" - "dev2_md5_before={{ (dev2_md5_before.stdout | default('NA')) | trim }}" - "dev2_md5_after={{ (dev2_md5_after.stdout | default('NA')) | trim }}" - "update_c_len={{ (dev2_update_check.stdout | default('') ) | length }}" - "write_done={{ (dev2_up_write.stdout | default('')) is search('update is complete') if (dev2_up_write is defined) else 'NA' }}" - "active_before={{ (dev2_active_before.stdout | default('NA')) | trim }}" - "active_after={{ (dev2_active_after.stdout | default('NA')) | trim }}" - "reboot_requested={{ reboot_requested | default(false) }}" - "reboot_delay_seconds={{ reboot_delay_seconds if (reboot_requested | default(false)) else 'NA' }}" - "reboot_delay_minutes={{ reboot_delay_minutes if (reboot_requested | default(false)) else 'NA' }}" - "reboot_applied={{ (dev2_reboot_sched is defined and dev2_reboot_sched.rc is defined and dev2_reboot_sched.rc == 0) | default(false) }}" - "prep_blocked={{ _prep_blocked | default(false) }}" - "blocked={{ _blocked | default(false) }}" - "journal={{ (_journal | default([])) | join(' || ') }}" post_tasks: - name: Cleanup (always) block: - ansible.builtin.debug: msg: "Entering cleanup block" changed_when: false delegate_to: localhost always: - name: Close SSH ControlMaster (best-effort) delegate_to: localhost ansible.builtin.shell: | ssh -S "{{ _ctrl_sock | default('/dev/null') }}" -O exit 2>/dev/null || true changed_when: false ignore_errors: true - name: Remove tunnel control dir (best-effort) delegate_to: localhost ansible.builtin.file: path: "{{ _ctrl_dir | default('/tmp/none') }}" state: absent ignore_errors: true - name: Remove temporary IP on DEV1 (tolerate 'Cannot assign requested address') ansible.builtin.raw: > {{ pathprefix }} ip a del {{ dev2_side_ip }} dev {{ dev1_iface }} register: del_ip changed_when: del_ip.rc == 0 failed_when: > del_ip.rc != 0 and ('Cannot assign requested address' not in (del_ip.stdout | default(''))) and ('Cannot assign requested address' not in (del_ip.stderr | default(''))) - name: Debug temp IP removal result ansible.builtin.debug: msg: - "del_ip.rc={{ del_ip.rc | default('') }}" - "del_ip.stdout={{ (del_ip.stdout | default('')) | trim }}" - "del_ip.stderr={{ (del_ip.stderr | default('')) | trim }}" when: del_ip is defined