diff --git a/files/ansible-playbooks/sot-updater-iponly.yml b/files/ansible-playbooks/sot-updater-iponly.yml new file mode 100644 index 0000000..d0cf1e1 --- /dev/null +++ b/files/ansible-playbooks/sot-updater-iponly.yml @@ -0,0 +1,278 @@ +# sot-updater-iponly.yml — NetBox IP sync ONLY via nb_onedevice_update.py (no device scan) + +- name: NetBox IP sync only (Cloud → NetBox via nb_onedevice_update.py); do not scan devices + hosts: all + gather_facts: no + + vars: + pathprefix: "PATH=/sbin:/usr/sbin:/bin:/usr/bin:$PATH; " + dev1_user: "root" + dev1_pass: "wavewave" + ssh_timeout: 30 + + dev2_host: "192.168.1.1" + dev2_port: 22 + dev2_ssh_user: "root" + dev2_passfiles: [ "basicpass", "basicpass2" ] + + dev2_side_ip: "192.168.1.11/24" + dev1_iface: "br-wan" + arping_iface: "eth0" + dev2_side_ip_addr: "{{ dev2_side_ip.split('/')[0] }}" + + ssh_opts_common: >- + -o PreferredAuthentications=password + -o PubkeyAuthentication=no + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o NumberOfPasswordPrompts=1 + -o ConnectTimeout=15 + + 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) }}" + + # --- Retry attempt tracking (default 0 if missing) --- + requeue_attempt: "{{ (lookup('env','REQUEUE_ATTEMPT') | default('0', true)) | int }}" + + pre_tasks: + - name: "NB preflight | Verify script exists" + delegate_to: localhost + ansible.builtin.stat: + path: "/opt/containers/ansible-worker/app/nb_onedevice_update.py" + register: nb_script + + - name: "NB preflight | Abort softly if script missing" + when: not nb_script.stat.exists + delegate_to: localhost + ansible.builtin.debug: + msg: + - "NB preflight skipped: /opt/containers/ansible-worker/app/nb_onedevice_update.py not found." + - "Tip: adjust chdir/path or script name." + + - name: "NB preflight | Run nb_onedevice_update.py for {{ inventory_hostname }}" + when: nb_script.stat.exists + delegate_to: localhost + environment: + PYTHONUNBUFFERED: "1" + args: + chdir: "/opt/containers/ansible-worker/app" + executable: /bin/bash + shell: | + set -o pipefail + python3 -u nb_onedevice_update.py "{{ inventory_hostname }}" --chatty 2>&1 + register: nb_preflight + changed_when: false + failed_when: false + + - name: "NB preflight | Show results" + when: nb_script.stat.exists + delegate_to: localhost + debug: + msg: + - "rc={{ nb_preflight.rc }}" + - "stdout_lines:" + - "{{ (nb_preflight.stdout_lines | default([''])) }}" + - "stderr_lines:" + - "{{ (nb_preflight.stderr_lines | default([''])) }}" + - "raw stdout (joined): {{ nb_preflight.stdout | default('') | trim }}" + + - name: "NB preflight | Detect cloud failure" + when: nb_script.stat.exists + delegate_to: localhost + vars: + _out: "{{ (nb_preflight.stdout | default('') | regex_replace('\r','')) }}" + _lines: >- + {{ _out.split('\n') | map('trim') | list }} + _cloud_fail_line: >- + {{ (_lines | select('match', '^FAIL\\s+Cloud\\b') | list | last | default('')) }} + _error_line: >- + {{ (_lines | select('match', '^FAIL\\s+') | list | last | default('')) }} + _rc_is_3: "{{ (nb_preflight.rc | default(1)) | int == 3 }}" + _cloud_error_type: >- + {%- if _error_line is search('network error') + and _error_line is search('500 error responses') -%} + cloud_http_500 + {%- elif (_cloud_fail_line | length) > 0 -%} + cloud_logic + {%- elif _rc_is_3 -%} + cloud_other + {%- else -%} + none + {%- endif -%} + _cloud_retryable: >- + {{ _cloud_error_type not in ['cloud_http_500'] }} + set_fact: + cloud_fail_line: "{{ _cloud_fail_line }}" + cloud_bad: "{{ _rc_is_3 or (( _cloud_fail_line | length ) > 0) }}" + cloud_error_type: "{{ _cloud_error_type }}" + cloud_retryable: "{{ _cloud_retryable }}" + + # --- Extract upgrade_cmd line if any --- + - name: "NB preflight | Extract upgrade_cmd line" + when: nb_script.stat.exists + delegate_to: localhost + set_fact: + nb_upgrade_line: >- + {{ + ( + nb_preflight.stdout_lines | default([]) | map('regex_replace','\r','') | map('trim') + | select('match', '^NB:\\s*upgrade_cmd\\s*=') + | list | first + ) | default('') + }} + + - name: "NB preflight | Parse upgrade_cmd value" + when: nb_upgrade_line | length > 0 + delegate_to: localhost + shell: | + printf '%s\n' "{{ nb_upgrade_line }}" | awk -F'=' '{print $2}' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' + args: { executable: /bin/bash } + register: up_cmd_sh + changed_when: false + + - name: "NB preflight | Set parsed upgrade_cmd" + when: nb_upgrade_line | length > 0 + delegate_to: localhost + set_fact: + nb_upgrade_cmd: "{{ up_cmd_sh.stdout | default('') | trim }}" + + - name: "NB preflight | Debug parsed upgrade_cmd" + when: nb_upgrade_line | length > 0 + delegate_to: localhost + debug: + msg: + - "upgrade_cmd='{{ nb_upgrade_cmd }}' (len={{ nb_upgrade_cmd|length }})" + + - name: "NB preflight | Parse OK line" + when: nb_script.stat.exists + delegate_to: localhost + vars: + nb_lines: >- + {{ (nb_preflight.stdout | default('') | regex_replace('\r','')) | split('\n') | map('trim') | list }} + nb_ok_line: >- + {{ (nb_lines | select('match', '^OK\\s+') | list | last | default('')) }} + nb_tokens: >- + {{ (nb_ok_line | regex_replace('^OK\\s+', '')).split() }} + nb_kv: >- + {{ + dict( + nb_tokens + | select('match', '^[a-zA-Z_]+=') + | map('split', '=', 1) + | map('list') + ) + }} + set_fact: + nb_ok: "{{ (nb_preflight.rc|default(1)) == 0 and (nb_ok_line|length)>0 }}" + nb_dev: "{{ (nb_tokens|first|default('')) if (nb_tokens|length>0) else '' }}" + nb_ip: "{{ nb_kv.get('ip','') }}" + nb_fw: "{{ nb_kv.get('fw','') }}" + nb_node: "{{ nb_kv.get('node','') }}" + nb_sector: "{{ nb_kv.get('sector','') }}" + nb_small: "{{ nb_kv.get('small','') }}" + nb_ok_line: "{{ nb_ok_line }}" + + - name: "NB preflight | Detect IP change" + when: nb_script.stat.exists + delegate_to: localhost + vars: + out: "{{ (nb_preflight.stdout | default('') | regex_replace('\r','')) }}" + reason: >- + {%- if 'IP: moving ' in out -%}moving + {%- elif 'IP: create new ' in out -%}create new + {%- elif 'IP: pruning stale ' in out -%}pruning stale + {%- else -%}none{%- endif -%} + set_fact: + nb_ip_changed: "{{ reason != 'none' }}" + nb_change_reason: "{{ reason }}" + + - name: "NB preflight | Verdict" + when: nb_script.stat.exists + delegate_to: localhost + debug: + msg: + - "Preflight verdict:" + - "Cloud fail: {{ cloud_bad|default(false) }}" + - "IP changed: {{ nb_ip_changed|default(false) }}" + - "Reason: {{ nb_change_reason|default('none') }}" + + - name: "NB preflight | Compute next_attempt" + delegate_to: localhost + set_fact: + next_attempt: "{{ (requeue_attempt | int) + 1 }}" + + # --- Requeue control: only 3 attempts, 300s apart --- + - name: "NB preflight | Publish delayed requeue (300s (10s)) if needed" + when: + - nb_script.stat.exists + - (nb_ip_changed | default(false)) or ( + (cloud_bad | default(false)) and + (cloud_error_type | default('none')) != 'cloud_http_500' + ) + - (requeue_attempt | int) < 3 + delegate_to: localhost + uri: + url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/app/deviceconfig.delayed/publish" + method: POST + user: "admin" + password: "change_me" + force_basic_auth: true + status_code: 200 + return_content: yes + headers: { content-type: "application/json" } + body_format: json + body: + properties: + content_type: "application/json" + headers: { x-delay: 900000 } + routing_key: "deviceconfig" + payload: "{{ { + 'inscope_device': (ansible_hostname | default(inventory_hostname)), + 'task_name': 'sot-updater', + 'attempt': next_attempt + } | to_json }}" + payload_encoding: "string" + register: rmq_requeue + changed_when: false + failed_when: false + + - name: "NB preflight | Log requeue publish response" + when: + - nb_script.stat.exists + - ((cloud_bad | default(false)) or (nb_ip_changed | default(false))) + - (requeue_attempt | int) < 3 + delegate_to: localhost + debug: + msg: + - "Requeue published (attempt {{ (requeue_attempt | int) + 1 }})" + - "HTTP: {{ rmq_requeue.status|default('n/a') }}" + - "JSON: {{ rmq_requeue.json|default('') }}" + + - name: "NB preflight | Stop host after requeue" + when: + - nb_script.stat.exists + - ( + (nb_ip_changed | default(false)) or + ( + (cloud_bad | default(false)) and + (cloud_error_type | default('none')) != 'cloud_http_500' + ) + ) + meta: end_host + + - name: "NB preflight | Pause 1s if OK" + when: + - nb_script.stat.exists + - not ((cloud_bad | default(false)) or (nb_ip_changed | default(false))) + delegate_to: localhost + pause: + seconds: 1 + + tasks: + - name: "IP-only | Stop after NetBox sync (no device scan)" + meta: end_host