Files
terdut-operator/internal/controller/terdutdeadmanswitch_controller.go
T
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

200 lines
7.1 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"
)
const deadmanFinalizerName = "terdut.ryuvia.com/terdutdeadmanswitch"
// TerdutDeadmanSwitchReconciler reconciles a TerdutDeadmanSwitch object.
type TerdutDeadmanSwitchReconciler struct {
client.Client
Scheme *runtime.Scheme
OperatorNamespace string
Recorder recorder.EventRecorder
NewClient func(endpoint string) *tdclient.Client
}
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutdeadmanswitches,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutdeadmanswitches/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutdeadmanswitches/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 *TerdutDeadmanSwitchReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logf.FromContext(ctx)
var sw terdutv1alpha1.TerdutDeadmanSwitch
if err := r.Get(ctx, req.NamespacedName, &sw); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
newClient := r.NewClient
if newClient == nil {
newClient = tdclient.New
}
if !sw.DeletionTimestamp.IsZero() {
return r.reconcileDeadmanDelete(ctx, &sw, newClient)
}
if !controllerutil.ContainsFinalizer(&sw, deadmanFinalizerName) {
controllerutil.AddFinalizer(&sw, deadmanFinalizerName)
if err := r.Update(ctx, &sw); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
team, tc, resolveErr := resolveTeamAndClient(ctx, r.Client, r.OperatorNamespace, sw.Namespace, sw.Spec.TeamRef, newClient)
if resolveErr != nil {
return r.setDeadmanNotReady(ctx, &sw, resolveErr.reason, resolveErr.message, waitInterval)
}
timeout, err := time.ParseDuration(sw.Spec.Timeout)
if err != nil {
return ctrl.Result{}, fmt.Errorf("spec.timeout %q: %w", sw.Spec.Timeout, err)
}
severity := sw.Spec.Severity
if severity == "" {
severity = "critical"
}
if sw.Status.SwitchID == 0 {
if err := r.createOrAdoptDeadmanSwitch(ctx, &sw, tc, team.Status.TeamID, timeout, severity); err != nil {
return ctrl.Result{}, err
}
} else if err := tc.UpdateDeadmanSwitch(ctx, team.Status.TeamID, sw.Status.SwitchID, sw.Spec.Name, sw.Spec.Matcher, int64(timeout.Seconds()), severity); err != nil {
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d/deadman/switches/%d: %w", team.Status.TeamID, sw.Status.SwitchID, err)
}
meta.SetStatusCondition(&sw.Status.Conditions, metav1.Condition{
Type: terdutv1alpha1.ConditionReady,
Status: metav1.ConditionTrue,
Reason: terdutv1alpha1.ReasonChildAdopted,
Message: fmt.Sprintf("switch %d applied on team %d", sw.Status.SwitchID, team.Status.TeamID),
})
sw.Status.ObservedGeneration = sw.Generation
if err := r.Status().Update(ctx, &sw); err != nil {
return ctrl.Result{}, err
}
if r.Recorder != nil {
r.Recorder.Eventf(&sw, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonChildAdopted, terdutv1alpha1.ReasonChildAdopted,
"dead man's switch applied")
}
log.Info("TerdutDeadmanSwitch applied", "name", sw.Name, "switchID", sw.Status.SwitchID)
return ctrl.Result{RequeueAfter: resyncInterval}, nil
}
// createOrAdoptDeadmanSwitch implements this resource's own idempotent-
// create shape (DESIGN.md §4.4, §5): there's no unique-name constraint
// server-side to 409 on, so this lists first and matches by name (the
// server's own derived name, when spec.name is empty) rather than adopting
// after a conflict the API would never actually raise.
func (r *TerdutDeadmanSwitchReconciler) createOrAdoptDeadmanSwitch(
ctx context.Context, sw *terdutv1alpha1.TerdutDeadmanSwitch, tc *tdclient.Client,
teamID int64, timeout time.Duration, severity string,
) error {
existing, err := tc.ListDeadmanSwitches(ctx, teamID)
if err != nil {
return fmt.Errorf("GET /api/teams/%d/deadman/switches: %w", teamID, err)
}
if sw.Spec.Name != "" {
for _, s := range existing {
if s.Name == sw.Spec.Name {
sw.Status.SwitchID = s.ID
return nil
}
}
}
created, err := tc.CreateDeadmanSwitch(ctx, teamID, sw.Spec.Name, sw.Spec.Matcher, int64(timeout.Seconds()), severity)
if err != nil {
return fmt.Errorf("POST /api/teams/%d/deadman/switches: %w", teamID, err)
}
sw.Status.SwitchID = created.ID
return nil
}
func (r *TerdutDeadmanSwitchReconciler) setDeadmanNotReady(
ctx context.Context, sw *terdutv1alpha1.TerdutDeadmanSwitch, reason, message string, d time.Duration,
) (ctrl.Result, error) {
meta.SetStatusCondition(&sw.Status.Conditions, metav1.Condition{
Type: terdutv1alpha1.ConditionReady,
Status: metav1.ConditionFalse,
Reason: reason,
Message: message,
})
sw.Status.ObservedGeneration = sw.Generation
if err := r.Status().Update(ctx, sw); err != nil {
return ctrl.Result{}, err
}
if r.Recorder != nil {
r.Recorder.Eventf(sw, nil, corev1.EventTypeWarning, reason, reason, message)
}
return ctrl.Result{RequeueAfter: d}, nil
}
// reconcileDeadmanDelete calls the real DELETE this resource actually has
// (unlike TerdutEscalationRule) if the team is still resolvable and a
// switch was ever created, then removes the finalizer unconditionally.
func (r *TerdutDeadmanSwitchReconciler) reconcileDeadmanDelete(
ctx context.Context, sw *terdutv1alpha1.TerdutDeadmanSwitch, newClient func(string) *tdclient.Client,
) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(sw, deadmanFinalizerName) {
return ctrl.Result{}, nil
}
if sw.Status.SwitchID != 0 {
if team, tc, resolveErr := resolveTeamAndClient(
ctx, r.Client, r.OperatorNamespace, sw.Namespace, sw.Spec.TeamRef, newClient,
); resolveErr == nil {
if err := tc.DeleteDeadmanSwitch(ctx, team.Status.TeamID, sw.Status.SwitchID); err != nil {
if r.Recorder != nil {
r.Recorder.Eventf(sw, nil, corev1.EventTypeWarning, "DeleteFailed", "DeleteFailed", err.Error())
}
return ctrl.Result{}, err
}
}
}
controllerutil.RemoveFinalizer(sw, deadmanFinalizerName)
return ctrl.Result{}, r.Update(ctx, sw)
}
// SetupWithManager sets up the controller with the Manager.
func (r *TerdutDeadmanSwitchReconciler) SetupWithManager(mgr ctrl.Manager) error {
if r.NewClient == nil {
r.NewClient = tdclient.New
}
if r.Recorder == nil {
r.Recorder = mgr.GetEventRecorder("terdutdeadmanswitch-controller")
}
return ctrl.NewControllerManagedBy(mgr).
For(&terdutv1alpha1.TerdutDeadmanSwitch{}).
Named("terdutdeadmanswitch").
Complete(r)
}