homelab/ups/nut-setup.yml

305 lines
11 KiB
YAML
Raw Permalink Normal View History

2026-01-11 22:17:05 +01:00
---
# 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