package controller import ( "context" "errors" "fmt" "net/http" 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" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" "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 deletes the team on the server before the CR goes. const teamFinalizerName = "terdut.ryuvia.com/terdutteam" // TerdutTeamReconciler reconciles a TerdutTeam: the team itself plus its // escalation ladder and dead man's switches, all through the referenced // TerdutServer's operator key. type TerdutTeamReconciler struct { client.Client Scheme *runtime.Scheme 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 // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch // +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch // teamExternalID is the identity this CR gives its team on the server, so the // team is found by who owns it and not by its (editable, global) display name. func teamExternalID(team *terdutv1alpha1.TerdutTeam) string { return team.Namespace + "/" + team.Name } 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) } if !meta.IsStatusConditionTrue(srv.Status.Conditions, terdutv1alpha1.ConditionReady) { return r.setTeamNotReady(ctx, &team, terdutv1alpha1.ReasonWaitingForServer, fmt.Sprintf("TerdutServer %q is not Ready yet", srv.Name)) } tc, err := r.serverClient(ctx, srv) if err != nil { return ctrl.Result{}, err } // Idempotent on the external id: the same team comes back whether this is // the first reconcile, a retry after a lost status write, or a repair after // somebody deleted the team behind our back. created, err := tc.CreateTeam(ctx, team.Spec.DisplayName, teamExternalID(&team)) if err != nil { if se, ok := errors.AsType[*tdclient.StatusError](err); ok && se.Code == http.StatusConflict { return r.setTeamNotReady(ctx, &team, terdutv1alpha1.ReasonTeamNameTaken, nameTakenMessage(&team)) } return ctrl.Result{}, fmt.Errorf("POST /api/teams: %w", err) } team.Status.TeamID = created.ID if err := tc.RenameTeam(ctx, created.ID, team.Spec.DisplayName); err != nil { if se, ok := errors.AsType[*tdclient.StatusError](err); ok && se.Code == http.StatusConflict { return r.setTeamNotReady(ctx, &team, terdutv1alpha1.ReasonTeamNameTaken, nameTakenMessage(&team)) } return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d: %w", created.ID, err) } if err := tc.SetTeamOIDCGroups(ctx, created.ID, team.Spec.OIDC.MemberGroup, team.Spec.OIDC.OwnerGroup); err != nil { return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d/oidc-groups: %w", created.ID, err) } if condErr, err := r.reconcileEscalation(ctx, tc, &team); err != nil { return ctrl.Result{}, err } else if condErr != nil { return r.setTeamNotReady(ctx, &team, condErr.reason, condErr.message) } if condErr, err := r.reconcileDeadmanSwitches(ctx, tc, &team); err != nil { return ctrl.Result{}, err } else if condErr != nil { return r.setTeamNotReady(ctx, &team, condErr.reason, condErr.message) } meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{ Type: terdutv1alpha1.ConditionReady, Status: metav1.ConditionTrue, Reason: terdutv1alpha1.ReasonTeamAdopted, Message: fmt.Sprintf("team %d applied", team.Status.TeamID), }) 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 applied") } log.Info("TerdutTeam ready", "name", team.Name, "teamID", team.Status.TeamID) return ctrl.Result{RequeueAfter: resyncInterval}, nil } func nameTakenMessage(team *terdutv1alpha1.TerdutTeam) string { return fmt.Sprintf("the server already has a different team named %q; choose another spec.displayName, "+ "or remove that team", team.Spec.DisplayName) } // serverClient builds a client for srv, authenticated with its operator key. func (r *TerdutTeamReconciler) serverClient(ctx context.Context, srv *terdutv1alpha1.TerdutServer) (*tdclient.Client, error) { key, err := readOperatorKey(ctx, r.Client, srv) if err != nil { return nil, err } newClient := r.NewClient if newClient == nil { newClient = tdclient.New } return newClient(serviceURL(srv)).WithToken(key), 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) { srv, ns, getErr := r.getServerRef(ctx, team) if getErr != nil { return nil, getErr } if ns == team.Namespace { return srv, nil } return r.checkConsent(ctx, team, srv, ns) } // getServerRef fetches the referenced TerdutServer and the namespace it was // looked up in, without the consent check. Deleting a team uses it directly: // consent gates what the operator will start acting on, not whether it may // clean up after itself once the server owner has narrowed allowedTeams. func (r *TerdutTeamReconciler) getServerRef(ctx context.Context, team *terdutv1alpha1.TerdutTeam) (*terdutv1alpha1.TerdutServer, string, *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, ns, &teamError{ reason: terdutv1alpha1.ReasonServerRefNotFound, message: fmt.Sprintf("TerdutServer %q not found in namespace %q", team.Spec.ServerRef.Name, ns), } } return nil, ns, &teamError{reason: terdutv1alpha1.ReasonServerRefNotFound, message: err.Error()} } return &srv, ns, nil } // checkConsent applies DESIGN.md §4.6's allowedTeams gate to a cross-namespace // reference. func (r *TerdutTeamReconciler) checkConsent(ctx context.Context, team *terdutv1alpha1.TerdutTeam, srv *terdutv1alpha1.TerdutServer, ns string) (*terdutv1alpha1.TerdutServer, *teamError) { 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, ) (ctrl.Result, error) { meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{ Type: terdutv1alpha1.ConditionReady, 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: waitInterval}, nil } // reconcileTeamDelete removes the team on the server, then the finalizer. The // server refuses while the team has open incidents (409), which surfaces as a // retried error: tidying up must not be how a live page disappears. 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 { 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 } } controllerutil.RemoveFinalizer(team, teamFinalizerName) return ctrl.Result{}, r.Update(ctx, team) } func (r *TerdutTeamReconciler) deleteTeamServerSide(ctx context.Context, team *terdutv1alpha1.TerdutTeam) error { // Consent is deliberately not checked here: it gates what the operator // starts acting on, not whether it may clean up after itself once the // server owner has narrowed allowedTeams. Only a TerdutServer that is // really gone lets the delete proceed (there is nothing to delete against); // any other failure is retried rather than orphaning the team. srv, ns, getErr := r.getServerRef(ctx, team) if getErr != nil { var probe terdutv1alpha1.TerdutServer err := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: team.Spec.ServerRef.Name}, &probe) if apierrors.IsNotFound(err) { return nil } return fmt.Errorf("reading TerdutServer %q/%q: %w", ns, team.Spec.ServerRef.Name, getErr) } tc, err := r.serverClient(ctx, srv) if err != nil { if apierrors.IsNotFound(err) { return nil // the key Secret went with its TerdutServer } return err } return tc.DeleteTeam(ctx, team.Status.TeamID) } // 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{}). Watches(&terdutv1alpha1.TerdutServer{}, handler.EnqueueRequestsFromMapFunc(r.teamsForServer)). Named("terdutteam"). Complete(r) } // teamsForServer re-reconciles every TerdutTeam that references a TerdutServer // when it changes (becomes Ready, say), instead of polling for it. func (r *TerdutTeamReconciler) teamsForServer(ctx context.Context, obj client.Object) []reconcile.Request { var teams terdutv1alpha1.TerdutTeamList if err := r.List(ctx, &teams); err != nil { return nil } var out []reconcile.Request for i := range teams.Items { t := &teams.Items[i] ns := t.Spec.ServerRef.Namespace if ns == "" { ns = t.Namespace } if t.Spec.ServerRef.Name == obj.GetName() && ns == obj.GetNamespace() { out = append(out, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(t)}) } } return out }