#!/usr/bin/env sh # shellcheck shell=sh # - - - - - - - - - - - - - - - - - - - - - - - - - ##@Version : 202605051654-git # @@Author : Jason Hempstead # @@Contact : jason@casjaysdev.pro # @@License : WTFPL # @@ReadME : healthcheck --help # @@Copyright : Copyright: (c) 2026 Jason Hempstead, Casjays Developments # @@Created : Tuesday, May 05, 2026 16:54 EDT # @@File : healthcheck # @@Description : Docker container healthcheck — HTTP/TCP/process/file checks # @@Changelog : Rewrote as a real Docker HEALTHCHECK probe # @@TODO : Better documentation # @@Other : # @@Resource : # @@Terminal App : no # @@sudo/root : no # @@Template : shell/sh # - - - - - - - - - - - - - - - - - - - - - - - - - # shellcheck disable=SC1001,SC1003,SC2001,SC2003,SC2016,SC2031,SC2090,SC2115,SC2120,SC2155,SC2199,SC2229,SC2317,SC2329 # - - - - - - - - - - - - - - - - - - - - - - - - - APPNAME="$(basename -- "$0" 2>/dev/null)" VERSION="202605051654-git" # - - - - - - - - - - - - - - - - - - - - - - - - - # Defaults (env vars override built-ins, CLI flags override env vars) HEALTHCHECK_URL="${HEALTHCHECK_URL:-}" HEALTHCHECK_HTTP_STATUS="${HEALTHCHECK_HTTP_STATUS:-2,3}" HEALTHCHECK_HOST="${HEALTHCHECK_HOST:-127.0.0.1}" HEALTHCHECK_PORT="${HEALTHCHECK_PORT:-}" HEALTHCHECK_PROCESS="${HEALTHCHECK_PROCESS:-}" HEALTHCHECK_FILE="${HEALTHCHECK_FILE:-}" HEALTHCHECK_FILE_MAX_AGE="${HEALTHCHECK_FILE_MAX_AGE:-}" HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-5}" HEALTHCHECK_VERBOSE="${HEALTHCHECK_VERBOSE:-}" # - - - - - - - - - - - - - - - - - - - - - - - - - __cmd_exists() { command -v "$1" >/dev/null 2>&1; } __log() { [ -n "$HEALTHCHECK_VERBOSE" ] && printf '%s\n' "$*" >&2; return 0; } __fail() { printf 'UNHEALTHY: %s\n' "$*" >&2; exit 1; } # - - - - - - - - - - - - - - - - - - - - - - - - - __usage() { cat <&2; __usage >&2; exit 1 ;; *) printf 'Unexpected argument: %s\n' "$1" >&2; exit 1 ;; esac done # - - - - - - - - - - - - - - - - - - - - - - - - - # Individual checks — each prints why it failed and exits 1 on failure __trim() { printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; } __check_one_http() { url="$1"; accepted="$2"; timeout="$3" if __cmd_exists curl; then code="$(curl -ksSL -o /dev/null -w '%{http_code}' --max-time "$timeout" "$url" 2>/dev/null)" \ || __fail "HTTP request to $url failed (curl error)" elif __cmd_exists wget; then code="$(wget -q -S --spider --timeout="$timeout" --tries=1 "$url" 2>&1 \ | awk '/^ HTTP\// {c=$2} END {print c+0}')" [ "$code" -gt 0 ] 2>/dev/null || __fail "HTTP request to $url failed (wget error)" else __fail "HTTP check requires curl or wget" fi IFS=',' for prefix in $accepted; do case "$code" in "$prefix"*) unset IFS; __log "HTTP ok: $url -> $code"; return 0 ;; esac done unset IFS __fail "HTTP $url returned $code (expected prefix in: $accepted)" } __check_http() { urls="$1"; accepted="$2"; timeout="$3" __log "HTTP: urls=$urls (timeout=${timeout}s, accept=${accepted})" IFS=',' for u in $urls; do unset IFS u="$(__trim "$u")" [ -n "$u" ] || { IFS=','; continue; } __check_one_http "$u" "$accepted" "$timeout" IFS=',' done unset IFS return 0 } __check_one_tcp() { host="$1"; port="$2"; timeout="$3" if __cmd_exists nc; then nc -z -w "$timeout" "$host" "$port" >/dev/null 2>&1 && { __log "TCP ok: $host:$port"; return 0; } fi if __cmd_exists ncat; then ncat -z -w "${timeout}s" "$host" "$port" >/dev/null 2>&1 && { __log "TCP ok (ncat): $host:$port"; return 0; } fi # Last resort: bash /dev/tcp (only if bash is available; sh-only systems skip) if __cmd_exists bash; then bash -c "exec 3<>/dev/tcp/$host/$port" >/dev/null 2>&1 && { __log "TCP ok (bash): $host:$port"; return 0; } fi return 1 } __check_tcp() { host="$1"; ports="$2"; timeout="$3" __log "TCP: host=$host ports=$ports (timeout=${timeout}s)" IFS=',' for p in $ports; do unset IFS p="$(__trim "$p")" [ -n "$p" ] || { IFS=','; continue; } __check_one_tcp "$host" "$p" "$timeout" || __fail "TCP $host:$p not reachable" IFS=',' done unset IFS return 0 } __check_one_process() { pattern="$1" if __cmd_exists pgrep; then # Match against process name (not full cmdline) so our own argv doesn't self-match pgrep -- "$pattern" >/dev/null 2>&1 && return 0 else # Portable fallback: ps -o comm= prints just the command name ps -e -o comm= 2>/dev/null | grep -v -e "^grep$" -e "^$APPNAME$" | grep -q -- "$pattern" && return 0 fi return 1 } __check_process() { patterns="$1" __log "Process: patterns=$patterns" IFS=',' for p in $patterns; do unset IFS p="$(__trim "$p")" [ -n "$p" ] || { IFS=','; continue; } __check_one_process "$p" || __fail "Process not running: $p" __log "Process ok: $p" IFS=',' done unset IFS return 0 } __check_one_file() { path="$1"; max_age="$2" [ -e "$path" ] || __fail "File not found: $path" if [ -n "$max_age" ]; then now="$(date +%s)" mtime="$(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path" 2>/dev/null \ || perl -e 'print((stat(shift))[9])' "$path" 2>/dev/null)" [ -n "$mtime" ] || __fail "Cannot determine mtime of $path" age=$(( now - mtime )) [ "$age" -le "$max_age" ] || __fail "File $path is stale (age=${age}s, max=${max_age}s)" fi __log "File ok: $path" return 0 } __check_file() { paths="$1"; max_age="$2" __log "File: paths=$paths max_age=${max_age:-none}" IFS=',' for f in $paths; do unset IFS f="$(__trim "$f")" [ -n "$f" ] || { IFS=','; continue; } __check_one_file "$f" "$max_age" IFS=',' done unset IFS return 0 } # - - - - - - - - - - - - - - - - - - - - - - - - - # Run checks ran_any=0 [ -n "$HEALTHCHECK_URL" ] && { __check_http "$HEALTHCHECK_URL" "$HEALTHCHECK_HTTP_STATUS" "$HEALTHCHECK_TIMEOUT"; ran_any=1; } [ -n "$HEALTHCHECK_PORT" ] && { __check_tcp "$HEALTHCHECK_HOST" "$HEALTHCHECK_PORT" "$HEALTHCHECK_TIMEOUT"; ran_any=1; } [ -n "$HEALTHCHECK_PROCESS" ] && { __check_process "$HEALTHCHECK_PROCESS"; ran_any=1; } [ -n "$HEALTHCHECK_FILE" ] && { __check_file "$HEALTHCHECK_FILE" "$HEALTHCHECK_FILE_MAX_AGE"; ran_any=1; } [ "$ran_any" -eq 1 ] || __fail "no checks configured (set HEALTHCHECK_URL/PORT/PROCESS/FILE or pass --url/--port/--process/--file)" __log "All checks passed" exit 0 # - - - - - - - - - - - - - - - - - - - - - - - - - # ex: ts=2 sw=2 et filetype=sh