fb9e6a38dc
CI / test (push) Has been cancelled
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%.
218 lines
8.3 KiB
Go
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)
|
|
}
|