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%.
200 lines
7.1 KiB
Go
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)
|
|
}
|