Add focused homelab doctor checks

This commit is contained in:
juvdiaz 2026-06-27 16:35:30 -06:00
parent d5ff049cae
commit 7beb0778c9
3 changed files with 238 additions and 2 deletions

View File

@ -245,6 +245,16 @@ It reports host memory/disk, systemd services, Docker Compose stacks,
Kubernetes health when the API is reachable, Pimox worker VM status, RPi Kubernetes health when the API is reachable, Pimox worker VM status, RPi
services, Tailscale, and key local/public HTTP endpoints. services, Tailscale, and key local/public HTTP endpoints.
Focused doctor commands run narrower read-only checks and print the most likely
next step:
```bash
./jeannie doctor-edge
./jeannie doctor-gitea
./jeannie doctor-rpi
./jeannie doctor-cluster
```
## RPi Services ## RPi Services
The Raspberry Pi runs lightweight always-on services outside Kubernetes from The Raspberry Pi runs lightweight always-on services outside Kubernetes from
@ -272,6 +282,9 @@ continue resolving if the Unbound container stops. Managed adlists live in
`infra/rpi-services/adlists.txt`; the bootstrap inserts those lists without `infra/rpi-services/adlists.txt`; the bootstrap inserts those lists without
deleting manually added Pi-hole lists. If `PIHOLE_WEBPASSWORD` is not provided, deleting manually added Pi-hole lists. If `PIHOLE_WEBPASSWORD` is not provided,
the bootstrap generates one and keeps it in `/opt/homelab-rpi-services/.env`. the bootstrap generates one and keeps it in `/opt/homelab-rpi-services/.env`.
`./jeannie status` and `./jeannie doctor-rpi` test Pi-hole DNS,
direct Unbound DNS, public fallback resolver readiness, Uptime Kuma HTTP, and
whether Docker is currently using the NVMe path or `/var/lib/docker`.
## Adding Nodes ## Adding Nodes

View File

@ -245,6 +245,16 @@ It reports host memory/disk, systemd services, Docker Compose stacks,
Kubernetes health when the API is reachable, Pimox worker VM status, RPi Kubernetes health when the API is reachable, Pimox worker VM status, RPi
services, Tailscale, and key local/public HTTP endpoints. services, Tailscale, and key local/public HTTP endpoints.
Focused doctor commands run narrower read-only checks and print the most likely
next step:
```bash
./{{ main_script }} doctor-edge
./{{ main_script }} doctor-gitea
./{{ main_script }} doctor-rpi
./{{ main_script }} doctor-cluster
```
## RPi Services ## RPi Services
The Raspberry Pi runs lightweight always-on services outside Kubernetes from The Raspberry Pi runs lightweight always-on services outside Kubernetes from
@ -272,6 +282,9 @@ continue resolving if the Unbound container stops. Managed adlists live in
`infra/rpi-services/adlists.txt`; the bootstrap inserts those lists without `infra/rpi-services/adlists.txt`; the bootstrap inserts those lists without
deleting manually added Pi-hole lists. If `PIHOLE_WEBPASSWORD` is not provided, deleting manually added Pi-hole lists. If `PIHOLE_WEBPASSWORD` is not provided,
the bootstrap generates one and keeps it in `/opt/homelab-rpi-services/.env`. the bootstrap generates one and keeps it in `/opt/homelab-rpi-services/.env`.
`./{{ main_script }} status` and `./{{ main_script }} doctor-rpi` test Pi-hole DNS,
direct Unbound DNS, public fallback resolver readiness, Uptime Kuma HTTP, and
whether Docker is currently using the NVMe path or `/var/lib/docker`.
## Adding Nodes ## Adding Nodes

214
jeannie
View File

@ -3620,13 +3620,97 @@ status_rpi_services() {
local rpi_user="${LAB_RPI_USER:-${LAB_RASPBERRY_USER:-jv}}" local rpi_user="${LAB_RPI_USER:-${LAB_RASPBERRY_USER:-jv}}"
local rpi_key="${LAB_RPI_SSH_KEY_PATH:-${LAB_RASPBERRY_SSH_KEY_PATH:-/home/jv/.ssh/id_ed25519}}" local rpi_key="${LAB_RPI_SSH_KEY_PATH:-${LAB_RASPBERRY_SSH_KEY_PATH:-/home/jv/.ssh/id_ed25519}}"
local install_dir="${LAB_RPI_SERVICES_INSTALL_DIR:-/opt/homelab-rpi-services}" local install_dir="${LAB_RPI_SERVICES_INSTALL_DIR:-/opt/homelab-rpi-services}"
local docker_nvme_root="${LAB_RPI_DOCKER_NVME_ROOT:-/nvme-storage/docker}"
local docker_fallback_root="${LAB_RPI_DOCKER_FALLBACK_ROOT:-/var/lib/docker}"
local pihole_container="${PIHOLE_CONTAINER_NAME:-homelab-pihole}"
local unbound_container="${UNBOUND_CONTAINER_NAME:-homelab-unbound}"
local uptime_kuma_port="${UPTIME_KUMA_PORT:-3001}"
status_section "RPi Services" status_section "RPi Services"
ssh -i "${rpi_key}" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${rpi_user}@${rpi_host}" " ssh -i "${rpi_key}" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${rpi_user}@${rpi_host}" "
set +e
dns_query() {
server=\"\$1\"
port=\"\$2\"
name=\"\$3\"
python3 - \"\$server\" \"\$port\" \"\$name\" <<'PY'
import random
import socket
import struct
import sys
server, port, name = sys.argv[1], int(sys.argv[2]), sys.argv[3].rstrip('.')
query_id = random.randrange(0, 65536)
packet = struct.pack('!HHHHHH', query_id, 0x0100, 1, 0, 0, 0)
for label in name.split('.'):
packet += bytes([len(label)]) + label.encode('ascii')
packet += b'\x00' + struct.pack('!HH', 1, 1)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(5)
sock.sendto(packet, (server, port))
data, _ = sock.recvfrom(512)
if len(data) < 12:
raise SystemExit('short DNS response')
response_id, flags, _qd, an, _ns, _ar = struct.unpack('!HHHHHH', data[:12])
rcode = flags & 0x000f
if response_id != query_id:
raise SystemExit('mismatched DNS response id')
if rcode != 0:
raise SystemExit(f'DNS rcode {rcode}')
if an < 1:
raise SystemExit('DNS response had no answers')
print(f'ok answers={an}')
PY
}
check() {
label=\"\$1\"
shift
printf '%-34s ' \"\$label\"
output=\"\$("\$@" 2>&1)\"
status=\$?
if [ \"\$status\" -eq 0 ]; then
printf 'ok'
if [ -n \"\$output\" ]; then
printf ' - %s' \"\$(printf '%s' \"\$output\" | head -n 1)\"
fi
printf '\n'
else
printf 'fail - %s\n' \"\$(printf '%s' \"\$output\" | head -n 1)\"
fi
return 0
}
docker_root=\"\$(sudo docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)\"
printf '%-34s %s\n' 'Docker root' \"\${docker_root:-unknown}\"
if [ \"\$docker_root\" = '${docker_nvme_root}' ]; then
if mountpoint -q '${docker_nvme_root}'; then
printf '%-34s ok - nvme mount active\n' 'Docker root class'
else
printf '%-34s warn - configured for nvme but mountpoint is missing\n' 'Docker root class'
fi
elif [ \"\$docker_root\" = '${docker_fallback_root}' ]; then
printf '%-34s ok - fallback root active\n' 'Docker root class'
else
printf '%-34s warn - unexpected docker root\n' 'Docker root class'
fi
if [ -d '${install_dir}' ]; then if [ -d '${install_dir}' ]; then
cd '${install_dir}' cd '${install_dir}'
sudo docker info --format 'DockerRootDir={{.DockerRootDir}}' 2>/dev/null || true
sudo docker compose ps || true sudo docker compose ps || true
unbound_ip=\"\$(sudo docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' '${unbound_container}' 2>/dev/null || true)\"
check 'Pi-hole DNS query' dns_query 127.0.0.1 53 cloudflare.com
if [ -n \"\$unbound_ip\" ]; then
check 'Unbound DNS query' dns_query \"\$unbound_ip\" 53 cloudflare.com
else
printf '%-34s fail - container IP unavailable\n' 'Unbound DNS query'
fi
upstreams=\"\$(sudo docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' '${pihole_container}' 2>/dev/null | grep -E '^PIHOLE_DNS_=|^FTLCONF_dns_upstreams=' || true)\"
if printf '%s\n' \"\$upstreams\" | grep -Eq '1\\.1\\.1\\.1|9\\.9\\.9\\.9|8\\.8\\.8\\.8'; then
printf '%-34s ok - public fallback configured\n' 'Pi-hole fallback config'
else
printf '%-34s warn - no public fallback found in visible config\n' 'Pi-hole fallback config'
fi
check 'Fallback resolver 1.1.1.1' dns_query 1.1.1.1 53 cloudflare.com
check 'Fallback resolver 9.9.9.9' dns_query 9.9.9.9 53 cloudflare.com
check 'Uptime Kuma HTTP' curl -fsS --max-time 5 \"http://127.0.0.1:${uptime_kuma_port}/\"
else else
echo 'RPi services install directory is missing: ${install_dir}' echo 'RPi services install directory is missing: ${install_dir}'
fi" || printf 'unable to reach %s@%s\n' "${rpi_user}" "${rpi_host}" fi" || printf 'unable to reach %s@%s\n' "${rpi_user}" "${rpi_host}"
@ -3676,6 +3760,120 @@ status_report() {
status_http "arr prowlarr" "http://127.0.0.1:9696/" status_http "arr prowlarr" "http://127.0.0.1:9696/"
} }
doctor_edge() {
local edge_host="${LAB_EDGE_HOST:-132.145.170.74}"
local edge_user="${LAB_EDGE_USER:-ubuntu}"
local traefik_ip="${LAB_TRAEFIK_LB_IP:-192.168.100.240}"
local gitea_ts_ip="${LAB_GITEA_TAILSCALE_IP:-100.85.138.30}"
local gitea_http_port="${LAB_GITEA_HTTP_PORT:-3000}"
require_debian_server "doctor-edge"
status_section "Doctor Edge"
status_http "website public" "https://lab2025.duckdns.org/"
status_http "gitea public" "https://lab2025.duckdns.org/git/"
printf '\n-- edge backend reachability\n'
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${edge_user}@${edge_host}" "
set +e
printf '%-28s ' 'traefik backend'
curl -sS -o /dev/null --connect-timeout 5 --max-time 10 -w '%{http_code}\n' 'http://${traefik_ip}:80/' || echo unreachable
printf '%-28s ' 'gitea backend'
curl -sS -o /dev/null --connect-timeout 5 --max-time 10 -w '%{http_code}\n' 'http://${gitea_ts_ip}:${gitea_http_port}/' || echo unreachable
printf '\n-- tailscale\n'
tailscale status || true
"; then
printf 'unable to reach edge host %s@%s\n' "${edge_user}" "${edge_host}"
fi
cat <<'EOF'
Next steps:
- If only Traefik is unreachable, check cluster/MetalLB or run ./jeannie doctor-cluster.
- If only Gitea is unreachable, run ./jeannie doctor-gitea.
- If both are unreachable from OCI, check Tailscale routes and bootstrap/edge.
- Runbook: docs/runbooks/edge-failures.md
EOF
}
doctor_gitea() {
local edge_host="${LAB_EDGE_HOST:-132.145.170.74}"
local edge_user="${LAB_EDGE_USER:-ubuntu}"
local gitea_ts_ip="${LAB_GITEA_TAILSCALE_IP:-100.85.138.30}"
local gitea_host="${LAB_GITEA_HOST:-192.168.100.73}"
local gitea_http_port="${LAB_GITEA_HTTP_PORT:-3000}"
local gitea_ssh_port="${LAB_GITEA_SSH_PORT:-32222}"
require_debian_server "doctor-gitea"
status_section "Doctor Gitea"
status_http "gitea local" "http://127.0.0.1:${gitea_http_port}/"
status_http "gitea public" "https://lab2025.duckdns.org/git/"
status_compose_stack "gitea" "/data/homelab-gitea"
status_run "gitea git remote" git ls-remote gitea main
printf '\n-- gitea SSH\n'
ssh -T -p "${gitea_ssh_port}" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "git@${gitea_host}" || true
printf '\n-- edge to gitea backend\n'
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${edge_user}@${edge_host}" \
"curl -I --max-time 5 'http://${gitea_ts_ip}:${gitea_http_port}/'"; then
printf 'edge host cannot reach Gitea backend %s:%s\n' "${gitea_ts_ip}" "${gitea_http_port}"
fi
cat <<'EOF'
Next steps:
- If local Gitea is down, run ./jeannie deploy-gitea.
- If local works but public /git/ fails, check Tailscale and bootstrap/edge.
- If Git SSH fails, check the gitea remote and registered SSH key.
- Runbook: docs/runbooks/gitea-failures.md
EOF
}
doctor_rpi() {
require_debian_server "doctor-rpi"
status_rpi_services
cat <<'EOF'
Next steps:
- If Docker root is /var/lib/docker, the NVMe mount is unavailable or failed the writable check.
- If Pi-hole works but Unbound fails, DNS should still resolve through configured public fallbacks.
- If Pi-hole DNS fails, clients using the RPi as DNS will be affected.
- Reapply the stack with ./jeannie rpi-services.
EOF
}
doctor_cluster() {
require_debian_server "doctor-cluster"
status_section "Doctor Cluster"
status_systemd_units kubelet containerd docker
status_kubernetes
status_pimox_workers
status_http "traefik lb" "http://192.168.100.240/"
printf '\n-- local CRI containers\n'
if command -v crictl >/dev/null 2>&1; then
sudo crictl ps -a | head -50 || true
else
printf 'crictl not installed\n'
fi
cat <<'EOF'
Next steps:
- If API server is unreachable after a stop, run ./jeannie start-cluster.
- If nodes are NotReady, inspect kube-system pods and containerd/kubelet on that node.
- If Pimox workers are stopped, start them with ./jeannie start-cluster.
- If kubelet versions drift, run ./jeannie doctor-versions.
- Runbook: docs/runbooks/cluster-stop-start-failures.md
EOF
}
nuke() { nuke() {
local worker_ssh_targets local worker_ssh_targets
local worker_targets local worker_targets
@ -3866,6 +4064,18 @@ case "${1:-}" in
doctor-versions) doctor-versions)
doctor_versions doctor_versions
;; ;;
doctor-edge)
doctor_edge
;;
doctor-gitea)
doctor_gitea
;;
doctor-rpi)
doctor_rpi
;;
doctor-cluster)
doctor_cluster
;;
openwrt) openwrt)
openwrt openwrt
;; ;;
@ -3873,7 +4083,7 @@ case "${1:-}" in
nuke nuke
;; ;;
*) *)
echo "Usage: $0 {up|rebuild-cluster|stop-cluster|start-cluster|status|apps|website-translation-model|website-ollama-listen|deploy-gitea|rpi-services|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|openwrt|nuke}" echo "Usage: $0 {up|rebuild-cluster|stop-cluster|start-cluster|status|apps|website-translation-model|website-ollama-listen|deploy-gitea|rpi-services|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|openwrt|nuke}"
exit 1 exit 1
;; ;;
esac esac