incus-contrib/incusos/helpers/incusos-health

759 lines
23 KiB
Bash
Executable File

#!/usr/bin/env bash
# incusos-health -- Inspect IncusOS node health via the /os/1.0 REST API
#
# Queries one or more IncusOS cluster nodes for status, storage, security,
# services, network, and update information.
#
# Usage: incusos-health [OPTIONS] [ACTION]
# Run 'incusos-health --help' for full usage information.
set -euo pipefail
readonly VERSION="0.1.0"
readonly SCRIPT_NAME="incusos-health"
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
REMOTE="oc-node-01"
NODE=""
JSON_OUTPUT=false
DRY_RUN=false
VERBOSE=false
QUIET=false
ACTION=""
# Services to check (IncusOS built-in services)
readonly SERVICES=(iscsi lvm multipath nvme ovn tailscale usbip)
# ---------------------------------------------------------------------------
# Color and formatting
# ---------------------------------------------------------------------------
setup_colors() {
if [[ -t 1 ]] && [[ "${NO_COLOR:-}" != "1" ]] && [[ "${TERM:-}" != "dumb" ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' RESET=''
fi
}
info() { [[ "$QUIET" == true ]] && return; echo -e "${BLUE}[info]${RESET} $*"; }
success() { [[ "$QUIET" == true ]] && return; echo -e "${GREEN}[ok]${RESET} $*"; }
warn() { echo -e "${YELLOW}[warn]${RESET} $*" >&2; }
error() { echo -e "${RED}[error]${RESET} $*" >&2; }
step() { [[ "$QUIET" == true ]] && return; echo -e "${CYAN}[step]${RESET} ${BOLD}$*${RESET}"; }
detail() { [[ "$VERBOSE" != true ]] && return; echo -e "${DIM} $*${RESET}"; }
# ---------------------------------------------------------------------------
# Help
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
${BOLD}${SCRIPT_NAME}${RESET} v${VERSION} - Inspect IncusOS node health
${BOLD}USAGE${RESET}
${SCRIPT_NAME} [OPTIONS] ACTION
${BOLD}ACTIONS${RESET}
-s, --status Quick status overview (hostname, version, uptime,
memory, TPM, Secure Boot, trusted state, update)
-p, --partitions Storage and partition info (drives, ZFS pools,
volumes, encryption status)
-t, --tpm TPM and security details (TPM status, Secure Boot,
certificates, recovery keys, trusted state)
-S, --services Service configuration (iscsi, lvm, multipath, nvme,
ovn, tailscale, usbip; OVN details)
-N, --network Network configuration (interfaces, addresses, routes,
state, DNS)
-u, --update Update configuration and state (channel, frequency,
auto reboot, versions, last check)
-a, --all Run all of the above
${BOLD}OPTIONS${RESET}
-r, --remote NAME Incus remote (default: ${REMOTE})
--node NAME Specific node (default: all nodes in cluster)
-j, --json Output raw JSON instead of formatted text
-n, --dry-run Show what would be queried without executing
-v, --verbose Show detailed output
-q, --quiet Suppress informational output
-h, --help Show this help message
${BOLD}API ENDPOINTS${RESET}
/os/1.0 Environment (hostname, version, uptime)
/os/1.0/system/security TPM, Secure Boot, encryption
/os/1.0/system/storage Drives, ZFS pools, volumes
/os/1.0/system/resources CPU, memory
/os/1.0/system/network Interfaces, DNS
/os/1.0/system/update Update config and state
/os/1.0/services/SERVICE Per-service configuration
${BOLD}EXAMPLES${RESET}
# Quick status of all cluster nodes
${SCRIPT_NAME} --status
# Storage info for a specific node
${SCRIPT_NAME} --partitions --node oc-node-02
# All info as JSON
${SCRIPT_NAME} --all --json
# Different remote
${SCRIPT_NAME} --status --remote lab-node-01
# Preview queries without running
${SCRIPT_NAME} --all --dry-run
EOF
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-s|--status) ACTION="status";;
-p|--partitions) ACTION="partitions";;
-t|--tpm) ACTION="tpm";;
-S|--services) ACTION="services";;
-N|--network) ACTION="network";;
-u|--update) ACTION="update";;
-a|--all) ACTION="all";;
-r|--remote) REMOTE="$2"; shift;;
--node) NODE="$2"; shift;;
-j|--json) JSON_OUTPUT=true;;
-n|--dry-run) DRY_RUN=true;;
-v|--verbose) VERBOSE=true;;
-q|--quiet) QUIET=true;;
-h|--help) usage; exit 0;;
*)
error "Unknown option: $1"
echo "Run '${SCRIPT_NAME} --help' for usage." >&2
exit 1
;;
esac
shift
done
if [[ -z "$ACTION" ]]; then
error "No action specified"
echo "Run '${SCRIPT_NAME} --help' for usage." >&2
exit 1
fi
}
# ---------------------------------------------------------------------------
# Node discovery
# ---------------------------------------------------------------------------
discover_nodes() {
if [[ -n "$NODE" ]]; then
echo "$NODE"
return
fi
local members
members=$(incus cluster list "${REMOTE}:" -f csv 2>/dev/null \
| cut -d',' -f1 \
| sed 's/^ *//;s/ *$//') || true
if [[ -z "$members" ]]; then
# Not a cluster or cannot list -- fall back to the remote itself
echo "$REMOTE"
return
fi
echo "$members"
}
# ---------------------------------------------------------------------------
# Query helper
# ---------------------------------------------------------------------------
query_node() {
local node="$1" endpoint="$2"
if [[ "$DRY_RUN" == true ]]; then
info "(dry-run) incus query ${node}:${endpoint}"
return 0
fi
detail "incus query ${node}:${endpoint}"
incus query "${node}:${endpoint}" 2>/dev/null || echo "{}"
}
# ---------------------------------------------------------------------------
# Section: status
# ---------------------------------------------------------------------------
show_status() {
local node="$1"
local env_json resources_json security_json update_json
env_json=$(query_node "$node" "/os/1.0")
resources_json=$(query_node "$node" "/os/1.0/system/resources")
security_json=$(query_node "$node" "/os/1.0/system/security")
update_json=$(query_node "$node" "/os/1.0/system/update")
if [[ "$DRY_RUN" == true ]]; then return; fi
if [[ "$JSON_OUTPUT" == true ]]; then
python3 -c "
import json, sys
print(json.dumps({
'environment': json.loads(sys.argv[1]),
'resources': json.loads(sys.argv[2]),
'security': json.loads(sys.argv[3]),
'update': json.loads(sys.argv[4]),
}, indent=2))
" "$env_json" "$resources_json" "$security_json" "$update_json"
return
fi
python3 -c "
import json, sys
env = json.loads(sys.argv[1]).get('environment', json.loads(sys.argv[1]))
res = json.loads(sys.argv[2])
sec_raw = json.loads(sys.argv[3])
sec = sec_raw.get('state', sec_raw)
upd = json.loads(sys.argv[4])
hostname = env.get('hostname', 'unknown')
version = env.get('os_version', env.get('version', 'unknown'))
uptime_s = env.get('uptime', 0)
# Format uptime
days = uptime_s // 86400
hours = (uptime_s % 86400) // 3600
mins = (uptime_s % 3600) // 60
parts = []
if days: parts.append(f'{days}d')
if hours: parts.append(f'{hours}h')
parts.append(f'{mins}m')
uptime_str = ' '.join(parts)
# Memory
mem = res.get('memory', {})
mem_used = mem.get('used', 0)
mem_total = mem.get('total', 0)
def fmt_bytes(b):
for unit in ['B','KiB','MiB','GiB','TiB']:
if b < 1024:
return f'{b:.1f} {unit}'
b /= 1024
return f'{b:.1f} PiB'
mem_pct = (mem_used / mem_total * 100) if mem_total else 0
# Security (state level)
tpm_status = sec.get('tpm_status', 'unknown')
secboot_status = 'enabled' if sec.get('secure_boot_enabled', False) else 'disabled'
trusted = sec.get('system_state_is_trusted', 'unknown')
# Update
needs_reboot = upd.get('state', {}).get('needs_reboot', False)
last_check = upd.get('state', {}).get('last_check', 'never')
print(f' Hostname: {hostname}')
print(f' Version: {version}')
print(f' Uptime: {uptime_str}')
print(f' Memory: {fmt_bytes(mem_used)} / {fmt_bytes(mem_total)} ({mem_pct:.0f}%)')
print(f' TPM: {tpm_status}')
print(f' Secure Boot: {secboot_status}')
print(f' System trusted: {trusted}')
print(f' Needs reboot: {\"yes\" if needs_reboot else \"no\"}')
print(f' Last update: {last_check}')
" "$env_json" "$resources_json" "$security_json" "$update_json"
}
# ---------------------------------------------------------------------------
# Section: partitions
# ---------------------------------------------------------------------------
show_partitions() {
local node="$1"
local storage_json security_json
storage_json=$(query_node "$node" "/os/1.0/system/storage")
security_json=$(query_node "$node" "/os/1.0/system/security")
if [[ "$DRY_RUN" == true ]]; then return; fi
if [[ "$JSON_OUTPUT" == true ]]; then
python3 -c "
import json, sys
print(json.dumps({
'storage': json.loads(sys.argv[1]),
'security': json.loads(sys.argv[2]),
}, indent=2))
" "$storage_json" "$security_json"
return
fi
python3 -c "
import json, sys
stor_raw = json.loads(sys.argv[1])
sec_raw = json.loads(sys.argv[2])
stor = stor_raw.get('state', stor_raw)
sec = sec_raw.get('state', sec_raw)
def fmt_bytes(b):
for unit in ['B','KiB','MiB','GiB','TiB']:
if b < 1024:
return f'{b:.1f} {unit}'
b /= 1024
return f'{b:.1f} PiB'
# Drives
disks = stor.get('drives', stor.get('disks', []))
if disks:
print(' Drives:')
for d in disks:
model = d.get('model_name', d.get('model', 'unknown'))
bus = d.get('bus', 'unknown')
size = d.get('capacity_in_bytes', d.get('size', 0))
boot = ' [boot]' if d.get('boot') else ''
did = d.get('id', '?').split('/')[-1]
pool = d.get('member_pool', '')
pool_str = f' pool={pool}' if pool else ''
print(f' {did}: {model} ({bus}, {fmt_bytes(size)}){boot}{pool_str}')
else:
print(' Drives: none found')
# ZFS pools
pools = stor.get('pools', [])
if pools:
print(' ZFS pools:')
for p in pools:
name = p.get('name', 'unknown')
state = p.get('state', 'unknown')
ptype = p.get('type', 'unknown')
alloc = p.get('pool_allocated_space_in_bytes', 0)
total = p.get('raw_pool_size_in_bytes', p.get('usable_pool_size_in_bytes', 0))
pct = (alloc / total * 100) if total else 0
enc_key = p.get('encryption_key_status', 'N/A')
print(f' {name}: {state} ({ptype}, {fmt_bytes(alloc)} / {fmt_bytes(total)}, {pct:.0f}%) enc_key={enc_key}')
for v in p.get('volumes', []):
vname = v.get('name', '?')
vuse = v.get('use', '?')
vusage = v.get('usage_in_bytes', 0)
vquota = v.get('quota_in_bytes', 0)
quota_str = fmt_bytes(vquota) if vquota else 'no quota'
print(f' vol {vname} ({vuse}): {fmt_bytes(vusage)} / {quota_str}')
else:
print(' ZFS pools: none found')
# Encryption
encrypted_vols = sec.get('encrypted_volumes', [])
if encrypted_vols:
print(' Encryption:')
for v in encrypted_vols:
vol = v.get('volume', '?')
state = v.get('state', '?')
print(f' {vol}: {state}')
else:
print(' Encryption: not configured')
" "$storage_json" "$security_json"
}
# ---------------------------------------------------------------------------
# Section: tpm
# ---------------------------------------------------------------------------
show_tpm() {
local node="$1"
local security_json
security_json=$(query_node "$node" "/os/1.0/system/security")
if [[ "$DRY_RUN" == true ]]; then return; fi
if [[ "$JSON_OUTPUT" == true ]]; then
python3 -c "
import json, sys
print(json.dumps(json.loads(sys.argv[1]), indent=2))
" "$security_json"
return
fi
python3 -c "
import json, sys
sec_raw = json.loads(sys.argv[1])
sec = sec_raw.get('state', sec_raw)
sec_cfg = sec_raw.get('config', {})
# TPM
tpm_status = sec.get('tpm_status', 'unknown')
print(f' TPM status: {tpm_status}')
# Secure Boot
sb_enabled = sec.get('secure_boot_enabled', False)
print(f' Secure Boot: {\"enabled\" if sb_enabled else \"disabled\"}')
# Certificates
certs = sec.get('secure_boot_certificates', [])
if certs:
print(' Certificates:')
for c in certs:
ctype = c.get('type', '?')
subject = c.get('subject', 'unknown')
fp = c.get('fingerprint', '')
fp_short = fp[:16] + '...' if len(fp) > 16 else fp
print(f' {ctype:4s} {subject}')
if fp_short:
print(f' fingerprint: {fp_short}')
# Encrypted volumes
enc_vols = sec.get('encrypted_volumes', [])
if enc_vols:
print(' Encrypted volumes:')
for v in enc_vols:
print(f' {v.get(\"volume\", \"?\")}: {v.get(\"state\", \"?\")}')
# Recovery keys
recovery_keys = sec_cfg.get('encryption_recovery_keys', [])
retrieved = sec.get('encryption_recovery_keys_retrieved', False)
print(f' Recovery keys: {len(recovery_keys)} key(s), retrieved={retrieved}')
# Pool recovery keys
pool_keys = sec.get('pool_recovery_keys', {})
if pool_keys:
print(f' Pool keys: {list(pool_keys.keys())}')
# System trusted
trusted = sec.get('system_state_is_trusted', 'unknown')
print(f' System trusted: {trusted}')
" "$security_json"
}
# ---------------------------------------------------------------------------
# Section: services
# ---------------------------------------------------------------------------
show_services() {
local node="$1"
if [[ "$JSON_OUTPUT" == true ]]; then
local all_json="{"
local first=true
for svc in "${SERVICES[@]}"; do
local svc_json
svc_json=$(query_node "$node" "/os/1.0/services/${svc}")
if [[ "$DRY_RUN" == true ]]; then continue; fi
if [[ "$first" == true ]]; then
first=false
else
all_json+=","
fi
all_json+="\"${svc}\":${svc_json}"
done
all_json+="}"
if [[ "$DRY_RUN" != true ]]; then
echo "$all_json" | python3 -m json.tool
fi
return
fi
for svc in "${SERVICES[@]}"; do
local svc_json
svc_json=$(query_node "$node" "/os/1.0/services/${svc}")
if [[ "$DRY_RUN" == true ]]; then continue; fi
python3 -c "
import json, sys
svc_name = sys.argv[1]
data = json.loads(sys.argv[2])
config = data.get('config', data)
enabled = config.get('enabled', False)
status = 'enabled' if enabled else 'disabled'
line = f' {svc_name:12s} {status}'
print(line)
# Extra details for OVN
if svc_name == 'ovn' and enabled:
db = config.get('database', '')
tunnel_addr = config.get('tunnel_address', '')
tunnel_proto = config.get('tunnel_protocol', '')
if db:
print(f' database: {db}')
if tunnel_addr:
print(f' tunnel address: {tunnel_addr}')
if tunnel_proto:
print(f' tunnel protocol: {tunnel_proto}')
" "$svc" "$svc_json"
done
}
# ---------------------------------------------------------------------------
# Section: network
# ---------------------------------------------------------------------------
show_network() {
local node="$1"
local network_json
network_json=$(query_node "$node" "/os/1.0/system/network")
if [[ "$DRY_RUN" == true ]]; then return; fi
if [[ "$JSON_OUTPUT" == true ]]; then
echo "$network_json" | python3 -m json.tool
return
fi
python3 -c "
import json, sys
net = json.loads(sys.argv[1])
def fmt_bytes(b):
for unit in ['B','KiB','MiB','GiB','TiB']:
if b < 1024: return f'{b:.1f} {unit}'
b /= 1024
return f'{b:.1f} PiB'
# Interfaces
ifaces = net.get('interfaces', net.get('config', {}).get('interfaces', []))
if not isinstance(ifaces, list): ifaces = []
print(' Interfaces:')
for iface in ifaces:
name = iface.get('name', 'unknown')
hwaddr = iface.get('hwaddr', iface.get('mac', ''))
roles = iface.get('roles', [])
roles_str = ', '.join(roles) if isinstance(roles, list) and roles else ''
addrs = iface.get('addresses', [])
addr_strs = [a if isinstance(a, str) else a.get('address', '?') for a in addrs]
state = iface.get('state', {})
header = f' {name}'
if hwaddr: header += f' ({hwaddr})'
if roles_str: header += f' [{roles_str}]'
print(header)
if addr_strs:
print(f' addresses: {\", \".join(addr_strs)}')
link_state = state.get('state', state.get('link', ''))
mtu = state.get('mtu', iface.get('mtu', ''))
speed = state.get('speed', '')
if link_state:
parts = [f'state={link_state}']
if mtu: parts.append(f'mtu={mtu}')
if speed: parts.append(f'speed={speed}')
print(f' link: {\", \".join(parts)}')
routes = iface.get('routes', [])
if isinstance(routes, list) and routes:
route_strs = []
for r in routes:
if isinstance(r, str): route_strs.append(r)
elif isinstance(r, dict):
s = r.get('destination', r.get('to', '?'))
via = r.get('via', r.get('gateway', ''))
if via: s += f' via {via}'
route_strs.append(s)
if route_strs:
print(f' routes: {route_strs[0]}')
for rs in route_strs[1:]:
print(f' {rs}')
stats = state.get('stats', iface.get('stats', {}))
if isinstance(stats, dict) and stats:
rx = stats.get('rx_bytes', stats.get('rx', 0))
tx = stats.get('tx_bytes', stats.get('tx', 0))
if rx or tx:
print(f' stats: rx={fmt_bytes(rx)}, tx={fmt_bytes(tx)}')
# DNS
dns = net.get('dns', net.get('config', {}).get('dns', {}))
if isinstance(dns, dict) and dns:
print(' DNS:')
hostname = dns.get('hostname', dns.get('search', ''))
nameservers = dns.get('nameservers', dns.get('servers', []))
if hostname: print(f' hostname: {hostname}')
if isinstance(nameservers, list) and nameservers:
print(f' nameservers: {\", \".join(str(n) for n in nameservers)}')
" "$network_json"
}
# ---------------------------------------------------------------------------
# Section: update
# ---------------------------------------------------------------------------
show_update() {
local node="$1"
local update_json env_json
update_json=$(query_node "$node" "/os/1.0/system/update")
env_json=$(query_node "$node" "/os/1.0")
if [[ "$DRY_RUN" == true ]]; then return; fi
if [[ "$JSON_OUTPUT" == true ]]; then
echo "$update_json" | python3 -m json.tool
return
fi
python3 -c "
import json, sys
upd = json.loads(sys.argv[1])
env = json.loads(sys.argv[2]).get('environment', json.loads(sys.argv[2]))
config = upd.get('config', {})
state = upd.get('state', {})
channel = config.get('channel', 'unknown')
frequency = config.get('check_frequency', 'unknown')
auto_reboot = config.get('auto_reboot', 'unknown')
current = env.get('os_version', 'unknown')
next_ver = env.get('os_version_next', 'none')
update_status = state.get('status', '')
last_check = state.get('last_check', 'never')
needs_reboot = state.get('needs_reboot', False)
print(f' Channel: {channel}')
print(f' Check freq: {frequency}')
print(f' Auto reboot: {auto_reboot}')
print(f' Current: {current}')
print(f' Next version: {next_ver}')
print(f' Update status: {update_status}')
print(f' Last check: {last_check}')
print(f' Needs reboot: {\"yes\" if needs_reboot else \"no\"}')
" "$update_json" "$env_json"
}
# ---------------------------------------------------------------------------
# Run a section across all nodes
# ---------------------------------------------------------------------------
run_section() {
local section_name="$1"
local section_fn="$2"
shift 2
local nodes=("$@")
step "$section_name"
for node in "${nodes[@]}"; do
echo -e " ${BOLD}--- ${node} ---${RESET}"
"$section_fn" "$node"
echo
done
}
run_section_json() {
local section_fn="$1"
shift
local nodes=("$@")
if [[ ${#nodes[@]} -eq 1 ]]; then
"$section_fn" "${nodes[0]}"
else
echo "{"
local first=true
for node in "${nodes[@]}"; do
if [[ "$first" == true ]]; then
first=false
else
echo ","
fi
echo " \"${node}\":"
"$section_fn" "$node" | sed 's/^/ /'
done
echo "}"
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
setup_colors
parse_args "$@"
# Discover cluster nodes
if [[ "$DRY_RUN" == true ]]; then
if [[ -n "$NODE" ]]; then
info "(dry-run) Would query node: ${NODE}"
else
info "(dry-run) Would discover nodes via: incus cluster list ${REMOTE}: -f csv"
fi
fi
local nodes_raw
nodes_raw=$(discover_nodes)
local nodes=()
while IFS= read -r line; do
[[ -n "$line" ]] && nodes+=("$line")
done <<< "$nodes_raw"
if [[ ${#nodes[@]} -eq 0 ]]; then
error "No nodes found. Check that remote '${REMOTE}' is accessible."
error " incus remote list"
exit 1
fi
if [[ "$DRY_RUN" != true ]]; then
info "Nodes: ${nodes[*]}"
fi
echo
# Section lookup: action -> (title, function)
local -A SECTION_TITLE=(
[status]="Status" [partitions]="Storage & Partitions"
[tpm]="TPM & Security" [services]="Services"
[network]="Network" [update]="Update"
)
# Build section list
local sections=()
if [[ "$ACTION" == "all" ]]; then
sections=(status partitions tpm services network update)
else
sections=("$ACTION")
fi
# Dispatch
if [[ "$JSON_OUTPUT" == true && ${#sections[@]} -gt 1 ]]; then
echo "{"
local i=0
for section in "${sections[@]}"; do
echo " \"${section}\":"
run_section_json "show_${section}" "${nodes[@]}" | sed 's/^/ /'
i=$((i + 1))
[[ $i -lt ${#sections[@]} ]] && echo ","
done
echo "}"
else
for section in "${sections[@]}"; do
if [[ "$JSON_OUTPUT" == true ]]; then
run_section_json "show_${section}" "${nodes[@]}"
else
run_section "${SECTION_TITLE[$section]}" "show_${section}" "${nodes[@]}"
fi
done
fi
}
main "$@"