a0ea13955e
The actual fix for the human-onboarding gap niklas/terdut-server#23 found -- not a terdut-server change at all. A team-scoped credential is already owner-equivalent for POST/GET/DELETE /api/teams/{teamID}/invites (requireTeamOwner's synthetic-membership mechanism, ratified not accidental per that repo's SERVICE-ACCOUNTS.md), and invite redemption bypasses signup_mode entirely -- this TerdutTeam controller just never grew a feature to use either fact. New spec.invite{enabled, role (member|owner, default member), maxUses (1-100, default 1)} and status.inviteSecretRef. The Secret lives in the TerdutTeam's OWN namespace, not the operator's: unlike status.credentialsSecretRef (a durable, high-privilege credential, kept operator-side per DESIGN.md §6), an invite is bounded and limited-use, meant for this namespace's own human operators to read and hand out -- same precedent as TerdutAlertSource's status.webhookURLSecretRef, same- namespace and OwnerReference'd so deleting the TerdutTeam garbage-collects it automatically. internal/controller/terdutteam_invite.go: mints on first spec.invite.enabled, refreshes a day ahead of terdut-server's fixed 7-day TTL (reading the Secret's own stored expiresAt, no extra server round-trip per reconcile), revokes server-side and deletes the Secret when flipped back to false. A lost invite Secret is silently re-minted rather than treated as unrecoverable the way TerdutAlertSource's webhook key is -- nothing external holds a durable dependency on one specific invite link staying stable, it's read once by one human and handed out. New tdclient.Invite/CreateInvite/RevokeInvite. New envtest coverage: mint into the team's own namespace, refresh-before-expiry, revoke-on-disable (internal/controller/terdutteam_controller_test.go's new "spec.invite" Describe block), plus the fake server growing invite support (terdutserver_controller_test.go) -- its handleTeamSubPath dispatcher was split further (deadman switches into their own handleDeadmanSubPath, matching the existing handleIntegrationSubPath precedent) to stay under golangci-lint's gocyclo threshold with the new route added. examples/demo updated to prove this end to end: 02-team-platform.yaml turns on spec.invite; run-demo.sh's bootstrap_login/join_demo_teams (the psql signup_mode flip + a direct team_members INSERT) are replaced by redeem_platform_invite (reads status.inviteSecretRef, a real POST /api/signup with the invite token) and join_payments_team (POST /api/teams/{teamID}/members using Payments' own credential and alice's user id resolved via GET /api/users, deliberately not given its own spec.invite, so the demo shows both onboarding paths this feature unlocks) -- zero kubectl exec/psql calls remain anywhere in the script. README.md's "First login" section rewritten to match; it no longer documents the admin-token curl call that 403s against current terdut-server (niklas/terdut-server#23). Depends on niklas/terdut-server#24 (the callerMayManageServiceAccount fix for terdut-operator#3) being released before this is deployed for real -- not required to build or test this change itself, since the envtest fake never modeled that authorization gap to begin with.
321 lines
12 KiB
Go
321 lines
12 KiB
Go
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
|
|
}
|
|
endpoint := serviceURL(srv)
|
|
team.Status.ServerEndpoint = endpoint
|
|
instanceClient := newClient(endpoint).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(endpoint).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)
|
|
}
|
|
if err := r.reconcileInvite(ctx, &team, teamClient); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
|
|
Type: terdutv1alpha1.ConditionReady,
|
|
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.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: d}, nil
|
|
}
|
|
|
|
// teamCredentialsSecretName/teamServiceAccountName follow the same
|
|
// <namespace>.<name>-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)
|
|
}
|