package controller import ( "context" "fmt" "strconv" "time" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1" "git.ryuvia.com/niklas/terdut-operator/internal/tdclient" ) // Data keys inside the generated invite Secret, following the same naming // shape as TerdutAlertSource's webhookSecret*Key constants. const ( inviteSecretURLKey = "url" inviteSecretInviteIDKey = "inviteID" inviteSecretExpiresAtKey = "expiresAt" ) // inviteRefreshWindow is how far ahead of expiry this controller mints a // replacement link, so a human reading status.inviteSecretRef never finds a // dead link mid-use. terdut-server's invite TTL is a fixed, unconfigurable // 7 days (internal/api/signup.go's inviteTTL) -- refreshing a full day // ahead of that leaves comfortable margin against this controller's own // 5-minute resync interval ever being delayed. const inviteRefreshWindow = 24 * time.Hour func inviteSecretName(team *terdutv1alpha1.TerdutTeam) string { return team.Name + "-terdut-invite" } // reconcileInvite applies spec.invite against teamClient -- this team's own // team-scoped credential, already owner-equivalent for every /invites route // (terdut-server's SERVICE-ACCOUNTS.md, ratified not accidental). Mints, // refreshes ahead of expiry, or revokes, entirely independent of this // team's own Ready condition: an invite is a convenience for onboarding a // human, never something anything else in this reconcile waits on. func (r *TerdutTeamReconciler) reconcileInvite(ctx context.Context, team *terdutv1alpha1.TerdutTeam, teamClient *tdclient.Client) error { if !team.Spec.Invite.Enabled { return r.revokeInvite(ctx, team, teamClient) } secretName := inviteSecretName(team) var secret corev1.Secret err := r.Get(ctx, client.ObjectKey{Namespace: team.Namespace, Name: secretName}, &secret) switch { case err == nil: expiresAt, parseErr := time.Parse(time.RFC3339, string(secret.Data[inviteSecretExpiresAtKey])) if parseErr == nil && time.Until(expiresAt) > inviteRefreshWindow { team.Status.InviteSecretRef = &terdutv1alpha1.LocalSecretRef{Name: secretName} return nil // still fresh, nothing to do this reconcile } // Expired, about to expire, or unreadable: mint a replacement. // Revoke the old row by id first (best-effort) so a leaked old link // stops working immediately rather than lingering unrevoked until // its own TTL -- failure here is not fatal, since the replacement // below is what actually matters. if oldID, idErr := strconv.ParseInt(string(secret.Data[inviteSecretInviteIDKey]), 10, 64); idErr == nil { _ = teamClient.RevokeInvite(ctx, team.Status.TeamID, oldID) } return r.mintInvite(ctx, team, teamClient, secretName) case apierrors.IsNotFound(err): // Low stakes, unlike TerdutAlertSource's webhook URL: nothing // external holds a durable dependency on one specific invite link // staying stable the way an Alertmanager config depends on a // webhook URL -- it's read once by one human and handed out. So // this silently re-mints rather than failing closed the way // TerdutAlertSource's ReasonWebhookSecretLost does for its Secret. return r.mintInvite(ctx, team, teamClient, secretName) default: return err } } func (r *TerdutTeamReconciler) mintInvite( ctx context.Context, team *terdutv1alpha1.TerdutTeam, teamClient *tdclient.Client, secretName string, ) error { role := team.Spec.Invite.Role if role == "" { role = "member" } maxUses := team.Spec.Invite.MaxUses if maxUses == 0 { maxUses = 1 } inv, err := teamClient.CreateInvite(ctx, team.Status.TeamID, role, maxUses) if err != nil { return fmt.Errorf("POST /api/teams/%d/invites: %w", team.Status.TeamID, err) } secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: team.Namespace}} if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, secret, func() error { secret.Data = map[string][]byte{ inviteSecretURLKey: []byte(inv.URL), inviteSecretInviteIDKey: []byte(strconv.FormatInt(inv.ID, 10)), inviteSecretExpiresAtKey: []byte(inv.ExpiresAt.Format(time.RFC3339)), } return controllerutil.SetControllerReference(team, secret, r.Scheme) }); err != nil { return err } team.Status.InviteSecretRef = &terdutv1alpha1.LocalSecretRef{Name: secretName} if r.Recorder != nil { r.Recorder.Eventf(team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonInviteMinted, terdutv1alpha1.ReasonInviteMinted, "invite link minted into Secret %q", secretName) } return nil } // revokeInvite tears down spec.invite's Secret and server-side row when // spec.invite.enabled is false (or was never set). Same-namespace and // OwnerReference'd, so deleting the TerdutTeam itself already garbage- // collects this Secret -- this path exists for the narrower case of // flipping enabled back to false on an otherwise-live TerdutTeam. func (r *TerdutTeamReconciler) revokeInvite(ctx context.Context, team *terdutv1alpha1.TerdutTeam, teamClient *tdclient.Client) error { if team.Status.InviteSecretRef == nil { return nil } name := team.Status.InviteSecretRef.Name var secret corev1.Secret err := r.Get(ctx, client.ObjectKey{Namespace: team.Namespace, Name: name}, &secret) switch { case err == nil: if id, idErr := strconv.ParseInt(string(secret.Data[inviteSecretInviteIDKey]), 10, 64); idErr == nil { if err := teamClient.RevokeInvite(ctx, team.Status.TeamID, id); err != nil { return fmt.Errorf("DELETE /api/teams/%d/invites/%d: %w", team.Status.TeamID, id, err) } } if err := r.Delete(ctx, &secret); err != nil && !apierrors.IsNotFound(err) { return err } case !apierrors.IsNotFound(err): return err } team.Status.InviteSecretRef = nil if r.Recorder != nil { r.Recorder.Eventf(team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonInviteRevoked, terdutv1alpha1.ReasonInviteRevoked, "invite link revoked") } return nil }