#!/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 <&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 "$@"