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
|
||||
path: git.ryuvia.com/niklas/terdut-operator/api/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"
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// TerdutServerRef names the TerdutServer this team belongs to. namespace is
|
||||
// optional and defaults to this TerdutTeam's own namespace; set, it's the
|
||||
// one cross-namespace edge DESIGN.md §1/§4.6 allows, gated by the target
|
||||
// TerdutServer's own spec.allowedTeams consent.
|
||||
type TerdutServerRef struct {
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
Name string `json:"name"`
|
||||
// +optional
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
}
|
||||
|
||||
// TerdutTeamOIDC binds which identity-provider groups grant membership and
|
||||
// ownership of this team (DESIGN.md §4.2). Both empty means no group grants
|
||||
// either role here — matches terdut-server's own NULLIF-on-empty-string
|
||||
// handling (internal/api/oidc_teams.go).
|
||||
type TerdutTeamOIDC struct {
|
||||
// +optional
|
||||
MemberGroup string `json:"memberGroup,omitempty"`
|
||||
// +optional
|
||||
OwnerGroup string `json:"ownerGroup,omitempty"`
|
||||
}
|
||||
|
||||
// TerdutTeamSpec defines the desired state of TerdutTeam.
|
||||
type TerdutTeamSpec struct {
|
||||
// serverRef names the TerdutServer this team belongs to.
|
||||
// +required
|
||||
ServerRef TerdutServerRef `json:"serverRef"`
|
||||
|
||||
// displayName is this team's name, both in terdut-server's own data
|
||||
// (POST /api/teams {"name": ...}) and as the identity POST /api/teams
|
||||
// and GET /api/teams?name= correlate on (DESIGN.md §5's idempotent-
|
||||
// create rule, via TEAM-LOOKUP.md).
|
||||
// +required
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
DisplayName string `json:"displayName"`
|
||||
|
||||
// +optional
|
||||
OIDC TerdutTeamOIDC `json:"oidc,omitempty"`
|
||||
}
|
||||
|
||||
// Condition types this controller sets on TerdutTeam.
|
||||
const (
|
||||
ConditionTeamReady = "Ready"
|
||||
)
|
||||
|
||||
// Condition reasons this controller sets.
|
||||
const (
|
||||
// ReasonServerRefNotFound: spec.serverRef names no TerdutServer (yet).
|
||||
ReasonServerRefNotFound = "ServerRefNotFound"
|
||||
// ReasonRefNotPermitted: spec.serverRef is cross-namespace, and the
|
||||
// target TerdutServer's spec.allowedTeams doesn't admit this
|
||||
// TerdutTeam's namespace (DESIGN.md §4.6).
|
||||
ReasonRefNotPermitted = "RefNotPermitted"
|
||||
// ReasonWaitingForServer: the referenced TerdutServer exists but isn't
|
||||
// Bootstrapped yet (no status.credentialsSecretRef to read) --
|
||||
// DESIGN.md §5's "every child requeues with backoff, no cross-
|
||||
// controller RPC" rule.
|
||||
ReasonWaitingForServer = "WaitingForServer"
|
||||
// ReasonTeamAdopted: the happy path.
|
||||
ReasonTeamAdopted = "Adopted"
|
||||
)
|
||||
|
||||
// TerdutTeamStatus defines the observed state of TerdutTeam.
|
||||
type TerdutTeamStatus struct {
|
||||
// +listType=map
|
||||
// +listMapKey=type
|
||||
// +optional
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
|
||||
// teamID is the server-side id -- needed by every child object's
|
||||
// controller (DESIGN.md §4.2).
|
||||
// +optional
|
||||
TeamID int64 `json:"teamID,omitempty"`
|
||||
|
||||
// credentialsSecretRef is this team's own scoped credential
|
||||
// (DESIGN.md §6 point 3) -- pure output, always in the operator's own
|
||||
// namespace, under a fixed data key ("token").
|
||||
// +optional
|
||||
CredentialsSecretRef *SecretKeyRef `json:"credentialsSecretRef,omitempty"`
|
||||
|
||||
// +optional
|
||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:printcolumn:name="Server",type=string,JSONPath=`.spec.serverRef.name`
|
||||
// +kubebuilder:printcolumn:name="TeamID",type=integer,JSONPath=`.status.teamID`
|
||||
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
|
||||
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
|
||||
|
||||
// TerdutTeam is the Schema for the terdutteams API
|
||||
type TerdutTeam struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
|
||||
// metadata is a standard object metadata
|
||||
// +optional
|
||||
metav1.ObjectMeta `json:"metadata,omitzero"`
|
||||
|
||||
// spec defines the desired state of TerdutTeam
|
||||
// +required
|
||||
Spec TerdutTeamSpec `json:"spec"`
|
||||
|
||||
// status defines the observed state of TerdutTeam
|
||||
// +optional
|
||||
Status TerdutTeamStatus `json:"status,omitzero"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
// TerdutTeamList contains a list of TerdutTeam
|
||||
type TerdutTeamList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitzero"`
|
||||
Items []TerdutTeam `json:"items"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
SchemeBuilder.Register(func(s *runtime.Scheme) error {
|
||||
s.AddKnownTypes(SchemeGroupVersion, &TerdutTeam{}, &TerdutTeamList{})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -264,6 +264,21 @@ func (in *TerdutServerList) DeepCopyObject() runtime.Object {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TerdutServerRef) DeepCopyInto(out *TerdutServerRef) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutServerRef.
|
||||
func (in *TerdutServerRef) DeepCopy() *TerdutServerRef {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(TerdutServerRef)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TerdutServerSpec) DeepCopyInto(out *TerdutServerSpec) {
|
||||
*out = *in
|
||||
@@ -313,3 +328,121 @@ func (in *TerdutServerStatus) DeepCopy() *TerdutServerStatus {
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TerdutTeam) DeepCopyInto(out *TerdutTeam) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
out.Spec = in.Spec
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeam.
|
||||
func (in *TerdutTeam) DeepCopy() *TerdutTeam {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(TerdutTeam)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *TerdutTeam) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TerdutTeamList) DeepCopyInto(out *TerdutTeamList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]TerdutTeam, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamList.
|
||||
func (in *TerdutTeamList) DeepCopy() *TerdutTeamList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(TerdutTeamList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *TerdutTeamList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TerdutTeamOIDC) DeepCopyInto(out *TerdutTeamOIDC) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamOIDC.
|
||||
func (in *TerdutTeamOIDC) DeepCopy() *TerdutTeamOIDC {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(TerdutTeamOIDC)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TerdutTeamSpec) DeepCopyInto(out *TerdutTeamSpec) {
|
||||
*out = *in
|
||||
out.ServerRef = in.ServerRef
|
||||
out.OIDC = in.OIDC
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamSpec.
|
||||
func (in *TerdutTeamSpec) DeepCopy() *TerdutTeamSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(TerdutTeamSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TerdutTeamStatus) DeepCopyInto(out *TerdutTeamStatus) {
|
||||
*out = *in
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]v1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
if in.CredentialsSecretRef != nil {
|
||||
in, out := &in.CredentialsSecretRef, &out.CredentialsSecretRef
|
||||
*out = new(SecretKeyRef)
|
||||
**out = **in
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamStatus.
|
||||
func (in *TerdutTeamStatus) DeepCopy() *TerdutTeamStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(TerdutTeamStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -185,6 +185,14 @@ func main() {
|
||||
setupLog.Error(err, "Failed to create controller", "controller", "terdutserver")
|
||||
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
|
||||
|
||||
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
|
||||
resources:
|
||||
- bases/terdut.ryuvia.com_terdutservers.yaml
|
||||
- bases/terdut.ryuvia.com_terdutteams.yaml
|
||||
# +kubebuilder:scaffold:crdkustomizeresource
|
||||
|
||||
patches:
|
||||
|
||||
@@ -22,6 +22,9 @@ resources:
|
||||
# default, aiding admins in cluster management. Those roles are
|
||||
# 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.
|
||||
- terdutteam_admin_role.yaml
|
||||
- terdutteam_editor_role.yaml
|
||||
- terdutteam_viewer_role.yaml
|
||||
- terdutserver_admin_role.yaml
|
||||
- terdutserver_editor_role.yaml
|
||||
- terdutserver_viewer_role.yaml
|
||||
|
||||
@@ -4,6 +4,14 @@ kind: ClusterRole
|
||||
metadata:
|
||||
name: manager-role
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- namespaces
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
@@ -48,6 +56,7 @@ rules:
|
||||
- terdut.ryuvia.com
|
||||
resources:
|
||||
- terdutservers
|
||||
- terdutteams
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
@@ -60,12 +69,14 @@ rules:
|
||||
- terdut.ryuvia.com
|
||||
resources:
|
||||
- terdutservers/finalizers
|
||||
- terdutteams/finalizers
|
||||
verbs:
|
||||
- update
|
||||
- apiGroups:
|
||||
- terdut.ryuvia.com
|
||||
resources:
|
||||
- terdutservers/status
|
||||
- terdutteams/status
|
||||
verbs:
|
||||
- get
|
||||
- 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 ##
|
||||
resources:
|
||||
- terdut_v1alpha1_terdutserver.yaml
|
||||
- terdut_v1alpha1_terdutteam.yaml
|
||||
# +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 (
|
||||
github.com/onsi/ginkgo/v2 v2.27.4
|
||||
github.com/onsi/gomega v1.39.0
|
||||
k8s.io/api v0.37.0
|
||||
k8s.io/apimachinery v0.37.0
|
||||
k8s.io/client-go v0.37.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
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // 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/apiserver 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"
|
||||
)
|
||||
|
||||
// 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
|
||||
// case: a checkpointed admin credential was used and then lost before the
|
||||
// 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)
|
||||
if err := r.writeSecret(ctx, credsName, instanceKey); err != nil {
|
||||
if err := writeOperatorSecret(ctx, r.Client, r.OperatorNamespace, credsName, instanceKey); err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 result.APIKey.Key, nil
|
||||
@@ -135,19 +129,3 @@ func (r *TerdutServerReconciler) getOrMintInstanceServiceAccountKey(ctx context.
|
||||
}
|
||||
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"
|
||||
)
|
||||
|
||||
// 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
|
||||
// /api/bootstrap, /api/service-accounts and /api/version that the
|
||||
// bootstrap flow depends on (DESIGN.md §6, §11: "terdut-server's REST API
|
||||
@@ -37,10 +45,23 @@ type fakeTerdutServer struct {
|
||||
nextID int64
|
||||
accounts map[string]int64 // name -> id
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -50,11 +71,11 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch {
|
||||
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:
|
||||
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
|
||||
}
|
||||
f.bootstrapped = true
|
||||
@@ -69,7 +90,7 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
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
|
||||
}
|
||||
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"}})
|
||||
|
||||
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:
|
||||
if id, name, ok := parseKeysPath(r.URL.Path); ok && r.Method == http.MethodPost {
|
||||
f.keyMints[id]++
|
||||
@@ -99,10 +146,78 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if id, ok := parseTeamPath(r.URL.Path); ok {
|
||||
f.handleTeamByID(w, r, id)
|
||||
return
|
||||
}
|
||||
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) {
|
||||
var parsedID int64
|
||||
n, err := fmt.Sscanf(path, "/api/service-accounts/%d/keys", &parsedID)
|
||||
@@ -151,9 +266,9 @@ var _ = Describe("TerdutServer Controller", func() {
|
||||
|
||||
dsnSpec := func() 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},
|
||||
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 {
|
||||
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"))
|
||||
|
||||
var svc corev1.Service
|
||||
@@ -266,7 +381,7 @@ var _ = Describe("TerdutServer Controller", func() {
|
||||
|
||||
// The earlier attempt's checkpoint survived (that's how this
|
||||
// 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},
|
||||
}), "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
|
||||
// account. A zero-value token works for endpoints that don't need one
|
||||
// (Version, Bootstrap).
|
||||
|
||||
// fieldName is the JSON key every create/rename request body below shares.
|
||||
const fieldName = "name"
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
@@ -199,8 +203,32 @@ type CreateServiceAccountResult struct {
|
||||
// this as a hard failure.
|
||||
func (c *Client) CreateInstanceServiceAccount(ctx context.Context, name string) (*CreateServiceAccountResult, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodPost, "/api/service-accounts", map[string]string{
|
||||
"name": name,
|
||||
"scope": "instance",
|
||||
fieldName: name,
|
||||
"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 {
|
||||
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) {
|
||||
req, err := c.newRequest(ctx, http.MethodPost,
|
||||
fmt.Sprintf("/api/service-accounts/%d/keys", serviceAccountID),
|
||||
map[string]string{"name": name})
|
||||
map[string]string{fieldName: name})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -249,3 +277,83 @@ func (c *Client) CreateServiceAccountKey(ctx context.Context, serviceAccountID i
|
||||
}
|
||||
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