Files
terdut-operator/internal/controller/terdutescalationrule_controller.go
Niklas Ye fb9e6a38dc
CI / test (push) Has been cancelled
Stage 3: TerdutEscalationRule + TerdutDeadmanSwitch
Both child CRDs resolve their own teamRef -> TerdutTeam.status via the new
shared resolveTeamAndClient helper (childref.go), never chaining up to
TerdutServer (DESIGN.md §5) -- TerdutTeam.status.serverEndpoint, added in
this same stage, is what makes that literally true.

TerdutEscalationRule: one PUT /api/teams/{id}/escalation per reconcile
(an upsert server-side, confirmed against source), resolving each "user"
target's username to a user_id via GET /api/users first and reporting
Ready: False, reason: UnknownUser if it doesn't resolve. No DELETE exists
for this resource, so its delete path PUTs an empty policy as the closest
available undo.

TerdutDeadmanSwitch: real create/update-in-place/delete, using
terdut-server v0.33.0's PUT (added specifically for this operator). No
unique-name constraint server-side, so idempotent-create here is
GET-list-and-match-by-name rather than adopt-on-409.

Extends tdclient with User/GetUserByUsername, the escalation request types
+ SetEscalation, and DeadmanSwitch + its CRUD methods. Also folds
ConditionTeamReady into the single shared ConditionReady constant, since
both were literally "Ready" and Stage 3 would otherwise have needed a
third same-valued constant.

internal/controller/terdutserver_controller_test.go's fakeTerdutServer
grows GET /api/users, PUT .../escalation, and the full dead man's switch
collection/item routes, replacing the old parseTeamPath/handleTeamByID
pair with a more general parseTeamSubPath/handleTeamSubPath dispatcher
that still covers every existing Stage 1/2 route unchanged.

make fmt lint test build all clean; envtest coverage for
internal/controller: 50.5% -> 71.7%.
2026-10-01 13:50:08 +02:00

218 lines
8.3 KiB
Go

package controller
import (
"context"
"fmt"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/recorder"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
)
// escalationFinalizerName exists for the one undo PUT /api/teams/{teamID}/escalation
// supports, on delete: an empty policy (DESIGN.md §5's general finalizer
// rule expects a server-side counterpart to be undone, but this resource's
// API is GET/PUT-only, with no DELETE at all -- a zeroed PUT is the closest
// equivalent terdut-server itself recognizes as "no policy"
// (escalationPolicy.configured(), confirmed against source: "a policy row
// with no levels is the same as no policy").
const escalationFinalizerName = "terdut.ryuvia.com/terdutescalationrule"
// TerdutEscalationRuleReconciler reconciles a TerdutEscalationRule object.
type TerdutEscalationRuleReconciler struct {
client.Client
Scheme *runtime.Scheme
OperatorNamespace string
Recorder recorder.EventRecorder
NewClient func(endpoint string) *tdclient.Client
}
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutescalationrules,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutescalationrules/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutescalationrules/finalizers,verbs=update
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
func (r *TerdutEscalationRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logf.FromContext(ctx)
var rule terdutv1alpha1.TerdutEscalationRule
if err := r.Get(ctx, req.NamespacedName, &rule); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
newClient := r.NewClient
if newClient == nil {
newClient = tdclient.New
}
if !rule.DeletionTimestamp.IsZero() {
return r.reconcileEscalationDelete(ctx, &rule, newClient)
}
if !controllerutil.ContainsFinalizer(&rule, escalationFinalizerName) {
controllerutil.AddFinalizer(&rule, escalationFinalizerName)
if err := r.Update(ctx, &rule); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
team, tc, resolveErr := resolveTeamAndClient(ctx, r.Client, r.OperatorNamespace, rule.Namespace, rule.Spec.TeamRef, newClient)
if resolveErr != nil {
return r.setEscalationNotReady(ctx, &rule, resolveErr.reason, resolveErr.message, waitInterval)
}
body, unknownUser, err := buildEscalationRequest(ctx, tc, rule.Spec)
if err != nil {
return ctrl.Result{}, err
}
if unknownUser != "" {
return r.setEscalationNotReady(ctx, &rule, terdutv1alpha1.ReasonUnknownUser,
fmt.Sprintf("username %q does not resolve to any user", unknownUser), waitInterval)
}
if err := tc.SetEscalation(ctx, team.Status.TeamID, body); err != nil {
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d/escalation: %w", team.Status.TeamID, err)
}
meta.SetStatusCondition(&rule.Status.Conditions, metav1.Condition{
Type: terdutv1alpha1.ConditionReady, // "Ready" -- same name, shared across every CRD (DESIGN.md §7)
Status: metav1.ConditionTrue,
Reason: terdutv1alpha1.ReasonChildAdopted,
Message: fmt.Sprintf("escalation policy applied to team %d", team.Status.TeamID),
})
rule.Status.ObservedGeneration = rule.Generation
if err := r.Status().Update(ctx, &rule); err != nil {
return ctrl.Result{}, err
}
if r.Recorder != nil {
r.Recorder.Eventf(&rule, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonChildAdopted, terdutv1alpha1.ReasonChildAdopted,
"escalation policy applied")
}
log.Info("TerdutEscalationRule applied", "name", rule.Name, "teamID", team.Status.TeamID)
return ctrl.Result{RequeueAfter: resyncInterval}, nil
}
// buildEscalationRequest resolves every "user" target's username to a
// user_id (DESIGN.md §4.3) and translates spec into the wire shape
// SetEscalation sends. Returns the first unresolvable username, if any,
// distinct from a plain error: that's an expected, reportable condition
// (ReasonUnknownUser), not a reconcile failure.
func buildEscalationRequest(
ctx context.Context, tc *tdclient.Client, spec terdutv1alpha1.TerdutEscalationRuleSpec,
) (tdclient.SetEscalationRequest, string, error) {
levels := make([]tdclient.EscalationLevelRequest, len(spec.Levels))
for i, lvl := range spec.Levels {
timeout, err := time.ParseDuration(lvl.Timeout)
if err != nil {
return tdclient.SetEscalationRequest{}, "", fmt.Errorf("spec.levels[%d].timeout %q: %w", i, lvl.Timeout, err)
}
targets := make([]tdclient.EscalationTargetRequest, len(lvl.Targets))
for j, t := range lvl.Targets {
if t.Kind == terdutv1alpha1.EscalationTargetOncall {
targets[j] = tdclient.EscalationTargetRequest{Kind: string(terdutv1alpha1.EscalationTargetOncall)}
continue
}
user, err := tc.GetUserByUsername(ctx, t.Username)
if err != nil {
return tdclient.SetEscalationRequest{}, "", fmt.Errorf("GET /api/users (resolving %q): %w", t.Username, err)
}
if user == nil {
return tdclient.SetEscalationRequest{}, t.Username, nil
}
targets[j] = tdclient.EscalationTargetRequest{Kind: string(terdutv1alpha1.EscalationTargetUser), UserID: &user.ID}
}
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
}
func (r *TerdutEscalationRuleReconciler) setEscalationNotReady(
ctx context.Context, rule *terdutv1alpha1.TerdutEscalationRule, reason, message string, d time.Duration,
) (ctrl.Result, error) {
meta.SetStatusCondition(&rule.Status.Conditions, metav1.Condition{
Type: terdutv1alpha1.ConditionReady,
Status: metav1.ConditionFalse,
Reason: reason,
Message: message,
})
rule.Status.ObservedGeneration = rule.Generation
if err := r.Status().Update(ctx, rule); err != nil {
return ctrl.Result{}, err
}
if r.Recorder != nil {
r.Recorder.Eventf(rule, nil, corev1.EventTypeWarning, reason, reason, message)
}
return ctrl.Result{RequeueAfter: d}, nil
}
// reconcileEscalationDelete PUTs an empty policy (this resource's only
// available "undo", per this file's own const comment) if the team is
// still resolvable, then removes the finalizer unconditionally -- same
// "the parent's probably going away too" reasoning TerdutTeam's own delete
// path uses for a TerdutServer that's gone.
func (r *TerdutEscalationRuleReconciler) reconcileEscalationDelete(
ctx context.Context, rule *terdutv1alpha1.TerdutEscalationRule, newClient func(string) *tdclient.Client,
) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(rule, escalationFinalizerName) {
return ctrl.Result{}, nil
}
if team, tc, resolveErr := resolveTeamAndClient(
ctx, r.Client, r.OperatorNamespace, rule.Namespace, rule.Spec.TeamRef, newClient,
); resolveErr == nil {
if err := tc.SetEscalation(ctx, team.Status.TeamID, tdclient.SetEscalationRequest{Levels: []tdclient.EscalationLevelRequest{}}); err != nil {
if r.Recorder != nil {
r.Recorder.Eventf(rule, nil, corev1.EventTypeWarning, "DeleteFailed", "DeleteFailed", err.Error())
}
return ctrl.Result{}, err
}
}
controllerutil.RemoveFinalizer(rule, escalationFinalizerName)
return ctrl.Result{}, r.Update(ctx, rule)
}
// SetupWithManager sets up the controller with the Manager.
func (r *TerdutEscalationRuleReconciler) SetupWithManager(mgr ctrl.Manager) error {
if r.NewClient == nil {
r.NewClient = tdclient.New
}
if r.Recorder == nil {
r.Recorder = mgr.GetEventRecorder("terdutescalationrule-controller")
}
return ctrl.NewControllerManagedBy(mgr).
For(&terdutv1alpha1.TerdutEscalationRule{}).
Named("terdutescalationrule").
Complete(r)
}