304 lines
11 KiB
YAML
304 lines
11 KiB
YAML
---
|
|
# NUT (Network UPS Tools) Setup Playbook
|
|
# Run from laptop: ansible-playbook -i inventory nut-setup.yml
|
|
#
|
|
# Prerequisites:
|
|
# - UPS physically connected to server via USB
|
|
# - SSH access to target server
|
|
#
|
|
# Variables to customize:
|
|
# - ups_name: logical name for the UPS in NUT
|
|
# - ups_password: password for upsmon user
|
|
# - uptime_kuma_push_url: your Uptime Kuma push monitor URL
|
|
|
|
- name: Setup NUT for CyberPower UPS
|
|
hosts: nodito
|
|
become: true
|
|
vars:
|
|
ups_name: cyberpower
|
|
ups_desc: "CyberPower CP900EPFCLCD"
|
|
ups_driver: usbhid-ups
|
|
ups_port: auto
|
|
ups_user: counterweight
|
|
ups_password: "changeme" # TODO: use ansible-vault in production
|
|
ups_offdelay: 120 # Seconds after shutdown command before UPS cuts outlet power
|
|
ups_ondelay: 30 # Seconds after mains returns before UPS restores outlet power
|
|
# Note: Shutdown threshold is controlled by UPS's battery.runtime.low (default 300s = 5 min)
|
|
uptime_kuma_push_url: "https://uptime.example.com/api/push/xxxxx"
|
|
|
|
tasks:
|
|
# ------------------------------------------------------------------
|
|
# Installation
|
|
# ------------------------------------------------------------------
|
|
- name: Install NUT packages
|
|
ansible.builtin.apt:
|
|
name:
|
|
- nut
|
|
- nut-client
|
|
- nut-server
|
|
state: present
|
|
update_cache: true
|
|
|
|
# ------------------------------------------------------------------
|
|
# Verify UPS is detected (informational)
|
|
# ------------------------------------------------------------------
|
|
- name: Check if UPS is detected via USB
|
|
ansible.builtin.shell: lsusb | grep -i cyber
|
|
register: lsusb_output
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Display USB detection result
|
|
ansible.builtin.debug:
|
|
msg: "{{ lsusb_output.stdout | default('UPS not detected via USB - ensure it is plugged in') }}"
|
|
|
|
- name: Reload udev rules (NUT installs rules but they need triggering for already-plugged devices)
|
|
ansible.builtin.shell: |
|
|
udevadm control --reload-rules
|
|
udevadm trigger --subsystem-match=usb --action=add
|
|
changed_when: true
|
|
|
|
- name: Verify USB device has nut group permissions
|
|
ansible.builtin.shell: |
|
|
# Find the UPS device and check its permissions
|
|
BUS_DEV=$(lsusb | grep -i cyber | grep -oP 'Bus \K\d+|Device \K\d+' | tr '\n' '/' | sed 's/\/$//')
|
|
if [ -n "$BUS_DEV" ]; then
|
|
BUS=$(echo $BUS_DEV | cut -d'/' -f1)
|
|
DEV=$(echo $BUS_DEV | cut -d'/' -f2)
|
|
ls -la /dev/bus/usb/$BUS/$DEV
|
|
else
|
|
echo "UPS device not found"
|
|
exit 1
|
|
fi
|
|
register: usb_permissions
|
|
changed_when: false
|
|
|
|
- name: Display USB permissions
|
|
ansible.builtin.debug:
|
|
msg: "{{ usb_permissions.stdout }} — should show 'root nut', not 'root root'"
|
|
|
|
- name: Scan for UPS with nut-scanner
|
|
ansible.builtin.command: nut-scanner -U
|
|
register: nut_scanner_output
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Display nut-scanner result
|
|
ansible.builtin.debug:
|
|
msg: "{{ nut_scanner_output.stdout_lines }}"
|
|
|
|
# ------------------------------------------------------------------
|
|
# Configuration files
|
|
# ------------------------------------------------------------------
|
|
- name: Configure NUT mode (standalone)
|
|
ansible.builtin.copy:
|
|
dest: /etc/nut/nut.conf
|
|
content: |
|
|
# Managed by Ansible
|
|
MODE=standalone
|
|
owner: root
|
|
group: nut
|
|
mode: "0640"
|
|
notify: Restart NUT services
|
|
|
|
- name: Configure UPS device
|
|
ansible.builtin.copy:
|
|
dest: /etc/nut/ups.conf
|
|
content: |
|
|
# Managed by Ansible
|
|
[{{ ups_name }}]
|
|
driver = {{ ups_driver }}
|
|
port = {{ ups_port }}
|
|
desc = "{{ ups_desc }}"
|
|
offdelay = {{ ups_offdelay }}
|
|
ondelay = {{ ups_ondelay }}
|
|
owner: root
|
|
group: nut
|
|
mode: "0640"
|
|
notify: Restart NUT services
|
|
|
|
- name: Configure upsd to listen on localhost
|
|
ansible.builtin.copy:
|
|
dest: /etc/nut/upsd.conf
|
|
content: |
|
|
# Managed by Ansible
|
|
LISTEN 127.0.0.1 3493
|
|
owner: root
|
|
group: nut
|
|
mode: "0640"
|
|
notify: Restart NUT services
|
|
|
|
- name: Configure upsd users
|
|
ansible.builtin.copy:
|
|
dest: /etc/nut/upsd.users
|
|
content: |
|
|
# Managed by Ansible
|
|
[{{ ups_user }}]
|
|
password = {{ ups_password }}
|
|
upsmon master
|
|
owner: root
|
|
group: nut
|
|
mode: "0640"
|
|
notify: Restart NUT services
|
|
|
|
- name: Configure upsmon
|
|
ansible.builtin.copy:
|
|
dest: /etc/nut/upsmon.conf
|
|
content: |
|
|
# Managed by Ansible
|
|
MONITOR {{ ups_name }}@localhost 1 {{ ups_user }} {{ ups_password }} master
|
|
|
|
MINSUPPLIES 1
|
|
SHUTDOWNCMD "/sbin/shutdown -h +0"
|
|
POLLFREQ 5
|
|
POLLFREQALERT 5
|
|
HOSTSYNC 15
|
|
DEADTIME 15
|
|
POWERDOWNFLAG /etc/killpower
|
|
|
|
# Notifications
|
|
NOTIFYMSG ONLINE "UPS %s on line power"
|
|
NOTIFYMSG ONBATT "UPS %s on battery"
|
|
NOTIFYMSG LOWBATT "UPS %s battery is low"
|
|
NOTIFYMSG FSD "UPS %s: forced shutdown in progress"
|
|
NOTIFYMSG COMMOK "Communications with UPS %s established"
|
|
NOTIFYMSG COMMBAD "Communications with UPS %s lost"
|
|
NOTIFYMSG SHUTDOWN "Auto logout and shutdown proceeding"
|
|
NOTIFYMSG REPLBATT "UPS %s battery needs replacing"
|
|
|
|
# Log all events to syslog (upsmon handles LB shutdown automatically)
|
|
NOTIFYFLAG ONLINE SYSLOG
|
|
NOTIFYFLAG ONBATT SYSLOG
|
|
NOTIFYFLAG LOWBATT SYSLOG
|
|
NOTIFYFLAG FSD SYSLOG
|
|
NOTIFYFLAG COMMOK SYSLOG
|
|
NOTIFYFLAG COMMBAD SYSLOG
|
|
NOTIFYFLAG SHUTDOWN SYSLOG
|
|
NOTIFYFLAG REPLBATT SYSLOG
|
|
owner: root
|
|
group: nut
|
|
mode: "0640"
|
|
notify: Restart NUT services
|
|
|
|
# NOTE: No upssched configuration needed. The UPS sets LB (Low Battery) flag
|
|
# when runtime < battery.runtime.low (default 300s) or charge < battery.charge.low (default 10%).
|
|
# upsmon handles LB automatically and triggers shutdown—no custom scripts required.
|
|
|
|
# NOTE: /lib/systemd/system-shutdown/nutshutdown is provided by the NUT package.
|
|
# It already checks for the killpower flag and runs `upsdrvctl shutdown` to tell
|
|
# the UPS to cut outlet power. No need to create or modify it.
|
|
|
|
- name: Verify late-stage shutdown script exists
|
|
ansible.builtin.stat:
|
|
path: /lib/systemd/system-shutdown/nutshutdown
|
|
register: nutshutdown_script
|
|
|
|
- name: Warn if nutshutdown script is missing
|
|
ansible.builtin.debug:
|
|
msg: "WARNING: /lib/systemd/system-shutdown/nutshutdown not found. UPS may not cut power after shutdown, breaking auto-restart."
|
|
when: not nutshutdown_script.stat.exists
|
|
|
|
# ------------------------------------------------------------------
|
|
# Services
|
|
# Note: nut-driver-enumerator reads ups.conf and starts drivers via nut-driver@<name>.service
|
|
# ------------------------------------------------------------------
|
|
- name: Enable and start NUT driver enumerator
|
|
ansible.builtin.systemd:
|
|
name: nut-driver-enumerator
|
|
enabled: true
|
|
state: started
|
|
|
|
- name: Enable and start NUT server
|
|
ansible.builtin.systemd:
|
|
name: nut-server
|
|
enabled: true
|
|
state: started
|
|
|
|
- name: Enable and start NUT monitor
|
|
ansible.builtin.systemd:
|
|
name: nut-monitor
|
|
enabled: true
|
|
state: started
|
|
|
|
# ------------------------------------------------------------------
|
|
# Uptime Kuma heartbeat monitoring
|
|
# ------------------------------------------------------------------
|
|
- name: Create UPS heartbeat script
|
|
ansible.builtin.copy:
|
|
dest: /usr/local/bin/ups-heartbeat.sh
|
|
content: |
|
|
#!/bin/bash
|
|
# UPS heartbeat for Uptime Kuma - Managed by Ansible
|
|
STATUS=$(upsc {{ ups_name }}@localhost ups.status 2>/dev/null)
|
|
|
|
if [[ -z "$STATUS" ]]; then
|
|
# Cannot communicate with UPS
|
|
curl -fsS "{{ uptime_kuma_push_url }}?status=down&msg=UPS%20communication%20lost" > /dev/null 2>&1
|
|
elif [[ "$STATUS" == *"OL"* ]]; then
|
|
# On line power
|
|
curl -fsS "{{ uptime_kuma_push_url }}?status=up&msg=UPS%20on%20mains" > /dev/null 2>&1
|
|
else
|
|
# On battery or other state
|
|
curl -fsS "{{ uptime_kuma_push_url }}?status=down&msg=UPS%20on%20battery%20($STATUS)" > /dev/null 2>&1
|
|
fi
|
|
owner: root
|
|
group: root
|
|
mode: "0755"
|
|
|
|
- name: Setup cron job for UPS heartbeat
|
|
ansible.builtin.cron:
|
|
name: "UPS heartbeat to Uptime Kuma"
|
|
minute: "*"
|
|
job: "/usr/local/bin/ups-heartbeat.sh"
|
|
user: root
|
|
|
|
# ------------------------------------------------------------------
|
|
# Verification
|
|
# ------------------------------------------------------------------
|
|
- name: Verify NUT can communicate with UPS
|
|
ansible.builtin.command: upsc {{ ups_name }}@localhost
|
|
register: upsc_output
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Display UPS status
|
|
ansible.builtin.debug:
|
|
msg: "{{ upsc_output.stdout_lines }}"
|
|
|
|
- name: Get UPS status summary
|
|
ansible.builtin.shell: |
|
|
echo "Status: $(upsc {{ ups_name }}@localhost ups.status 2>/dev/null)"
|
|
echo "Battery: $(upsc {{ ups_name }}@localhost battery.charge 2>/dev/null)%"
|
|
echo "Runtime: $(upsc {{ ups_name }}@localhost battery.runtime 2>/dev/null)s"
|
|
echo "Load: $(upsc {{ ups_name }}@localhost ups.load 2>/dev/null)%"
|
|
register: ups_summary
|
|
changed_when: false
|
|
|
|
- name: Display UPS summary
|
|
ansible.builtin.debug:
|
|
msg: "{{ ups_summary.stdout_lines }}"
|
|
|
|
- name: Verify low battery thresholds
|
|
ansible.builtin.shell: |
|
|
echo "Runtime threshold: $(upsc {{ ups_name }}@localhost battery.runtime.low 2>/dev/null)s"
|
|
echo "Charge threshold: $(upsc {{ ups_name }}@localhost battery.charge.low 2>/dev/null)%"
|
|
echo "LB triggers when runtime < threshold OR charge < threshold"
|
|
register: thresholds
|
|
changed_when: false
|
|
|
|
- name: Display low battery thresholds
|
|
ansible.builtin.debug:
|
|
msg: "{{ thresholds.stdout_lines }}"
|
|
|
|
# ------------------------------------------------------------------
|
|
# Handlers
|
|
# ------------------------------------------------------------------
|
|
handlers:
|
|
- name: Restart NUT services
|
|
ansible.builtin.systemd:
|
|
name: "{{ item }}"
|
|
state: restarted
|
|
loop:
|
|
- nut-driver-enumerator
|
|
- nut-server
|
|
- nut-monitor
|