fix(#1356): tea login resolution fails closed on a declared git identity (#1361)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: fred <[email protected]>
This commit was merged in pull request #1361.
This commit is contained in:
+181
@@ -0,0 +1,181 @@
|
||||
#!/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 ]
|
||||
Reference in New Issue
Block a user