rig/test/cli.sh

431 lines
26 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# Dependency-free CLI assertions. Run: bash test/cli.sh
# Deliberately no `set -e` — the harness asserts on failing commands.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PASS=0 FAIL=0
# check <desc> <want_exit> <want_substr> <cmd...>
# Runs cmd, asserts exit code and (if non-empty) that combined output
# contains want_substr.
check() {
local desc="$1" want="$2" substr="$3"; shift 3
local out rc
out="$("$@" 2>&1)"; rc=$?
if [ "$rc" -ne "$want" ]; then
echo "FAIL: $desc — exit $rc, wanted $want"
printf '%s\n' "$out" | sed 's/^/ /'
FAIL=$((FAIL + 1)); return
fi
if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then
echo "FAIL: $desc — output missing '$substr'"
printf '%s\n' "$out" | sed 's/^/ /'
FAIL=$((FAIL + 1)); return
fi
echo "ok: $desc"; PASS=$((PASS + 1))
}
check "no args shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig"
check "--help exits 0" 0 "usage:" "$ROOT/bin/rig" --help
check "help exits 0" 0 "usage:" "$ROOT/bin/rig" help
check "unknown command exits 2" 2 "unknown command" "$ROOT/bin/rig" frobnicate
check "bare coolify shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" coolify
check "bootstrap: role required, exit 2" 2 "role required" "$ROOT/commands/bootstrap.sh"
check "bootstrap: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap.sh" --help
check "bootstrap: unknown role exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
check "bootstrap: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap.sh" workload --nope
check "bootstrap: hostname needs value" 2 "needs a value" "$ROOT/commands/bootstrap.sh" workload --hostname
# --ts-tag is REMOVED, not demoted: the tag now comes from the pre-auth key and
# rig verifies the GRANTED tag after join. The old runner-refuses-tag:server test
# asserted the request-time refusal THROUGH this flag; that policy now lives on
# the EFFECTIVE tag and needs a real tailnet, so it belongs to the rehearsal, not
# here. What this harness CAN prove is that the flag dies with a message pointing
# at the key (exit 2, a usage error), rather than an "unknown flag" that would
# leave an operator guessing where the tag went — value present or absent.
check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pre-auth key" \
"$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server
check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \
"$ROOT/commands/bootstrap.sh" runner --ts-tag
check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth key" \
"$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server
# The staging tag:server refusal rides the EFFECTIVE tag, inside
# verify_effective_tag — a path that needs a real tailnet, so it belongs to the
# rehearsal. What the harness CAN prove is that the refusal exists in the
# shipped script: grep the die message, so a deleted guard cannot ship green
# (the same reason the runner-install repo guard is grepped below).
check "bootstrap: staging effective-tag refusal is present" 0 "" \
grep -q "role staging joined with tag:server" "$ROOT/commands/bootstrap.sh"
# --- traits: roles are presets, every trait individually settable (#26) -----
check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato
check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe
check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon
check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \
"$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey
check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1
# workstation is join=login by preset: a set TS_AUTHKEY is a usage error, and it
# must die BEFORE the root check — provable non-root, which also proves the
# preset actually landed.
check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation
# A trait override changes derived behavior, provable non-root: dev is
# join=authkey (TS_AUTHKEY fine → falls through to the root check), but
# --join login flips it into the TS_AUTHKEY refusal.
check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login
# The login-path inverted assertion needs a real tailnet; grep the refusal so a
# deleted guard cannot ship green (repo precedent: staging/runner tag greps).
check "bootstrap: login-path tagged refusal is present" 0 "" \
grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh"
fix(bootstrap): review findings — keep-mode for authkey re-runs, fail-closed login verify, class-gated root-door assertion Three refusals, one doctrine: detect, refuse, name the repair — and never back out state rig did not create. - verify_effective_tag grows the same <back-out|keep> mode discipline as verify_user_owned. First join keeps the logout-and-die on an untagged key; the already-joined path now refuses WITHOUT logout — the untagged node may be a login-joined workstation (untagged by design) that a join=authkey re-run must not tear off the tailnet. The die names both ways out. - verify_user_owned fails CLOSED on a stalled backend: empty tags is its success signal, so a 30s poll that never saw Running waved a tagged node on a slow tailscaled through as user-owned. state!=Running now dies in both modes, logging nothing out — nothing was verified, so the repair is to re-run and verify, not to undo a join that may be fine. - The permitrootlogin acceptance is class-gated. class=human keeps no|prohibit-password|without-password (`no` is the close-root state). class=server accepts only prohibit-password|without-password: root SSH is the control plane's automation door, and `no` there means a leftover 00-rig-users.conf from a former class=human life has fleet management silently dead. Refused loudly, drop-in named, never auto-removed — silently reopening a root door is worse than a loud stop. Harness greps pin all three die messages so a deleted guard cannot ship green (repo precedent: the tag-refusal greps). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:51:53 +00:00
# Re-running with join=authkey on a box that was legitimately login-joined
# (untagged BY DESIGN) lands in verify_effective_tag's untagged branch. Backing
# out a join this run did not perform would tear down a user-owned workstation;
# the already-joined path must refuse WITHOUT logout and name both repairs.
# Needs a real tailnet to exercise, so grep the keep-mode die instead.
check "bootstrap: already-joined untagged refusal keeps the join" 0 "" \
grep -q "joined but UNTAGGED" "$ROOT/commands/bootstrap.sh"
# verify_user_owned must fail CLOSED on a stalled backend: empty tags is its
# SUCCESS signal, so a 30s poll that never saw Running would wave a tagged node
# through as user-owned. Grep the timeout die (same real-tailnet excuse).
check "bootstrap: login verify fails closed on a stalled backend" 0 "" \
grep -q "could not verify the join is user-owned" "$ROOT/commands/bootstrap.sh"
# The marker is the traits' ground truth for rig users; assert the write exists.
check "bootstrap: role marker write is present" 0 "" \
grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh"
if [ "$(id -u)" -ne 0 ]; then
check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload
check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner
check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev
check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation
check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey
else
echo "skip: bootstrap non-root refusals (running as root)"
fi
check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh"
check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help
check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version
check "coolify: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/coolify-install.sh" --nope
if [ "$(id -u)" -ne 0 ]; then
check "coolify: refuses non-root" 1 "must run as root" "$ROOT/commands/coolify-install.sh" --version 4.1.2
else
echo "skip: coolify non-root refusal (running as root)"
fi
feat(coolify): install the control-plane dump as a systemd timer The Coolify control-plane database holds the GitHub App private key, every registered server's SSH key, and every environment value for every environment it manages. Backing it up was a manual runbook step, and the dump script lived in cast — the off-box tool, whose src never references it. It runs on the box, as root, under a scheduler: that is rig's job description. It matters beyond tidiness. The dump is forensics, not a restore path — a lost control plane is rebuilt fresh and reconciled from the manifest. So there will be a next control-plane box, and as a runbook step it was born un-backed-up, depending on someone remembering mid-incident. Now it is backed up from birth. rig installs the machinery and templates /etc/coolify-dump.env empty at 0600, never reading it back — no credential passes through rig. The script's own guards make an unfilled file fail the unit loudly rather than ship plaintext. systemd timer over cron: EnvironmentFile is the right idiom for 0600 secrets, failures surface in systemctl status instead of being mailed into the void, and Persistent=true catches a run missed while the box was down. Two hazards the cast script missed, carried into the unit: - aws-cli >= 2.23 enables default upload checksums that S3-compatible backends reject; Debian 13 ships 2.23.6, so the unit defaults both checksum knobs to when_required. - A failed pg_dump piped into age still yields a valid, tiny, encrypted file that uploads cleanly every night and looks exactly like a working backup. The script now refuses to upload an empty artifact. Closes #8 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:14:07 +00:00
check "bare coolify backup shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" coolify backup
check "coolify backup: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" coolify backup frobnicate
check "coolify backup: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-backup-install.sh" --help
check "coolify backup: schedule needs value" 2 "needs a value" "$ROOT/commands/coolify-backup-install.sh" --schedule
check "coolify backup: pg-container needs value" 2 "needs a value" "$ROOT/commands/coolify-backup-install.sh" --pg-container
check "coolify backup: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/coolify-backup-install.sh" --nope
if [ "$(id -u)" -ne 0 ]; then
check "coolify backup: refuses non-root" 1 "must run as root" "$ROOT/commands/coolify-backup-install.sh"
else
echo "skip: coolify backup non-root refusal (running as root)"
fi
check "bare runner shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" runner
check "runner: --help exits 0" 0 "usage:" "$ROOT/commands/runner-install.sh" --help
check "runner: repo required, exit 2" 2 "--repo" "$ROOT/commands/runner-install.sh" --version 2.335.1
check "runner: version needs value" 2 "needs a value" "$ROOT/commands/runner-install.sh" --repo acme/widgets --version
check "runner: repo needs value" 2 "needs a value" "$ROOT/commands/runner-install.sh" --repo
check "runner: rejects bad repo slug" 2 "owner/repo" "$ROOT/commands/runner-install.sh" --repo not-a-slug --version 2.335.1
check "runner: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-install.sh" --repo acme/widgets --version 2.335.1 --user root
check "runner: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-install.sh" --nope
if [ "$(id -u)" -ne 0 ]; then
check "runner: refuses non-root" 1 "must run as root" env RUNNER_TOKEN=x "$ROOT/commands/runner-install.sh" --repo acme/widgets --version 2.335.1
else
echo "skip: runner non-root refusal (running as root)"
fi
check "runner: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" runner frobnicate
fix(runner): install refuses a box registered to another repo `rig runner install --repo <B>` on a box already registered to repo A treated the mere existence of .runner as "already registered", skipped configure, restarted the service still pointed at A, and reported success. --repo was accepted, validated, and then ignored — leaving B with zero runners and its `runs-on` jobs queued against one that will never come. This is the natural next command after a partial `repoint`, and the failure is worse than a no-op: moving a runner between repos is a trust-boundary act, so quietly putting it back on the old one defeats the point of the move. Gate install on the repo .runner actually names. Convergence — the property worth keeping — is untouched: re-running against the repo the box is already on still skips registration, never prompts for a token, and exits 0. Skipping when the repo *differs* was never convergence, only a silently ignored argument, so it now fails and names both repos, pointing at `runner repoint` (move) or `runner remove` (start over). An unreadable .runner is refused too — it is no licence to assume a match. The .runner reader that `status` and `repoint` each carried is lifted into commands/lib/runner-config.sh, which now also holds the guard. Its json_field no longer dies bare under `set -o pipefail` when a key is missing, which is what `status`'s own ${REPO_URL:-unknown} fallback always assumed. Tests: the guard is exercised against a fixture .runner (refuses another repo naming both, points at repoint, no-ops on the same repo, passes an unregistered box, refuses an unreadable one) plus an ordering assertion that it precedes svc.sh start — reaching it through the CLI would need root and a really-registered runner, which the dependency-free harness cannot fabricate. All three mutants (guard deleted, guard comparing nothing, guard moved below the service start) go red. Closes #13
2026-07-13 14:57:28 +00:00
# --- runner install: --repo must agree with what the box is already on -------
# The bug: `install --repo B` on a box registered to repo A skipped configure,
# restarted the service on A, and reported success — --repo accepted, validated,
# then ignored. The guard is exercised here through the shared lib, against a
# fixture .runner: reaching it via the CLI needs root AND a really-registered
# runner, neither of which this harness can fabricate.
guard() { # guard <runner_dir> <owner/repo>
bash -c 'set -euo pipefail
. "$1/commands/lib/runner-config.sh"
assert_runner_repo "$2" "$3"' _ "$ROOT" "$1" "$2"
}
REG_DIR="$(mktemp -d)" # a box registered to acme/alpha
EMPTY_DIR="$(mktemp -d)" # a box with no runner at all
printf '%s\n' '{"agentId":7,"agentName":"ci-box","gitHubUrl":"https://github.com/acme/alpha","workFolder":"_work"}' \
> "$REG_DIR/.runner"
check "runner install: refuses a repo the box is not registered to" \
1 "already registered to https://github.com/acme/alpha" guard "$REG_DIR" acme/beta
check "runner install: the refusal names the repo that was asked for" \
1 "not https://github.com/acme/beta" guard "$REG_DIR" acme/beta
check "runner install: the refusal points at repoint" \
1 "rig runner repoint --repo acme/beta" guard "$REG_DIR" acme/beta
# Convergence is the property worth keeping: same repo stays a clean no-op.
check "runner install: the repo it is already on is a no-op" \
0 "" guard "$REG_DIR" acme/alpha
check "runner install: an unregistered box passes the guard" \
0 "" guard "$EMPTY_DIR" acme/beta
# A .runner rig cannot read is not a licence to assume it matches.
printf '%s\n' '{"agentName":"ci-box"}' > "$REG_DIR/.runner"
check "runner install: refuses an unreadable registration" \
1 "names no repository" guard "$REG_DIR" acme/alpha
rm -rf "$REG_DIR" "$EMPTY_DIR"
# --- json_string_array: json_field's array-aware sibling ---------------------
# bootstrap reads `.Self.Tags` (a JSON array) out of `tailscale status --json` to
# assert the tag control GRANTED the node — and a rig box has no jq. Exercise the
# reader against fixture netmaps here, the same shared-lib way the guard above is:
# the bootstrap path that calls it needs a real tailnet this harness cannot fake.
tags() { # tags <file> — prints one tag per line, exactly like the reader
bash -c 'set -euo pipefail
. "$1/commands/lib/runner-config.sh"
json_string_array "$2" Tags' _ "$ROOT" "$1"
}
tags_count() { # tags_count <file> — prints how many tags were read (0 if none)
bash -c 'set -euo pipefail
. "$1/commands/lib/runner-config.sh"
json_string_array "$2" Tags | grep -c . || true' _ "$ROOT" "$1"
}
tags_empty() { # tags_empty <file> — exit 0 iff the reader prints NOTHING
bash -c 'set -euo pipefail
. "$1/commands/lib/runner-config.sh"
[ -z "$(json_string_array "$2" Tags)" ]' _ "$ROOT" "$1"
}
FIX_TAGGED="$(mktemp)" # Self carries two tags; a peer carries a third
FIX_UNTAGGED="$(mktemp)" # Self has no Tags key at all — the untagged hazard
cat > "$FIX_TAGGED" <<'JSON'
{
"BackendState": "Running",
"Self": {
"HostName": "ci-box",
"Tags": [
"tag:ci",
"tag:build"
]
},
"Peer": {
"nodekey:abc": {
"HostName": "coolify-box",
"Tags": [
"tag:server"
]
}
}
}
JSON
cat > "$FIX_UNTAGGED" <<'JSON'
{
"BackendState": "Running",
"Self": {
"HostName": "user-owned-box"
}
}
JSON
check "json_string_array: reads the first array element" 0 "tag:ci" tags "$FIX_TAGGED"
check "json_string_array: reads a later array element" 0 "tag:build" tags "$FIX_TAGGED"
# Self precedes Peer in the netmap, so the FIRST "Tags" is the node's own: exactly
# two elements read proves the peer's tag:server did not leak into Self's tags.
check "json_string_array: reads Self's array, not a peer's" 0 "2" tags_count "$FIX_TAGGED"
# An absent key omits itself (Go omitempty), never emits []: empty is the signal
# bootstrap turns into a hard untagged-key refusal, so it must read as empty here.
check "json_string_array: absent Tags key prints nothing" 0 "" tags_empty "$FIX_UNTAGGED"
rm -f "$FIX_TAGGED" "$FIX_UNTAGGED"
fix(runner): install refuses a box registered to another repo `rig runner install --repo <B>` on a box already registered to repo A treated the mere existence of .runner as "already registered", skipped configure, restarted the service still pointed at A, and reported success. --repo was accepted, validated, and then ignored — leaving B with zero runners and its `runs-on` jobs queued against one that will never come. This is the natural next command after a partial `repoint`, and the failure is worse than a no-op: moving a runner between repos is a trust-boundary act, so quietly putting it back on the old one defeats the point of the move. Gate install on the repo .runner actually names. Convergence — the property worth keeping — is untouched: re-running against the repo the box is already on still skips registration, never prompts for a token, and exits 0. Skipping when the repo *differs* was never convergence, only a silently ignored argument, so it now fails and names both repos, pointing at `runner repoint` (move) or `runner remove` (start over). An unreadable .runner is refused too — it is no licence to assume a match. The .runner reader that `status` and `repoint` each carried is lifted into commands/lib/runner-config.sh, which now also holds the guard. Its json_field no longer dies bare under `set -o pipefail` when a key is missing, which is what `status`'s own ${REPO_URL:-unknown} fallback always assumed. Tests: the guard is exercised against a fixture .runner (refuses another repo naming both, points at repoint, no-ops on the same repo, passes an unregistered box, refuses an unreadable one) plus an ordering assertion that it precedes svc.sh start — reaching it through the CLI would need root and a really-registered runner, which the dependency-free harness cannot fabricate. All three mutants (guard deleted, guard comparing nothing, guard moved below the service start) go red. Closes #13
2026-07-13 14:57:28 +00:00
# The guard is only worth something if it runs BEFORE the box is touched: the
# token prompt, the download, configure and svc.sh start all come after it.
# Ordering is the whole fix, so assert it rather than trust it.
# Matches the CALL, not the word: the comment above it mentions assert_runner_repo
# too, and a plain grep would keep finding that after the call itself was deleted.
# The defaults fail closed, so a guard that is gone cannot read as one that merely
# sits early in the file.
guard_at="$(grep -nE '^[[:space:]]*assert_runner_repo ' "$ROOT/commands/runner-install.sh" | head -n1 | cut -d: -f1)"
start_at="$(grep -n 'svc.sh start' "$ROOT/commands/runner-install.sh" | head -n1 | cut -d: -f1)"
check "runner install: the repo guard precedes svc.sh start" \
0 "" test "${guard_at:-999999}" -lt "${start_at:-0}"
check "runner status: --help exits 0" 0 "usage:" "$ROOT/commands/runner-status.sh" --help
check "runner status: user needs value" 2 "needs a value" "$ROOT/commands/runner-status.sh" --user
check "runner status: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-status.sh" --user root
check "runner status: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-status.sh" --nope
check "runner remove: --help exits 0" 0 "usage:" "$ROOT/commands/runner-remove.sh" --help
check "runner remove: user needs value" 2 "needs a value" "$ROOT/commands/runner-remove.sh" --user
check "runner remove: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-remove.sh" --user root
check "runner remove: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-remove.sh" --nope
check "runner repoint: --help exits 0" 0 "usage:" "$ROOT/commands/runner-repoint.sh" --help
check "runner repoint: repo required" 2 "--repo" "$ROOT/commands/runner-repoint.sh"
check "runner repoint: repo needs value" 2 "needs a value" "$ROOT/commands/runner-repoint.sh" --repo
check "runner repoint: rejects bad slug" 2 "owner/repo" "$ROOT/commands/runner-repoint.sh" --repo not-a-slug
check "runner repoint: labels need value" 2 "needs a value" "$ROOT/commands/runner-repoint.sh" --repo acme/widgets --labels
check "runner repoint: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-repoint.sh" --repo acme/widgets --user root
check "runner repoint: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-repoint.sh" --nope
if [ "$(id -u)" -ne 0 ]; then
check "runner status: refuses non-root" 1 "must run as root" "$ROOT/commands/runner-status.sh"
check "runner remove: refuses non-root" 1 "must run as root" \
env RUNNER_REMOVE_TOKEN=x "$ROOT/commands/runner-remove.sh"
# --local too: the token-free path must still not be runnable by the runner user.
check "runner remove: --local refuses non-root" 1 "must run as root" \
"$ROOT/commands/runner-remove.sh" --local
check "runner repoint: refuses non-root" 1 "must run as root" \
env RUNNER_REMOVE_TOKEN=x RUNNER_TOKEN=y "$ROOT/commands/runner-repoint.sh" --repo acme/widgets
else
echo "skip: runner status/remove/repoint non-root refusals (running as root)"
fi
check "bare users shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" users
check "users: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" users frobnicate
check "users apply: --help exits 0" 0 "usage:" "$ROOT/commands/users-apply.sh" --help
check "users apply: --file required" 2 "--file" "$ROOT/commands/users-apply.sh"
check "users apply: --file needs value" 2 "needs a value" "$ROOT/commands/users-apply.sh" --file
check "users apply: missing file exits 2" 2 "cannot read" "$ROOT/commands/users-apply.sh" --file /nonexistent/users
check "users apply: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-apply.sh" --nope
check "users status: --help exits 0" 0 "usage:" "$ROOT/commands/users-status.sh" --help
# --- users file refusal matrix, through the sourced parser -------------------
# Reaching the parser via the CLI stops at the root check; it is pure and
# sourceable on purpose (repo precedent: assert_runner_repo, json_string_array),
# so the refusals are proven here against fixtures, non-root and network-free.
parse() { # parse <file> — the users-file parser, exactly as apply runs it
bash -c 'set -euo pipefail
. "$1/commands/lib/users-config.sh"
parse_users_file "$2"' _ "$ROOT" "$1"
}
FIX_OK="$(mktemp)" # two operators; dan carries a second key on a repeat line
FIX_BAD="$(mktemp)" # rewritten per refusal below
cat > "$FIX_OK" <<'USERS'
# fleet operators
dan admin,box ssh-ed25519 AAAAC3fixture dan@laptop
dan admin,box ssh-ed25519 AAAAC3second dan@desk
maria rig ssh-ed25519 AAAAC3fixture maria@mac
USERS
printf '%s\n' 'maria ops ssh-ed25519 AAAA maria@mac' > "$FIX_BAD"
check "users parser: unknown role names the valid set" 1 "valid roles: admin rig box" parse "$FIX_BAD"
printf '%s\n' 'dan admin ssh-ed25519 AAAA a' 'dan admin,box ssh-ed25519 BBBB b' > "$FIX_BAD"
check "users parser: differing roles across one user's lines" 1 "roles must be identical" parse "$FIX_BAD"
printf '%s\n' 'root admin ssh-ed25519 AAAA r' > "$FIX_BAD"
check "users parser: root is refused" 1 "not a rig-managed user" parse "$FIX_BAD"
printf '%s\n' 'dan admin' > "$FIX_BAD"
check "users parser: malformed line is refused" 1 "malformed" parse "$FIX_BAD"
check "users parser: valid file emits dan (both keys' roles agree)" \
0 "dan|admin,box|ssh-ed25519 AAAAC3second dan@desk" parse "$FIX_OK"
check "users parser: valid file emits maria too" 0 "maria|rig|ssh-ed25519" parse "$FIX_OK"
# ALL errors in ONE pass: a bad file costs one fix cycle, not one per error.
# A single invocation, both messages asserted from its one stderr.
printf '%s\n' 'root admin ssh-ed25519 AAAA r' 'maria ops ssh-ed25519 AAAA m' > "$FIX_BAD"
MULTI_ERRS="$(mktemp)"
parse "$FIX_BAD" 2> "$MULTI_ERRS"; multi_rc=$?
check "users parser: multi-error file exits 1" 0 "" test "$multi_rc" -eq 1
check "users parser: one run reports the root line" 0 "" grep -q "not a rig-managed user" "$MULTI_ERRS"
check "users parser: same run reports the bad role" 0 "" grep -q "unknown role" "$MULTI_ERRS"
rm -f "$MULTI_ERRS"
if [ "$(id -u)" -ne 0 ]; then
# A VALID fixture proves the whole file-validation pass sits before the
# root check — a parse failure here would exit 2, not 1.
check "users apply: refuses non-root" 1 "must run as root" "$ROOT/commands/users-apply.sh" --file "$FIX_OK"
check "users status: refuses non-root" 1 "must run as root" "$ROOT/commands/users-status.sh"
else
echo "skip: users non-root refusals (running as root)"
fi
rm -f "$FIX_OK" "$FIX_BAD"
# Validate-then-apply: `visudo -c` must pass before anything lands in
# /etc/sudoers.d — a bad drop-in takes down ALL of sudo, locking every admin
# out of the escalation path apply just granted. Assert the order in the file,
# matching the calls rather than comments (repo precedent: the runner-install
# repo-guard ordering check). Defaults fail closed.
visudo_at="$(grep -n 'visudo -c' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)"
sudoers_at="$(grep -nE 'install .*sudoers\.d/rig-roles' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)"
check "users apply: visudo -c precedes the sudoers install" \
0 "" test "${visudo_at:-999999}" -lt "${sudoers_at:-0}"
# --- users close-root: the human-class root-door shutter ---------------------
check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help
check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope
# The whole command rests on first-wins + lexical include order: '-' (0x2D)
# sorts before '.' (0x2E), so 00-rig-users.conf is read before bootstrap's
# 00-rig.conf and its PermitRootLogin wins. Assert the actual comparison the
# glob makes, so a renamed drop-in cannot silently lose the fight.
check "users close-root: drop-in name sorts before bootstrap's" 0 "" \
bash -c '[ "00-rig-users.conf" \< "00-rig.conf" ]'
check "users close-root: drop-in name is the load-bearing one" 0 "" \
grep -q "00-rig-users.conf" "$ROOT/commands/users-close-root.sh"
# Validate-then-apply: `sshd -t` on the merged config must precede the restart —
# on a box whose only door is SSH (exactly what this box is about to become),
# bouncing the daemon into a config it refuses to parse leaves no way back in.
# Match the call, not the word (repo precedent: the repo-guard ordering check);
# defaults fail closed.
sshdt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
restart_at="$(grep -n 'systemctl restart ssh' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
check "users close-root: sshd -t precedes the ssh restart" \
0 "" test "${sshdt_at:-999999}" -lt "${restart_at:-0}"
# Marker-gate refusals through the sourced lib against fixture markers: the CLI
# path sits behind the root check, so the gate is a pure lib function on
# purpose (repo precedent: parse_users_file, assert_runner_repo). The command
# reads the marker path from RIG_ROLE_MARKER for the same reason — so the gate
# stays pointable at fixtures.
marker_gate() { # marker_gate <marker_path>
bash -c 'set -euo pipefail
. "$1/commands/lib/users-config.sh"
assert_marker_human "$2"' _ "$ROOT" "$1"
}
MARKER_DIR="$(mktemp -d)"
printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_DIR/server"
printf 'role=dev class=human host=yes join=authkey\n' > "$MARKER_DIR/human"
check "users close-root: absent marker refuses, names bootstrap as the repair" \
1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent"
check "users close-root: class=server refuses, names the control plane" \
1 "control plane" marker_gate "$MARKER_DIR/server"
check "users close-root: class=human passes the gate" \
0 "" marker_gate "$MARKER_DIR/human"
rm -rf "$MARKER_DIR"
if [ "$(id -u)" -ne 0 ]; then
check "users close-root: refuses non-root" 1 "must run as root" "$ROOT/commands/users-close-root.sh"
else
echo "skip: users close-root non-root refusal (running as root)"
fi
# Bootstrap must read the closed door as hardened, not broken: `no` is the
# post-close-root state, strictly harder than what bootstrap installs. Byte-grep
# the widened assertion so a revert cannot ship green.
check "bootstrap: permitrootlogin assertion accepts the closed state" 0 "" \
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/bootstrap.sh"
fix(bootstrap): review findings — keep-mode for authkey re-runs, fail-closed login verify, class-gated root-door assertion Three refusals, one doctrine: detect, refuse, name the repair — and never back out state rig did not create. - verify_effective_tag grows the same <back-out|keep> mode discipline as verify_user_owned. First join keeps the logout-and-die on an untagged key; the already-joined path now refuses WITHOUT logout — the untagged node may be a login-joined workstation (untagged by design) that a join=authkey re-run must not tear off the tailnet. The die names both ways out. - verify_user_owned fails CLOSED on a stalled backend: empty tags is its success signal, so a 30s poll that never saw Running waved a tagged node on a slow tailscaled through as user-owned. state!=Running now dies in both modes, logging nothing out — nothing was verified, so the repair is to re-run and verify, not to undo a join that may be fine. - The permitrootlogin acceptance is class-gated. class=human keeps no|prohibit-password|without-password (`no` is the close-root state). class=server accepts only prohibit-password|without-password: root SSH is the control plane's automation door, and `no` there means a leftover 00-rig-users.conf from a former class=human life has fleet management silently dead. Refused loudly, drop-in named, never auto-removed — silently reopening a root door is worse than a loud stop. Harness greps pin all three die messages so a deleted guard cannot ship green (repo precedent: the tag-refusal greps). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:51:53 +00:00
# ...but only for class=human. On class=server a closed root door is a BROKEN
# box — root SSH is the control plane's automation door — and the usual cause
# is a 00-rig-users.conf left over from a former class=human life. The refusal
# must name that drop-in or the operator greps sshd configs blind; the path
# needs root + a doctored sshd, so grep the die message (repo precedent above).
check "bootstrap: class=server refusal names the stale close-root drop-in" 0 "" \
grep -q "leftover /etc/ssh/sshd_config.d/00-rig-users.conf" "$ROOT/commands/bootstrap.sh"
fix(coolify): validate the dump bindings, and stop printing $EDITOR Both found by the first run on a real control-plane box — neither was reachable by the argument-parsing tests. $EDITOR is unset on a freshly-bootstrapped server, which is precisely rig's target environment. The printed next-step `$EDITOR /etc/coolify-dump.env` expanded to nothing, so bash tried to EXECUTE the 0600 bindings file and said "Permission denied" — an error that reads like a filesystem problem and is not one. Print `nano`. A bare bucket name in S3_BUCKET reads to `aws` as a LOCAL path, so the upload died with "Invalid argument type" and a usage dump — after pg_dump had run and age had encrypted 14MB, with nothing in the error pointing at the actual mistake. The script now validates the bindings up front: S3_BUCKET must be an s3:// URI, S3_ENDPOINT must carry a scheme. Both fail with the value quoted and the reason stated, before a database is read. Note what still cannot be validated, and now says so in the script: age's X25519 header does not reveal its recipient, so a valid-but-WRONG key (staging's instead of prod's) yields a flawless backup nobody can open. Only decrypting an artifact proves the recipient. The printed next-steps now walk through that read-back explicitly, from a machine holding the private key — never the box. The dump script ships as an embedded heredoc, so a typo in it would first surface at 04:00 on a live control plane. test/cli.sh now extracts it and asserts it is valid bash and that both new guards fire. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:55:39 +00:00
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax
# error in it would be invisible here and would first surface at 04:00 on a live
# control plane. Extract it and syntax-check what actually gets written.
DUMP_TMP="$(mktemp)"
sed -n "/<<'DUMP_SCRIPT'/,/^DUMP_SCRIPT\$/p" "$ROOT/commands/coolify-backup-install.sh" \
| sed '1d;$d' > "$DUMP_TMP"
check "embedded dump script extracted (guards the sed above)" 0 "" grep -q "pg_dump" "$DUMP_TMP"
check "embedded dump script is valid bash" 0 "" bash -n "$DUMP_TMP"
check "embedded dump script rejects a bare bucket name" 1 "must be an s3:// URI" \
env AGE_RECIPIENT=age1x S3_BUCKET=my-bucket S3_ENDPOINT=https://s3.example.com bash "$DUMP_TMP"
check "embedded dump script rejects a schemeless endpoint" 1 "needs a scheme" \
env AGE_RECIPIENT=age1x S3_BUCKET=s3://b/k S3_ENDPOINT=s3.example.com bash "$DUMP_TMP"
rm -f "$DUMP_TMP"
# Regression: /etc/os-release defines VERSION (e.g. "13 (trixie)" on Debian);
# sourcing it in the main shell clobbers a script's $VERSION and splices the
# OS string into download URLs. It must only ever be sourced in a subshell.
check "no main-shell os-release sourcing" 1 "" \
grep -rnE '^[[:space:]]*\.[[:space:]]+/etc/os-release' "$ROOT/commands"
echo "---"
echo "$PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]