e1103f2b7d
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
128 lines
4.7 KiB
Go
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
|
|
}
|