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