first commit
This commit is contained in:
826
files/ansible-playbooks/multissidfix-changessid.yml
Normal file
826
files/ansible-playbooks/multissidfix-changessid.yml
Normal file
@@ -0,0 +1,826 @@
|
||||
---
|
||||
- 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
|
||||
|
||||
Reference in New Issue
Block a user