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) }