--- - name: Deploy scroll24 script and cron configuration hosts: all gather_facts: no vars: ssh_user: "{{ ansible_user | default('root') }}" ssh_pass: "{{ ansible_password | default(ansible_ssh_pass) }}" # Version tag for scroll24 deployment (operator-controlled) scroll24_version: "v1.1" # Controller-side source paths (same place as wifidebug.sh) src_script: "files/scroll24.sh" src_cron_snippet: "files/crond-root-scroll24" # Remote destinations remote_script: "/root/scroll24.sh" remote_crontab: "/etc/crontabs/root" tmp_cron_snippet: "/tmp/crond-root-scroll24.snippet" tmp_cron_new: "/tmp/cron.root.new" crontab_backup_dir: "/etc/crontabs" # RabbitMQ (same contract/style as your other playbooks) 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) }}" tasks: ########################################################################## # 0. Controller sanity check for tools ########################################################################## - name: Verify controller tools (sshpass, sha256sum/busybox) delegate_to: localhost shell: | command -v sshpass >/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 failed_when: ctrl_tools.rc != 0 ########################################################################## # 1. Upload /root/scroll24.sh if changed (sha256 verified) ########################################################################## - name: Compute local sha256 of scroll24.sh delegate_to: localhost command: sha256sum {{ src_script }} register: sha_local changed_when: false - name: Extract local sha256 digest (regex) delegate_to: localhost set_fact: local_hash: "{{ (sha_local.stdout | default('')) | regex_search('([A-Fa-f0-9]{64})') | default('') }}" - name: Compute remote sha256 of /root/scroll24.sh raw: "sha256sum {{ remote_script }} 2>/dev/null || busybox sha256sum {{ remote_script }} 2>/dev/null || true" register: sha_remote changed_when: false failed_when: false - name: Extract remote sha256 digest (regex) set_fact: remote_hash: "{{ (sha_remote.stdout | default('')) | regex_search('([A-Fa-f0-9]{64})') | default('') }}" - name: Decide if scroll24.sh needs upload set_fact: script_changed: "{{ (remote_hash | length == 0) or (local_hash != remote_hash) }}" - name: Upload scroll24.sh via scp (if needed) when: script_changed | bool delegate_to: localhost command: > sshpass -p {{ ssh_pass | quote }} scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null {{ src_script }} {{ ssh_user }}@{{ ansible_host }}:{{ remote_script }} register: scp_scroll24 changed_when: true - name: Ensure /root/scroll24.sh permissions and ownership raw: "chown root:root {{ remote_script }} && chmod 0755 {{ remote_script }}" changed_when: script_changed | bool - name: Recompute remote sha256 after upload when: script_changed | bool raw: "sha256sum {{ remote_script }} || busybox sha256sum {{ remote_script }}" register: sha_remote_after changed_when: false failed_when: false - name: Extract remote-after sha256 digest (regex) when: script_changed | bool set_fact: remote_hash_after: "{{ (sha_remote_after.stdout | default('')) | regex_search('([A-Fa-f0-9]{64})') | default('') }}" - name: Fail if scroll24.sh sha256 mismatch after upload when: script_changed | bool and (remote_hash_after | default('')) != (local_hash | default('')) fail: msg: "sha256 mismatch between controller and remote scroll24.sh" ########################################################################## # 1b. Extract the period (minutes) from the first line of the snippet ########################################################################## - name: Extract period minutes from first snippet line (*/N …) delegate_to: localhost shell: | awk 'NR==1{ f=$1; if (f ~ /^\*\/[0-9]+$/) { gsub("^\\*/","",f); print f; exit } else if (f ~ /^[0-9]+$/) { print f; exit } else { print ""; exit } }' {{ src_cron_snippet }} args: { executable: /bin/bash } register: cron_period_cmd changed_when: false failed_when: false - name: Set cron_period fact delegate_to: localhost set_fact: cron_period: "{{ (cron_period_cmd.stdout | trim) }}" ########################################################################## # 2. Upload cron snippet to device /tmp (operator-provided content) ########################################################################## - name: Push cron snippet to device tmp delegate_to: localhost command: > sshpass -p {{ ssh_pass | quote }} scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null {{ src_cron_snippet }} {{ ssh_user }}@{{ ansible_host }}:{{ tmp_cron_snippet }} register: scp_snippet changed_when: true ########################################################################## # 3. Crontab subset logic (only scroll24.sh lines) ########################################################################## - name: Ensure /etc/crontabs/root exists (with perms) raw: | if [ ! -f {{ remote_crontab }} ]; then touch {{ remote_crontab }}; fi chown root:root {{ remote_crontab }}; chmod 0644 {{ remote_crontab }}; changed_when: false - name: Extract current scroll24 lines from crontab raw: "grep -F 'scroll24.sh' {{ remote_crontab }} || true" register: cron_subset changed_when: false failed_when: false - name: Normalize controller snippet (for compare only) delegate_to: localhost shell: | grep -v '^[[:space:]]*$' {{ src_cron_snippet }} \ | sed 's/[[:space:]]\+/ /g' | sed 's/[[:space:]]*$//' | sort -u register: norm_snippet changed_when: false - name: Normalize device scroll24 subset (for compare only) delegate_to: localhost shell: | printf "%s\n" "{{ cron_subset.stdout | default('') }}" \ | grep -v '^[[:space:]]*$' \ | sed 's/[[:space:]]\+/ /g' | sed 's/[[:space:]]*$//' | sort -u register: norm_remote_subset changed_when: false - name: Decide if crontab needs update (subset compare) delegate_to: localhost set_fact: crontab_changed: "{{ (norm_snippet.stdout | trim) != (norm_remote_subset.stdout | trim) }}" - name: Backup current crontab (timestamped) when: crontab_changed | bool raw: "cp -a {{ remote_crontab }} {{ crontab_backup_dir }}/root.bak.$(date +%Y%m%d%H%M%S)" changed_when: true - name: Replace scroll24 lines in crontab (preserve all others; tidy splice) when: crontab_changed | bool raw: "grep -v 'scroll24\\.sh' {{ remote_crontab }} > {{ tmp_cron_new }} && awk 'BEGIN{for(i=1;i<=NR;i++)a[i]=$0} {a[NR]=$0} END{e=NR; while(e>0 && a[e] ~ /^[[:space:]]*$/){e--}; for(i=1;i<=e;i++) print a[i]}' {{ tmp_cron_new }} > {{ tmp_cron_new }}.trim && mv {{ tmp_cron_new }}.trim {{ tmp_cron_new }} && cat {{ tmp_cron_snippet }} >> {{ tmp_cron_new }} && printf '\\n' >> {{ tmp_cron_new }} && mv {{ tmp_cron_new }} {{ remote_crontab }} && chown root:root {{ remote_crontab }} && chmod 0644 {{ remote_crontab }}" changed_when: true ########################################################################## # 4. Restart crond if script or cron changed (with :51–:59 guard) ########################################################################## - name: Check if restart required set_fact: need_restart: "{{ (script_changed | bool) or (crontab_changed | bool) }}" - name: Get current seconds when: need_restart | bool raw: "date +%S" register: nowsec changed_when: false - name: Sleep 10s if seconds 51-59 when: need_restart | bool and (nowsec.stdout | int >= 51) pause: seconds: 10 - name: Restart crond via move/move when: need_restart | bool raw: "mv /tmp/launchd/services/crond /root/crond && sleep 1 && mv /root/crond /tmp/launchd/services/crond" register: crond_restart changed_when: true failed_when: false - name: Verify crond is running when: need_restart | bool raw: "pgrep -f '/usr/sbin/crond' || busybox pgrep crond || echo missing" register: crond_pid changed_when: false failed_when: false ########################################################################## # 5. Final journals to RabbitMQ (controller) ########################################################################## - name: Publish final scroll24 deployment journal 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': ( 'scroll24: ' ~ (script_changed | ternary('script installed/updated; ', 'script up-to-date; ')) ~ (crontab_changed | ternary('cron updated; ', 'cron already matches; ')) ~ (need_restart | ternary('crond restarted; ', 'crond unchanged; ')) ) } | to_json }}" payload_encoding: "string" changed_when: false - name: Set NetBox custom field scroll24 -> "_" 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': 'scroll24', 'task_result': scroll24_version ~ '_' ~ (cron_period | default('')) } | to_json }}" payload_encoding: "string" changed_when: false