Authenticate with a seeded operator key; fold escalation and switches into TerdutTeam

Credentials: the TerdutServer controller generates <name>-operator-key in
the server's own namespace (owned by it) and hands it to the pods as
TERDUT_OPERATOR_KEY; the server creates its instance-scoped account from it
at every start. A replaced Secret rolls the pods. The bootstrap handshake,
the checkpoint Secret, per-team service accounts and credentials Secrets,
BootstrapStateLost and credentials.deletionPolicy are gone.

CRDs: TerdutServer, TerdutTeam and TerdutAlertSource. TerdutEscalationRule
and TerdutDeadmanSwitch become spec.escalation and spec.deadmanSwitches[]
on the team (matched by name, extras removed); team invites are removed.
A team is created under the identity <namespace>/<name> (external_id), so a
retry, a lost status or a deleted team heal by repeating the same call, and
a display name owned by another team is TeamNameTaken instead of an
adoption. The server resolves escalation usernames (UnknownUser condition).
OIDC claim names and trustEmail are spec fields.

Fixes: query values are URL-escaped; every delete treats 404 as success;
deleting a team no longer depends on allowedTeams consent; a switch or
integration deleted on the server is recreated; unnamed switches take the
CR's name.

Cleanup: scaffold e2e test, AGENTS.md, devcontainer, unused config/ pieces
and Client.Version() removed; DESIGN.md, README, ROADMAP and the demo
(run-demo.sh, manifests) rewritten for the new design. Secret RBAC stays
cluster-wide, now stated in DESIGN.md section 9.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-09 14:56:22 +02:00
parent b0a431f2a4
commit e1103f2b7d
92 changed files with 2082 additions and 7561 deletions
+47 -95
View File
@@ -103,7 +103,7 @@ install_operator() {
apply_demo() {
log "creating namespace $NAMESPACE"
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
log "applying demo CRs (00-09) into $NAMESPACE"
log "applying demo CRs (00-05) into $NAMESPACE"
kubectl apply -n "$NAMESPACE" -k "$SCRIPT_DIR" >/dev/null
}
@@ -116,28 +116,18 @@ wait_for_objects() {
done
}
# Just the server and the two teams -- everything redeem_platform_invite and
# join_payments_team need. Deliberately NOT the escalation rules here: this
# demo kit's own terdutescalationrule-platform names alice as a level-1
# target, and that CR cannot reach Ready until alice actually exists
# (terdut-server resolves every named username at reconcile time, not just
# at escalation time) -- a real dependency this script has to satisfy by
# creating her first, not something kubectl wait can be told to ignore.
wait_for_teams_ready() {
wait_for_objects \
"terdutserver/terdut-operator-demo" \
"terdutteam/terdutteam-platform" \
"terdutteam/terdutteam-payments"
# Only the server: the teams cannot reach Ready until alice exists (their
# escalation ladders name her, and terdut-server resolves usernames when the
# ladder is applied), and alice is created below, through the server itself.
wait_for_server_ready() {
wait_for_objects "terdutserver/terdut-operator-demo"
}
# Everything that was waiting on alice (or just on the teams above, now
# already satisfied) to exist.
# Everything that was waiting on alice to exist.
wait_for_remaining_ready() {
wait_for_objects \
"terdutescalationrule/terdutescalationrule-platform" \
"terdutescalationrule/terdutescalationrule-payments" \
"terdutdeadmanswitch/terdutdeadmanswitch-platform" \
"terdutdeadmanswitch/terdutdeadmanswitch-payments" \
"terdutteam/terdutteam-platform" \
"terdutteam/terdutteam-payments" \
"terdutalertsource/terdutalertsource-platform" \
"terdutalertsource/terdutalertsource-payments"
}
@@ -173,91 +163,54 @@ start_port_forward() {
log "port-forward ready (pid $STARTED_PF_PID, log $PF_LOGFILE)"
}
# Redeems Platform's own invite link -- minted by its TerdutTeam
# (02-team-platform.yaml's spec.invite.enabled, reconciled through that
# team's own already-working team-scoped credential, which requireTeamOwner
# already treats as owner-equivalent for /invites -- ratified, not a
# workaround, in terdut-server's SERVICE-ACCOUNTS.md) and redeemed through
# the ordinary signup endpoint. Invite redemption bypasses signup_mode
# entirely (terdut-server's internal/api/signup.go), so this needs no admin
# credential, no signup_mode flip, and no direct Postgres access at all --
# unlike an earlier version of this script, before terdut-operator grew
# this feature (see niklas/terdut-server#23).
redeem_platform_invite() {
log "reading Platform's invite link"
local secret_name invite_url invite_token tries=0
until secret_name="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-platform \
-o jsonpath='{.status.inviteSecretRef.name}' 2>/dev/null)" && [ -n "$secret_name" ]; do
tries=$((tries + 1))
[ "$tries" -lt 30 ] || die "terdutteam-platform never reported status.inviteSecretRef -- check spec.invite.enabled and kubectl describe it"
sleep 1
done
invite_url="$(kubectl -n "$NAMESPACE" get secret "$secret_name" -o jsonpath='{.data.url}' | base64 -d)"
invite_token="${invite_url##*invite=}"
[ -n "$invite_token" ] || die "could not parse an invite token out of $invite_url"
# A re-run: the invite was spent by the first run, and the server answers a
# spent invite with 403 before it ever looks at the username, so the 409
# handled below never arrives. If alice can already sign in, she exists.
# Creates alice as the install's first user and administrator through
# /api/bootstrap, which is open until a first user exists -- the operator
# authenticates with its own seeded key and never uses it, so this is how a
# person gets in. An administrator may manage any team, so alice's own key is
# enough to add her to both; the teams' own status.teamID is set as soon as
# the operator has created each team, even while they still wait on her.
create_alice_and_join_teams() {
# A re-run: she exists already (and was added to the teams the first time).
local login_code
login_code="$(curl -sS -o /dev/null -w '%{http_code}' \
-X POST "${BASE_URL}/api/login" -H 'Content-Type: application/json' \
-d "$(jq -n --arg u "$ALICE_USERNAME" --arg p "$DEMO_PASSWORD" '{username: $u, password: $p}')")"
if [ "$login_code" = "200" ]; then
log "account ${ALICE_USERNAME} already exists and can sign in, skipping signup (re-run detected)"
log "account ${ALICE_USERNAME} already exists and can sign in, skipping creation (re-run detected)"
return 0
fi
log "signing up ${ALICE_USERNAME} via Platform's invite"
local body resp_file code
body="$(jq -n \
--arg u "$ALICE_USERNAME" --arg e "$ALICE_EMAIL" \
--arg p "$DEMO_PASSWORD" --arg i "$invite_token" \
'{username: $u, email: $e, password: $p, invite: $i}')"
log "creating ${ALICE_USERNAME} as the first user (administrator)"
local resp_file code alice_key
resp_file="$(mktemp)"
code="$(curl -sS -o "$resp_file" -w '%{http_code}' \
-X POST "${BASE_URL}/api/signup" -H 'Content-Type: application/json' -d "$body")"
case "$code" in
201) log "created local account ${ALICE_USERNAME} (joined Platform)" ;;
409) log "account ${ALICE_USERNAME} already exists, skipping (re-run detected)" ;;
*) die "signup failed (HTTP $code): $(cat "$resp_file")" ;;
esac
-X POST "${BASE_URL}/api/bootstrap" -H 'Content-Type: application/json' \
-d "$(jq -n --arg u "$ALICE_USERNAME" --arg e "$ALICE_EMAIL" --arg p "$DEMO_PASSWORD" \
'{username: $u, email: $e, password: $p}')")"
[ "$code" = "201" ] || die "bootstrap failed (HTTP $code): $(cat "$resp_file")"
alice_key="$(jq -r '.api_key.key' "$resp_file")"
rm -f "$resp_file"
}
# redeem_platform_invite's signup already used up alice's one signup -- a
# second POST /api/signup would just 409 on the taken username, it doesn't
# join an existing account to another team. Payments is joined through the
# ordinary team-scoped member endpoint instead, using that team's own
# already-working team-scoped credential (owner-equivalent, same reach the
# invite route above relies on) and alice's user id resolved via
# GET /api/users -- the same lookup tdclient.GetUserByUsername does
# operator-side. The endpoint upserts on (team_id, user_id), so this is
# naturally idempotent across re-runs with no separate conflict handling
# needed.
join_payments_team() {
log "adding ${ALICE_USERNAME} to Payments"
local payments_id payments_secret payments_key alice_id resp_file code
payments_id="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-payments -o jsonpath='{.status.teamID}')"
[ -n "$payments_id" ] || die "could not read status.teamID off terdutteam-payments"
payments_secret="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-payments \
-o jsonpath='{.status.credentialsSecretRef.name}')"
[ -n "$payments_secret" ] || die "terdutteam-payments has no status.credentialsSecretRef yet"
payments_key="$(kubectl -n "$OPERATOR_NAMESPACE" get secret "$payments_secret" -o jsonpath='{.data.token}' | base64 -d)"
alice_id="$(curl -sS -H "Authorization: Bearer $payments_key" "${BASE_URL}/api/users" \
| jq -r --arg u "$ALICE_USERNAME" '.[] | select(.username == $u) | .id')"
[ -n "$alice_id" ] || die "could not resolve ${ALICE_USERNAME}'s user id via GET /api/users"
resp_file="$(mktemp)"
code="$(curl -sS -o "$resp_file" -w '%{http_code}' \
-X POST "${BASE_URL}/api/teams/${payments_id}/members" \
-H "Authorization: Bearer $payments_key" -H 'Content-Type: application/json' \
-d "$(jq -n --argjson id "$alice_id" '{user_id: $id, role: "member"}')")"
[ "$code" = "204" ] || die "adding ${ALICE_USERNAME} to Payments failed (HTTP $code): $(cat "$resp_file")"
log "added ${ALICE_USERNAME} to Payments"
rm -f "$resp_file"
local team team_id
for team in terdutteam-platform terdutteam-payments; do
team_id=""
local tries=0
until team_id="$(kubectl -n "$NAMESPACE" get terdutteam "$team" -o jsonpath='{.status.teamID}' 2>/dev/null)" \
&& [ -n "$team_id" ]; do
tries=$((tries + 1))
[ "$tries" -lt 60 ] || die "$team never reported status.teamID -- kubectl describe it"
sleep 1
done
local alice_id
alice_id="$(curl -sS -H "Authorization: Bearer $alice_key" "${BASE_URL}/api/users" \
| jq -r --arg u "$ALICE_USERNAME" '.[] | select(.username == $u) | .id')"
code="$(curl -sS -o /dev/null -w '%{http_code}' \
-X POST "${BASE_URL}/api/teams/${team_id}/members" \
-H "Authorization: Bearer $alice_key" -H 'Content-Type: application/json' \
-d "$(jq -n --argjson id "$alice_id" '{user_id: $id, role: "owner"}')")"
[ "$code" = "204" ] || die "adding ${ALICE_USERNAME} to ${team} failed (HTTP $code)"
log "added ${ALICE_USERNAME} to ${team}"
done
}
fire_demo_alerts() {
@@ -350,10 +303,9 @@ main() {
ensure_kind_cluster
install_operator
apply_demo
wait_for_teams_ready
wait_for_server_ready
start_port_forward
redeem_platform_invite
join_payments_team
create_alice_and_join_teams
wait_for_remaining_ready
fire_demo_alerts
print_summary