mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-12 00:05:09 +00:00
228 lines
12 KiB
Bash
Executable File
228 lines
12 KiB
Bash
Executable File
#!/bin/sh
|
|
# Everything CI should know about the fleet join script.
|
|
#
|
|
# The script is served verbatim from the backend at GET /join.sh and is what a
|
|
# stranger pipes into a root shell to add a machine, which makes it the
|
|
# highest-consequence file in the repo that is not Go. Nothing else covered it,
|
|
# and that is how a systemd unit that could never start, an env file with a
|
|
# stray JSON fragment in it, and a state directory the node could not write all
|
|
# reached the branch at once.
|
|
#
|
|
# What is checked:
|
|
# - POSIX parse under dash, which is /bin/sh on Debian and Ubuntu
|
|
# - shellcheck, in sh mode
|
|
# - --help exits 0 and says something
|
|
# - the generated systemd unit is ONE ExecStart line with the image as a
|
|
# systemd variable, not a command substitution systemd would never expand
|
|
# - the mount list always includes the agent directory
|
|
# - a relative BLOB_FS_ROOT is refused rather than mounted
|
|
# - the local override env file is passed AFTER node.env, so it wins, and is
|
|
# created without ever truncating one that is already there
|
|
# - --dry-run masks every credential, including the ones that hide inside a
|
|
# URL, and does it with an expression this sed actually supports
|
|
set -eu
|
|
|
|
SCRIPT="internal/api/handler/nodescript/join.sh"
|
|
fail() { printf 'check-join-script: %s\n' "$*" >&2; exit 1; }
|
|
ok() { printf ' ok %s\n' "$*"; }
|
|
|
|
[ -f "$SCRIPT" ] || fail "$SCRIPT not found (run from the repository root)"
|
|
|
|
# POSIX parse. sh -n under a non-POSIX shell proves nothing about dash, which
|
|
# is /bin/sh on Debian and Ubuntu, so dash is used when it is there.
|
|
parse_check() {
|
|
if command -v dash >/dev/null 2>&1; then
|
|
dash -n "$1" || fail "dash -n failed on $1"
|
|
else
|
|
sh -n "$1" || fail "sh -n failed on $1"
|
|
fi
|
|
}
|
|
|
|
parse_check "$SCRIPT"
|
|
# The checker too: its own shellcheck-disable directives are load-bearing.
|
|
parse_check "$0"
|
|
if command -v dash >/dev/null 2>&1; then
|
|
ok "dash -n (script and checker)"
|
|
else
|
|
printf ' -- dash not installed; parsed with sh -n instead\n'
|
|
fi
|
|
|
|
if command -v shellcheck >/dev/null 2>&1; then
|
|
shellcheck -s sh "$SCRIPT" || fail "shellcheck failed on $SCRIPT"
|
|
shellcheck -s sh "$0" || fail "shellcheck failed on $0"
|
|
ok "shellcheck -s sh (script and checker)"
|
|
else
|
|
printf ' -- shellcheck not installed; skipped\n'
|
|
fi
|
|
|
|
out=$(sh "$SCRIPT" --help) || fail "--help exited non-zero"
|
|
printf '%s' "$out" | grep -q -- "--token" || fail "--help does not document --token"
|
|
ok "--help"
|
|
|
|
# Everything below asserts on what the script RENDERS, never on its source
|
|
# text. A previous version of this file checked a heredoc copied in here, which
|
|
# meant putting the original `$(cat ...)` bug back left it passing green.
|
|
# The one non-default environment the unit is rendered under. Named for what
|
|
# it is rather than dressed up as a list: sh has no clean way to iterate blocks
|
|
# that themselves contain newlines, so a third variant means adding it to the
|
|
# `for` below by hand.
|
|
FS_BLOB_ENV='BLOB_PROVIDER=fs
|
|
BLOB_FS_ROOT=/var/lib/warmbly/blobs'
|
|
|
|
# NODE_ENV is forced empty rather than inherited: this repo exports NODE_ENV in
|
|
# several trees, and an inherited value would render a unit this check did not
|
|
# choose, or fail validate_blob_root for an unrelated reason.
|
|
unit=$(NODE_ENV="" sh "$SCRIPT" --print-unit) || fail "--print-unit failed"
|
|
|
|
# shellcheck disable=SC2016 # the pattern is literal on purpose; it must not expand
|
|
printf '%s\n' "$unit" | grep -q 'ExecStart=.*\${WARMBLY_IMAGE_REF}$' \
|
|
|| fail "ExecStart must end with the systemd variable \${WARMBLY_IMAGE_REF}"
|
|
# shellcheck disable=SC2016 # literal on purpose
|
|
if printf '%s\n' "$unit" | grep -q 'ExecStart=.*\$('; then
|
|
fail "ExecStart contains a command substitution; systemd never expands one"
|
|
fi
|
|
[ "$(printf '%s\n' "$unit" | grep -c '^ExecStart=')" = "1" ] \
|
|
|| fail "ExecStart must be exactly one line"
|
|
printf '%s\n' "$unit" | grep -q '^EnvironmentFile=/var/lib/warmbly/image-ref$' \
|
|
|| fail "image-ref must stay in the root-owned state dir; the node must not be able to rewrite it"
|
|
printf '%s\n' "$unit" | grep -q 'ExecStart=.*-v /var/lib/warmbly/node:/var/lib/warmbly/node' \
|
|
|| fail "the agent directory must always be mounted, or auto-update stops silently"
|
|
ok "rendered unit (no blob mount)"
|
|
|
|
# node.local.env is the operator's half of the config and the only one a
|
|
# re-join does not rewrite. Docker applies --env-file in order, so it has to
|
|
# come AFTER node.env or an override silently loses to the generated value.
|
|
# Matched as one ordered pattern rather than two greps, which would pass with
|
|
# the files reversed.
|
|
printf '%s\n' "$unit" | grep -q 'ExecStart=.*--env-file /etc/warmbly/node.env .*--env-file /etc/warmbly/node.local.env' \
|
|
|| fail "node.local.env must be passed after node.env, or a local override loses to the generated value"
|
|
ok "local override env file is passed last"
|
|
|
|
# With local blobs the root has to be mounted too, and the line must still be
|
|
# one line: a multi-line mount list is how the continuation collapsed before.
|
|
unit=$(NODE_ENV="$FS_BLOB_ENV" sh "$SCRIPT" --print-unit) || fail "--print-unit with blobs failed"
|
|
printf '%s\n' "$unit" | grep -q 'ExecStart=.*-v /var/lib/warmbly/blobs:/var/lib/warmbly/blobs' \
|
|
|| fail "BLOB_FS_ROOT must be mounted (the fs alias counts as filesystem)"
|
|
[ "$(printf '%s\n' "$unit" | grep -c '^ExecStart=')" = "1" ] \
|
|
|| fail "ExecStart must stay one line when a blob mount is added"
|
|
ok "rendered unit (fs alias + blob mount)"
|
|
|
|
# A relative root is refused rather than rendered into a mount docker rejects.
|
|
if NODE_ENV="BLOB_PROVIDER=filesystem
|
|
BLOB_FS_ROOT=data/blobs" sh "$SCRIPT" --print-unit >/dev/null 2>&1; then
|
|
fail "a relative BLOB_FS_ROOT must be refused, not mounted"
|
|
fi
|
|
ok "relative BLOB_FS_ROOT refused"
|
|
|
|
# NO EnvironmentFile may point into the node-writable mount. Asserting only
|
|
# that the right one exists is not enough: an extra one under AGENT_DIR would
|
|
# let the container choose the image root's `docker run --network host` runs.
|
|
# Checked in every render, not just the default one. EnvironmentFile does not
|
|
# vary with NODE_ENV today, but the point of this assertion is what someone
|
|
# changes tomorrow, and "today it is redundant" is exactly the reasoning that
|
|
# already dropped this guard once. One extra subshell is a fair price.
|
|
for variant_env in "" "$FS_BLOB_ENV"; do
|
|
v_unit=$(NODE_ENV="$variant_env" sh "$SCRIPT" --print-unit) || fail "--print-unit failed"
|
|
if printf '%s\n' "$v_unit" | grep '^EnvironmentFile=' | grep -q '/var/lib/warmbly/node'; then
|
|
fail "an EnvironmentFile points into the node-writable mount; the node could choose the image root runs"
|
|
fi
|
|
done
|
|
ok "no EnvironmentFile is node-writable (every render)"
|
|
|
|
# --dry-run prints the config a node would receive, which now includes a
|
|
# database DSN. Run the real function against the shapes a node is actually
|
|
# sent, rather than reading the expression: the first version of it used a BRE
|
|
# alternation, which is a GNU extension, so on any other sed it matched nothing
|
|
# and printed every secret in clear while looking correct in review.
|
|
redact_fn=$(awk '/^redact\(\) \{/ { inside = 1 } inside { print } inside && $0 == "}" { exit }' "$SCRIPT")
|
|
[ -n "$redact_fn" ] || fail "function redact() not found in $SCRIPT"
|
|
|
|
redacted=$(printf '%s\n' "$redact_fn" > /tmp/warmbly-redact.$$ && \
|
|
printf '%s\n' \
|
|
'PRIMARY_DB=postgres://warmbly:dbsecret@db.example.com:5432/warmbly' \
|
|
'NATS_URL=tls://bussecret@bus.example.com:4222' \
|
|
'REDIS=rediss://:cachesecret@bus.example.com:6380' \
|
|
'INTERNAL_API_TOKEN=tokensecret' \
|
|
'NODE_BROKER_TOKEN=brokersecret' \
|
|
'CREDENTIALS_ENCRYPTION_KEY=keysecret' \
|
|
'BOX_GOOGLE_CLIENT_SECRET=oauthsecret' \
|
|
'ENCRYPTED_KEYS_BACKEND_URL=https://api.example.com' \
|
|
| sh -c ". /tmp/warmbly-redact.$$; redact")
|
|
rm -f "/tmp/warmbly-redact.$$"
|
|
|
|
for leaked in dbsecret bussecret cachesecret tokensecret brokersecret keysecret oauthsecret; do
|
|
if printf '%s\n' "$redacted" | grep -q "$leaked"; then
|
|
fail "--dry-run prints $leaked in clear; the redaction does not cover it on this sed"
|
|
fi
|
|
done
|
|
# The addresses are the reason --dry-run exists, so they have to survive.
|
|
printf '%s\n' "$redacted" | grep -q 'db.example.com:5432' \
|
|
|| fail "redaction ate the database host; only the credential should go"
|
|
printf '%s\n' "$redacted" | grep -q '^ENCRYPTED_KEYS_BACKEND_URL=https://api.example.com$' \
|
|
|| fail "redaction masked an address that carries no credential"
|
|
ok "--dry-run masks every credential and keeps the addresses"
|
|
|
|
# Two invariants that leave no trace in the rendered unit and so cannot be
|
|
# caught above: both were real defects, so they are asserted at their call
|
|
# sites. Comments are stripped and the call is matched in command position, so
|
|
# a commented-out call fails while reformatting does not.
|
|
# body_of prints one function's body. Tolerant about the definition's spacing,
|
|
# and loud when the function is not found: an empty body would otherwise fail
|
|
# every assertion below with a message about the wrong thing.
|
|
body_of() {
|
|
out=$(awk -v fn="$1" '
|
|
$0 ~ "^" fn "[ \t]*\\([ \t]*\\)[ \t]*{" { inside = 1 }
|
|
inside { print }
|
|
inside && $0 == "}" { exit }' "$SCRIPT")
|
|
[ -n "$out" ] || fail "function $1() not found in $SCRIPT"
|
|
printf '%s\n' "$out"
|
|
}
|
|
|
|
# Match the call lines themselves, not any line mentioning the word: "enrol"
|
|
# also appears inside the word "enrolment" in a comment.
|
|
# Captured before asserting: body_of fails when the function is missing, and a
|
|
# pipeline would run it in a subshell where that failure is lost and the
|
|
# misleading assertion message wins.
|
|
main_body=$(body_of main)
|
|
printf '%s\n' "$main_body" | awk '
|
|
$1 == "enrol" { e = NR }
|
|
$1 == "validate_blob_root" { v = NR }
|
|
$1 == "write_config" { w = NR }
|
|
END { exit !(e && v && w && e < v && v < w) }' \
|
|
|| fail "main must call validate_blob_root between enrol and write_config"
|
|
ok "config is validated before anything is written"
|
|
|
|
# Field match on the first word, which cannot be fooled by the name appearing
|
|
# inside a string or a comment. That does mean the call has to stay a
|
|
# standalone statement; join.sh says so at the call site. A looser regex was
|
|
# tried and was satisfied by `warn "... ensure_blob_root ..."`, which is a much
|
|
# worse failure than a reformat that reports itself clearly.
|
|
install_body=$(body_of install_units)
|
|
printf '%s\n' "$install_body" | awk '$1 == "ensure_blob_root" { found = 1 } END { exit !found }' \
|
|
|| fail "install_units must call ensure_blob_root as a standalone statement, or a filesystem-blob node restart-loops"
|
|
ok "blob root is prepared before the unit is installed"
|
|
|
|
# The unit names node.local.env, so a join that does not create it leaves the
|
|
# service unable to start at all: docker refuses a missing --env-file.
|
|
write_body=$(body_of write_config)
|
|
printf '%s\n' "$write_body" | awk '$1 == "ensure_local_env" { found = 1 } END { exit !found }' \
|
|
|| fail "write_config must call ensure_local_env as a standalone statement; the unit names the file and docker refuses a missing --env-file"
|
|
ok "local override env file is created on join"
|
|
|
|
# The whole point of the file is that a re-join keeps it. A creation path that
|
|
# can truncate would discard the credential an operator put there, which is
|
|
# both silent and unrecoverable.
|
|
# Matched on the shape of the invariant rather than one spelling of it: some
|
|
# existence test naming the file, before the line that writes it. `if [ -f ]`,
|
|
# `if ! test -f` and an AND-OR all satisfy this; only dropping the guard does
|
|
# not.
|
|
local_body=$(body_of ensure_local_env)
|
|
printf '%s\n' "$local_body" | awk '
|
|
/-f .*node\.local\.env/ { guard = NR }
|
|
/> *"?\$CONFIG_DIR\/node\.local\.env"?/ { write = NR }
|
|
END { exit !(guard && write && guard < write) }' \
|
|
|| fail "ensure_local_env must test for an existing file before writing one, or a re-join truncates the operator's file"
|
|
ok "an existing local override file is never truncated"
|
|
|
|
printf 'check-join-script: all checks passed\n'
|