package controller import ( "context" "errors" "fmt" "net/http" "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" ) // teamFinalizerName cleans up the team-scoped credentials Secret this // controller generates in the operator's own namespace on delete. const teamFinalizerName = "terdut.ryuvia.com/terdutteam" // TerdutTeamReconciler reconciles a TerdutTeam object. // // Every child resolves its own teamRef/serverRef independently and never // chains up through another controller (DESIGN.md §5) — this one talks // directly to the TerdutServer it references and to terdut-server's API, // never to TerdutServerReconciler. type TerdutTeamReconciler struct { client.Client Scheme *runtime.Scheme // OperatorNamespace is where every credentials Secret this controller // reads (the referenced TerdutServer's) or writes (this team's own) // lives (DESIGN.md §6) — never a TerdutTeam's or TerdutServer's own // namespace. OperatorNamespace string Recorder recorder.EventRecorder NewClient func(endpoint string) *tdclient.Client } // +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams/status,verbs=get;update;patch // +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams/finalizers,verbs=update // +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch // +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch func (r *TerdutTeamReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := logf.FromContext(ctx) var team terdutv1alpha1.TerdutTeam if err := r.Get(ctx, req.NamespacedName, &team); err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil } return ctrl.Result{}, err } if !team.DeletionTimestamp.IsZero() { return r.reconcileTeamDelete(ctx, &team) } if !controllerutil.ContainsFinalizer(&team, teamFinalizerName) { controllerutil.AddFinalizer(&team, teamFinalizerName) if err := r.Update(ctx, &team); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil } srv, resolveErr := r.resolveServerRef(ctx, &team) if resolveErr != nil { return r.setTeamNotReady(ctx, &team, resolveErr.reason, resolveErr.message, waitInterval) } if srv.Status.CredentialsSecretRef == nil { return r.setTeamNotReady(ctx, &team, terdutv1alpha1.ReasonWaitingForServer, fmt.Sprintf("TerdutServer %q is not Bootstrapped yet", srv.Name), waitInterval) } newClient := r.NewClient if newClient == nil { newClient = tdclient.New } instanceKey, err := readOperatorSecret(ctx, r.Client, r.OperatorNamespace, srv.Status.CredentialsSecretRef) if err != nil { return ctrl.Result{}, err } instanceClient := newClient(serviceURL(srv)).WithToken(instanceKey) if team.Status.TeamID == 0 { if err := r.createOrAdoptTeam(ctx, &team, instanceClient); err != nil { return ctrl.Result{}, err } } if team.Status.CredentialsSecretRef == nil { if err := r.mintTeamCredential(ctx, &team, instanceClient); err != nil { return ctrl.Result{}, err } } teamKey, err := readOperatorSecret(ctx, r.Client, r.OperatorNamespace, team.Status.CredentialsSecretRef) if err != nil { return ctrl.Result{}, err } teamClient := newClient(serviceURL(srv)).WithToken(teamKey) // Owner-gated on terdut-server, so this always runs with the // team-scoped credential just minted above, never the instance-scoped // one used to create the team (internal/api/teams.go's requireTeamOwner // has no branch for an instance-scoped service account, confirmed // against source). Applied unconditionally rather than diffed against a // stored "last-applied" value: both calls are idempotent PUTs of the // whole resource, the same "cheap because it's small" reasoning §5 // already applies to the escalation policy's whole-policy PUT. if err := teamClient.RenameTeam(ctx, team.Status.TeamID, team.Spec.DisplayName); err != nil { return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d: %w", team.Status.TeamID, err) } if err := teamClient.SetTeamOIDCGroups(ctx, team.Status.TeamID, team.Spec.OIDC.MemberGroup, team.Spec.OIDC.OwnerGroup); err != nil { return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d/oidc-groups: %w", team.Status.TeamID, err) } meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{ Type: terdutv1alpha1.ConditionTeamReady, Status: metav1.ConditionTrue, Reason: terdutv1alpha1.ReasonTeamAdopted, Message: fmt.Sprintf("team %d ready, credentials in Secret %q", team.Status.TeamID, team.Status.CredentialsSecretRef.Name), }) team.Status.ObservedGeneration = team.Generation if err := r.Status().Update(ctx, &team); err != nil { return ctrl.Result{}, err } if r.Recorder != nil { r.Recorder.Eventf(&team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonTeamAdopted, terdutv1alpha1.ReasonTeamAdopted, "team ready") } log.Info("TerdutTeam ready", "name", team.Name, "teamID", team.Status.TeamID) return ctrl.Result{RequeueAfter: resyncInterval}, nil } // teamError carries a condition reason/message, the same role databaseError // plays for TerdutServer: an expected, requeue-and-retry outcome, not a // reconcile failure. type teamError struct { reason string message string } func (e *teamError) Error() string { return e.message } // resolveServerRef resolves spec.serverRef, including the cross-namespace // consent check (DESIGN.md §4.6) when serverRef.namespace differs from this // TerdutTeam's own. func (r *TerdutTeamReconciler) resolveServerRef(ctx context.Context, team *terdutv1alpha1.TerdutTeam) (*terdutv1alpha1.TerdutServer, *teamError) { ns := team.Spec.ServerRef.Namespace if ns == "" { ns = team.Namespace } var srv terdutv1alpha1.TerdutServer if err := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: team.Spec.ServerRef.Name}, &srv); err != nil { if apierrors.IsNotFound(err) { return nil, &teamError{ reason: terdutv1alpha1.ReasonServerRefNotFound, message: fmt.Sprintf("TerdutServer %q not found in namespace %q", team.Spec.ServerRef.Name, ns), } } return nil, &teamError{reason: terdutv1alpha1.ReasonServerRefNotFound, message: err.Error()} } if ns == team.Namespace { return &srv, nil } var ownNamespace corev1.Namespace if err := r.Get(ctx, client.ObjectKey{Name: team.Namespace}, &ownNamespace); err != nil { return nil, &teamError{ reason: terdutv1alpha1.ReasonRefNotPermitted, message: fmt.Sprintf("could not read this TerdutTeam's own namespace %q labels: %v", team.Namespace, err), } } permitted, err := allowedTeamsPermits(srv.Spec.AllowedTeams.Namespaces, ownNamespace.Labels) if err != nil { return nil, &teamError{reason: terdutv1alpha1.ReasonRefNotPermitted, message: err.Error()} } if !permitted { return nil, &teamError{ reason: terdutv1alpha1.ReasonRefNotPermitted, message: fmt.Sprintf("TerdutServer %q/%q's spec.allowedTeams does not admit namespace %q", ns, team.Spec.ServerRef.Name, team.Namespace), } } return &srv, nil } func (r *TerdutTeamReconciler) setTeamNotReady( ctx context.Context, team *terdutv1alpha1.TerdutTeam, reason, message string, d time.Duration, ) (ctrl.Result, error) { meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{ Type: terdutv1alpha1.ConditionTeamReady, Status: metav1.ConditionFalse, Reason: reason, Message: message, }) team.Status.ObservedGeneration = team.Generation if err := r.Status().Update(ctx, team); err != nil { return ctrl.Result{}, err } if r.Recorder != nil { r.Recorder.Eventf(team, nil, corev1.EventTypeWarning, reason, reason, message) } return ctrl.Result{RequeueAfter: d}, nil } // teamCredentialsSecretName/teamServiceAccountName follow the same // .-suffix convention TerdutServer's secrets use // (DESIGN.md §6 point 3), keyed on the TerdutTeam CR's own identity rather // than its (mutable) displayName -- a service account's own name is // globally unique across the whole install (internal/db/migrations/ // 014_service_accounts.sql's UNIQUE constraint, confirmed against source), // so this has to be collision-safe the same way the credentials Secret // names already are. func teamCredentialsSecretName(team *terdutv1alpha1.TerdutTeam) string { return fmt.Sprintf("%s.%s-team-credentials", team.Namespace, team.Name) } func teamServiceAccountName(team *terdutv1alpha1.TerdutTeam) string { return fmt.Sprintf("terdut-team.%s.%s", team.Namespace, team.Name) } // reconcileTeamDelete cleans up the team-scoped credentials Secret and, if // one was ever minted, deletes the team server-side first. If // status.teamID or status.credentialsSecretRef was never set (the CR was // deleted before reconciliation ever got that far), there is deliberately // no attempt to clean up server-side: terdut-server's DELETE /api/teams/ // {teamID} is owner-gated (requireTeamOwner), and an instance-scoped // credential -- the only one this controller would otherwise hold -- does // not satisfy that check (confirmed against source, same finding as // TEAM-LOOKUP.md's). A team created but never fully reconciled to Ready is // left orphaned server-side for a human with real owner/admin access to // clean up -- a known, documented limitation, not a silent gap. func (r *TerdutTeamReconciler) reconcileTeamDelete(ctx context.Context, team *terdutv1alpha1.TerdutTeam) (ctrl.Result, error) { if !controllerutil.ContainsFinalizer(team, teamFinalizerName) { return ctrl.Result{}, nil } if team.Status.TeamID != 0 && team.Status.CredentialsSecretRef != nil { if err := r.deleteTeamServerSide(ctx, team); err != nil { if r.Recorder != nil { r.Recorder.Eventf(team, nil, corev1.EventTypeWarning, "DeleteFailed", "DeleteFailed", err.Error()) } return ctrl.Result{}, err } } secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: teamCredentialsSecretName(team), Namespace: r.OperatorNamespace}} if err := r.Delete(ctx, secret); err != nil && !apierrors.IsNotFound(err) { return ctrl.Result{}, err } controllerutil.RemoveFinalizer(team, teamFinalizerName) return ctrl.Result{}, r.Update(ctx, team) } func (r *TerdutTeamReconciler) deleteTeamServerSide(ctx context.Context, team *terdutv1alpha1.TerdutTeam) error { srv, resolveErr := r.resolveServerRef(ctx, team) if resolveErr != nil { // The TerdutServer (or the namespace consent for it) is gone too -- // most likely the whole install is being torn down together. // Nothing to delete against; proceed rather than block forever on // a parent that no longer exists. return nil } teamKey, err := readOperatorSecret(ctx, r.Client, r.OperatorNamespace, team.Status.CredentialsSecretRef) if err != nil { if apierrors.IsNotFound(err) { return nil } return err } newClient := r.NewClient if newClient == nil { newClient = tdclient.New } err = newClient(serviceURL(srv)).WithToken(teamKey).DeleteTeam(ctx, team.Status.TeamID) if statusErr, ok := errors.AsType[*tdclient.StatusError](err); ok && statusErr.Code == http.StatusNotFound { return nil } return err } // SetupWithManager sets up the controller with the Manager. func (r *TerdutTeamReconciler) SetupWithManager(mgr ctrl.Manager) error { if r.NewClient == nil { r.NewClient = tdclient.New } if r.Recorder == nil { r.Recorder = mgr.GetEventRecorder("terdutteam-controller") } return ctrl.NewControllerManagedBy(mgr). For(&terdutv1alpha1.TerdutTeam{}). Named("terdutteam"). Complete(r) }