Queue-North-Website/scripts/status.sh

679 lines
27 KiB
Bash
Raw Normal View History

chore: adopt template scripts and git hooks, retire phase-versioning Ten scripts from ~/.openclaw/Projects/Template, taken one at a time and configured against this deployment rather than copied wholesale. Configured, not just copied: - check-env.sh SPEC written from what server/index.js actually reads — 24 variables, each with the consequence of getting it wrong - secrets.sh plus this project's own shapes: a bare 60+ hex run, which is how the Zoho WebToLead tokens leaked into four commits, and a reCAPTCHA key shape as NOTED rather than a failure, because the site key and the secret key are indistinguishable by shape - status.sh nebula / qn-website-dev - healthcheck.sh /api/health, asserting 200 AND "status":"ok" AND "db":"ok". The template probed /healthz, which does not exist here - preflight.sh https://qn.isnull.dev, no --auth — there are no accounts - verify.sh GUARD_DIR=scripts/verify.d, since this project has no test runner and no typecheck for it to detect - backup.sh ENGINE block replaced for SQLite: better-sqlite3's online .backup() inside the container, verified with PRAGMA integrity_check before anything is renamed into place - restore-check.sh rewritten rather than configured — the template's is pg_restore/psql end to end with no seam. Replays the dump from SQL into a scratch database and times it Three guards in scripts/verify.d, because verify.sh would otherwise detect nothing and exit 2: the build, the tracked-tree secret scan, and a check that every document carries a valid Status, Governs and Review trigger. Every guard was proven to fail before being trusted, per GUARDS.md rule 1: healthcheck against a 200 that is not this app, secrets against the real historical leak replayed out of 033bdf6, doc-headers against both a missing Review trigger and the Status word "Historical", restore-check against a truncated dump, an empty database and a raised row floor. pre-commit is ADAPTED, not the template's. That one runs `npx tsc --noEmit` and `npx vitest run`; this project has neither, so unchanged it would refuse every commit. It runs the secret scan and `npm run build`. Hooks are not activated by this commit — `git config core.hooksPath .githooks` is a separate, per-clone act. package.json: adds `verify`, and corrects the version to 0.9.3. It said 0.8.3 while the last four commits said batch 0.9.0 through 0.9.3 — the second drift of the phase-versioning rule, which is retired in the following commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:18:20 -05:00
#!/usr/bin/env bash
#
# What is actually running right now on the host this project deploys to, and
# its logs. Read-only: it inspects and it tails, and it does nothing else.
#
# bash scripts/status.sh # up? which version? how long?
# bash scripts/status.sh --logs # last 100 lines
# bash scripts/status.sh --logs 500
# bash scripts/status.sh --logs all
# bash scripts/status.sh --follow # stream until Ctrl-C
# bash scripts/status.sh --follow 20 # stream, after 20 lines of context
# bash scripts/status.sh --deployed-version # just the version, for scripts
# bash scripts/status.sh --dry-run # print the commands, contact nothing
#
# STATUS_CONTAINER=other-app bash scripts/status.sh # one-off, another target
#
# Exit codes, because the whole point is that a caller can act on the answer:
#
# 0 running — healthy, still starting, or no healthcheck defined
# 1 configuration or usage error; nothing was contacted
# 2 COULD NOT DETERMINE — host unreachable, daemon silent, or an error this
# does not recognise. This is not an outage report. Nothing is known.
# 3 the container does not exist on that host
# 4 the container exists but is not running
# 5 running, and its own healthcheck says unhealthy
#
# stdout carries only what a caller should parse — the log lines, or the one
# version string. The human report goes to stderr, so `status.sh
# --deployed-version` can be read straight into a variable without a filter.
#
# ===========================================================================
# TEMPLATE COPY — configure this before the first run
# ===========================================================================
#
# Copy to `scripts/status.sh` and set the two values in the CONFIGURATION block
# below. The script refuses to run until they are set: it has no defaults,
# deliberately, for the reason `release.sh` gives at length — a copied script
# carrying another project's host and container name answers confidently about
# the wrong machine, and an answer is exactly what you came here for. A status
# tool that lies is worse than no status tool, because you act on it.
#
# Assumes: bash, coreutils, git-less (it reads nothing from the repository), and
# docker on the target. `ssh` only when the target is not this machine.
#
# ## Why this exists
#
# Without it, "is it up?" is answered by typing docker incantations from memory,
# which in practice means `docker ps | grep`. That shows a name and a status and
# none of the four things you actually wanted: which version is deployed, how
# long it has been up, how many times it has restarted, and what its healthcheck
# thinks. So the question gets half-answered, and the half that is missing —
# restart count — is the one that distinguishes "running" from "crash-looping
# and up for nine seconds".
#
# The other half is worse. When there is no command that answers "what version
# is deployed", every script that needs the answer grows its own copy of the
# lookup. `release.sh` has one: a single `ssh … docker inspect --format` whose
# empty return value is the ONLY thing standing between the prune and deleting
# the published image production is currently running. That fact is load-bearing
# enough to deserve a name, a documented exit code, and one place to fix — not a
# line pasted into whichever script needed it that week, each copy free to
# disagree about what an empty answer means.
#
# Hence `--deployed-version`: one fact, on stdout, non-zero when it could not be
# read. A caller that treats non-zero as "stop" cannot make the prune's mistake.
#
# ## Unreachable is not stopped
#
# These are different facts and only one of them is an outage you caused:
#
# - the container is not running → something happened to the deployment
# - I could not reach the host → something happened to the network, or to
# ssh, or to the daemon, and the container
# is very probably still serving traffic
#
# Rendering the second as the first is how a 2am investigation starts by
# restarting a healthy service. So a failed connection exits 2 and says so in
# those words; only an inspect that succeeded and reported a non-running state
# exits 4. Nothing here ever prints "stopped", "0" or "ok" for something it did
# not measure — an unknown is printed as an unknown, every time.
#
# ssh runs with BatchMode=yes and a connect timeout, so a host that would prompt
# for a password or a host key is read as unreachable in seconds instead of
# hanging forever on a prompt nobody is watching.
#
# ## There is no --host flag
#
# Host and container come from the CONFIGURATION block, or from the environment
# for a one-off. A flag would make it one keystroke to read host A and file the
# answer under host B, and the environment form is self-documenting in shell
# history — which is where you will be reading it back from.
#
# ## What it deliberately does not do
#
# It never starts, stops, restarts, recreates, kills or removes anything, and it
# never pulls or prunes an image. Every docker verb it runs is `inspect`,
# `version` or `logs`. This is not an oversight to be fixed later: a status tool
# is the one command people run half-awake and without reading, so it must be
# safe to run half-awake and without reading. Fixing what it reports is a
# separate, deliberate act with a separate command.
#
# It does not compare the deployed version against this repository, and does not
# read package.json or any other file. Whether the deployed version is the RIGHT
# version is a judgement, and a judgement needs a human or a release script that
# owns the decision; this only reports what is there.
#
# It reports one configured container, not every container on the host. A tool
# that enumerates is a tool that finds another project's container and reports it
# as yours.
set -uo pipefail
# ---------------------------------------------------------------------------
# CONFIGURATION — set these two, then delete this banner.
#
# Both are empty on purpose. See the note at the top.
# ---------------------------------------------------------------------------
# An ssh destination — a Host in ~/.ssh/config, an IP, or user@host — or the
# literal word `local` to ask this machine's own docker daemon.
#
# `local` is a word you have to type rather than what an empty value means,
# because "you did not configure a host" and "the host is this machine" must not
# look the same. Defaulting to local would make an unconfigured copy of this
# script quietly report on your laptop and call it production.
HOST="${STATUS_HOST:-nebula}"
# The container name (or id) to report on. Exactly one.
CONTAINER="${STATUS_CONTAINER:-qn-website-dev}"
# Seconds to wait for the ssh connection. Short on purpose: this command exists
# to be run when something might be wrong, and a status tool that hangs is
# indistinguishable from the outage it was meant to describe.
SSH_TIMEOUT="${STATUS_SSH_TIMEOUT:-10}"
# Lines tailed by `--logs` with no number given.
TAIL_DEFAULT="${STATUS_TAIL:-100}"
# The image label carrying the deployed version. This one has a default because
# it is an open standard rather than a fact about your project; override it only
# if your build stamps a different label.
VERSION_LABEL="${STATUS_VERSION_LABEL:-org.opencontainers.image.version}"
# ---------------------------------------------------------------------------
E_CONFIG=1
E_UNKNOWN=2
E_MISSING=3
E_STOPPED=4
E_UNHEALTHY=5
say() { printf '\033[1mstatus:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1mstatus:\033[0m %s\n' "$*" >&2; exit "$E_CONFIG"; }
# die() with a chosen code. Every caller of this is a distinct fact a script
# downstream is expected to branch on, so the code is passed explicitly at the
# point the fact is established rather than mapped from a message afterwards.
quit() { local code="$1"; shift; printf '\033[1mstatus:\033[0m %s\n' "$*" >&2; exit "$code"; }
field() { printf '\033[1mstatus:\033[0m %-9s %s\n' "$1" "$2" >&2; }
usage() {
say "usage: bash scripts/status.sh [--logs [N|all]] [--follow [N]] [--deployed-version] [--dry-run]"
say " set STATUS_HOST and STATUS_CONTAINER, or edit the CONFIGURATION block."
}
# ---------------------------------------------------------------------------
# Arguments, before the configuration check, so that `--help` answers on a
# fresh unconfigured copy — which is the one moment somebody needs it.
# ---------------------------------------------------------------------------
MODE="report"
TAIL=""
DRY_RUN=""
# `("$@")` with no arguments is an unbound expansion under `set -u` on bash
# before 4.4, which is still what ships on macOS. Guarded rather than assumed.
if [ "$#" -gt 0 ]; then ARGS=("$@"); else ARGS=(); fi
# Refused rather than resolved last-flag-wins. `--deployed-version --logs` from a
# script would otherwise return a log stream on stdout to a caller that is about
# to compare it to a version string.
set_mode() {
[ "$MODE" = "report" ] || [ "$MODE" = "$1" ] \
|| die "--logs, --follow and --deployed-version ask different questions; pick one."
MODE="$1"
}
i=0
while [ "$i" -lt "${#ARGS[@]}" ]; do
arg="${ARGS[$i]}"
case "$arg" in
--logs|-l|--follow|-f)
case "$arg" in
--logs|-l) set_mode "logs" ;;
*) set_mode "follow" ;;
esac
# An optional operand rather than a required one, so `--logs` alone is a
# whole command. Only consumed when it looks like a count — otherwise
# `--logs --dry-run` would swallow the flag and silently mean something
# else.
if [ $((i + 1)) -lt "${#ARGS[@]}" ]; then
case "${ARGS[$((i + 1))]}" in
all|[0-9]*) TAIL="${ARGS[$((i + 1))]}"; i=$((i + 1)) ;;
esac
fi
;;
--logs=*) set_mode "logs"; TAIL="${arg#*=}" ;;
--follow=*) set_mode "follow"; TAIL="${arg#*=}" ;;
--deployed-version) set_mode "version" ;;
--dry-run) DRY_RUN="yes" ;;
-h|--help) usage; exit 0 ;;
*) usage; die "unknown argument '$arg'." ;;
esac
i=$((i + 1))
done
[ -n "$TAIL" ] || TAIL="$TAIL_DEFAULT"
case "$TAIL" in
all) : ;;
''|*[!0-9]*) die "line count must be a whole number or 'all', got '$TAIL'." ;;
esac
case "$SSH_TIMEOUT" in
''|*[!0-9]*) die "STATUS_SSH_TIMEOUT must be a whole number of seconds, got '$SSH_TIMEOUT'." ;;
esac
# ---------------------------------------------------------------------------
# Refuse to run half-configured, before anything is contacted, and name the
# missing value one at a time so the message says which one.
# ---------------------------------------------------------------------------
[ -n "$HOST" ] || die "set HOST (or STATUS_HOST) — the ssh host running the container, or the word 'local'. See the CONFIGURATION block."
[ -n "$CONTAINER" ] || die "set CONTAINER (or STATUS_CONTAINER) — the container name to report on."
# ssh takes its destination positionally, so a host beginning with a dash is
# parsed as an option and the failure that follows describes something else
# entirely. Refused here, where the message can still name the real cause.
case "$HOST" in
-*) die "HOST ('$HOST') begins with '-'; ssh would read it as an option. Use user@host, or a Host from ~/.ssh/config." ;;
esac
case "$CONTAINER" in
-*) die "CONTAINER ('$CONTAINER') begins with '-'; docker would read it as a flag." ;;
esac
# The label name is interpolated into a Go template as a quoted string below. A
# quote or backslash in it would end that string early, and the template error
# that followed would be reported as "could not inspect" — an unknown, blamed on
# the host, caused here.
case "$VERSION_LABEL" in
''|*[\"\\]*) die "STATUS_VERSION_LABEL must be a plain label name without quotes or backslashes, got '$VERSION_LABEL'." ;;
esac
# ---------------------------------------------------------------------------
# Running docker here or over there.
#
# ssh joins its command arguments with spaces and hands the result to a remote
# shell, which splits it again. Anything containing a space — every Go template
# below does — arrives as several arguments unless it is quoted for that second
# parse. Quoting is done here, once, rather than by hand at each call site.
# ---------------------------------------------------------------------------
shquote() {
local out="" a q="'" esc="'\\''"
for a in "$@"; do
a="${a//$q/$esc}"
out="${out}${out:+ }'${a}'"
done
printf '%s' "$out"
}
# ServerAlive is here for --follow: a connection that dies mid-stream otherwise
# leaves the terminal sitting quietly forever, which looks exactly like a service
# that has stopped logging. Absence of output must not be able to mean two
# things, so the connection is made to fail loudly instead.
ssh_opts() {
printf '%s' "-o BatchMode=yes -o ConnectTimeout=${SSH_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
}
SSH_WANT_TTY=""
dock() {
if [ "$HOST" = "local" ]; then
docker "$@"
return
fi
# A tty makes the remote `docker logs -f` receive a hangup when you press
# Ctrl-C. Without one it keeps running on the far side, holding the daemon's
# log stream open, and enough abandoned follows are a real resource leak on a
# box you are already worried about. Requested only when we have a tty to
# give, because ssh -t without one is a warning and no tty anyway.
if [ -n "$SSH_WANT_TTY" ] && [ -t 0 ]; then
# shellcheck disable=SC2046 # deliberate word-splitting of the option list
ssh -t $(ssh_opts) "$HOST" "docker $(shquote "$@")"
else
# shellcheck disable=SC2046
ssh $(ssh_opts) "$HOST" "docker $(shquote "$@")"
fi
}
# The remote form is printed on two lines — the connection, then the command
# string the far shell receives — because the single-line form is the same text
# quoted twice and the host and container names disappear into the punctuation.
# Those two names are the entire reason to run --dry-run.
dock_show() {
if [ "$HOST" = "local" ]; then
printf ' docker %s\n' "$(shquote "$@")" >&2
else
printf ' ssh %s %s\n' "$(ssh_opts)" "$HOST" >&2
printf ' docker %s\n' "$(shquote "$@")" >&2
fi
}
# ---------------------------------------------------------------------------
# Timestamps.
#
# Docker reports RFC3339 with nanoseconds; `date -d` is GNU and `date -j -f` is
# BSD, so both are tried and NEITHER working is reported as not knowing the
# uptime rather than as an uptime of zero.
# ---------------------------------------------------------------------------
epoch_of() {
local t="${1%Z}"
t="${t%%.*}"
local e
if e=$(date -u -d "${t}Z" +%s 2>/dev/null) && [ -n "$e" ]; then
printf '%s' "$e"
return 0
fi
if e=$(date -u -j -f '%Y-%m-%dT%H:%M:%S' "$t" +%s 2>/dev/null) && [ -n "$e" ]; then
printf '%s' "$e"
return 0
fi
return 1
}
human_duration() {
local s="$1" d h m
d=$((s / 86400)); s=$((s % 86400))
h=$((s / 3600)); s=$((s % 3600))
m=$((s / 60)); s=$((s % 60))
if [ "$d" -gt 0 ]; then printf '%dd %dh %dm' "$d" "$h" "$m"
elif [ "$h" -gt 0 ]; then printf '%dh %dm' "$h" "$m"
elif [ "$m" -gt 0 ]; then printf '%dm %ds' "$m" "$s"
else printf '%ds' "$s"
fi
}
# Elapsed time since an RFC3339 stamp, or nothing at all.
#
# The zero timestamp means the event never happened — a container created but
# never started carries 0001-01-01 in StartedAt, and subtracting it yields two
# millennia of uptime presented with a straight face.
#
# A negative result is clock skew between this machine and that one, not time
# travel, and is likewise reported as not knowing rather than as a number.
elapsed_since() {
local stamp="$1" at now diff
case "$stamp" in
''|0001-01-01*) return 1 ;;
esac
at=$(epoch_of "$stamp") || return 1
now=$(date -u +%s) || return 1
diff=$((now - at))
[ "$diff" -ge 0 ] || return 1
human_duration "$diff"
}
# ---------------------------------------------------------------------------
# One inspect, one round trip.
#
# Fields are read together rather than one call each: two calls can straddle a
# restart and produce a report that never described a single moment — an uptime
# from before the restart beside a restart count from after it.
#
# The free-text fields are LAST. `read` gives the final variable everything that
# remains, delimiters included, so a '|' inside an image name or a version label
# lands harmlessly in the last field instead of shifting every field after it.
# ---------------------------------------------------------------------------
FMT='{{.State.Status}}|{{.State.Running}}|{{.RestartCount}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}{{end}}|{{.State.StartedAt}}|{{.State.FinishedAt}}|{{.Image}}|{{.Config.Image}}|{{index .Config.Labels "'"$VERSION_LABEL"'"}}'
C_STATUS=""; C_RUNNING=""; C_RESTARTS=""; C_EXIT=""; C_HEALTH=""
C_STARTED=""; C_FINISHED=""; C_DIGEST=""; C_IMAGE=""; C_VERSION=""
# Work out WHY the inspect failed, and exit with the code for that specific
# fact. Reached only after a failure, so the extra round trips cost nothing in
# the normal case and buy the one distinction this script exists to preserve.
diagnose_and_exit() {
local err="$1"
if [ "$HOST" != "local" ]; then
# shellcheck disable=SC2046
if ! ssh $(ssh_opts) "$HOST" true >/dev/null 2>&1; then
say "cannot reach ${HOST} over ssh (BatchMode, ${SSH_TIMEOUT}s timeout)."
say " This is NOT a report that the container is down — nothing was"
say " determined about it. Check the network, the ssh key, and"
say " whether the host would have prompted for a password."
exit "$E_UNKNOWN"
fi
fi
if ! dock version --format '{{.Server.Version}}' >/dev/null 2>&1; then
if [ "$HOST" = "local" ]; then
say "this machine's docker daemon did not answer."
else
say "reached ${HOST} over ssh, but its docker daemon did not answer."
fi
say " The container's state is unknown — the daemon being down does"
say " not tell you whether the container was running before it went."
exit "$E_UNKNOWN"
fi
# Matched case-insensitively against a lowercased copy, because the wording
# is not stable across docker versions: "Error: No such object: x" on one
# daemon here, "error: no such object: x" on another, and "No such container"
# from older ones. A case-sensitive match reports a container that is simply
# absent as an unknown — which is the safe direction to be wrong in, and
# still the wrong answer.
local lower
lower=$(printf '%s' "$err" | tr '[:upper:]' '[:lower:]')
case "$lower" in
*"no such object"*|*"no such container"*)
quit "$E_MISSING" "there is no container named '${CONTAINER}' on ${HOST}." ;;
esac
# An error nobody anticipated is printed verbatim and treated as an unknown.
# Guessing at an unfamiliar message is how "I could not check" becomes "there
# is nothing there".
say "could not inspect '${CONTAINER}' on ${HOST}, and the error is not one"
say " this script recognises. Nothing is known about the container:"
printf ' %s\n' "${err:-(no error output)}" >&2
exit "$E_UNKNOWN"
}
# The one file this script creates, and the one thing it ever deletes.
#
# The path is read when the trap FIRES rather than pasted into the trap's source
# text. `trap "rm -f '$f'" EXIT` looks equivalent and is not: it builds a shell
# command out of mktemp's output, which is built out of $TMPDIR, so a directory
# whose name contains a single quote closes that string early and everything
# after it runs as shell. `TMPDIR="/tmp/x'; rm -rf ~; echo '"` is a working
# exploit against the double-quoted form. This is the only destructive verb in
# the script and it must not be constructible from the environment.
#
# The -n guard means rm is never handed an empty path, and `--` means a path
# beginning with a dash is a path rather than a bundle of options.
ERRFILE=""
# shellcheck disable=SC2329 # invoked by the EXIT trap below, not by name
cleanup() {
[ -n "$ERRFILE" ] || return 0
rm -f -- "$ERRFILE"
return 0
}
trap cleanup EXIT
inspect_container() {
local out rc
ERRFILE=$(mktemp) || die "cannot create a temporary file"
out=$(dock inspect --format "$FMT" "$CONTAINER" 2>"$ERRFILE")
rc=$?
if [ "$rc" -ne 0 ] || [ -z "$out" ]; then
diagnose_and_exit "$(tr -d '\r' < "$ERRFILE" | head -n 3)"
fi
IFS='|' read -r C_STATUS C_RUNNING C_RESTARTS C_EXIT C_HEALTH \
C_STARTED C_FINISHED C_DIGEST C_IMAGE C_VERSION \
<<EOF
$(printf '%s' "$out" | tr -d '\r' | head -n 1)
EOF
}
# ---------------------------------------------------------------------------
# --dry-run: say exactly what would be contacted, and contact nothing.
#
# Nothing here is destructive, so this is not a safety catch — it is how you
# check WHICH host and WHICH container this copy is configured for before you
# quote its answer at somebody, or before a release script trusts it.
# ---------------------------------------------------------------------------
if [ -n "$DRY_RUN" ]; then
say "--dry-run: nothing was contacted. Target: ${CONTAINER} on ${HOST}."
say "It would have run:"
dock_show inspect --format "$FMT" "$CONTAINER"
case "$MODE" in
logs) dock_show logs --tail "$TAIL" "$CONTAINER" ;;
follow) dock_show logs --tail "$TAIL" --follow "$CONTAINER" ;;
esac
exit 0
fi
if [ "$HOST" = "local" ]; then
command -v docker >/dev/null 2>&1 \
|| quit "$E_UNKNOWN" "docker is not on PATH, so nothing could be determined. Install it, or set HOST to the machine that runs the container."
else
command -v ssh >/dev/null 2>&1 \
|| quit "$E_UNKNOWN" "ssh is not on PATH, so ${HOST} could not be contacted."
fi
inspect_container
# ---------------------------------------------------------------------------
# --deployed-version: one fact on stdout, non-zero when it is not known.
#
# The contract callers depend on: a zero exit means the string on stdout was
# read from the running container's image. Anything else means stop — never
# "assume none", which is the assumption that lets a prune delete production's
# image.
# ---------------------------------------------------------------------------
if [ "$MODE" = "version" ]; then
[ -n "$C_VERSION" ] \
|| quit "$E_UNKNOWN" "'${CONTAINER}' on ${HOST} is ${C_STATUS}, but its image carries no ${VERSION_LABEL} label — the deployed version is unknown."
[ "$C_RUNNING" = "true" ] \
|| quit "$E_STOPPED" "'${CONTAINER}' on ${HOST} is ${C_STATUS}, not running. Its image says ${C_VERSION}, but nothing is serving from it."
printf '%s\n' "$C_VERSION"
exit 0
fi
# ---------------------------------------------------------------------------
# --logs / --follow.
#
# The inspect above already ran, which is what makes an empty tail meaningful:
# the container is known to exist, so no output means it has logged nothing.
# Tailing blind cannot tell that apart from a container that is not there.
# ---------------------------------------------------------------------------
if [ "$MODE" = "logs" ] || [ "$MODE" = "follow" ]; then
field container "${CONTAINER} on ${HOST}"
field state "${C_STATUS}${C_VERSION:+ (${C_VERSION})}"
# Said before the first line of output, because a wall of old log lines from a
# container that died an hour ago is indistinguishable from a live one.
if [ "$C_RUNNING" != "true" ]; then
say "note: the container is ${C_STATUS}. These are the logs it left behind,"
if [ "$MODE" = "follow" ]; then
say " not a live stream — nothing is writing, so this will return at once."
else
say " not a live stream."
fi
fi
if [ "$MODE" = "follow" ]; then
SSH_WANT_TTY="yes"
say "following ${CONTAINER} — Ctrl-C to stop."
dock logs --tail "$TAIL" --follow "$CONTAINER"
else
dock logs --tail "$TAIL" "$CONTAINER"
fi
rc=$?
# A tail that failed after a successful inspect is a new fact, not a quiet
# zero: the container exists, so an empty screen would otherwise read as a
# silent service.
[ "$rc" -eq 0 ] || quit "$E_UNKNOWN" "the log stream ended with status ${rc}; some or all of the output may be missing."
# The logs printed either way; only the code differs. Exiting 0 here would
# report the documented meaning of 0 — "running" — for a container that is
# not, which is the same mistake as printing "ok" for something unmeasured.
# The state is the fact the exit code carries, in every mode.
[ "$C_RUNNING" = "true" ] \
|| quit "$E_STOPPED" "'${CONTAINER}' exists on ${HOST} but is ${C_STATUS}; the logs above are what it left behind, not a live service."
exit 0
fi
# ---------------------------------------------------------------------------
# The report.
# ---------------------------------------------------------------------------
field container "${CONTAINER} on ${HOST}"
if [ "$C_RUNNING" = "true" ]; then
field state "running"
else
field state "${C_STATUS:-unknown} (exit code ${C_EXIT:-unknown})"
fi
field image "${C_IMAGE:-(unknown)}"
# The digest is printed beside the tag because the tag is a claim and the digest
# is the fact. A tag can be moved after a container was created, at which point
# the container's image reference names something it was never built from.
if [ -n "$C_DIGEST" ]; then
case "$C_DIGEST" in
sha256:*)
short="${C_DIGEST#sha256:}"
field digest "sha256:${short:0:12}"
;;
*)
# Whatever this is, it is not a sha256 digest, so it is not labelled as
# one. The stripping form prints "sha256:" in front of the first twelve
# characters of anything at all — podman and older daemons report a bare
# image id here, and it would have been rendered as a digest it is not.
field digest "$C_DIGEST"
;;
esac
fi
if [ -n "$C_VERSION" ]; then
field version "$C_VERSION"
else
field version "(not set — this image carries no ${VERSION_LABEL} label)"
fi
if [ "$C_RUNNING" = "true" ]; then
if up=$(elapsed_since "$C_STARTED"); then
field uptime "${up} (started ${C_STARTED})"
else
# Reported as unknown, with the raw stamp, rather than as a plausible
# number computed from a timestamp that could not be read.
field uptime "unknown — could not read the start time '${C_STARTED}'"
fi
else
if down=$(elapsed_since "$C_FINISHED"); then
field stopped "${down} ago (${C_FINISHED})"
else
field stopped "at '${C_FINISHED:-unknown}'"
fi
fi
field restarts "${C_RESTARTS:-unknown}"
# The count alone is not the signal; the count beside a short uptime is. Said
# out loud because a crash loop reads as "running" in every tool that shows only
# a state, and that is the case this command was written to catch.
if [ -n "$C_RESTARTS" ] && [ "$C_RESTARTS" != "0" ] && [ "$C_RUNNING" = "true" ]; then
say " ^ restarted ${C_RESTARTS} time(s). A climbing count with a short"
say " uptime is a crash loop, not a healthy service."
fi
if [ -n "$C_HEALTH" ]; then
field health "$C_HEALTH"
else
# Not "healthy", and not "ok". No healthcheck is defined, so nothing about
# this container's health has been measured by anyone.
field health "not configured — this image defines no HEALTHCHECK, so nothing is being checked"
fi
if [ "$C_RUNNING" != "true" ]; then
quit "$E_STOPPED" "'${CONTAINER}' exists on ${HOST} but is ${C_STATUS}."
fi
if [ "$C_HEALTH" = "unhealthy" ]; then
quit "$E_UNHEALTHY" "'${CONTAINER}' is running, but its healthcheck reports unhealthy. Try --logs."
fi
exit 0