package controller import ( "context" "fmt" "strconv" "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 alertSourceFinalizerName = "terdut.ryuvia.com/terdutalertsource" // Data keys inside the generated webhook Secret (DESIGN.md §4.5). url/key // are what a sender actually needs; integrationID exists purely so this // Secret -- once written -- is also this CR's own record that a create // already succeeded (see reconcileCreate's own comment for why that // matters more here than for any other child kind). const ( webhookSecretURLKey = "url" webhookSecretKeyDataKey = "key" webhookSecretIntegrationIDKey = "integrationID" ) // TerdutAlertSourceReconciler reconciles a TerdutAlertSource object. type TerdutAlertSourceReconciler struct { client.Client Scheme *runtime.Scheme OperatorNamespace string Recorder recorder.EventRecorder NewClient func(endpoint string) *tdclient.Client } // +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutalertsources,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutalertsources/status,verbs=get;update;patch // +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutalertsources/finalizers,verbs=update // +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch // +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch func (r *TerdutAlertSourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := logf.FromContext(ctx) var as terdutv1alpha1.TerdutAlertSource if err := r.Get(ctx, req.NamespacedName, &as); err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil } return ctrl.Result{}, err } newClient := r.NewClient if newClient == nil { newClient = tdclient.New } if !as.DeletionTimestamp.IsZero() { return r.reconcileDelete(ctx, &as, newClient) } if !controllerutil.ContainsFinalizer(&as, alertSourceFinalizerName) { controllerutil.AddFinalizer(&as, alertSourceFinalizerName) if err := r.Update(ctx, &as); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil } team, tc, resolveErr := resolveTeamAndClient(ctx, r.Client, r.OperatorNamespace, as.Namespace, as.Spec.TeamRef, newClient) if resolveErr != nil { return r.setNotReady(ctx, &as, resolveErr.reason, resolveErr.message, waitInterval) } teamID := team.Status.TeamID if as.Status.IntegrationID == 0 { if err := r.reconcileCreate(ctx, &as, tc, teamID); err != nil { return ctrl.Result{}, err } } else { secretName := webhookSecretName(&as) var secret corev1.Secret if err := r.Get(ctx, client.ObjectKey{Namespace: as.Namespace, Name: secretName}, &secret); err != nil { if !apierrors.IsNotFound(err) { return ctrl.Result{}, err } // Fail-closed, not self-healed (DESIGN.md §5): the key is // genuinely gone and can never be re-read from terdut-server, // so minting a replacement here would silently rotate a live // webhook URL with no spec change to explain why. Deleting // and recreating this CR is the supported recovery. return r.setNotReady(ctx, &as, terdutv1alpha1.ReasonWebhookSecretLost, fmt.Sprintf("webhook Secret %s/%s is gone; delete and recreate this TerdutAlertSource to rotate a new one", as.Namespace, secretName), waitInterval) } if as.Spec.Kind != as.Status.LastAppliedKind { if err := r.rotateKind(ctx, &as, tc, teamID); err != nil { return ctrl.Result{}, err } } else if err := tc.RenameIntegration(ctx, teamID, as.Status.IntegrationID, as.Spec.Name); err != nil { return ctrl.Result{}, fmt.Errorf("PATCH /api/teams/%d/integrations/%d: %w", teamID, as.Status.IntegrationID, err) } } meta.SetStatusCondition(&as.Status.Conditions, metav1.Condition{ Type: terdutv1alpha1.ConditionReady, Status: metav1.ConditionTrue, Reason: terdutv1alpha1.ReasonChildAdopted, Message: fmt.Sprintf("integration %d applied on team %d", as.Status.IntegrationID, teamID), }) as.Status.ObservedGeneration = as.Generation if err := r.Status().Update(ctx, &as); err != nil { return ctrl.Result{}, err } if r.Recorder != nil { r.Recorder.Eventf(&as, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonChildAdopted, terdutv1alpha1.ReasonChildAdopted, "alert source applied") } log.Info("TerdutAlertSource applied", "name", as.Name, "integrationID", as.Status.IntegrationID) return ctrl.Result{RequeueAfter: resyncInterval}, nil } // webhookSecretName is deterministic from this object's own, immutable // metadata.name -- not spec.name, which can be renamed freely without the // Secret needing to follow (DESIGN.md §4.5: renaming never rotates the key). func webhookSecretName(as *terdutv1alpha1.TerdutAlertSource) string { return as.Name + "-terdut-webhook" } // reconcileCreate implements this resource's own idempotent-create shape -- // deliberately not list-and-match (TerdutDeadmanSwitch) or adopt-on-409 // (Team/service-account): terdut-server shows the webhook key exactly once, // at creation, and never again (DESIGN.md §4.5), so there is no server-side // lookup that could ever recover it. Instead, the webhook Secret itself -- // written immediately after a successful POST, before status is ever // touched -- is this CR's only durable record that a create already // succeeded. A crash between the POST and the Secret write is the one // unrecoverable case: on the next reconcile the Secret still won't exist, // so this mints a brand new integration rather than risk adopting an // orphaned, keyless row by name -- same accepted tradeoff as the "orphaned // first key some interrupted attempt minted and never used" footnote // DESIGN.md §6 already documents for service-account keys. func (r *TerdutAlertSourceReconciler) reconcileCreate( ctx context.Context, as *terdutv1alpha1.TerdutAlertSource, tc *tdclient.Client, teamID int64, ) error { secretName := webhookSecretName(as) var secret corev1.Secret err := r.Get(ctx, client.ObjectKey{Namespace: as.Namespace, Name: secretName}, &secret) switch { case err == nil: // Crash recovery: a previous reconcile got as far as writing the // Secret but died before writing status -- read the id back out // of it rather than calling the server again. id, parseErr := strconv.ParseInt(string(secret.Data[webhookSecretIntegrationIDKey]), 10, 64) if parseErr != nil { return fmt.Errorf("webhook Secret %s/%s has no valid %s: %w", as.Namespace, secretName, webhookSecretIntegrationIDKey, parseErr) } as.Status.IntegrationID = id as.Status.LastAppliedKind = as.Spec.Kind as.Status.WebhookURLSecretRef = &terdutv1alpha1.LocalSecretRef{Name: secretName} return nil case !apierrors.IsNotFound(err): return err } created, err := tc.CreateIntegration(ctx, teamID, as.Spec.Name, as.Spec.Kind) if err != nil { return fmt.Errorf("POST /api/teams/%d/integrations: %w", teamID, err) } if err := r.writeWebhookSecret(ctx, as, secretName, created); err != nil { return err } as.Status.IntegrationID = created.ID as.Status.LastAppliedKind = as.Spec.Kind as.Status.WebhookURLSecretRef = &terdutv1alpha1.LocalSecretRef{Name: secretName} return nil } // rotateKind deletes the currently-live integration and creates a fresh one // under the new kind (DESIGN.md §5's reconciliation table: kind is the one // spec field with no in-place update verb at all), firing a Warning event // since this rotates the webhook key and breaks whatever still sends to the // old URL. func (r *TerdutAlertSourceReconciler) rotateKind( ctx context.Context, as *terdutv1alpha1.TerdutAlertSource, tc *tdclient.Client, teamID int64, ) error { oldKind, oldID := as.Status.LastAppliedKind, as.Status.IntegrationID if err := tc.DeleteIntegration(ctx, teamID, oldID); err != nil { return fmt.Errorf("DELETE /api/teams/%d/integrations/%d (rotating kind %q -> %q): %w", teamID, oldID, oldKind, as.Spec.Kind, err) } // reconcileCreate's own crash-recovery path trusts this Secret's mere // existence as "a create already succeeded" -- it must be gone before // calling it here, or rotation would misread the about-to-be-stale // old id right back out of it instead of minting a replacement. secretName := webhookSecretName(as) if err := r.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: as.Namespace}}); err != nil && !apierrors.IsNotFound(err) { return fmt.Errorf("deleting stale webhook Secret %s/%s: %w", as.Namespace, secretName, err) } as.Status.IntegrationID = 0 as.Status.WebhookURLSecretRef = nil if err := r.reconcileCreate(ctx, as, tc, teamID); err != nil { return err } if r.Recorder != nil { r.Recorder.Eventf(as, nil, corev1.EventTypeWarning, terdutv1alpha1.ReasonKindRotated, terdutv1alpha1.ReasonKindRotated, "spec.kind changed from %q to %q: deleted integration %d and minted a new one (%d) -- the webhook URL/key changed, update whatever was sending to the old one", oldKind, as.Spec.Kind, oldID, as.Status.IntegrationID) } return nil } // writeWebhookSecret creates or replaces the generated Secret holding // integ's key material, owned by as (plain OwnerReference, same namespace, // per DESIGN.md §7 -- no finalizer needed for this one, unlike the // cross-namespace credential Secrets elsewhere in this operator). func (r *TerdutAlertSourceReconciler) writeWebhookSecret( ctx context.Context, as *terdutv1alpha1.TerdutAlertSource, name string, integ *tdclient.Integration, ) error { secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: as.Namespace}} _, err := controllerutil.CreateOrUpdate(ctx, r.Client, secret, func() error { secret.Data = map[string][]byte{ webhookSecretURLKey: []byte(integ.URL), webhookSecretKeyDataKey: []byte(integ.Key), webhookSecretIntegrationIDKey: []byte(strconv.FormatInt(integ.ID, 10)), } return controllerutil.SetControllerReference(as, secret, r.Scheme) }) return err } func (r *TerdutAlertSourceReconciler) setNotReady( ctx context.Context, as *terdutv1alpha1.TerdutAlertSource, reason, message string, d time.Duration, ) (ctrl.Result, error) { meta.SetStatusCondition(&as.Status.Conditions, metav1.Condition{ Type: terdutv1alpha1.ConditionReady, Status: metav1.ConditionFalse, Reason: reason, Message: message, }) as.Status.ObservedGeneration = as.Generation if err := r.Status().Update(ctx, as); err != nil { return ctrl.Result{}, err } if r.Recorder != nil { r.Recorder.Eventf(as, nil, corev1.EventTypeWarning, reason, reason, message) } return ctrl.Result{RequeueAfter: d}, nil } // reconcileDelete calls the real DELETE this resource has (unlike // TerdutEscalationRule) if the team is still resolvable and an integration // was ever created, then removes the finalizer unconditionally. The // webhook Secret needs no explicit cleanup here -- it's same-namespace and // OwnerReference-GC'd (DESIGN.md §7), not this finalizer's job. func (r *TerdutAlertSourceReconciler) reconcileDelete( ctx context.Context, as *terdutv1alpha1.TerdutAlertSource, newClient func(string) *tdclient.Client, ) (ctrl.Result, error) { if !controllerutil.ContainsFinalizer(as, alertSourceFinalizerName) { return ctrl.Result{}, nil } if as.Status.IntegrationID != 0 { if team, tc, resolveErr := resolveTeamAndClient( ctx, r.Client, r.OperatorNamespace, as.Namespace, as.Spec.TeamRef, newClient, ); resolveErr == nil { if err := tc.DeleteIntegration(ctx, team.Status.TeamID, as.Status.IntegrationID); err != nil { if r.Recorder != nil { r.Recorder.Eventf(as, nil, corev1.EventTypeWarning, "DeleteFailed", "DeleteFailed", err.Error()) } return ctrl.Result{}, err } } } controllerutil.RemoveFinalizer(as, alertSourceFinalizerName) return ctrl.Result{}, r.Update(ctx, as) } // SetupWithManager sets up the controller with the Manager. func (r *TerdutAlertSourceReconciler) SetupWithManager(mgr ctrl.Manager) error { if r.NewClient == nil { r.NewClient = tdclient.New } if r.Recorder == nil { r.Recorder = mgr.GetEventRecorder("terdutalertsource-controller") } return ctrl.NewControllerManagedBy(mgr). For(&terdutv1alpha1.TerdutAlertSource{}). // Watched, not just the CR (DESIGN.md §5): the webhook Secret's // loss has to be noticed on its own, independent of any spec // change to this CR, for ReasonWebhookSecretLost to ever fire. Owns(&corev1.Secret{}). Named("terdutalertsource"). Complete(r) }