Files
ansible-worker/files/ansible-playbooks/multissidfix-changessid.yml
ansible user 110301f862 first commit
2025-10-22 14:11:49 +02:00

827 lines
34 KiB
YAML

---
- name: "Multi-SSID fix (phase 1: persist + runtime apply)"
hosts: all
gather_facts: false
vars:
ssh_user: "{{ ansible_user | default('root') }}"
ssh_pass: "{{ ansible_password | default(ansible_ssh_pass) }}"
# RabbitMQ (same contract as wifidebug16.yml)
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','CONTROL_QUEUE') | default('queue_controls', true) }}"
# Reboot parachute delay (seconds)
parachute_delay_s: 600
# Local working paths (controller)
local_cfg: "/tmp/{{ inventory_hostname }}_config.json"
local_new: "/tmp/{{ inventory_hostname }}_config.json.new"
local_step: "/tmp/{{ inventory_hostname }}_config.step"
local_diff: "/tmp/{{ inventory_hostname }}_config.diff"
local_dd: "/tmp/{{ inventory_hostname }}_dd.json"
# External scripts/filters (next to wifidebug.sh in your repo)
dd_script_src: "files/wirelessduediligence.sh"
f_1vap0_src: "files/filter_1vap_id0.jq"
f_1vap1_src: "files/filter_1vap_id1.jq"
f_2_01_src: "files/filter_2vaps_0_1.jq"
f_2_10_src: "files/filter_2vaps_1_0.jq"
# Controller tmp destinations
dd_script: "/tmp/wirelessduediligence.sh"
f_1vap0: "/tmp/filter_1vap_id0.jq"
f_1vap1: "/tmp/filter_1vap_id1.jq"
f_2_01: "/tmp/filter_2vaps_0_1.jq"
f_2_10: "/tmp/filter_2vaps_1_0.jq"
# Remote (device)
remote_cfg: "/tmp/config.json"
remote_new: "/tmp/config.json.new"
remote_backup: "/tmp/config.json.multissidfix1.backup"
# Optional new SSID name (set via env NEW_SSID to trigger rename)
new_ssid_name: "{{ lookup('env','NEW_SSID') | default('ikeja R5 a day hotspot', true) }}"
tasks:
##########################################################################
# a) Connectivity + basic sanity
##########################################################################
- name: SSH reachability probe
raw: "echo ping"
register: ping_result
ignore_errors: true
- name: Stop if SSH unreachable (soft-fail)
when: ping_result is failed
block:
- name: Journal soft-fail (no SSH)
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': 'journal_add',
'task_result': 'multissidfix: soft-fail — SSH not reachable'
} | to_json }}"
payload_encoding: "string"
changed_when: false
- meta: end_host
- name: Ensure remote /tmp/config.json exists
raw: "test -s {{ remote_cfg }}"
register: remote_cfg_check
changed_when: false
- name: Stop if /tmp/config.json missing (soft-fail)
when: remote_cfg_check.rc != 0
block:
- name: Journal soft-fail (missing config.json)
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': 'journal_add',
'task_result': 'multissidfix: soft-fail — missing /tmp/config.json'
} | to_json }}"
payload_encoding: "string"
changed_when: false
- meta: end_host
- name: Check controller tools (jq/sshpass/diff/sha256sum)
delegate_to: localhost
shell: |
command -v jq >/dev/null 2>&1 \
&& command -v sshpass >/dev/null 2>&1 \
&& command -v diff >/dev/null 2>&1 \
&& (command -v sha256sum >/dev/null 2>&1 || command -v busybox >/dev/null 2>&1)
args: { executable: /bin/bash }
register: ctrl_tools
changed_when: false
- name: Stop if controller tools missing (soft-fail)
when: ctrl_tools.rc != 0
block:
- name: Journal soft-fail (missing tools)
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': 'journal_add',
'task_result': 'multissidfix: soft-fail — controller missing jq/sshpass/diff/sha256sum'
} | to_json }}"
payload_encoding: "string"
changed_when: false
- meta: end_host
##########################################################################
# b) Due diligence (external script, run on controller)
##########################################################################
- name: Copy due diligence script to controller tmp
delegate_to: localhost
copy:
src: "{{ dd_script_src }}"
dest: "{{ dd_script }}"
mode: "0755"
- name: Pull /tmp/config.json from device to controller
delegate_to: localhost
command: >
sshpass -p {{ ssh_pass | quote }}
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
{{ ssh_user }}@{{ ansible_host }}:{{ remote_cfg }}
{{ local_cfg }}
- name: Run due diligence on controller
delegate_to: localhost
shell: "sh {{ dd_script | quote }} {{ local_cfg | quote }} > {{ local_dd | quote }}"
args: { executable: /bin/sh }
changed_when: false
- name: Parse due diligence JSON
delegate_to: localhost
set_fact:
dd: "{{ lookup('file', local_dd) | from_json }}"
# Normalize types for robust `when:` checks
- name: Extract & normalize dd facts
delegate_to: localhost
set_fact:
w0_total_i: "{{ (dd.radios.wifi0.vaps_total | int) }}"
w1_total_i: "{{ (dd.radios.wifi1.vaps_total | int) }}"
w0_idx_i: "{{ (dd.radios.wifi0.indices | map('int') | list) }}"
w1_idx_i: "{{ (dd.radios.wifi1.indices | map('int') | list) }}"
##########################################################################
# c) Soft-fail: layout policy checks (one journal)
##########################################################################
- name: Init soft-fail reasons (policy)
delegate_to: localhost
set_fact:
softfail_reasons: []
- name: wifi0 — add reason if ONE_VAP but index not 0/1
delegate_to: localhost
when: (w0_total_i | int) == 1 and (0 not in w0_idx_i and 1 not in w0_idx_i)
set_fact:
softfail_reasons: "{{ softfail_reasons + [ 'wifi0 ONE_VAP but index not 0/1 (indices=' ~ (w0_idx_i | string) ~ ')' ] }}"
- name: wifi0 — add reason if TWO_VAPS but indices not (0,1)/(1,0)
delegate_to: localhost
when: (w0_total_i | int) == 2 and (w0_idx_i | sort not in [[0,1],[1,0]])
set_fact:
softfail_reasons: "{{ softfail_reasons + [ 'wifi0 TWO_VAPS but indices not (0,1)/(1,0) (indices=' ~ (w0_idx_i | string) ~ ')' ] }}"
- name: wifi0 — add reason if MORE_THAN_TWO_VAPS
delegate_to: localhost
when: (w0_total_i | int) > 2
set_fact:
softfail_reasons: "{{ softfail_reasons + [ 'wifi0 MORE_THAN_TWO_VAPS (indices=' ~ (w0_idx_i | string) ~ ')' ] }}"
- name: wifi1 — add reason if ONE_VAP but index not 0/1
delegate_to: localhost
when: (w1_total_i | int) == 1 and (0 not in w1_idx_i and 1 not in w1_idx_i)
set_fact:
softfail_reasons: "{{ softfail_reasons + [ 'wifi1 ONE_VAP but index not 0/1 (indices=' ~ (w1_idx_i | string) ~ ')' ] }}"
- name: wifi1 — add reason if TWO_VAPS but indices not (0,1)/(1,0)
delegate_to: localhost
when: (w1_total_i | int) == 2 and (w1_idx_i | sort not in [[0,1],[1,0]])
set_fact:
softfail_reasons: "{{ softfail_reasons + [ 'wifi1 TWO_VAPS but indices not (0,1)/(1,0) (indices=' ~ (w1_idx_i | string) ~ ')' ] }}"
- name: wifi1 — add reason if MORE_THAN_TWO_VAPS
delegate_to: localhost
when: (w1_total_i | int) > 2
set_fact:
softfail_reasons: "{{ softfail_reasons + [ 'wifi1 MORE_THAN_TWO_VAPS (indices=' ~ (w1_idx_i | string) ~ ')' ] }}"
- name: Publish soft-fail journal & stop host (policy)
when: (softfail_reasons | length) > 0
block:
- name: Journal soft-fail (policy)
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': 'journal_add',
'task_result': 'multissidfix: soft-fail (policy) — ' ~ (softfail_reasons | join('; '))
} | to_json }}"
payload_encoding: "string"
changed_when: false
- meta: end_host
##########################################################################
# d) Filters to controller tmp + candidate generation (per radio)
##########################################################################
- name: Copy jq filters to controller tmp
delegate_to: localhost
copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "0644"
loop:
- { src: "{{ f_1vap0_src }}", dest: "{{ f_1vap0 }}" }
- { src: "{{ f_1vap1_src }}", dest: "{{ f_1vap1 }}" }
- { src: "{{ f_2_01_src }}", dest: "{{ f_2_01 }}" }
- { src: "{{ f_2_10_src }}", dest: "{{ f_2_10 }}" }
- name: Start from current config as working file
delegate_to: localhost
copy:
src: "{{ local_cfg }}"
dest: "{{ local_step }}"
mode: "0644"
# wifi0 choice
- name: wifi0 — apply filter 1vap_id0
delegate_to: localhost
shell: "jq --arg r wifi0 -f {{ f_1vap0 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}"
args: { executable: /bin/bash }
when: (w0_total_i | int) == 1 and (0 in w0_idx_i)
changed_when: true
- name: wifi0 — apply filter 1vap_id1 (Policy B)
delegate_to: localhost
shell: "jq --arg r wifi0 -f {{ f_1vap1 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}"
args: { executable: /bin/bash }
when: (w0_total_i | int) == 1 and (1 in w0_idx_i)
changed_when: true
- name: wifi0 — apply filter 2vaps_0_1
delegate_to: localhost
shell: "jq --arg r wifi0 -f {{ f_2_01 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}"
args: { executable: /bin/bash }
when: (w0_total_i | int) == 2 and (w0_idx_i | sort == [0,1])
changed_when: true
- name: wifi0 — apply filter 2vaps_1_0 (actual order 1,0)
delegate_to: localhost
shell: "jq --arg r wifi0 -f {{ f_2_10 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}"
args: { executable: /bin/bash }
when: (w0_total_i | int) == 2 and (w0_idx_i == [1,0])
changed_when: true
# wifi1 choice
- name: wifi1 — apply filter 1vap_id0
delegate_to: localhost
shell: "jq --arg r wifi1 -f {{ f_1vap0 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}"
args: { executable: /bin/bash }
when: (w1_total_i | int) == 1 and (0 in w1_idx_i)
changed_when: true
- name: wifi1 — apply filter 1vap_id1 (Policy B)
delegate_to: localhost
shell: "jq --arg r wifi1 -f {{ f_1vap1 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}"
args: { executable: /bin/bash }
when: (w1_total_i | int) == 1 and (1 in w1_idx_i)
changed_when: true
- name: wifi1 — apply filter 2vaps_0_1
delegate_to: localhost
shell: "jq --arg r wifi1 -f {{ f_2_01 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}"
args: { executable: /bin/bash }
when: (w1_total_i | int) == 2 and (w1_idx_i | sort == [0,1])
changed_when: true
- name: wifi1 — apply filter 2vaps_1_0 (actual order 1,0)
delegate_to: localhost
shell: "jq --arg r wifi1 -f {{ f_2_10 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}"
args: { executable: /bin/bash }
when: (w1_total_i | int) == 2 and (w1_idx_i == [1,0])
changed_when: true
# Optional SSID rename (only if new_ssid_name is defined)
- name: Set SSID for the single enabled AP VAP per radio (wifi0 & wifi1)
delegate_to: localhost
shell: |
jq --arg SSID "{{ new_ssid_name }}" '
.wireless.radios |=
with_entries(
.value.vaps =
((.value.vaps // []) | map(
if (.mode=="ap" and (.enabled==true)) then
(.ssid = $SSID)
else .
end
))
)
' {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}
args: { executable: /bin/bash }
when: new_ssid_name is defined
changed_when: true
- name: Debug normalized types
delegate_to: localhost
debug:
msg:
- "w0_total_i(type)={{ w0_total_i | type_debug }} value={{ w0_total_i }}"
- "w1_total_i(type)={{ w1_total_i | type_debug }} value={{ w1_total_i }}"
- name: Move working file to final candidate
delegate_to: localhost
copy:
src: "{{ local_step }}"
dest: "{{ local_new }}"
mode: "0644"
##########################################################################
# e) Candidate checks — accumulate reasons; soft-fail once if any
##########################################################################
- name: Init soft-fail reasons (candidate)
delegate_to: localhost
set_fact:
cand_reasons: []
- name: Check JSON validity
delegate_to: localhost
shell: "jq -e '.' {{ local_new | quote }} >/dev/null"
args: { executable: /bin/bash }
register: json_valid
changed_when: false
failed_when: false
- name: Add reason if JSON invalid
delegate_to: localhost
when: json_valid.rc != 0
set_fact:
cand_reasons: "{{ cand_reasons + [ 'invalid JSON syntax in candidate' ] }}"
- name: Semantic — wifi0 exactly one enabled AP VAP
delegate_to: localhost
shell: |
jq -e '(.wireless.radios.wifi0.vaps // [])
| map(select(.mode=="ap" and (.enabled==true))) | length == 1' {{ local_new | quote }} >/dev/null
args: { executable: /bin/bash }
register: sem_w0
changed_when: false
failed_when: false
- name: Add reason if wifi0 semantic fails
delegate_to: localhost
when: sem_w0.rc != 0
set_fact:
cand_reasons: "{{ cand_reasons + [ 'wifi0 must have exactly one enabled AP VAP' ] }}"
- name: Semantic — wifi1 exactly one enabled AP VAP
delegate_to: localhost
shell: |
jq -e '(.wireless.radios.wifi1.vaps // [])
| map(select(.mode=="ap" and (.enabled==true))) | length == 1' {{ local_new | quote }} >/dev/null
args: { executable: /bin/bash }
register: sem_w1
changed_when: false
failed_when: false
- name: Add reason if wifi1 semantic fails
delegate_to: localhost
when: sem_w1.rc != 0
set_fact:
cand_reasons: "{{ cand_reasons + [ 'wifi1 must have exactly one enabled AP VAP' ] }}"
- name: Semantic — wifi0 enabled AP VAP has lbd=true
delegate_to: localhost
shell: |
jq -e '(.wireless.radios.wifi0.vaps // [])
| map(select(.mode=="ap" and (.enabled==true) and (.lbd==true))) | length == 1' {{ local_new | quote }} >/dev/null
args: { executable: /bin/bash }
register: sem_w0_lbd
changed_when: false
failed_when: false
- name: Add reason if wifi0 lbd check fails
delegate_to: localhost
when: sem_w0_lbd.rc != 0
set_fact:
cand_reasons: "{{ cand_reasons + [ 'wifi0 enabled AP VAP must have lbd=true' ] }}"
- name: Semantic — wifi1 enabled AP VAP has lbd=true
delegate_to: localhost
shell: |
jq -e '(.wireless.radios.wifi1.vaps // [])
| map(select(.mode=="ap" and (.enabled==true) and (.lbd==true))) | length == 1' {{ local_new | quote }} >/dev/null
args: { executable: /bin/bash }
register: sem_w1_lbd
changed_when: false
failed_when: false
- name: Add reason if wifi1 lbd check fails
delegate_to: localhost
when: sem_w1_lbd.rc != 0
set_fact:
cand_reasons: "{{ cand_reasons + [ 'wifi1 enabled AP VAP must have lbd=true' ] }}"
- name: Get original file size
delegate_to: localhost
stat:
path: "{{ local_cfg }}"
register: stat_old
changed_when: false
- name: Get new file size
delegate_to: localhost
stat:
path: "{{ local_new }}"
register: stat_new
changed_when: false
- name: Add reason if size delta > 2%
delegate_to: localhost
when: stat_old.stat.size | int == 0 or
( ((stat_new.stat.size | int) - (stat_old.stat.size | int)) | abs ) > ( (stat_old.stat.size | int) * 0.02 )
set_fact:
cand_reasons: "{{ cand_reasons + [ 'candidate size delta >2% (old=' ~ (stat_old.stat.size | string) ~ ', new=' ~ (stat_new.stat.size | string) ~ ')' ] }}"
- name: Build unified diff (first 200 lines)
delegate_to: localhost
shell: "diff -u {{ local_cfg | quote }} {{ local_new | quote }} | sed -n '1,200p' > {{ local_diff | quote }} || true"
args: { executable: /bin/bash }
changed_when: false
- name: Show unified diff (first 200 lines)
delegate_to: localhost
debug:
msg: "{{ lookup('file', local_diff) | default('(no diff output)') }}"
- name: sha256 (controller) of candidate
delegate_to: localhost
command: sha256sum {{ local_new }}
register: sha_local
changed_when: false
- name: Copy candidate to device temp
delegate_to: localhost
command: >
sshpass -p {{ ssh_pass | quote }}
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
{{ local_new }}
{{ ssh_user }}@{{ ansible_host }}:{{ remote_new }}
register: scp_new
changed_when: true
- name: sha256 (remote) of candidate
raw: "sha256sum {{ remote_new }} || busybox sha256sum {{ remote_new }}"
register: sha_remote
changed_when: false
- name: Add reason if sha256 mismatch
delegate_to: localhost
when: (sha_local.stdout.split()[0]) != (sha_remote.stdout.split()[0])
set_fact:
cand_reasons: "{{ cand_reasons + [ 'sha256 mismatch controller vs remote' ] }}"
- name: Detect SSIDs with NBSP (informational)
delegate_to: localhost
shell: |
nbsp="$(printf '\302\240')"
jq -r '..|objects|.ssid? // empty' {{ local_new | quote }} | grep -F "$nbsp" || true
args: { executable: /bin/bash }
register: nbsp_lines
changed_when: false
- name: Save NBSP report lines
set_fact:
ssid_nbsp_lines: "{{ nbsp_lines.stdout_lines | default([]) }}"
- name: Publish soft-fail journal & stop host (candidate issues)
when: (cand_reasons | length) > 0
block:
- name: Journal soft-fail (candidate)
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': 'journal_add',
'task_result': 'multissidfix: soft-fail (candidate) — ' ~ (cand_reasons | join('; '))
} | to_json }}"
payload_encoding: "string"
changed_when: false
- meta: end_host
##########################################################################
# f) Backup, parachute, promote, persist, apply, outcome
##########################################################################
- name: Backup current config on device
raw: "cp -a {{ remote_cfg }} {{ remote_backup }}"
changed_when: true
# --- PRE-APPLY HEADS-UP ---
- name: Journal — candidate validated, will promote/persist/apply shortly
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': 'journal_add',
'task_result': (
'multissidfix: candidate passed checks; proceeding to backup + parachute + promote + sysconf -w + system-stop/start. '
~ 'size(old/new)=' ~ stat_old.stat.size|string ~ '/' ~ stat_new.stat.size|string
)
} | to_json }}"
payload_encoding: "string"
changed_when: false
- name: Arm reboot parachute (BusyBox reboot -d {{ parachute_delay_s }})
raw: "sh -c 'trap \"\" HUP; reboot -d {{ parachute_delay_s }} </dev/null >/dev/null 2>&1 &'"
changed_when: true
ignore_errors: true
- name: Promote candidate to active config
raw: "mv {{ remote_new }} {{ remote_cfg }} && chown root:root {{ remote_cfg }} && chmod 0644 {{ remote_cfg }}"
changed_when: true
# IMPORTANT: persist BEFORE runtime restart
- name: Persist config to flash (sysconf -w)
raw: "sysconf -w"
register: sysconf_write
changed_when: true
failed_when: false
# --- PRE-RESTART COMMIT ---
- name: Pre-restart journal (promoted & persisted; about to restart from controller)
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': 'journal_add',
'task_result': (
'multissidfix: promoted candidate, persisted (sysconf -w rc=' ~ (sysconf_write.rc | default('n/a')) | string ~ '), '
~ 'system-stop/start launching from controller with 10s cap; '
~ 'parachute -d ' ~ parachute_delay_s|string ~ 's armed. '
~ 'size(old/new)=' ~ stat_old.stat.size|string ~ '/' ~ stat_new.stat.size|string
~ ', nbsp_ssids=' ~ (ssid_nbsp_lines|length)|string
)
} | to_json }}"
payload_encoding: "string"
changed_when: false
- name: Launch restart from controller with 10s cap (SSH command)
delegate_to: localhost
shell: "timeout 10s sshpass -p {{ ssh_pass | quote }} ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ServerAliveInterval=2 -o ServerAliveCountMax=1 {{ ssh_user }}@{{ ansible_host }} '/usr/sbin/system-stop ; sleep 1 ; /usr/sbin/system-start'"
args:
executable: /bin/bash
register: restart_kick
changed_when: true
failed_when: false
- name: Note restart_kick result (debug)
delegate_to: localhost
debug:
msg:
- "restart_kick.rc={{ restart_kick.rc }}"
- "stdout(last 5 lines): {{ (restart_kick.stdout_lines | default([]))[-5:] | default([]) }}"
- "stderr(last 5 lines): {{ (restart_kick.stderr_lines | default([]))[-5:] | default([]) }}"
# probe SSH with nc: up to 24 tries, 5s each (nc timeout -w 3)
- name: Probe SSH with nc (24 tries, 5s each)
delegate_to: localhost
shell: "nc -z -w 3 {{ ansible_host }} 22"
register: nc_probe
retries: 24
delay: 5
until: nc_probe.rc == 0
changed_when: false
failed_when: false
- name: Set ssh_up fact
delegate_to: localhost
set_fact:
ssh_up: "{{ (nc_probe.rc | default(1)) == 0 }}"
# --- SUCCESS PATH ---
- name: Post-return actions (only if SSH came back)
when: ssh_up | bool
block:
- name: Disarm delayed reboot if present
raw: "pgrep -x reboot && kill -9 $(pgrep -x reboot) || true"
changed_when: true
ignore_errors: true
register: disarm_reboot
# Re-run simple semantic checks on the active config
- name: Device semantic — wifi0 exactly one enabled AP VAP
raw: |
jq -e '(.wireless.radios.wifi0.vaps // [])
| map(select(.mode=="ap" and (.enabled==true))) | length == 1' {{ remote_cfg }} >/dev/null
register: dev_sem_w0
changed_when: false
failed_when: false
- name: Device semantic — wifi1 exactly one enabled AP VAP
raw: |
jq -e '(.wireless.radios.wifi1.vaps // [])
| map(select(.mode=="ap" and (.enabled==true))) | length == 1' {{ remote_cfg }} >/dev/null
register: dev_sem_w1
changed_when: false
failed_when: false
- name: Device semantic — wifi0 enabled AP VAP has lbd=true
raw: |
jq -e '(.wireless.radios.wifi0.vaps // [])
| map(select(.mode=="ap" and (.enabled==true) and (.lbd==true))) | length == 1' {{ remote_cfg }} >/dev/null
register: dev_sem_w0_lbd
changed_when: false
failed_when: false
- name: Device semantic — wifi1 enabled AP VAP has lbd=true
raw: |
jq -e '(.wireless.radios.wifi1.vaps // [])
| map(select(.mode=="ap" and (.enabled==true) and (.lbd==true))) | length == 1' {{ remote_cfg }} >/dev/null
register: dev_sem_w1_lbd
changed_when: false
failed_when: false
- name: sha256 (remote) of current active config (post-return)
raw: "sha256sum {{ remote_cfg }} || busybox sha256sum {{ remote_cfg }}"
register: sha_remote_after
changed_when: false
failed_when: false
- name: Decide post-return checks summary (controller side)
delegate_to: localhost
set_fact:
multissid_post_summary:
reboot_disarmed: "{{ (disarm_reboot is defined) and (disarm_reboot.rc is defined) and (disarm_reboot.rc in [0]) }}"
persisted_ok: "{{ (sysconf_write.rc | default(0)) == 0 }}"
dev_sem_ok:
w0_one: "{{ (dev_sem_w0.rc | default(1)) == 0 }}"
w1_one: "{{ (dev_sem_w1.rc | default(1)) == 0 }}"
w0_lbd: "{{ (dev_sem_w0_lbd.rc | default(1)) == 0 }}"
w1_lbd: "{{ (dev_sem_w1_lbd.rc | default(1)) == 0 }}"
cfg_hash_match: "{{ (sha_remote_after.stdout.split()[0] | default('')) == (sha_remote.stdout.split()[0] | default('')) }}"
old_size: "{{ stat_old.stat.size | default('n/a') }}"
new_size: "{{ stat_new.stat.size | default('n/a') }}"
nbsp_count: "{{ (ssid_nbsp_lines | default([])) | length }}"
- name: Publish final success journal (device returned; persisted; checks pass)
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': 'journal_add',
'task_result':
(
'multissidfix: success — device is back; '
~ (multissid_post_summary.reboot_disarmed | ternary('parachute disarmed; ', 'parachute was not armed; '))
~ (multissid_post_summary.persisted_ok | ternary('persisted (sysconf -w); ', 'persist failed; '))
~ 'semantics: '
~ 'w0_one=' ~ (multissid_post_summary.dev_sem_ok.w0_one | string) ~ ', '
~ 'w1_one=' ~ (multissid_post_summary.dev_sem_ok.w1_one | string) ~ ', '
~ 'w0_lbd=' ~ (multissid_post_summary.dev_sem_ok.w0_lbd | string) ~ ', '
~ 'w1_lbd=' ~ (multissid_post_summary.dev_sem_ok.w1_lbd | string) ~ '; '
~ 'cfg_match=' ~ (multissid_post_summary.cfg_hash_match | string) ~ '; '
~ 'size(old/new)=' ~ (multissid_post_summary.old_size | string) ~ '/' ~ (multissid_post_summary.new_size | string) ~ '; '
~ 'nbsp_ssids=' ~ (multissid_post_summary.nbsp_count | string)
)
} | to_json }}"
payload_encoding: "string"
changed_when: false
- name: Set NetBox custom field multissidfix=v1.1
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': 'v1.1'
} | to_json }}"
payload_encoding: "string"
changed_when: false
# --- FAILURE PATH (device did not return) ---
- name: Journal — restart/SSH probe failed
when: not ssh_up
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': 'journal_add',
'task_result': (
'multissidfix: restart issued; SSH did not return after 24 x 5s checks — leaving parachute active.'
)
} | to_json }}"
payload_encoding: "string"
changed_when: false
- name: Stop host after failed SSH probe
when: not ssh_up
meta: end_host