679 lines
27 KiB
Bash
Executable File
679 lines
27 KiB
Bash
Executable File
#!/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:-}"
|
|
|
|
# The container name (or id) to report on. Exactly one.
|
|
CONTAINER="${STATUS_CONTAINER:-}"
|
|
|
|
# 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
|