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
+9
View File
@@ -18,4 +18,13 @@ resources:
kind: TerdutServer kind: TerdutServer
path: git.ryuvia.com/niklas/terdut-operator/api/v1alpha1 path: git.ryuvia.com/niklas/terdut-operator/api/v1alpha1
version: v1alpha1 version: v1alpha1
- api:
crdVersion: v1
namespaced: true
controller: true
domain: ryuvia.com
group: terdut
kind: TerdutTeam
path: git.ryuvia.com/niklas/terdut-operator/api/v1alpha1
version: v1alpha1
version: "3" version: "3"
+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 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. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutServerSpec) DeepCopyInto(out *TerdutServerSpec) { func (in *TerdutServerSpec) DeepCopyInto(out *TerdutServerSpec) {
*out = *in *out = *in
@@ -313,3 +328,121 @@ func (in *TerdutServerStatus) DeepCopy() *TerdutServerStatus {
in.DeepCopyInto(out) in.DeepCopyInto(out)
return 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
}
+8
View File
@@ -185,6 +185,14 @@ func main() {
setupLog.Error(err, "Failed to create controller", "controller", "terdutserver") setupLog.Error(err, "Failed to create controller", "controller", "terdutserver")
os.Exit(1) os.Exit(1)
} }
if err := (&controller.TerdutTeamReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
OperatorNamespace: operatorNamespace,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "Failed to create controller", "controller", "terdutteam")
os.Exit(1)
}
// +kubebuilder:scaffold:builder // +kubebuilder:scaffold:builder
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
@@ -0,0 +1,187 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.22.0
name: terdutteams.terdut.ryuvia.com
spec:
group: terdut.ryuvia.com
names:
kind: TerdutTeam
listKind: TerdutTeamList
plural: terdutteams
singular: terdutteam
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.serverRef.name
name: Server
type: string
- jsonPath: .status.teamID
name: TeamID
type: integer
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].reason
name: Reason
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: TerdutTeam is the Schema for the terdutteams API
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: spec defines the desired state of TerdutTeam
properties:
displayName:
description: |-
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).
minLength: 1
type: string
oidc:
description: |-
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).
properties:
memberGroup:
type: string
ownerGroup:
type: string
type: object
serverRef:
description: serverRef names the TerdutServer this team belongs to.
properties:
name:
minLength: 1
type: string
namespace:
type: string
required:
- name
type: object
required:
- displayName
- serverRef
type: object
status:
description: status defines the observed state of TerdutTeam
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
credentialsSecretRef:
description: |-
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").
properties:
key:
description: key is the data key inside the Secret holding the
raw value.
minLength: 1
type: string
name:
description: name is the Secret's name.
minLength: 1
type: string
required:
- key
- name
type: object
observedGeneration:
format: int64
type: integer
teamID:
description: |-
teamID is the server-side id -- needed by every child object's
controller (DESIGN.md §4.2).
format: int64
type: integer
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
+1
View File
@@ -3,6 +3,7 @@
# It should be run by config/default # It should be run by config/default
resources: resources:
- bases/terdut.ryuvia.com_terdutservers.yaml - bases/terdut.ryuvia.com_terdutservers.yaml
- bases/terdut.ryuvia.com_terdutteams.yaml
# +kubebuilder:scaffold:crdkustomizeresource # +kubebuilder:scaffold:crdkustomizeresource
patches: patches:
+3
View File
@@ -22,6 +22,9 @@ resources:
# default, aiding admins in cluster management. Those roles are # default, aiding admins in cluster management. Those roles are
# not used by the terdut-operator itself. You can comment the following lines # not used by the terdut-operator itself. You can comment the following lines
# if you do not want those helpers be installed with your Project. # if you do not want those helpers be installed with your Project.
- terdutteam_admin_role.yaml
- terdutteam_editor_role.yaml
- terdutteam_viewer_role.yaml
- terdutserver_admin_role.yaml - terdutserver_admin_role.yaml
- terdutserver_editor_role.yaml - terdutserver_editor_role.yaml
- terdutserver_viewer_role.yaml - terdutserver_viewer_role.yaml
+11
View File
@@ -4,6 +4,14 @@ kind: ClusterRole
metadata: metadata:
name: manager-role name: manager-role
rules: rules:
- apiGroups:
- ""
resources:
- namespaces
verbs:
- get
- list
- watch
- apiGroups: - apiGroups:
- "" - ""
resources: resources:
@@ -48,6 +56,7 @@ rules:
- terdut.ryuvia.com - terdut.ryuvia.com
resources: resources:
- terdutservers - terdutservers
- terdutteams
verbs: verbs:
- create - create
- delete - delete
@@ -60,12 +69,14 @@ rules:
- terdut.ryuvia.com - terdut.ryuvia.com
resources: resources:
- terdutservers/finalizers - terdutservers/finalizers
- terdutteams/finalizers
verbs: verbs:
- update - update
- apiGroups: - apiGroups:
- terdut.ryuvia.com - terdut.ryuvia.com
resources: resources:
- terdutservers/status - terdutservers/status
- terdutteams/status
verbs: verbs:
- get - get
- patch - patch
+27
View File
@@ -0,0 +1,27 @@
# This rule is not used by the project terdut-operator itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants full permissions ('*') over terdut.ryuvia.com.
# This role is intended for users authorized to modify roles and bindings within the cluster,
# enabling them to delegate specific permissions to other users or groups as needed.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: terdut-operator
app.kubernetes.io/managed-by: kustomize
name: terdutteam-admin-role
rules:
- apiGroups:
- terdut.ryuvia.com
resources:
- terdutteams
verbs:
- '*'
- apiGroups:
- terdut.ryuvia.com
resources:
- terdutteams/status
verbs:
- get
+33
View File
@@ -0,0 +1,33 @@
# This rule is not used by the project terdut-operator itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants permissions to create, update, and delete resources within the terdut.ryuvia.com.
# This role is intended for users who need to manage these resources
# but should not control RBAC or manage permissions for others.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: terdut-operator
app.kubernetes.io/managed-by: kustomize
name: terdutteam-editor-role
rules:
- apiGroups:
- terdut.ryuvia.com
resources:
- terdutteams
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- terdut.ryuvia.com
resources:
- terdutteams/status
verbs:
- get
+29
View File
@@ -0,0 +1,29 @@
# This rule is not used by the project terdut-operator itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants read-only access to terdut.ryuvia.com resources.
# This role is intended for users who need visibility into these resources
# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: terdut-operator
app.kubernetes.io/managed-by: kustomize
name: terdutteam-viewer-role
rules:
- apiGroups:
- terdut.ryuvia.com
resources:
- terdutteams
verbs:
- get
- list
- watch
- apiGroups:
- terdut.ryuvia.com
resources:
- terdutteams/status
verbs:
- get
+1
View File
@@ -1,4 +1,5 @@
## Append samples of your project ## ## Append samples of your project ##
resources: resources:
- terdut_v1alpha1_terdutserver.yaml - terdut_v1alpha1_terdutserver.yaml
- terdut_v1alpha1_terdutteam.yaml
# +kubebuilder:scaffold:manifestskustomizesamples # +kubebuilder:scaffold:manifestskustomizesamples
@@ -0,0 +1,20 @@
apiVersion: terdut.ryuvia.com/v1alpha1
kind: TerdutTeam
metadata:
labels:
app.kubernetes.io/name: terdut-operator
app.kubernetes.io/managed-by: kustomize
name: terdutteam-sample
spec:
# serverRef.namespace is optional, defaulting to this TerdutTeam's own
# namespace (the common case). Set it only to reference a TerdutServer in
# a different namespace -- which needs that TerdutServer's own
# spec.allowedTeams to admit this namespace (DESIGN.md §4.6), otherwise
# this reports Ready: False, reason: RefNotPermitted.
serverRef:
name: terdutserver-sample
displayName: Platform
# oidc is optional -- omit entirely for a password-login-only install.
# oidc:
# memberGroup: terdut-platform-members
# ownerGroup: terdut-platform-owners
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.26.0
require ( require (
github.com/onsi/ginkgo/v2 v2.27.4 github.com/onsi/ginkgo/v2 v2.27.4
github.com/onsi/gomega v1.39.0 github.com/onsi/gomega v1.39.0
k8s.io/api v0.37.0
k8s.io/apimachinery v0.37.0 k8s.io/apimachinery v0.37.0
k8s.io/client-go v0.37.0 k8s.io/client-go v0.37.0
sigs.k8s.io/controller-runtime v0.25.0 sigs.k8s.io/controller-runtime v0.25.0
@@ -91,7 +92,6 @@ require (
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/api v0.37.0 // indirect
k8s.io/apiextensions-apiserver v0.37.0 // indirect k8s.io/apiextensions-apiserver v0.37.0 // indirect
k8s.io/apiserver v0.37.0 // indirect k8s.io/apiserver v0.37.0 // indirect
k8s.io/component-base v0.37.0 // indirect k8s.io/component-base v0.37.0 // indirect
+49
View File
@@ -0,0 +1,49 @@
package controller
import (
"context"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
)
// credentialsSecretDataKey is the fixed data key every generated
// credentials Secret this controller writes uses — "whatever a human chose"
// only applied to the bring-your-own input an earlier design draft had and
// removed (DESIGN.md §6); every Secret any controller in this repo
// generates itself uses this one key.
const credentialsSecretDataKey = "token"
// writeOperatorSecret creates or replaces a Secret in namespace (always the
// operator's own, DESIGN.md §6) holding one raw value under
// credentialsSecretDataKey. Shared by every controller that generates a
// credential there -- TerdutServer's instance-scoped key and the bootstrap
// admin-key checkpoint, TerdutTeam's team-scoped key.
func writeOperatorSecret(ctx context.Context, c client.Client, namespace, name, rawValue string) error {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Data: map[string][]byte{credentialsSecretDataKey: []byte(rawValue)},
}
if err := c.Create(ctx, secret); err != nil {
if apierrors.IsAlreadyExists(err) {
return c.Update(ctx, secret)
}
return err
}
return nil
}
// readOperatorSecret reads one generated credential back, by the
// SecretKeyRef a controller's own status stores (always resolved in the
// operator's own namespace, never the referencing CR's -- DESIGN.md §6).
func readOperatorSecret(ctx context.Context, c client.Client, namespace string, ref *terdutv1alpha1.SecretKeyRef) (string, error) {
var secret corev1.Secret
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref.Name}, &secret); err != nil {
return "", err
}
return string(secret.Data[ref.Key]), nil
}
+2 -24
View File
@@ -15,12 +15,6 @@ import (
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient" "git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
) )
// credentialsSecretDataKey is the fixed data key every generated
// credentials Secret this controller writes uses — "whatever a human chose"
// only applied to the bring-your-own input this design removed (DESIGN.md
// §6); every Secret this controller itself generates uses this one key.
const credentialsSecretDataKey = "token"
// bootstrapStateLostError is DESIGN.md §6's one genuinely pathological // bootstrapStateLostError is DESIGN.md §6's one genuinely pathological
// case: a checkpointed admin credential was used and then lost before the // case: a checkpointed admin credential was used and then lost before the
// lasting credential it was for could be persisted. Distinct from a plain // lasting credential it was for could be persisted. Distinct from a plain
@@ -54,7 +48,7 @@ func (r *TerdutServerReconciler) reconcileBootstrap(ctx context.Context, srv *te
} }
credsName := credentialsSecretName(srv) credsName := credentialsSecretName(srv)
if err := r.writeSecret(ctx, credsName, instanceKey); err != nil { if err := writeOperatorSecret(ctx, r.Client, r.OperatorNamespace, credsName, instanceKey); err != nil {
return err return err
} }
srv.Status.CredentialsSecretRef = &terdutv1alpha1.SecretKeyRef{Name: credsName, Key: credentialsSecretDataKey} srv.Status.CredentialsSecretRef = &terdutv1alpha1.SecretKeyRef{Name: credsName, Key: credentialsSecretDataKey}
@@ -100,7 +94,7 @@ func (r *TerdutServerReconciler) getOrCreateCheckpointedAdminKey(ctx context.Con
return "", fmt.Errorf("POST /api/bootstrap: %w", err) return "", fmt.Errorf("POST /api/bootstrap: %w", err)
} }
if err := r.writeSecret(ctx, checkpointName, result.APIKey.Key); err != nil { if err := writeOperatorSecret(ctx, r.Client, r.OperatorNamespace, checkpointName, result.APIKey.Key); err != nil {
return "", fmt.Errorf("checkpointing admin key: %w", err) return "", fmt.Errorf("checkpointing admin key: %w", err)
} }
return result.APIKey.Key, nil return result.APIKey.Key, nil
@@ -135,19 +129,3 @@ func (r *TerdutServerReconciler) getOrMintInstanceServiceAccountKey(ctx context.
} }
return key.Key, nil return key.Key, nil
} }
// writeSecret creates or replaces a Secret in the operator's own namespace
// holding one raw value under credentialsSecretDataKey.
func (r *TerdutServerReconciler) writeSecret(ctx context.Context, name, rawValue string) error {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: r.OperatorNamespace},
Data: map[string][]byte{credentialsSecretDataKey: []byte(rawValue)},
}
if err := r.Create(ctx, secret); err != nil {
if apierrors.IsAlreadyExists(err) {
return r.Update(ctx, secret)
}
return err
}
return nil
}
@@ -23,6 +23,14 @@ import (
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient" "git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
) )
// fakeVersionString/errJSONKey are shared by every response fakeTerdutServer
// writes -- goconst would otherwise flag "test" and "error" as repeated
// literals across its handlers.
const (
fakeVersionString = "test"
errJSONKey = "error"
)
// fakeTerdutServer reproduces the exact stateful semantics of // fakeTerdutServer reproduces the exact stateful semantics of
// /api/bootstrap, /api/service-accounts and /api/version that the // /api/bootstrap, /api/service-accounts and /api/version that the
// bootstrap flow depends on (DESIGN.md §6, §11: "terdut-server's REST API // bootstrap flow depends on (DESIGN.md §6, §11: "terdut-server's REST API
@@ -37,10 +45,23 @@ type fakeTerdutServer struct {
nextID int64 nextID int64
accounts map[string]int64 // name -> id accounts map[string]int64 // name -> id
keyMints map[int64]int // id -> number of keys minted so far keyMints map[int64]int // id -> number of keys minted so far
nextTeamID int64
teams map[string]int64 // name -> id
teamNames map[int64]string // id -> current name (renames update this)
teamOIDC map[int64][2]string
teamDelete map[int64]bool // id -> true once DELETEd, for 404-on-redelete
} }
func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) { func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) {
f := &fakeTerdutServer{accounts: map[string]int64{}, keyMints: map[int64]int{}} f := &fakeTerdutServer{
accounts: map[string]int64{},
keyMints: map[int64]int{},
teams: map[string]int64{},
teamNames: map[int64]string{},
teamOIDC: map[int64][2]string{},
teamDelete: map[int64]bool{},
}
return f, httptest.NewServer(f) return f, httptest.NewServer(f)
} }
@@ -50,11 +71,11 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.URL.Path == "/api/version": case r.URL.Path == "/api/version":
writeJSON(w, http.StatusOK, map[string]string{"version": "test"}) writeJSON(w, http.StatusOK, map[string]string{"version": fakeVersionString})
case r.URL.Path == "/api/bootstrap" && r.Method == http.MethodPost: case r.URL.Path == "/api/bootstrap" && r.Method == http.MethodPost:
if f.bootstrapped || f.bootstrap403 { if f.bootstrapped || f.bootstrap403 {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "bootstrap already completed"}) writeJSON(w, http.StatusForbidden, map[string]string{errJSONKey: "bootstrap already completed"})
return return
} }
f.bootstrapped = true f.bootstrapped = true
@@ -69,7 +90,7 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
_ = json.NewDecoder(r.Body).Decode(&req) _ = json.NewDecoder(r.Body).Decode(&req)
if _, exists := f.accounts[req.Name]; exists { if _, exists := f.accounts[req.Name]; exists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "a service account with that name already exists"}) writeJSON(w, http.StatusConflict, map[string]string{errJSONKey: "a service account with that name already exists"})
return return
} }
f.nextID++ f.nextID++
@@ -90,6 +111,32 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, []tdclient.ServiceAccount{{ID: id, Name: name, Scope: "instance"}}) writeJSON(w, http.StatusOK, []tdclient.ServiceAccount{{ID: id, Name: name, Scope: "instance"}})
case r.URL.Path == "/api/teams" && r.Method == http.MethodPost:
var req struct {
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if _, exists := f.teams[req.Name]; exists {
writeJSON(w, http.StatusConflict, map[string]string{errJSONKey: "a team with that name already exists"})
return
}
f.nextTeamID++
id := f.nextTeamID
f.teams[req.Name] = id
f.teamNames[id] = req.Name
writeJSON(w, http.StatusCreated, tdclient.Team{ID: id, Name: req.Name})
case r.URL.Path == "/api/teams" && r.Method == http.MethodGet:
// The controller never calls this without ?name= (TEAM-LOOKUP.md's
// own lookup shape) -- the fake only needs to answer that form.
name := r.URL.Query().Get("name")
id, exists := f.teams[name]
if !exists {
writeJSON(w, http.StatusOK, []tdclient.Team{})
return
}
writeJSON(w, http.StatusOK, []tdclient.Team{{ID: id, Name: f.teamNames[id]}})
default: default:
if id, name, ok := parseKeysPath(r.URL.Path); ok && r.Method == http.MethodPost { if id, name, ok := parseKeysPath(r.URL.Path); ok && r.Method == http.MethodPost {
f.keyMints[id]++ f.keyMints[id]++
@@ -99,10 +146,78 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}) })
return return
} }
if id, ok := parseTeamPath(r.URL.Path); ok {
f.handleTeamByID(w, r, id)
return
}
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
} }
// handleTeamByID answers PUT /api/teams/{id}, PUT /api/teams/{id}/oidc-groups
// and DELETE /api/teams/{id}.
func (f *fakeTerdutServer) handleTeamByID(w http.ResponseWriter, r *http.Request, id int64) {
oidcSuffix := fmt.Sprintf("/api/teams/%d/oidc-groups", id)
switch {
case r.URL.Path == oidcSuffix && r.Method == http.MethodPut:
var req struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if _, exists := f.teamNames[id]; !exists {
w.WriteHeader(http.StatusNotFound)
return
}
f.teamOIDC[id] = [2]string{req.MemberGroup, req.OwnerGroup}
w.WriteHeader(http.StatusNoContent)
case r.URL.Path == fmt.Sprintf("/api/teams/%d", id) && r.Method == http.MethodPut:
var req struct {
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
oldName, exists := f.teamNames[id]
if !exists {
w.WriteHeader(http.StatusNotFound)
return
}
delete(f.teams, oldName)
f.teamNames[id] = req.Name
f.teams[req.Name] = id
w.WriteHeader(http.StatusNoContent)
case r.URL.Path == fmt.Sprintf("/api/teams/%d", id) && r.Method == http.MethodDelete:
name, exists := f.teamNames[id]
if !exists {
w.WriteHeader(http.StatusNotFound)
return
}
delete(f.teams, name)
delete(f.teamNames, id)
f.teamDelete[id] = true
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}
// parseTeamPath extracts the numeric id from "/api/teams/{id}" or
// "/api/teams/{id}/oidc-groups" -- anything with more or fewer segments
// doesn't match (handleTeamByID's own switch sorts out which of the two).
func parseTeamPath(path string) (id int64, ok bool) {
var parsedID int64
if n, err := fmt.Sscanf(path, "/api/teams/%d/oidc-groups", &parsedID); err == nil && n == 1 {
return parsedID, true
}
if n, err := fmt.Sscanf(path, "/api/teams/%d", &parsedID); err == nil && n == 1 {
return parsedID, true
}
return 0, false
}
func parseKeysPath(path string) (id int64, mintName string, ok bool) { func parseKeysPath(path string) (id int64, mintName string, ok bool) {
var parsedID int64 var parsedID int64
n, err := fmt.Sscanf(path, "/api/service-accounts/%d/keys", &parsedID) n, err := fmt.Sscanf(path, "/api/service-accounts/%d/keys", &parsedID)
@@ -151,9 +266,9 @@ var _ = Describe("TerdutServer Controller", func() {
dsnSpec := func() terdutv1alpha1.TerdutServerSpec { dsnSpec := func() terdutv1alpha1.TerdutServerSpec {
return terdutv1alpha1.TerdutServerSpec{ return terdutv1alpha1.TerdutServerSpec{
Image: terdutv1alpha1.ImageSpec{Repository: "example.invalid/terdut-server", Tag: "test"}, Image: terdutv1alpha1.ImageSpec{Repository: testImageRepo, Tag: testImageTag},
Networking: terdutv1alpha1.NetworkingSpec{Hostname: "terdut.example.invalid", ServicePort: 8080}, Networking: terdutv1alpha1.NetworkingSpec{Hostname: "terdut.example.invalid", ServicePort: 8080},
Database: terdutv1alpha1.DatabaseSpec{DSN: "postgres://terdut@test-postgres:5432/terdut?sslmode=require"}, Database: terdutv1alpha1.DatabaseSpec{DSN: testDSN},
} }
} }
@@ -212,7 +327,7 @@ var _ = Describe("TerdutServer Controller", func() {
for _, e := range deploy.Spec.Template.Spec.Containers[0].Env { for _, e := range deploy.Spec.Template.Spec.Containers[0].Env {
envNames[e.Name] = e.Value envNames[e.Name] = e.Value
} }
Expect(envNames).To(HaveKeyWithValue("TERDUT_DB_DSN", "postgres://terdut@test-postgres:5432/terdut?sslmode=require")) Expect(envNames).To(HaveKeyWithValue("TERDUT_DB_DSN", testDSN))
Expect(envNames).To(HaveKeyWithValue("TERDUT_OPERATOR_MODE", "true")) Expect(envNames).To(HaveKeyWithValue("TERDUT_OPERATOR_MODE", "true"))
var svc corev1.Service var svc corev1.Service
@@ -266,7 +381,7 @@ var _ = Describe("TerdutServer Controller", func() {
// The earlier attempt's checkpoint survived (that's how this // The earlier attempt's checkpoint survived (that's how this
// reconcile can authenticate at all to recover). // reconcile can authenticate at all to recover).
Expect(reconciler.writeSecret(ctx, checkpointSecretName(&terdutv1alpha1.TerdutServer{ Expect(writeOperatorSecret(ctx, k8sClient, operatorNamespace, checkpointSecretName(&terdutv1alpha1.TerdutServer{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: operatorNamespace}, ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: operatorNamespace},
}), "admin-key-raw")).To(Succeed()) }), "admin-key-raw")).To(Succeed())
@@ -0,0 +1,36 @@
package controller
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
)
// allowedTeamsPermits implements DESIGN.md §4.6's consent check in
// isolation from any client, so it's unit-testable against hand-built
// inputs the way classifyClusterGetError is. Same-namespace callers never
// call this at all (DESIGN.md §4.6: "same-namespace TerdutTeams are always
// allowed, regardless of this field") — it's only ever consulted for a
// cross-namespace serverRef.
func allowedTeamsPermits(allowed terdutv1alpha1.AllowedTeamsNamespaces, nsLabels map[string]string) (bool, error) {
switch allowed.From {
case "All":
return true, nil
case "Selector":
if allowed.Selector == nil {
return false, nil
}
sel, err := metav1.LabelSelectorAsSelector(allowed.Selector)
if err != nil {
return false, err
}
return sel.Matches(labels.Set(nsLabels)), nil
default:
// "", "None", "Same" -- Same is equivalent to None in effect for a
// cross-namespace caller (DESIGN.md §4.6: same-namespace is
// unrestricted either way, Same only exists for parity with the
// upstream enum this mirrors).
return false, nil
}
}
@@ -0,0 +1,81 @@
package controller
import (
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
)
const (
testSelectorLabelKey = "terdut.ryuvia.com/allowed"
testSelectorFrom = "Selector"
testSelectorLabelValue = "true"
)
func TestAllowedTeamsPermits(t *testing.T) {
cases := []struct {
name string
allowed terdutv1alpha1.AllowedTeamsNamespaces
labels map[string]string
want bool
}{
{
name: "unset defaults closed",
allowed: terdutv1alpha1.AllowedTeamsNamespaces{},
want: false,
},
{
name: "None is closed",
allowed: terdutv1alpha1.AllowedTeamsNamespaces{From: "None"},
want: false,
},
{
name: "Same is closed (same-namespace never reaches this function anyway)",
allowed: terdutv1alpha1.AllowedTeamsNamespaces{From: "Same"},
want: false,
},
{
name: "All is open regardless of labels",
allowed: terdutv1alpha1.AllowedTeamsNamespaces{From: "All"},
labels: nil,
want: true,
},
{
name: "Selector with no selector set is closed",
allowed: terdutv1alpha1.AllowedTeamsNamespaces{From: testSelectorFrom},
want: false,
},
{
name: "Selector matching labels is open",
allowed: terdutv1alpha1.AllowedTeamsNamespaces{
From: testSelectorFrom,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{testSelectorLabelKey: testSelectorLabelValue}},
},
labels: map[string]string{testSelectorLabelKey: testSelectorLabelValue},
want: true,
},
{
name: "Selector not matching labels is closed",
allowed: terdutv1alpha1.AllowedTeamsNamespaces{
From: testSelectorFrom,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{testSelectorLabelKey: testSelectorLabelValue}},
},
labels: map[string]string{"something-else": "true"},
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := allowedTeamsPermits(tc.allowed, tc.labels)
if err != nil {
t.Fatalf("allowedTeamsPermits() error = %v", err)
}
if got != tc.want {
t.Errorf("allowedTeamsPermits() = %v, want %v", got, tc.want)
}
})
}
}
@@ -0,0 +1,84 @@
package controller
import (
"context"
"errors"
"fmt"
"net/http"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
)
// createOrAdoptTeam calls POST /api/teams with the TerdutServer's
// instance-scoped credential, or, if an earlier interrupted attempt
// already created this name (409), adopts it via GET /api/teams?name=
// (TEAM-LOOKUP.md) rather than treating the conflict as an error --
// DESIGN.md §5's general adopt-on-conflict rule, the same shape
// TerdutServer's own bootstrap flow uses for minting its instance account.
func (r *TerdutTeamReconciler) createOrAdoptTeam(ctx context.Context, team *terdutv1alpha1.TerdutTeam, instanceClient *tdclient.Client) error {
created, err := instanceClient.CreateTeam(ctx, team.Spec.DisplayName)
if err == nil {
team.Status.TeamID = created.ID
return nil
}
statusErr, ok := errors.AsType[*tdclient.StatusError](err)
if !ok || statusErr.Code != http.StatusConflict {
return fmt.Errorf("POST /api/teams: %w", err)
}
found, err := instanceClient.GetTeamByName(ctx, team.Spec.DisplayName)
if err != nil {
return fmt.Errorf("GET /api/teams?name=%s (adopting after 409): %w", team.Spec.DisplayName, err)
}
if found == nil {
// Genuinely pathological, not just a narrow crash window: the name
// was taken a moment ago and isn't now. Surfaced as a plain error
// (standard requeue-with-backoff) rather than a dedicated
// condition -- there's no documented recovery to point at that
// differs from "try again".
return fmt.Errorf("POST /api/teams 409'd for %q but GET found nothing", team.Spec.DisplayName)
}
team.Status.TeamID = found.ID
return nil
}
// mintTeamCredential mints this team's own team-scoped service account,
// using the TerdutServer's instance-scoped credential (DESIGN.md §6 point
// 3: an instance-scoped caller may do this against any team). Adopts via
// GET+mint-new-key on a 409, the same pattern TerdutServer's own bootstrap
// flow uses.
func (r *TerdutTeamReconciler) mintTeamCredential(ctx context.Context, team *terdutv1alpha1.TerdutTeam, instanceClient *tdclient.Client) error {
saName := teamServiceAccountName(team)
result, err := instanceClient.CreateTeamServiceAccount(ctx, saName, team.Status.TeamID)
var key string
if err == nil {
key = result.Key.Key
} else {
statusErr, ok := errors.AsType[*tdclient.StatusError](err)
if !ok || statusErr.Code != http.StatusConflict {
return fmt.Errorf("POST /api/service-accounts (team scope): %w", err)
}
sa, err := instanceClient.GetServiceAccountByName(ctx, saName)
if err != nil {
return fmt.Errorf("GET /api/service-accounts?name=%s (adopting after 409): %w", saName, err)
}
if sa == nil {
return fmt.Errorf("POST /api/service-accounts 409'd for %q but GET found nothing", saName)
}
minted, err := instanceClient.CreateServiceAccountKey(ctx, sa.ID, "initial")
if err != nil {
return fmt.Errorf("POST /api/service-accounts/%d/keys (adopting after 409): %w", sa.ID, err)
}
key = minted.Key
}
credsName := teamCredentialsSecretName(team)
if err := writeOperatorSecret(ctx, r.Client, r.OperatorNamespace, credsName, key); err != nil {
return err
}
team.Status.CredentialsSecretRef = &terdutv1alpha1.SecretKeyRef{Name: credsName, Key: credentialsSecretDataKey}
return nil
}
@@ -0,0 +1,315 @@
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
}
instanceClient := newClient(serviceURL(srv)).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(serviceURL(srv)).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)
}
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
Type: terdutv1alpha1.ConditionTeamReady,
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.ConditionTeamReady,
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)
}
@@ -0,0 +1,279 @@
package controller
import (
"context"
"fmt"
"net/http/httptest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
)
var _ = Describe("TerdutTeam Controller", func() {
const operatorNamespace = "default"
var (
reconciler *TerdutTeamReconciler
fake *fakeTerdutServer
fakeSrv *httptest.Server
srv *terdutv1alpha1.TerdutServer
teamName string
teamKey types.NamespacedName
)
BeforeEach(func(ctx SpecContext) {
fake, fakeSrv = newFakeTerdutServer()
DeferCleanup(fakeSrv.Close)
srv = bootstrapReadyTerdutServer(ctx, operatorNamespace, uniqueName("ttserver"), fakeSrv.URL)
reconciler = &TerdutTeamReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
OperatorNamespace: operatorNamespace,
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) },
}
teamName = uniqueName("team")
teamKey = types.NamespacedName{Name: teamName, Namespace: operatorNamespace}
})
AfterEach(func(ctx SpecContext) {
team := &terdutv1alpha1.TerdutTeam{}
if err := k8sClient.Get(ctx, teamKey, team); err == nil {
team.Finalizers = nil
_ = k8sClient.Update(ctx, team)
_ = k8sClient.Delete(ctx, team)
}
_ = k8sClient.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s.%s-team-credentials", operatorNamespace, teamName), Namespace: operatorNamespace,
}})
// The TerdutServer bootstrapReadyTerdutServer created in BeforeEach
// carries its own finalizer; clear it directly the same way, rather
// than relying on TerdutServerReconciler to ever run again here.
srvKey := types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}
if err := k8sClient.Get(ctx, srvKey, srv); err == nil {
srv.Finalizers = nil
_ = k8sClient.Update(ctx, srv)
_ = k8sClient.Delete(ctx, srv)
}
_ = k8sClient.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s.%s-instance-credentials", operatorNamespace, srv.Name), Namespace: operatorNamespace,
}})
})
createTeam := func(ctx context.Context, displayName string, serverRef terdutv1alpha1.TerdutServerRef) {
team := &terdutv1alpha1.TerdutTeam{
ObjectMeta: metav1.ObjectMeta{Name: teamName, Namespace: operatorNamespace},
Spec: terdutv1alpha1.TerdutTeamSpec{ServerRef: serverRef, DisplayName: displayName},
}
Expect(k8sClient.Create(ctx, team)).To(Succeed())
}
reconcileOnce := func(ctx context.Context) {
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: teamKey})
Expect(err).NotTo(HaveOccurred())
}
readyCondition := func(ctx context.Context) metav1.Condition {
team := &terdutv1alpha1.TerdutTeam{}
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
c := meta.FindStatusCondition(team.Status.Conditions, terdutv1alpha1.ConditionTeamReady)
Expect(c).NotTo(BeNil())
return *c
}
sameNSRef := func() terdutv1alpha1.TerdutServerRef {
return terdutv1alpha1.TerdutServerRef{Name: srv.Name}
}
Describe("the happy path", func() {
It("creates the team, mints its credential, and applies rename/oidc-groups", func(ctx SpecContext) {
createTeam(ctx, "platform", sameNSRef())
reconcileOnce(ctx) // finalizer
reconcileOnce(ctx) // full create+mint+apply
cond := readyCondition(ctx)
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonTeamAdopted))
team := &terdutv1alpha1.TerdutTeam{}
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
Expect(team.Status.TeamID).To(Equal(fake.teams["platform"]))
Expect(team.Status.CredentialsSecretRef).NotTo(BeNil())
var credsSecret corev1.Secret
Expect(k8sClient.Get(ctx, types.NamespacedName{
Name: team.Status.CredentialsSecretRef.Name, Namespace: operatorNamespace,
}, &credsSecret)).To(Succeed())
Expect(credsSecret.Data[credentialsSecretDataKey]).NotTo(BeEmpty())
})
})
Describe("waiting on the referenced TerdutServer", func() {
It("reports ServerRefNotFound when the TerdutServer doesn't exist", func(ctx SpecContext) {
createTeam(ctx, "orphan", terdutv1alpha1.TerdutServerRef{Name: "does-not-exist"})
reconcileOnce(ctx) // finalizer
reconcileOnce(ctx)
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonServerRefNotFound))
})
It("reports WaitingForServer when the TerdutServer exists but isn't Bootstrapped yet", func(ctx SpecContext) {
unreadyName := uniqueName("ttserver-unready")
unready := &terdutv1alpha1.TerdutServer{
ObjectMeta: metav1.ObjectMeta{Name: unreadyName, Namespace: operatorNamespace},
Spec: terdutv1alpha1.TerdutServerSpec{
Image: terdutv1alpha1.ImageSpec{Repository: testImageRepo, Tag: testImageTag},
Networking: terdutv1alpha1.NetworkingSpec{Hostname: "unready.example.invalid", ServicePort: 8080},
Database: terdutv1alpha1.DatabaseSpec{DSN: testDSN},
},
}
Expect(k8sClient.Create(ctx, unready)).To(Succeed())
DeferCleanup(func() { _ = k8sClient.Delete(ctx, unready) })
createTeam(ctx, "waiting", terdutv1alpha1.TerdutServerRef{Name: unreadyName})
reconcileOnce(ctx) // finalizer
reconcileOnce(ctx)
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonWaitingForServer))
})
})
Describe("cross-namespace serverRef", func() {
var otherNS string
BeforeEach(func(ctx SpecContext) {
otherNS = uniqueName("ns")
Expect(k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: otherNS}})).To(Succeed())
DeferCleanup(func() { _ = k8sClient.Delete(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: otherNS}}) })
})
It("denies by default (From: None)", func(ctx SpecContext) {
team := &terdutv1alpha1.TerdutTeam{
ObjectMeta: metav1.ObjectMeta{Name: teamName, Namespace: otherNS},
Spec: terdutv1alpha1.TerdutTeamSpec{
ServerRef: terdutv1alpha1.TerdutServerRef{Name: srv.Name, Namespace: operatorNamespace},
DisplayName: "cross-ns-denied",
},
}
Expect(k8sClient.Create(ctx, team)).To(Succeed())
DeferCleanup(func() { _ = k8sClient.Delete(ctx, team) })
crossKey := types.NamespacedName{Name: teamName, Namespace: otherNS}
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: crossKey}) // finalizer
Expect(err).NotTo(HaveOccurred())
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: crossKey})
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Get(ctx, crossKey, team)).To(Succeed())
cond := meta.FindStatusCondition(team.Status.Conditions, terdutv1alpha1.ConditionTeamReady)
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonRefNotPermitted))
})
It("permits when the TerdutServer's allowedTeams.namespaces.from is All", func(ctx SpecContext) {
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}, srv)).To(Succeed())
srv.Spec.AllowedTeams.Namespaces.From = "All"
Expect(k8sClient.Update(ctx, srv)).To(Succeed())
team := &terdutv1alpha1.TerdutTeam{
ObjectMeta: metav1.ObjectMeta{Name: teamName, Namespace: otherNS},
Spec: terdutv1alpha1.TerdutTeamSpec{
ServerRef: terdutv1alpha1.TerdutServerRef{Name: srv.Name, Namespace: operatorNamespace},
DisplayName: "cross-ns-allowed",
},
}
Expect(k8sClient.Create(ctx, team)).To(Succeed())
DeferCleanup(func() { _ = k8sClient.Delete(ctx, team) })
crossKey := types.NamespacedName{Name: teamName, Namespace: otherNS}
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: crossKey}) // finalizer
Expect(err).NotTo(HaveOccurred())
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: crossKey})
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Get(ctx, crossKey, team)).To(Succeed())
cond := meta.FindStatusCondition(team.Status.Conditions, terdutv1alpha1.ConditionTeamReady)
// Past the gate: Adopted (the fake server has no reason to
// reject this), definitely not RefNotPermitted.
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonTeamAdopted))
})
})
Describe("adopt-on-409 recovery", func() {
It("adopts an already-created team instead of erroring", func(ctx SpecContext) {
fake.nextTeamID = 1
fake.teams["platform"] = 1
fake.teamNames[1] = "platform"
createTeam(ctx, "platform", sameNSRef())
reconcileOnce(ctx) // finalizer
reconcileOnce(ctx)
team := &terdutv1alpha1.TerdutTeam{}
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
Expect(team.Status.TeamID).To(Equal(int64(1)))
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
})
It("adopts an already-created team-scoped service account instead of erroring", func(ctx SpecContext) {
createTeam(ctx, "platform", sameNSRef())
reconcileOnce(ctx) // finalizer
// Pre-seed the service account the mint step is about to try to
// create, simulating an attempt that got this far before being
// interrupted.
fake.nextID = 1
saName := fmt.Sprintf("terdut-team.%s.%s", operatorNamespace, teamName)
fake.accounts[saName] = 1
fake.keyMints[1] = 1
reconcileOnce(ctx)
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
team := &terdutv1alpha1.TerdutTeam{}
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
var credsSecret corev1.Secret
Expect(k8sClient.Get(ctx, types.NamespacedName{
Name: team.Status.CredentialsSecretRef.Name, Namespace: operatorNamespace,
}, &credsSecret)).To(Succeed())
// Minted fresh (mint2), not the pre-seeded account's original
// (never-issued-to-this-reconcile) key.
Expect(string(credsSecret.Data[credentialsSecretDataKey])).To(Equal("instance-key-1-mint2"))
})
})
Describe("deletion", func() {
It("deletes the team server-side and removes the credentials Secret", func(ctx SpecContext) {
createTeam(ctx, "to-delete", sameNSRef())
reconcileOnce(ctx)
reconcileOnce(ctx)
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
team := &terdutv1alpha1.TerdutTeam{}
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
teamID := team.Status.TeamID
credsName := team.Status.CredentialsSecretRef.Name
Expect(k8sClient.Delete(ctx, team)).To(Succeed())
reconcileOnce(ctx) // runs the finalizer
Expect(fake.teamDelete[teamID]).To(BeTrue())
err := k8sClient.Get(ctx, teamKey, team)
Expect(err).To(HaveOccurred(), "the TerdutTeam itself should be gone once the finalizer clears")
var leftover corev1.Secret
err = k8sClient.Get(ctx, types.NamespacedName{Name: credsName, Namespace: operatorNamespace}, &leftover)
Expect(err).To(HaveOccurred(), "the team-credentials Secret should have been cleaned up")
})
})
})
+82
View File
@@ -0,0 +1,82 @@
package controller
import (
"context"
"fmt"
"sync/atomic"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
appsv1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
)
// Shared fixture values -- a bring-your-own DSN TerdutServer spec, reused
// across terdutserver_controller_test.go, terdutteam_controller_test.go and
// this file, so there's exactly one definition to keep consistent.
const (
testImageRepo = "example.invalid/terdut-server"
testImageTag = "test"
testDSN = "postgres://terdut@test-postgres:5432/terdut?sslmode=require"
)
// bootstrapReadyTerdutServer creates a TerdutServer with a bring-your-own
// DSN and drives it to Ready against fakeURL, the same three-pass sequence
// terdutserver_controller_test.go's own happy-path test exercises directly
// -- shared here so TerdutTeam's tests (which need a real, Ready
// TerdutServer to resolve against) don't duplicate it.
func bootstrapReadyTerdutServer(ctx context.Context, namespace, name, fakeURL string) *terdutv1alpha1.TerdutServer {
GinkgoHelper()
reconciler := &TerdutServerReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
OperatorNamespace: namespace,
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeURL) },
}
objKey := types.NamespacedName{Name: name, Namespace: namespace}
srv := &terdutv1alpha1.TerdutServer{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: terdutv1alpha1.TerdutServerSpec{
Image: terdutv1alpha1.ImageSpec{Repository: testImageRepo, Tag: testImageTag},
Networking: terdutv1alpha1.NetworkingSpec{Hostname: "terdut.example.invalid", ServicePort: 8080},
Database: terdutv1alpha1.DatabaseSpec{DSN: testDSN},
},
}
Expect(k8sClient.Create(ctx, srv)).To(Succeed())
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // finalizer
Expect(err).NotTo(HaveOccurred())
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // Deployment/Service
Expect(err).NotTo(HaveOccurred())
var deploy appsv1.Deployment
Expect(k8sClient.Get(ctx, objKey, &deploy)).To(Succeed())
deploy.Status.ReadyReplicas = 1
deploy.Status.Replicas = 1
Expect(k8sClient.Status().Update(ctx, &deploy)).To(Succeed())
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // bootstrap
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
Expect(srv.Status.CredentialsSecretRef).NotTo(BeNil(), "test setup: TerdutServer %s/%s did not reach Bootstrapped", namespace, name)
return srv
}
// uniqueNameCounter backs uniqueName. GinkgoRandomSeed()/GinkgoParallelProcess()
// are constants for the whole suite run, not per-spec -- an earlier version
// of this helper used them and collided across every spec that called it
// more than once; an atomic counter is genuinely unique per call instead.
var uniqueNameCounter atomic.Int64
// uniqueName returns a Kubernetes-object-safe name unique to this call.
func uniqueName(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, uniqueNameCounter.Add(1))
}
+110 -2
View File
@@ -19,6 +19,10 @@ import (
// Client talks to one terdut-server install, optionally as a service // Client talks to one terdut-server install, optionally as a service
// account. A zero-value token works for endpoints that don't need one // account. A zero-value token works for endpoints that don't need one
// (Version, Bootstrap). // (Version, Bootstrap).
// fieldName is the JSON key every create/rename request body below shares.
const fieldName = "name"
type Client struct { type Client struct {
baseURL string baseURL string
httpClient *http.Client httpClient *http.Client
@@ -199,7 +203,7 @@ type CreateServiceAccountResult struct {
// this as a hard failure. // this as a hard failure.
func (c *Client) CreateInstanceServiceAccount(ctx context.Context, name string) (*CreateServiceAccountResult, error) { func (c *Client) CreateInstanceServiceAccount(ctx context.Context, name string) (*CreateServiceAccountResult, error) {
req, err := c.newRequest(ctx, http.MethodPost, "/api/service-accounts", map[string]string{ req, err := c.newRequest(ctx, http.MethodPost, "/api/service-accounts", map[string]string{
"name": name, fieldName: name,
"scope": "instance", "scope": "instance",
}) })
if err != nil { if err != nil {
@@ -212,6 +216,30 @@ func (c *Client) CreateInstanceServiceAccount(ctx context.Context, name string)
return &result, nil return &result, nil
} }
// CreateTeamServiceAccount calls POST /api/service-accounts with scope
// "team" for teamID, authenticated with c's current token -- the
// TerdutServer's instance-scoped credential, per DESIGN.md §6 point 3: an
// instance-scoped caller may mint a team-scoped account against any team
// (internal/api/service_accounts.go's handleCreateServiceAccount,
// confirmed against source), which is what lets TerdutTeam's own
// controller do this without ever touching a human credential. Same
// adopt-on-409 contract as CreateInstanceServiceAccount.
func (c *Client) CreateTeamServiceAccount(ctx context.Context, name string, teamID int64) (*CreateServiceAccountResult, error) {
req, err := c.newRequest(ctx, http.MethodPost, "/api/service-accounts", map[string]any{
fieldName: name,
"scope": "team",
"team_id": teamID,
})
if err != nil {
return nil, err
}
var result CreateServiceAccountResult
if err := c.do(req, &result); err != nil {
return nil, err
}
return &result, nil
}
// GetServiceAccountByName calls GET /api/service-accounts?name=... -- // GetServiceAccountByName calls GET /api/service-accounts?name=... --
// authenticated (terdut-server's AuthMiddleware hard-rejects any // authenticated (terdut-server's AuthMiddleware hard-rejects any
// unauthenticated request before this endpoint's own, more permissive // unauthenticated request before this endpoint's own, more permissive
@@ -239,7 +267,7 @@ func (c *Client) GetServiceAccountByName(ctx context.Context, name string) (*Ser
func (c *Client) CreateServiceAccountKey(ctx context.Context, serviceAccountID int64, name string) (*APIKey, error) { func (c *Client) CreateServiceAccountKey(ctx context.Context, serviceAccountID int64, name string) (*APIKey, error) {
req, err := c.newRequest(ctx, http.MethodPost, req, err := c.newRequest(ctx, http.MethodPost,
fmt.Sprintf("/api/service-accounts/%d/keys", serviceAccountID), fmt.Sprintf("/api/service-accounts/%d/keys", serviceAccountID),
map[string]string{"name": name}) map[string]string{fieldName: name})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -249,3 +277,83 @@ func (c *Client) CreateServiceAccountKey(ctx context.Context, serviceAccountID i
} }
return &key, nil return &key, nil
} }
// Team mirrors terdut-server's models.Team (internal/models/team.go), minus
// Role/Source, which are only ever populated for a human caller's own
// membership and never apply to a service account's view of a team.
type Team struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
// CreateTeam calls POST /api/teams, authenticated with c's current token --
// the TerdutServer's instance-scoped credential (DESIGN.md §6 point 3). A
// StatusError with Code 409 means a prior, interrupted attempt already
// created this name — GetTeamByName (TEAM-LOOKUP.md) is the adopt-rather-
// than-error recovery, the same contract CreateInstanceServiceAccount has.
func (c *Client) CreateTeam(ctx context.Context, name string) (*Team, error) {
req, err := c.newRequest(ctx, http.MethodPost, "/api/teams", map[string]string{fieldName: name})
if err != nil {
return nil, err
}
var team Team
if err := c.do(req, &team); err != nil {
return nil, err
}
return &team, nil
}
// GetTeamByName calls GET /api/teams?name=... (TEAM-LOOKUP.md) — open to
// any authenticated caller, not just the team's own members. Returns nil,
// nil on no match, not an error.
func (c *Client) GetTeamByName(ctx context.Context, name string) (*Team, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/api/teams?name="+name, nil)
if err != nil {
return nil, err
}
var teams []Team
if err := c.do(req, &teams); err != nil {
return nil, err
}
if len(teams) == 0 {
return nil, nil
}
return &teams[0], nil
}
// RenameTeam calls PUT /api/teams/{teamID} — owner-gated server-side
// (requireTeamOwner), so c must hold this team's own team-scoped
// credential, not the instance-scoped one CreateTeam used.
func (c *Client) RenameTeam(ctx context.Context, teamID int64, name string) error {
req, err := c.newRequest(ctx, http.MethodPut,
fmt.Sprintf("/api/teams/%d", teamID), map[string]string{fieldName: name})
if err != nil {
return err
}
return c.do(req, nil)
}
// DeleteTeam calls DELETE /api/teams/{teamID} — owner-gated, same
// credential requirement as RenameTeam. terdut-server refuses this while
// the team has open incidents (409) — surfaced to the caller as a
// StatusError, not retried specially here.
func (c *Client) DeleteTeam(ctx context.Context, teamID int64) error {
req, err := c.newRequest(ctx, http.MethodDelete, fmt.Sprintf("/api/teams/%d", teamID), nil)
if err != nil {
return err
}
return c.do(req, nil)
}
// SetTeamOIDCGroups calls PUT /api/teams/{teamID}/oidc-groups — owner-gated,
// same credential requirement as RenameTeam. An empty group string clears
// that binding server-side (terdut-server's own NULLIF handling).
func (c *Client) SetTeamOIDCGroups(ctx context.Context, teamID int64, memberGroup, ownerGroup string) error {
req, err := c.newRequest(ctx, http.MethodPut,
fmt.Sprintf("/api/teams/%d/oidc-groups", teamID),
map[string]string{"member_group": memberGroup, "owner_group": ownerGroup})
if err != nil {
return err
}
return c.do(req, nil)
}