Stage 2: TerdutTeam (create, mint team credential, rename/oidc-groups, delete)
CI / test (push) Successful in 1m41s
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:
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user