Files
terdut-operator/internal/controller/terdutteam_config.go
T
Niklas Ye e1103f2b7d 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
2026-10-09 14:56:22 +02:00

128 lines
4.7 KiB
Go

package controller
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
)
// reconcileEscalation applies spec.escalation as the team's whole ladder, or
// clears it when the field is absent (an empty ladder is the server's "none").
// The second return is an expected, reportable condition (a bad duration, an
// unknown user), the third a failure to retry.
func (r *TerdutTeamReconciler) reconcileEscalation(
ctx context.Context, tc *tdclient.Client, team *terdutv1alpha1.TerdutTeam,
) (*teamError, error) {
body, err := buildEscalationRequest(team.Spec.Escalation)
if err != nil {
return &teamError{reason: terdutv1alpha1.ReasonInvalidSpec, message: err.Error()}, nil
}
if err := tc.SetEscalation(ctx, team.Status.TeamID, body); err != nil {
// The server resolves usernames; one it does not know is a state to
// wait out (the person may be created later), not a failure.
if se, ok := errors.AsType[*tdclient.StatusError](err); ok &&
se.Code == http.StatusBadRequest && strings.HasPrefix(se.Message, "unknown user") {
return &teamError{reason: terdutv1alpha1.ReasonUnknownUser, message: se.Message}, nil
}
return nil, fmt.Errorf("PUT /api/teams/%d/escalation: %w", team.Status.TeamID, err)
}
return nil, nil
}
func buildEscalationRequest(spec *terdutv1alpha1.EscalationSpec) (tdclient.SetEscalationRequest, error) {
if spec == nil {
return tdclient.SetEscalationRequest{Levels: []tdclient.EscalationLevelRequest{}}, nil
}
levels := make([]tdclient.EscalationLevelRequest, len(spec.Levels))
for i, lvl := range spec.Levels {
timeout, err := time.ParseDuration(lvl.Timeout)
if err != nil || timeout <= 0 {
return tdclient.SetEscalationRequest{}, fmt.Errorf("spec.escalation.levels[%d].timeout %q is not a positive duration", i, lvl.Timeout)
}
targets := make([]tdclient.EscalationTargetRequest, len(lvl.Targets))
for j, t := range lvl.Targets {
targets[j] = tdclient.EscalationTargetRequest{Kind: string(t.Kind), Username: t.Username}
}
levels[i] = tdclient.EscalationLevelRequest{
Position: int64(i + 1),
TimeoutSeconds: int64(timeout.Seconds()),
Targets: targets,
}
}
return tdclient.SetEscalationRequest{
RepeatCount: spec.RepeatCount,
FallbackTopic: spec.FallbackTopic,
Levels: levels,
}, nil
}
// reconcileDeadmanSwitches makes the team's switches on the server exactly
// spec.deadmanSwitches, matched by name (unique per team server-side): create
// what is missing, update what differs, delete what is not listed. In operator
// mode nobody else can add one, so anything extra is leftover to remove.
func (r *TerdutTeamReconciler) reconcileDeadmanSwitches(
ctx context.Context, tc *tdclient.Client, team *terdutv1alpha1.TerdutTeam,
) (*teamError, error) {
type want struct {
spec terdutv1alpha1.DeadmanSwitchSpec
seconds int64
}
desired := make(map[string]want, len(team.Spec.DeadmanSwitches))
for _, sw := range team.Spec.DeadmanSwitches {
timeout, err := time.ParseDuration(sw.Timeout)
if err != nil || timeout <= 0 {
return &teamError{
reason: terdutv1alpha1.ReasonInvalidSpec,
message: fmt.Sprintf("spec.deadmanSwitches[%q].timeout %q is not a positive duration", sw.Name, sw.Timeout),
}, nil
}
desired[sw.Name] = want{spec: sw, seconds: int64(timeout.Seconds())}
}
existing, err := tc.ListDeadmanSwitches(ctx, team.Status.TeamID)
if err != nil {
return nil, fmt.Errorf("GET /api/teams/%d/deadman/switches: %w", team.Status.TeamID, err)
}
seen := make(map[string]bool, len(existing))
for _, have := range existing {
w, keep := desired[have.Name]
if !keep {
if err := tc.DeleteDeadmanSwitch(ctx, team.Status.TeamID, have.ID); err != nil {
return nil, fmt.Errorf("DELETE deadman switch %q: %w", have.Name, err)
}
continue
}
seen[have.Name] = true
severity := severityOrDefault(w.spec.Severity)
if have.Matcher == w.spec.Matcher && have.TimeoutSeconds == w.seconds && have.Severity == severity {
continue
}
if err := tc.UpdateDeadmanSwitch(ctx, team.Status.TeamID, have.ID, w.spec.Name, w.spec.Matcher, w.seconds, severity); err != nil {
return nil, fmt.Errorf("PUT deadman switch %q: %w", have.Name, err)
}
}
for _, sw := range team.Spec.DeadmanSwitches {
if seen[sw.Name] {
continue
}
w := desired[sw.Name]
if _, err := tc.CreateDeadmanSwitch(ctx, team.Status.TeamID, sw.Name, sw.Matcher, w.seconds, severityOrDefault(sw.Severity)); err != nil {
return nil, fmt.Errorf("POST deadman switch %q: %w", sw.Name, err)
}
}
return nil, nil
}
func severityOrDefault(s string) string {
if s == "" {
return "critical"
}
return s
}