diff --git a/PROJECT b/PROJECT index b7f573c..966dc71 100644 --- a/PROJECT +++ b/PROJECT @@ -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" diff --git a/api/v1alpha1/terdutteam_types.go b/api/v1alpha1/terdutteam_types.go new file mode 100644 index 0000000..f6aee3d --- /dev/null +++ b/api/v1alpha1/terdutteam_types.go @@ -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 + }) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 02349dd..0bdf0fd 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -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 +} diff --git a/cmd/main.go b/cmd/main.go index 1897ead..8216528 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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 { diff --git a/config/crd/bases/terdut.ryuvia.com_terdutteams.yaml b/config/crd/bases/terdut.ryuvia.com_terdutteams.yaml new file mode 100644 index 0000000..fac75b1 --- /dev/null +++ b/config/crd/bases/terdut.ryuvia.com_terdutteams.yaml @@ -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: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 4882ebd..10bf89c 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -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: diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 5a78faa..64d3ae4 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -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 diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index cfa880a..2a113b6 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/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 diff --git a/config/rbac/terdutteam_admin_role.yaml b/config/rbac/terdutteam_admin_role.yaml new file mode 100644 index 0000000..6e4fc86 --- /dev/null +++ b/config/rbac/terdutteam_admin_role.yaml @@ -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 diff --git a/config/rbac/terdutteam_editor_role.yaml b/config/rbac/terdutteam_editor_role.yaml new file mode 100644 index 0000000..8209cb6 --- /dev/null +++ b/config/rbac/terdutteam_editor_role.yaml @@ -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 diff --git a/config/rbac/terdutteam_viewer_role.yaml b/config/rbac/terdutteam_viewer_role.yaml new file mode 100644 index 0000000..8311d4c --- /dev/null +++ b/config/rbac/terdutteam_viewer_role.yaml @@ -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 diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 64bf38e..d8d3158 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -1,4 +1,5 @@ ## Append samples of your project ## resources: - terdut_v1alpha1_terdutserver.yaml +- terdut_v1alpha1_terdutteam.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/terdut_v1alpha1_terdutteam.yaml b/config/samples/terdut_v1alpha1_terdutteam.yaml new file mode 100644 index 0000000..bf6f2bb --- /dev/null +++ b/config/samples/terdut_v1alpha1_terdutteam.yaml @@ -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 diff --git a/go.mod b/go.mod index 1edb3f3..8ebb6f7 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/controller/secrets.go b/internal/controller/secrets.go new file mode 100644 index 0000000..6321dfc --- /dev/null +++ b/internal/controller/secrets.go @@ -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 +} diff --git a/internal/controller/terdutserver_bootstrap.go b/internal/controller/terdutserver_bootstrap.go index 5bae654..77b2f90 100644 --- a/internal/controller/terdutserver_bootstrap.go +++ b/internal/controller/terdutserver_bootstrap.go @@ -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 -} diff --git a/internal/controller/terdutserver_controller_test.go b/internal/controller/terdutserver_controller_test.go index e0f46ed..2072fe6 100644 --- a/internal/controller/terdutserver_controller_test.go +++ b/internal/controller/terdutserver_controller_test.go @@ -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()) diff --git a/internal/controller/terdutteam_allowedteams.go b/internal/controller/terdutteam_allowedteams.go new file mode 100644 index 0000000..a03998d --- /dev/null +++ b/internal/controller/terdutteam_allowedteams.go @@ -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 + } +} diff --git a/internal/controller/terdutteam_allowedteams_test.go b/internal/controller/terdutteam_allowedteams_test.go new file mode 100644 index 0000000..76998d1 --- /dev/null +++ b/internal/controller/terdutteam_allowedteams_test.go @@ -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) + } + }) + } +} diff --git a/internal/controller/terdutteam_bootstrap.go b/internal/controller/terdutteam_bootstrap.go new file mode 100644 index 0000000..d6d43f2 --- /dev/null +++ b/internal/controller/terdutteam_bootstrap.go @@ -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 +} diff --git a/internal/controller/terdutteam_controller.go b/internal/controller/terdutteam_controller.go new file mode 100644 index 0000000..1a7187d --- /dev/null +++ b/internal/controller/terdutteam_controller.go @@ -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 +// .-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) +} diff --git a/internal/controller/terdutteam_controller_test.go b/internal/controller/terdutteam_controller_test.go new file mode 100644 index 0000000..abc945a --- /dev/null +++ b/internal/controller/terdutteam_controller_test.go @@ -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") + }) + }) +}) diff --git a/internal/controller/testhelpers_test.go b/internal/controller/testhelpers_test.go new file mode 100644 index 0000000..173f5c3 --- /dev/null +++ b/internal/controller/testhelpers_test.go @@ -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)) +} diff --git a/internal/tdclient/client.go b/internal/tdclient/client.go index 7f53bea..7560a87 100644 --- a/internal/tdclient/client.go +++ b/internal/tdclient/client.go @@ -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) +}