ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: fred <[email protected]>
182 lines
6.8 KiB
Bash
Executable File
182 lines
6.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# seat-logins.sh — project seat credentials into tea's login config.
|
|
#
|
|
# Issue: mosaicstack/stack#1356 (tea login resolution fails open).
|
|
#
|
|
# WHY THIS EXISTS. tea 0.14.0 has no --token on its operations; it can only use a
|
|
# login already stored in ~/.config/tea/config.yml. So the wrappers cannot read the
|
|
# seat secrets dir on the tea path. The secrets dir stays authoritative and this
|
|
# script projects it into tea's config, which is a DERIVED CACHE: regenerate it,
|
|
# never hand-edit it. Same shape as the config-registry projector, same reason —
|
|
# a third-party tool that cannot read our store has to be fed.
|
|
#
|
|
# Canonical login name is "<instance>-<seat>", which is what the identity ladder in
|
|
# detect-platform.sh computes from the seat name. A login the ladder cannot compute
|
|
# is a fail-open surface, so an ad-hoc name is a defect, not a style.
|
|
#
|
|
# COLLISIONS. tea refuses to store one token under two names ("token already been
|
|
# used, delete login 'X' first"). A hand-made alias holding a seat's token there-
|
|
# fore BLOCKS its canonical name. Detected up front by hashing, so a dry run shows
|
|
# it; --adopt resolves it by deleting the alias and re-minting canonically. Same
|
|
# token, same access, only the label changes.
|
|
#
|
|
# Tokens are never printed, never logged, and never passed on a visible command
|
|
# line beyond tea's own --token, which is unavoidable with this client. tea's
|
|
# stderr is echoed on failure with any token-shaped string redacted.
|
|
#
|
|
# Usage:
|
|
# seat-logins.sh # dry run, all seats (default: changes nothing)
|
|
# seat-logins.sh --apply # mint/refresh all seats
|
|
# seat-logins.sh --seat <seat> # limit to one seat
|
|
# seat-logins.sh --apply --adopt # also rename ad-hoc aliases to canonical names
|
|
set -euo pipefail
|
|
|
|
BRAIN_HOME="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
|
TEA_CONFIG="${TEA_CONFIG:-$HOME/.config/tea/config.yml}"
|
|
APPLY=0
|
|
ADOPT=0
|
|
ONLY_SEAT=""
|
|
|
|
# Instance -> server URL.
|
|
#
|
|
# Instances are named here because there is no registry to read them from yet.
|
|
# Override per-instance without editing this file, which is how a deployment adds
|
|
# its own hosts: MOSAIC_GITEA_URL_<INSTANCE>=https://...
|
|
declare -A INSTANCE_URL=(
|
|
[mosaicstack]="https://git.mosaicstack.dev"
|
|
[usc]="https://git.uscllc.com"
|
|
)
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--apply) APPLY=1; shift ;;
|
|
--adopt) ADOPT=1; shift ;;
|
|
--seat) ONLY_SEAT="${2:?--seat needs a name}"; shift 2 ;;
|
|
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
|
|
*) echo "seat-logins.sh: unknown argument '$1'" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
command -v tea >/dev/null || { echo "seat-logins.sh: tea not on PATH" >&2; exit 1; }
|
|
|
|
url_for() {
|
|
local inst="$1" ovr
|
|
ovr="MOSAIC_GITEA_URL_$(printf '%s' "$inst" | tr '[:lower:]-' '[:upper:]_')"
|
|
if [ -n "${!ovr:-}" ]; then printf '%s' "${!ovr}"; return 0; fi
|
|
printf '%s' "${INSTANCE_URL[$inst]:-}"
|
|
}
|
|
|
|
# Redact anything token-shaped before any tea output reaches a log.
|
|
redact() { sed -E 's/[A-Za-z0-9]{30,}/<REDACTED>/g'; }
|
|
|
|
# token sha256 -> login name, for every login tea already holds. This is what
|
|
# makes collisions visible in a DRY RUN instead of only as an apply-time error.
|
|
declare -A TOKEN_OWNER=()
|
|
if [ -r "$TEA_CONFIG" ]; then
|
|
while read -r sha lname; do
|
|
[ -n "${sha:-}" ] && TOKEN_OWNER["$sha"]="$lname"
|
|
done < <(python3 - "$TEA_CONFIG" <<'PY'
|
|
import sys, yaml, hashlib
|
|
try:
|
|
cfg = yaml.safe_load(open(sys.argv[1])) or {}
|
|
except Exception:
|
|
sys.exit(0)
|
|
for l in (cfg.get('logins') or []):
|
|
t = l.get('token')
|
|
if t:
|
|
print(hashlib.sha256(t.encode()).hexdigest(), l.get('name'))
|
|
PY
|
|
)
|
|
fi
|
|
|
|
minted=0; refreshed=0; skipped=0; failed=0; planned=0; adopted=0; blocked=0
|
|
|
|
existing="$(tea login list --output simple 2>/dev/null | awk '{print $1}' || true)"
|
|
|
|
shopt -s nullglob
|
|
for tokfile in "$BRAIN_HOME"/fleet/agents/*/secrets/gitea-*.token; do
|
|
seat="${tokfile#"$BRAIN_HOME"/fleet/agents/}"; seat="${seat%%/*}"
|
|
[ -n "$ONLY_SEAT" ] && [ "$seat" != "$ONLY_SEAT" ] && continue
|
|
|
|
base="$(basename "$tokfile" .token)" # gitea-<instance>-<seat>
|
|
inst="${base#gitea-}"; inst="${inst%-"$seat"}"
|
|
name="${inst}-${seat}"
|
|
url="$(url_for "$inst")"
|
|
|
|
if [ -z "$url" ]; then
|
|
echo " SKIP $name — no URL known for instance '$inst' (set MOSAIC_GITEA_URL_${inst^^})"
|
|
skipped=$((skipped+1)); continue
|
|
fi
|
|
if [ ! -r "$tokfile" ]; then
|
|
echo " SKIP $name — token not readable"
|
|
skipped=$((skipped+1)); continue
|
|
fi
|
|
|
|
action="mint"
|
|
grep -qx "$name" <<<"$existing" && action="refresh"
|
|
|
|
# Is this exact token already stored under some OTHER name?
|
|
tsha="$(sha256sum < "$tokfile" | awk '{print $1}')"
|
|
owner="${TOKEN_OWNER[$tsha]:-}"
|
|
collision=""
|
|
[ -n "$owner" ] && [ "$owner" != "$name" ] && collision="$owner"
|
|
|
|
if [ "$APPLY" -eq 0 ]; then
|
|
if [ -n "$collision" ]; then
|
|
if [ "$ADOPT" -eq 1 ]; then
|
|
echo " PLAN adopt $collision -> $name ($url)"
|
|
else
|
|
echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt"
|
|
blocked=$((blocked+1)); continue
|
|
fi
|
|
else
|
|
echo " PLAN $action $name -> $url"
|
|
fi
|
|
planned=$((planned+1)); continue
|
|
fi
|
|
|
|
if [ -n "$collision" ]; then
|
|
if [ "$ADOPT" -eq 0 ]; then
|
|
echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt"
|
|
blocked=$((blocked+1)); continue
|
|
fi
|
|
tea login delete "$collision" >/dev/null 2>&1 || true
|
|
action="adopt"
|
|
fi
|
|
|
|
# tea has no idempotent add; refresh is delete-then-add so a rotated token lands.
|
|
[ "$action" = refresh ] && tea login delete "$name" >/dev/null 2>&1 || true
|
|
|
|
if err="$(tea login add --name "$name" --url "$url" \
|
|
--token "$(cat "$tokfile")" --no-version-check 2>&1 >/dev/null)"; then
|
|
case "$action" in
|
|
mint) minted=$((minted+1)) ;;
|
|
refresh) refreshed=$((refreshed+1)) ;;
|
|
adopt) adopted=$((adopted+1)) ;;
|
|
esac
|
|
if [ "$action" = adopt ]; then
|
|
echo " OK adopt $collision -> $name ($url)"
|
|
else
|
|
echo " OK $action $name -> $url"
|
|
fi
|
|
else
|
|
# A failure here is real information: the seat's token is dead, or the server
|
|
# refused it. Do not paper over it; the seat cannot act until it is reminted.
|
|
# tea's own words, redacted — a summarised FAIL hides whether the cause is the
|
|
# credential or the client, which cost a diagnosis on 2026-08-21.
|
|
echo " FAIL $action $name -> $url"
|
|
echo " tea: $(printf '%s' "$err" | redact | head -1)"
|
|
failed=$((failed+1))
|
|
fi
|
|
done
|
|
|
|
echo
|
|
if [ "$APPLY" -eq 0 ]; then
|
|
echo "dry run: $planned login(s) would be written, $skipped skipped, $blocked blocked."
|
|
[ "$blocked" -gt 0 ] && echo "re-run with --adopt to rename ad-hoc aliases to canonical names."
|
|
echo "no changes made. re-run with --apply."
|
|
else
|
|
echo "minted=$minted adopted=$adopted refreshed=$refreshed skipped=$skipped blocked=$blocked failed=$failed"
|
|
fi
|
|
[ "$failed" -eq 0 ] && [ "$blocked" -eq 0 ]
|