Stage 2: TerdutTeam (create, mint team credential, rename/oidc-groups, delete)
CI / test (push) Successful in 1m41s

Implements ROADMAP.md Stage 2 against terdut-server's now-real
GET /api/teams?name= (TEAM-LOOKUP.md, landed in terdut-server just
before this commit) -- without it, the adopt-on-409 pattern this
controller depends on for team creation had no server-side lookup to
call, the same gap TerdutServer's own bootstrap flow hit and fixed in
Stage 1.

- api/v1alpha1: TerdutTeamSpec per DESIGN.md §4.2 (serverRef, displayName,
  oidc). status.credentialsSecretRef drops namespace for key, matching
  the fix already applied to TerdutServer's.
- internal/controller:
  - terdutteam_controller.go: resolves serverRef (same-namespace by
    default; cross-namespace gated by the target TerdutServer's
    spec.allowedTeams, DESIGN.md §4.6), waits for that TerdutServer to be
    Bootstrapped (no cross-controller RPC -- reads its
    status.credentialsSecretRef directly, DESIGN.md §5), creates the team
    and mints its team-scoped credential using the TerdutServer's
    instance-scoped one, then applies rename/oidc-groups with the
    team-scoped credential every reconcile (both are idempotent PUTs of
    the whole resource -- applied unconditionally rather than diffed
    against a stored last-applied value, same "cheap because it's small"
    reasoning §5 already gives the escalation policy's whole-policy PUT).
  - terdutteam_allowedteams.go: the §4.6 consent check in isolation from
    any client, unit-tested directly against hand-built inputs.
  - terdutteam_bootstrap.go: create-or-adopt-on-409 for the team itself
    (via TEAM-LOOKUP.md) and for its team-scoped service account (via the
    same GET-by-name+mint-new-key pattern Stage 1 already uses for the
    instance account).
  - secrets.go: extracted TerdutServer's write/read-credential-Secret
    helpers into free functions, now shared by both controllers rather
    than duplicated.
  - Finalizer deletes the team server-side (owner-gated, needs the
    team-scoped credential -- confirmed against source that an
    instance-scoped one does not satisfy requireTeamOwner, same finding
    as TEAM-LOOKUP.md's) and cleans up its credentials Secret. A team
    created but never fully reconciled to Ready (no team-scoped
    credential ever minted) is left orphaned server-side on delete -- a
    known, documented limitation (terdut-server has no delete path that
    doesn't require owner-equivalent access), not a silent gap.
- internal/tdclient: Team type, CreateTeam, GetTeamByName, RenameTeam,
  DeleteTeam, SetTeamOIDCGroups, CreateTeamServiceAccount -- matching
  terdut-server's real handlers' shapes field-for-field, same as Stage
  1's client additions.
- Tests: envtest covering the happy path, both not-ready reasons
  (ServerRefNotFound, WaitingForServer), cross-namespace allow/deny
  (default-closed and explicit All), both adopt-on-409 paths (team
  itself, team-scoped service account), and deletion. Extended the shared
  fakeTerdutServer (Stage 1) with team endpoints rather than writing a
  second, separately-drifting fake. 72.8%/30.6% coverage, 0 lint issues.

Verified locally: make fmt lint test build all clean.
This commit is contained in:
Niklas Ye
2026-10-01 11:09:40 +02:00
parent 72c979e3e8
commit c9c52af2f7
24 changed files with 1755 additions and 36 deletions
+130
View File
@@ -0,0 +1,130 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
// TerdutServerRef names the TerdutServer this team belongs to. namespace is
// optional and defaults to this TerdutTeam's own namespace; set, it's the
// one cross-namespace edge DESIGN.md §1/§4.6 allows, gated by the target
// TerdutServer's own spec.allowedTeams consent.
type TerdutServerRef struct {
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
// +optional
Namespace string `json:"namespace,omitempty"`
}
// TerdutTeamOIDC binds which identity-provider groups grant membership and
// ownership of this team (DESIGN.md §4.2). Both empty means no group grants
// either role here — matches terdut-server's own NULLIF-on-empty-string
// handling (internal/api/oidc_teams.go).
type TerdutTeamOIDC struct {
// +optional
MemberGroup string `json:"memberGroup,omitempty"`
// +optional
OwnerGroup string `json:"ownerGroup,omitempty"`
}
// TerdutTeamSpec defines the desired state of TerdutTeam.
type TerdutTeamSpec struct {
// serverRef names the TerdutServer this team belongs to.
// +required
ServerRef TerdutServerRef `json:"serverRef"`
// displayName is this team's name, both in terdut-server's own data
// (POST /api/teams {"name": ...}) and as the identity POST /api/teams
// and GET /api/teams?name= correlate on (DESIGN.md §5's idempotent-
// create rule, via TEAM-LOOKUP.md).
// +required
// +kubebuilder:validation:MinLength=1
DisplayName string `json:"displayName"`
// +optional
OIDC TerdutTeamOIDC `json:"oidc,omitempty"`
}
// Condition types this controller sets on TerdutTeam.
const (
ConditionTeamReady = "Ready"
)
// Condition reasons this controller sets.
const (
// ReasonServerRefNotFound: spec.serverRef names no TerdutServer (yet).
ReasonServerRefNotFound = "ServerRefNotFound"
// ReasonRefNotPermitted: spec.serverRef is cross-namespace, and the
// target TerdutServer's spec.allowedTeams doesn't admit this
// TerdutTeam's namespace (DESIGN.md §4.6).
ReasonRefNotPermitted = "RefNotPermitted"
// ReasonWaitingForServer: the referenced TerdutServer exists but isn't
// Bootstrapped yet (no status.credentialsSecretRef to read) --
// DESIGN.md §5's "every child requeues with backoff, no cross-
// controller RPC" rule.
ReasonWaitingForServer = "WaitingForServer"
// ReasonTeamAdopted: the happy path.
ReasonTeamAdopted = "Adopted"
)
// TerdutTeamStatus defines the observed state of TerdutTeam.
type TerdutTeamStatus struct {
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
// teamID is the server-side id -- needed by every child object's
// controller (DESIGN.md §4.2).
// +optional
TeamID int64 `json:"teamID,omitempty"`
// credentialsSecretRef is this team's own scoped credential
// (DESIGN.md §6 point 3) -- pure output, always in the operator's own
// namespace, under a fixed data key ("token").
// +optional
CredentialsSecretRef *SecretKeyRef `json:"credentialsSecretRef,omitempty"`
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Server",type=string,JSONPath=`.spec.serverRef.name`
// +kubebuilder:printcolumn:name="TeamID",type=integer,JSONPath=`.status.teamID`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
// TerdutTeam is the Schema for the terdutteams API
type TerdutTeam struct {
metav1.TypeMeta `json:",inline"`
// metadata is a standard object metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitzero"`
// spec defines the desired state of TerdutTeam
// +required
Spec TerdutTeamSpec `json:"spec"`
// status defines the observed state of TerdutTeam
// +optional
Status TerdutTeamStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
// TerdutTeamList contains a list of TerdutTeam
type TerdutTeamList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []TerdutTeam `json:"items"`
}
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &TerdutTeam{}, &TerdutTeamList{})
return nil
})
}
+133
View File
@@ -264,6 +264,21 @@ func (in *TerdutServerList) DeepCopyObject() runtime.Object {
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutServerRef) DeepCopyInto(out *TerdutServerRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutServerRef.
func (in *TerdutServerRef) DeepCopy() *TerdutServerRef {
if in == nil {
return nil
}
out := new(TerdutServerRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutServerSpec) DeepCopyInto(out *TerdutServerSpec) {
*out = *in
@@ -313,3 +328,121 @@ func (in *TerdutServerStatus) DeepCopy() *TerdutServerStatus {
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutTeam) DeepCopyInto(out *TerdutTeam) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeam.
func (in *TerdutTeam) DeepCopy() *TerdutTeam {
if in == nil {
return nil
}
out := new(TerdutTeam)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *TerdutTeam) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutTeamList) DeepCopyInto(out *TerdutTeamList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]TerdutTeam, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamList.
func (in *TerdutTeamList) DeepCopy() *TerdutTeamList {
if in == nil {
return nil
}
out := new(TerdutTeamList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *TerdutTeamList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutTeamOIDC) DeepCopyInto(out *TerdutTeamOIDC) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamOIDC.
func (in *TerdutTeamOIDC) DeepCopy() *TerdutTeamOIDC {
if in == nil {
return nil
}
out := new(TerdutTeamOIDC)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutTeamSpec) DeepCopyInto(out *TerdutTeamSpec) {
*out = *in
out.ServerRef = in.ServerRef
out.OIDC = in.OIDC
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamSpec.
func (in *TerdutTeamSpec) DeepCopy() *TerdutTeamSpec {
if in == nil {
return nil
}
out := new(TerdutTeamSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutTeamStatus) DeepCopyInto(out *TerdutTeamStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.CredentialsSecretRef != nil {
in, out := &in.CredentialsSecretRef, &out.CredentialsSecretRef
*out = new(SecretKeyRef)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamStatus.
func (in *TerdutTeamStatus) DeepCopy() *TerdutTeamStatus {
if in == nil {
return nil
}
out := new(TerdutTeamStatus)
in.DeepCopyInto(out)
return out
}