Files
terdut-operator/internal/controller/terdutteam_config.go
T
Niklas Ye 2d8fe0f275 Use a typed escalation fallback instead of fallbackTopic
TerdutTeam spec.escalation.fallback is now {type, address}, mirroring the
server's notification channel model. CRDs regenerated.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-12 21:42:30 +02:00

132 lines
4.8 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,
}
}
var fallback tdclient.NotificationTarget
if spec.Fallback != nil {
fallback = tdclient.NotificationTarget{Type: spec.Fallback.Type, Address: spec.Fallback.Address}
}
return tdclient.SetEscalationRequest{
RepeatCount: spec.RepeatCount,
Fallback: fallback,
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
}