Files
terdut-operator/internal/controller/terdutteam_controller.go
T
Niklas Ye e1103f2b7d Authenticate with a seeded operator key; fold escalation and switches into TerdutTeam
Credentials: the TerdutServer controller generates <name>-operator-key in
the server's own namespace (owned by it) and hands it to the pods as
TERDUT_OPERATOR_KEY; the server creates its instance-scoped account from it
at every start. A replaced Secret rolls the pods. The bootstrap handshake,
the checkpoint Secret, per-team service accounts and credentials Secrets,
BootstrapStateLost and credentials.deletionPolicy are gone.

CRDs: TerdutServer, TerdutTeam and TerdutAlertSource. TerdutEscalationRule
and TerdutDeadmanSwitch become spec.escalation and spec.deadmanSwitches[]
on the team (matched by name, extras removed); team invites are removed.
A team is created under the identity <namespace>/<name> (external_id), so a
retry, a lost status or a deleted team heal by repeating the same call, and
a display name owned by another team is TeamNameTaken instead of an
adoption. The server resolves escalation usernames (UnknownUser condition).
OIDC claim names and trustEmail are spec fields.

Fixes: query values are URL-escaped; every delete treats 404 as success;
deleting a team no longer depends on allowedTeams consent; a switch or
integration deleted on the server is recreated; unnamed switches take the
CR's name.

Cleanup: scaffold e2e test, AGENTS.md, devcontainer, unused config/ pieces
and Client.Version() removed; DESIGN.md, README, ROADMAP and the demo
(run-demo.sh, manifests) rewritten for the new design. Secret RBAC stays
cluster-wide, now stated in DESIGN.md section 9.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-09 14:56:22 +02:00

332 lines
12 KiB
Go

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
}