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:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: {}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
+111
-3
@@ -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,8 +203,32 @@ 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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result CreateServiceAccountResult
|
||||||
|
if err := c.do(req, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user