#!/usr/bin/env bash set -euo pipefail readonly VERSION="0.1.0" readonly SCRIPT_NAME="lab-status" readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" readonly ENVS_DIR="${REPO_DIR}/envs" # --- Defaults ----------------------------------------------------------------- QUIET=false VERBOSE=false ENV_NAME="" NO_CHECK=false JSON_OUTPUT=false # Service arrays (parallel indexed) declare -a SVC_NAMES=() declare -a SVC_URLS=() declare -a SVC_STATUSES=() # --- Colors & logging -------------------------------------------------------- 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; } detail() { [[ "$VERBOSE" != true ]] && return; echo -e "${DIM} $*${RESET}"; } # --- Usage -------------------------------------------------------------------- usage() { cat </ and checks whether each service is reachable. Any HTTP response (even 401/403) counts as UP; connection timeout or refused counts as DOWN. Services detected: Proxmox, OC Server, Cluster Nodes, Aether, AWX, HAProxy VIP, Grafana, Prometheus. ${BOLD}EXAMPLES${RESET} # Show hetzner lab with reachability checks ${SCRIPT_NAME} --env hetzner # Show beelink lab without checks ${SCRIPT_NAME} --env beelink --no-check # JSON output for scripting ${SCRIPT_NAME} --env hetzner --json EOF } # --- YAML helpers ------------------------------------------------------------- # Extract a value from a YAML section: yaml_val FILE SECTION KEY yaml_val() { local file="$1" section="$2" key="$3" grep -A50 "^${section}:" "$file" \ | grep "^ *${key}:" | head -1 \ | sed "s/^ *${key}: *//" | tr -d '"' | sed 's/ *#.*//' || true } # Extract a top-level YAML value: yaml_val_top FILE KEY yaml_val_top() { local file="$1" key="$2" grep "^${key}:" "$file" | head -1 \ | sed "s/^${key}: *//" | tr -d '"' | sed 's/ *#.*//' || true } # --- Service collection ------------------------------------------------------- add_service() { local name="$1" url="$2" SVC_NAMES+=("$name") SVC_URLS+=("$url") SVC_STATUSES+=("") } detect_env() { if [[ -n "$ENV_NAME" ]]; then if [[ ! -d "${ENVS_DIR}/${ENV_NAME}" ]]; then error "Environment not found: ${ENV_NAME}" local available available=$(ls "$ENVS_DIR" 2>/dev/null | tr '\n' ' ') [[ -n "$available" ]] && error "Available: ${available}" exit 1 fi return fi local envs=() for d in "$ENVS_DIR"/*/; do [[ -d "$d" ]] && envs+=("$(basename "$d")") done if [[ ${#envs[@]} -eq 1 ]]; then ENV_NAME="${envs[0]}" info "Auto-detected environment: ${ENV_NAME}" elif [[ ${#envs[@]} -eq 0 ]]; then error "No environments found in ${ENVS_DIR}/" exit 1 else error "Multiple environments found: ${envs[*]}" error "Use --env NAME to select one" exit 1 fi } collect_proxmox() { local env_dir="${ENVS_DIR}/${ENV_NAME}" local pve_file="" if [[ -f "${env_dir}/proxmox.yaml" ]]; then pve_file="${env_dir}/proxmox.yaml" elif [[ -f "${env_dir}/proxmox.yaml.example" ]]; then pve_file="${env_dir}/proxmox.yaml.example" warn "Using proxmox.yaml.example (proxmox.yaml not found)" else detail "No proxmox.yaml found, skipping Proxmox" return fi local host host=$(yaml_val_top "$pve_file" "host") if [[ -n "$host" ]]; then host="${host%%/*}" add_service "Proxmox" "https://${host}:8006" detail "Proxmox from $(basename "$pve_file"): ${host}" fi } collect_lab_vms() { local env_dir="${ENVS_DIR}/${ENV_NAME}" local lab_file="" # Prefer lab-production.yaml over lab-cluster.yaml if [[ -f "${env_dir}/lab-production.yaml" ]]; then lab_file="${env_dir}/lab-production.yaml" elif [[ -f "${env_dir}/lab-cluster.yaml" ]]; then lab_file="${env_dir}/lab-cluster.yaml" else detail "No lab-*.yaml found, skipping cluster VMs" return fi detail "Lab VMs from $(basename "$lab_file")" local vm_data vm_data=$(python3 -c " import sys, re try: import yaml with open(sys.argv[1]) as f: data = yaml.safe_load(f) except ImportError: data = {'vms': []} with open(sys.argv[1]) as f: content = f.read() in_vms = False current = {} for line in content.split('\n'): if line.startswith('vms:'): in_vms = True continue if in_vms: if line.startswith(' - '): if current: data['vms'].append(current) current = {} line = line[4:] elif line.startswith(' '): line = line[4:] elif line and not line.startswith(' ') and not line.startswith('#'): if current: data['vms'].append(current) break else: continue m = re.match(r'(\w+):\s*(.*)', line.strip()) if m: current[m.group(1)] = m.group(2).strip('\"') if current: data['vms'].append(current) for vm in data.get('vms', []): name = vm.get('name', '') app = vm.get('app', '') ip = vm.get('ip', '').split('/')[0] print(f'{name}|{app}|{ip}') " "$lab_file" 2>/dev/null) || return local node_idx=0 while IFS='|' read -r name app ip; do [[ -z "$ip" ]] && continue case "$app" in operations-center) add_service "OC Server" "https://${ip}:8443" ;; incus) node_idx=$((node_idx + 1)) local label label=$(printf "Cluster Node %02d" "$node_idx") add_service "$label" "https://${ip}:8443" ;; esac done <<< "$vm_data" } collect_haproxy() { local env_dir="${ENVS_DIR}/${ENV_NAME}" local file="${env_dir}/haproxy.yaml" [[ -f "$file" ]] || return local aether_url vip aether_url=$(yaml_val "$file" "haproxy" "aether_url") vip=$(yaml_val "$file" "haproxy" "vip") if [[ -n "$aether_url" ]]; then add_service "Aether" "$aether_url" detail "Aether from haproxy.yaml: ${aether_url}" fi if [[ -n "$vip" ]]; then vip="${vip%%/*}" add_service "HAProxy VIP" "http://${vip}:80" detail "HAProxy VIP from haproxy.yaml: ${vip}" fi } collect_awx() { local env_dir="${ENVS_DIR}/${ENV_NAME}" local file="${env_dir}/awx.yaml" [[ -f "$file" ]] || return local ip ip=$(yaml_val "$file" "awx" "ip") [[ -z "$ip" ]] && return ip="${ip%%/*}" add_service "AWX" "http://${ip}:30080" detail "AWX from awx.yaml: ${ip}" } collect_observability() { local env_dir="${ENVS_DIR}/${ENV_NAME}" local file="${env_dir}/observability.yaml" [[ -f "$file" ]] || return local forward_vip forward_vip=$(yaml_val "$file" "observability" "forward_vip") [[ -z "$forward_vip" ]] && return forward_vip="${forward_vip%%/*}" add_service "Grafana" "http://${forward_vip}:3000" add_service "Prometheus" "http://${forward_vip}:9090" detail "Observability from observability.yaml: forward_vip=${forward_vip}" } # --- Reachability checks ----------------------------------------------------- check_reachability() { local url="$1" result_file="$2" local http_code http_code=$(curl -sk --connect-timeout 2 --max-time 3 \ -o /dev/null -w '%{http_code}' "$url" 2>/dev/null) || http_code="000" if [[ "$http_code" != "000" ]]; then echo "UP" > "$result_file" else echo "DOWN" > "$result_file" fi } run_checks() { local tmpdir tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/${SCRIPT_NAME}-XXXXXX") # shellcheck disable=SC2064 trap "rm -rf '$tmpdir'" EXIT local i for i in "${!SVC_URLS[@]}"; do check_reachability "${SVC_URLS[$i]}" "${tmpdir}/${i}" & done wait for i in "${!SVC_URLS[@]}"; do if [[ -f "${tmpdir}/${i}" ]]; then SVC_STATUSES[$i]=$(cat "${tmpdir}/${i}") else SVC_STATUSES[$i]="DOWN" fi done } # --- Output ------------------------------------------------------------------- print_table() { local max_name=7 max_url=3 # minimum: "Service" / "URL" local i for i in "${!SVC_NAMES[@]}"; do [[ ${#SVC_NAMES[$i]} -gt $max_name ]] && max_name=${#SVC_NAMES[$i]} [[ ${#SVC_URLS[$i]} -gt $max_url ]] && max_url=${#SVC_URLS[$i]} done local total_width=$((max_name + max_url + 14)) local sep sep=$(printf '%*s' "$total_width" '' | tr ' ' '─') echo echo -e "${BOLD}Lab Environment: ${ENV_NAME}${RESET}" echo "$sep" printf "%-${max_name}s %-${max_url}s %s\n" "Service" "URL" "Status" echo "$sep" for i in "${!SVC_NAMES[@]}"; do local status="${SVC_STATUSES[$i]:-—}" local status_colored case "$status" in UP) status_colored="${GREEN}UP${RESET}" ;; DOWN) status_colored="${RED}DOWN${RESET}" ;; *) status_colored="$status" ;; esac printf "%-${max_name}s %-${max_url}s " "${SVC_NAMES[$i]}" "${SVC_URLS[$i]}" echo -e "$status_colored" done echo "$sep" echo } print_json() { local last=$((${#SVC_NAMES[@]} - 1)) echo "[" local i for i in "${!SVC_NAMES[@]}"; do local comma="," [[ $i -eq $last ]] && comma="" local status_json if [[ -n "${SVC_STATUSES[$i]:-}" ]]; then status_json="\"${SVC_STATUSES[$i]}\"" else status_json="null" fi printf ' {"service": "%s", "url": "%s", "status": %s}%s\n' \ "${SVC_NAMES[$i]}" "${SVC_URLS[$i]}" "$status_json" "$comma" done echo "]" } # --- Argument parsing --------------------------------------------------------- parse_args() { while [[ $# -gt 0 ]]; do case "$1" in -e|--env) [[ $# -lt 2 ]] && { error "--env requires a value"; exit 1; } ENV_NAME="$2"; shift ;; --no-check) NO_CHECK=true ;; --json) JSON_OUTPUT=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." exit 1 ;; esac shift done } # --- Main --------------------------------------------------------------------- main() { setup_colors parse_args "$@" detect_env # Collect services from all config files collect_proxmox collect_lab_vms collect_haproxy collect_awx collect_observability if [[ ${#SVC_NAMES[@]} -eq 0 ]]; then warn "No services found in envs/${ENV_NAME}/" exit 0 fi # Run reachability checks (parallel) if [[ "$NO_CHECK" != true ]]; then info "Checking ${#SVC_NAMES[@]} services..." run_checks fi # Output if [[ "$JSON_OUTPUT" == true ]]; then print_json else print_table fi } main "$@"