048f4448c4
CI / test (push) Successful in 1m34s
Covers webhook Secret generation/ownership (DESIGN.md §4.5, §7), the
WebhookSecretLost fail-closed condition, and the kind-change
delete-and-recreate rotation path.
Idempotent-create here is deliberately neither adopt-on-409
(Team/service-account) nor list-and-match-by-name (TerdutDeadmanSwitch):
terdut-server shows the webhook key exactly once, at creation, and never
again, so no server-side lookup could ever recover it after a crash.
Instead the generated webhook Secret itself -- written immediately after
the POST, before status is ever touched -- is this CR's only durable
record that a create already succeeded; found with status.integrationID
still unset on a later reconcile, it's read back directly rather than
POSTing a second, orphaned integration. Found missing with
status.integrationID *set* instead, that's the already-designed
WebhookSecretLost case: fail closed, not self-healed, since the key is
genuinely gone and recreating it would rotate a live webhook URL with no
spec change to explain why.
Renaming (PATCH) never touches the key, so it's applied unconditionally
every reconcile, same as the escalation policy's whole-policy PUT. A
spec.kind change is the one case with no in-place update verb at all:
DELETE the old integration, delete the stale webhook Secret, then run the
same create path fresh -- fires a Warning event since this breaks whatever
still sends to the old URL.
Also: fakeTerdutServer grows POST/PATCH/DELETE .../integrations routes
behind a new handleIntegrationSubPath, split out of handleTeamSubPath to
stay under gocyclo's threshold; three goconst-flagged test literals
("does-not-exist", "unready") and one unparam-flagged test helper
parameter (bootstrapReadyTerdutServer's always-"default" namespace) get
shared/removed now that a fourth same-shaped caller made the repetition
concrete enough for the linter to flag.
DESIGN.md §13 gains one honest gap found while grounding this stage, not
introduced by it: no child CRD specially detects a mid-life teamRef
change; all three always resolve spec.teamRef fresh and trust the
already-stored server-side id remains valid there.
make fmt lint test build all clean; internal/controller envtest coverage
holds at 71.6%.
314 lines
13 KiB
Go
314 lines
13 KiB
Go
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)
|
|
}
|