From 1be7cf2b7f3e3abd64e39d19827821db63a67340 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Wed, 30 Sep 2026 22:30:22 +0200 Subject: [PATCH] Stage 1: TerdutServer, bring-your-own bootstrap credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the narrowed Stage 1 scope from ROADMAP.md, against the bootstrap-flow fix from DESIGN.md §4.1/§6 (the earlier self-registration flow couldn't work unauthenticated against terdut-server's real AuthMiddleware -- see that commit for the full trace). - api/v1alpha1: TerdutServer with spec.endpoint + spec.credentialsSecretRef + spec.allowedTeams (image/replicas/networking/database deferred to Stage 5, per DESIGN.md's own narrowing). SecretKeyRef has no namespace field -- always the operator's own, by construction. - internal/controller: TerdutServerReconciler implements exactly the bring-your-own path -- adopt spec.credentialsSecretRef if the Secret exists and has data under the given key, probe GET /api/version as a reachability check, set Ready/Bootstrapped conditions accordingly. Self-registration (the /api/bootstrap race) is not implemented; unset spec.credentialsSecretRef reports Ready: False, reason: CredentialsSecretRefRequired, not an attempt at a flow that would fail unauthenticated anyway. No finalizer: this stage creates nothing server-side and adopts rather than generates its Secret, so there's nothing to clean up on delete yet. - internal/tdclient: minimal terdut-server API client (Version only, the one call this stage needs), styled after terdut-tui's own internal/api/client.go per terdut/CLAUDE.md's mirroring convention. - Tests: envtest suite covering all four not-ready paths plus the happy path (fake terdut-server via httptest.Server, per DESIGN.md §11), and a focused unit suite for tdclient. 75.6%/82.4% coverage. - Event recording uses the new events.k8s.io/v1 recorder API (mgr.GetEventRecorder), not the deprecated GetEventRecorderFor -- caught by golangci-lint's staticcheck before it shipped. Verified locally: make fmt lint test build all clean, 0 lint issues, all specs pass. --- PROJECT | 10 + api/v1alpha1/groupversion_info.go | 28 ++ api/v1alpha1/terdutserver_types.go | 182 ++++++++++++ api/v1alpha1/zz_generated.deepcopy.go | 168 +++++++++++ cmd/main.go | 23 ++ .../terdut.ryuvia.com_terdutservers.yaml | 265 ++++++++++++++++++ config/crd/kustomization.yaml | 16 ++ config/crd/kustomizeconfig.yaml | 12 + config/default/kustomization.yaml | 2 +- config/manager/manager.yaml | 9 + config/rbac/kustomization.yaml | 8 + config/rbac/role.yaml | 41 ++- config/rbac/terdutserver_admin_role.yaml | 27 ++ config/rbac/terdutserver_editor_role.yaml | 33 +++ config/rbac/terdutserver_viewer_role.yaml | 29 ++ config/samples/kustomization.yaml | 4 + .../samples/terdut_v1alpha1_terdutserver.yaml | 23 ++ internal/controller/suite_test.go | 100 +++++++ .../controller/terdutserver_controller.go | 180 ++++++++++++ .../terdutserver_controller_test.go | 216 ++++++++++++++ internal/tdclient/client.go | 115 ++++++++ internal/tdclient/client_test.go | 91 ++++++ 22 files changed, 1575 insertions(+), 7 deletions(-) create mode 100644 api/v1alpha1/groupversion_info.go create mode 100644 api/v1alpha1/terdutserver_types.go create mode 100644 api/v1alpha1/zz_generated.deepcopy.go create mode 100644 config/crd/bases/terdut.ryuvia.com_terdutservers.yaml create mode 100644 config/crd/kustomization.yaml create mode 100644 config/crd/kustomizeconfig.yaml create mode 100644 config/rbac/terdutserver_admin_role.yaml create mode 100644 config/rbac/terdutserver_editor_role.yaml create mode 100644 config/rbac/terdutserver_viewer_role.yaml create mode 100644 config/samples/kustomization.yaml create mode 100644 config/samples/terdut_v1alpha1_terdutserver.yaml create mode 100644 internal/controller/suite_test.go create mode 100644 internal/controller/terdutserver_controller.go create mode 100644 internal/controller/terdutserver_controller_test.go create mode 100644 internal/tdclient/client.go create mode 100644 internal/tdclient/client_test.go diff --git a/PROJECT b/PROJECT index 887d457..b7f573c 100644 --- a/PROJECT +++ b/PROJECT @@ -8,4 +8,14 @@ layout: - go.kubebuilder.io/v4 projectName: terdut-operator repo: git.ryuvia.com/niklas/terdut-operator +resources: +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: ryuvia.com + group: terdut + kind: TerdutServer + path: git.ryuvia.com/niklas/terdut-operator/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..7ad011f --- /dev/null +++ b/api/v1alpha1/groupversion_info.go @@ -0,0 +1,28 @@ +// Package v1alpha1 contains API Schema definitions for the terdut v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=terdut.ryuvia.com +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var ( + // SchemeGroupVersion is group version used to register these objects. + // This name is used by applyconfiguration generators (e.g. controller-gen). + SchemeGroupVersion = schema.GroupVersion{Group: "terdut.ryuvia.com", Version: "v1alpha1"} + + // GroupVersion is an alias for SchemeGroupVersion, for backward compatibility. + GroupVersion = SchemeGroupVersion + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error { + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil + }) + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1alpha1/terdutserver_types.go b/api/v1alpha1/terdutserver_types.go new file mode 100644 index 0000000..2b66b76 --- /dev/null +++ b/api/v1alpha1/terdutserver_types.go @@ -0,0 +1,182 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// SecretKeyRef names one data key inside a Secret. Unlike a typical +// cross-namespace reference, there is deliberately no namespace field here: +// every Secret this type points at (DESIGN.md §6) lives in the operator's +// own namespace, always, by construction — never anywhere else, so there is +// nothing for a namespace field to vary. What does vary is the key: fixed +// ("token") when the controller generated the Secret itself, whatever a +// human chose when it was handed to the controller instead. +type SecretKeyRef struct { + // name is the Secret's name. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // key is the data key inside the Secret holding the raw value. + // +kubebuilder:validation:MinLength=1 + Key string `json:"key"` +} + +// AllowedTeamsNamespaces gates which namespaces a TerdutTeam may resolve a +// cross-namespace serverRef into this TerdutServer from (DESIGN.md §4.6). +// Same-namespace TerdutTeams are always allowed, regardless of this field. +// Modeled on Gateway API's Gateway.spec.allowedListeners.namespaces. +type AllowedTeamsNamespaces struct { + // from selects which namespaces may attach. Same is equivalent to None in + // effect (same-namespace is unrestricted either way) but kept for parity + // with the upstream enum this mirrors, and to make the policy + // self-documenting in a diff. + // +kubebuilder:validation:Enum=None;Same;All;Selector + // +kubebuilder:default=None + // +optional + From string `json:"from,omitempty"` + + // selector is required, and only meaningful, when from is Selector: a + // standard label selector over Namespace objects. + // +optional + Selector *metav1.LabelSelector `json:"selector,omitempty"` +} + +// AllowedTeams is consent for TerdutTeams in other namespaces to set +// serverRef at this TerdutServer (DESIGN.md §4.1, §4.6). +type AllowedTeams struct { + // +optional + Namespaces AllowedTeamsNamespaces `json:"namespaces,omitempty"` +} + +// TerdutServerSpec defines the desired state of TerdutServer. +// +// Narrowed to Stage 1 (ROADMAP.md): this covers only what the bootstrap/ +// credentials flow (DESIGN.md §6) needs against an already-running, +// already-bootstrapped terdut-server. The fields that would have the +// controller manage a Deployment/Service/database — image, replicas, +// networking, database — are deferred to Stage 5 and deliberately absent +// here, not an oversight; adding them later is additive, not a breaking +// change to this shape. +type TerdutServerSpec struct { + // endpoint is the base URL of an already-running terdut-server this + // TerdutServer represents, e.g. "http://terdut.oncall.svc:8080". This + // stage never creates or manages a Deployment/Service for it — the + // server is expected to already exist, deployed some other way (its own + // Helm chart, by hand). + // +required + // +kubebuilder:validation:MinLength=1 + Endpoint string `json:"endpoint"` + + // credentialsSecretRef names a Secret, in the operator's own namespace, + // that a human has already created by minting an instance-scoped service + // account with their own admin session (POST /api/service-accounts, + // DESIGN.md §6) and placing its raw key under the given key. Set, and + // the Secret found: the controller adopts it outright and skips + // bootstrap entirely — this is the expected path for Stage 1, not a + // fallback (DESIGN.md §6 explains why self-registration cannot complete + // unauthenticated in the setup this stage actually exercises). + // + // Unset: the controller has no way to acquire a credential in this + // stage (self-registration lands in Stage 5) and reports + // Ready: False, reason: CredentialsSecretRefRequired. + // +optional + CredentialsSecretRef *SecretKeyRef `json:"credentialsSecretRef,omitempty"` + + // allowedTeams gates cross-namespace TerdutTeams (DESIGN.md §4.6). + // Unused until TerdutTeam exists (Stage 2); present now so this CRD's + // schema doesn't need a breaking change to grow it later. + // +optional + AllowedTeams AllowedTeams `json:"allowedTeams,omitempty"` +} + +// Condition types this controller sets on TerdutServer. +const ( + // ConditionReady is the standard top-level condition every CRD carries + // (DESIGN.md §7). + ConditionReady = "Ready" + // ConditionBootstrapped reflects whether a working credential has been + // acquired — adopted from spec.credentialsSecretRef in this stage. + ConditionBootstrapped = "Bootstrapped" +) + +// Condition reasons this controller sets. +const ( + // ReasonCredentialsSecretRefRequired: spec.credentialsSecretRef is + // unset, and self-registration isn't implemented yet (Stage 5) — the + // expected, steady-state reason whenever no bring-your-own credential + // has been provided. + ReasonCredentialsSecretRefRequired = "CredentialsSecretRefRequired" + // ReasonCredentialsSecretNotFound: spec.credentialsSecretRef is set but + // no such Secret exists (yet) in the operator's own namespace. + ReasonCredentialsSecretNotFound = "CredentialsSecretNotFound" + // ReasonCredentialsSecretInvalid: the Secret exists but has no data + // under the given key, or it's empty. + ReasonCredentialsSecretInvalid = "CredentialsSecretInvalid" + // ReasonServerUnreachable: GET /api/version against spec.endpoint + // failed — a bad endpoint, or the server is down. + ReasonServerUnreachable = "ServerUnreachable" + // ReasonAdopted: the happy path. A working credential is in hand and the + // server answered its version probe. + ReasonAdopted = "Adopted" +) + +// TerdutServerStatus defines the observed state of TerdutServer. +type TerdutServerStatus struct { + // conditions represent the current state of the TerdutServer resource. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // observedGeneration is the .metadata.generation this status was last + // computed against — the standard way a client (or `kubectl wait`) + // tells "applied" from "seen" (DESIGN.md §7). + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // credentialsSecretRef mirrors spec.credentialsSecretRef once adopted — + // same Secret, same key, always in the operator's own namespace. Set + // only once Bootstrapped is True. + // +optional + CredentialsSecretRef *SecretKeyRef `json:"credentialsSecretRef,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.spec.endpoint` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason` + +// TerdutServer is the Schema for the terdutservers API +type TerdutServer struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // spec defines the desired state of TerdutServer + // +required + Spec TerdutServerSpec `json:"spec"` + + // status defines the observed state of TerdutServer + // +optional + Status TerdutServerStatus `json:"status,omitzero"` +} + +// +kubebuilder:object:root=true + +// TerdutServerList contains a list of TerdutServer +type TerdutServerList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []TerdutServer `json:"items"` +} + +func init() { + SchemeBuilder.Register(func(s *runtime.Scheme) error { + s.AddKnownTypes(SchemeGroupVersion, &TerdutServer{}, &TerdutServerList{}) + return nil + }) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..aa680ff --- /dev/null +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,168 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AllowedTeams) DeepCopyInto(out *AllowedTeams) { + *out = *in + in.Namespaces.DeepCopyInto(&out.Namespaces) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AllowedTeams. +func (in *AllowedTeams) DeepCopy() *AllowedTeams { + if in == nil { + return nil + } + out := new(AllowedTeams) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AllowedTeamsNamespaces) DeepCopyInto(out *AllowedTeamsNamespaces) { + *out = *in + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AllowedTeamsNamespaces. +func (in *AllowedTeamsNamespaces) DeepCopy() *AllowedTeamsNamespaces { + if in == nil { + return nil + } + out := new(AllowedTeamsNamespaces) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretKeyRef) DeepCopyInto(out *SecretKeyRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeyRef. +func (in *SecretKeyRef) DeepCopy() *SecretKeyRef { + if in == nil { + return nil + } + out := new(SecretKeyRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TerdutServer) DeepCopyInto(out *TerdutServer) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutServer. +func (in *TerdutServer) DeepCopy() *TerdutServer { + if in == nil { + return nil + } + out := new(TerdutServer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TerdutServer) 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 *TerdutServerList) DeepCopyInto(out *TerdutServerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TerdutServer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutServerList. +func (in *TerdutServerList) DeepCopy() *TerdutServerList { + if in == nil { + return nil + } + out := new(TerdutServerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TerdutServerList) 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 *TerdutServerSpec) DeepCopyInto(out *TerdutServerSpec) { + *out = *in + if in.CredentialsSecretRef != nil { + in, out := &in.CredentialsSecretRef, &out.CredentialsSecretRef + *out = new(SecretKeyRef) + **out = **in + } + in.AllowedTeams.DeepCopyInto(&out.AllowedTeams) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutServerSpec. +func (in *TerdutServerSpec) DeepCopy() *TerdutServerSpec { + if in == nil { + return nil + } + out := new(TerdutServerSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TerdutServerStatus) DeepCopyInto(out *TerdutServerStatus) { + *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 TerdutServerStatus. +func (in *TerdutServerStatus) DeepCopy() *TerdutServerStatus { + if in == nil { + return nil + } + out := new(TerdutServerStatus) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/main.go b/cmd/main.go index ae33104..1897ead 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -18,6 +18,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/metrics/filters" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" + + terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1" + "git.ryuvia.com/niklas/terdut-operator/internal/controller" // +kubebuilder:scaffold:imports ) @@ -29,6 +32,7 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(terdutv1alpha1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -162,6 +166,25 @@ func main() { os.Exit(1) } + // POD_NAMESPACE is the operator's own namespace, via the Deployment's + // downward API (config/manager/manager.yaml) — every credentials Secret + // TerdutServerReconciler reads or writes lives here, never in a + // TerdutServer's own namespace (DESIGN.md §6). Falling back to "default" + // keeps `go run` usable for local development against a real cluster; + // production always sets it. + operatorNamespace := os.Getenv("POD_NAMESPACE") + if operatorNamespace == "" { + operatorNamespace = "default" + } + + if err := (&controller.TerdutServerReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + OperatorNamespace: operatorNamespace, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create controller", "controller", "terdutserver") + os.Exit(1) + } // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/terdut.ryuvia.com_terdutservers.yaml b/config/crd/bases/terdut.ryuvia.com_terdutservers.yaml new file mode 100644 index 0000000..78fd9d4 --- /dev/null +++ b/config/crd/bases/terdut.ryuvia.com_terdutservers.yaml @@ -0,0 +1,265 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.22.0 + name: terdutservers.terdut.ryuvia.com +spec: + group: terdut.ryuvia.com + names: + kind: TerdutServer + listKind: TerdutServerList + plural: terdutservers + singular: terdutserver + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.endpoint + name: Endpoint + type: string + - 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: TerdutServer is the Schema for the terdutservers 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 TerdutServer + properties: + allowedTeams: + description: |- + allowedTeams gates cross-namespace TerdutTeams (DESIGN.md §4.6). + Unused until TerdutTeam exists (Stage 2); present now so this CRD's + schema doesn't need a breaking change to grow it later. + properties: + namespaces: + description: |- + AllowedTeamsNamespaces gates which namespaces a TerdutTeam may resolve a + cross-namespace serverRef into this TerdutServer from (DESIGN.md §4.6). + Same-namespace TerdutTeams are always allowed, regardless of this field. + Modeled on Gateway API's Gateway.spec.allowedListeners.namespaces. + properties: + from: + default: None + description: |- + from selects which namespaces may attach. Same is equivalent to None in + effect (same-namespace is unrestricted either way) but kept for parity + with the upstream enum this mirrors, and to make the policy + self-documenting in a diff. + enum: + - None + - Same + - All + - Selector + type: string + selector: + description: |- + selector is required, and only meaningful, when from is Selector: a + standard label selector over Namespace objects. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: object + type: object + credentialsSecretRef: + description: |- + credentialsSecretRef names a Secret, in the operator's own namespace, + that a human has already created by minting an instance-scoped service + account with their own admin session (POST /api/service-accounts, + DESIGN.md §6) and placing its raw key under the given key. Set, and + the Secret found: the controller adopts it outright and skips + bootstrap entirely — this is the expected path for Stage 1, not a + fallback (DESIGN.md §6 explains why self-registration cannot complete + unauthenticated in the setup this stage actually exercises). + + Unset: the controller has no way to acquire a credential in this + stage (self-registration lands in Stage 5) and reports + Ready: False, reason: CredentialsSecretRefRequired. + 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 + endpoint: + description: |- + endpoint is the base URL of an already-running terdut-server this + TerdutServer represents, e.g. "http://terdut.oncall.svc:8080". This + stage never creates or manages a Deployment/Service for it — the + server is expected to already exist, deployed some other way (its own + Helm chart, by hand). + minLength: 1 + type: string + required: + - endpoint + type: object + status: + description: status defines the observed state of TerdutServer + properties: + conditions: + description: conditions represent the current state of the TerdutServer + resource. + 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 mirrors spec.credentialsSecretRef once adopted — + same Secret, same key, always in the operator's own namespace. Set + only once Bootstrapped is True. + 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: + description: |- + observedGeneration is the .metadata.generation this status was last + computed against — the standard way a client (or `kubectl wait`) + tells "applied" from "seen" (DESIGN.md §7). + 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 new file mode 100644 index 0000000..4882ebd --- /dev/null +++ b/config/crd/kustomization.yaml @@ -0,0 +1,16 @@ +# This kustomization.yaml is not intended to be run by itself, +# since it depends on service name and namespace that are out of this kustomize package. +# It should be run by config/default +resources: +- bases/terdut.ryuvia.com_terdutservers.yaml +# +kubebuilder:scaffold:crdkustomizeresource + +patches: +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. +# patches here are for enabling the conversion webhook for each CRD +# +kubebuilder:scaffold:crdkustomizewebhookpatch + +# [WEBHOOK] To enable webhook, uncomment the following section +# the following config is for teaching kustomize how to do kustomization for CRDs. +#configurations: +#- kustomizeconfig.yaml diff --git a/config/crd/kustomizeconfig.yaml b/config/crd/kustomizeconfig.yaml new file mode 100644 index 0000000..61361ff --- /dev/null +++ b/config/crd/kustomizeconfig.yaml @@ -0,0 +1,12 @@ +# This file is for teaching kustomize how to substitute name and namespace reference in CRD +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: CustomResourceDefinition + version: v1 + group: apiextensions.k8s.io + path: spec/conversion/webhook/clientConfig/service/name + +varReference: +- path: metadata/annotations diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index ab8b649..0ad32fb 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -15,7 +15,7 @@ namePrefix: terdut-operator- # someName: someValue resources: -#- ../crd +- ../crd - ../rbac - ../manager # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 4a39e8a..e5b54e8 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -65,6 +65,15 @@ spec: - --health-probe-bind-address=:8081 image: controller:latest name: manager + env: + # The operator's own namespace — every credentials Secret the + # TerdutServer controller reads or writes lives here (DESIGN.md + # §6), never in a TerdutServer's own namespace. Downward API, not + # a hardcoded value, so this stays correct under any release name. + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace ports: - containerPort: 8081 name: health diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 5619aa0..5a78faa 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -18,3 +18,11 @@ resources: - metrics_auth_role.yaml - metrics_auth_role_binding.yaml - metrics_reader_role.yaml +# For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by +# 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. +- 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 ab13eca..0d069ab 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -1,11 +1,40 @@ +--- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - labels: - app.kubernetes.io/name: terdut-operator - app.kubernetes.io/managed-by: kustomize name: manager-role rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch"] +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list + - watch +- apiGroups: + - terdut.ryuvia.com + resources: + - terdutservers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - terdut.ryuvia.com + resources: + - terdutservers/finalizers + verbs: + - update +- apiGroups: + - terdut.ryuvia.com + resources: + - terdutservers/status + verbs: + - get + - patch + - update diff --git a/config/rbac/terdutserver_admin_role.yaml b/config/rbac/terdutserver_admin_role.yaml new file mode 100644 index 0000000..a856e46 --- /dev/null +++ b/config/rbac/terdutserver_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: terdutserver-admin-role +rules: +- apiGroups: + - terdut.ryuvia.com + resources: + - terdutservers + verbs: + - '*' +- apiGroups: + - terdut.ryuvia.com + resources: + - terdutservers/status + verbs: + - get diff --git a/config/rbac/terdutserver_editor_role.yaml b/config/rbac/terdutserver_editor_role.yaml new file mode 100644 index 0000000..10b1d5b --- /dev/null +++ b/config/rbac/terdutserver_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: terdutserver-editor-role +rules: +- apiGroups: + - terdut.ryuvia.com + resources: + - terdutservers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - terdut.ryuvia.com + resources: + - terdutservers/status + verbs: + - get diff --git a/config/rbac/terdutserver_viewer_role.yaml b/config/rbac/terdutserver_viewer_role.yaml new file mode 100644 index 0000000..571464b --- /dev/null +++ b/config/rbac/terdutserver_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: terdutserver-viewer-role +rules: +- apiGroups: + - terdut.ryuvia.com + resources: + - terdutservers + verbs: + - get + - list + - watch +- apiGroups: + - terdut.ryuvia.com + resources: + - terdutservers/status + verbs: + - get diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml new file mode 100644 index 0000000..64bf38e --- /dev/null +++ b/config/samples/kustomization.yaml @@ -0,0 +1,4 @@ +## Append samples of your project ## +resources: +- terdut_v1alpha1_terdutserver.yaml +# +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/terdut_v1alpha1_terdutserver.yaml b/config/samples/terdut_v1alpha1_terdutserver.yaml new file mode 100644 index 0000000..6362fe4 --- /dev/null +++ b/config/samples/terdut_v1alpha1_terdutserver.yaml @@ -0,0 +1,23 @@ +apiVersion: terdut.ryuvia.com/v1alpha1 +kind: TerdutServer +metadata: + labels: + app.kubernetes.io/name: terdut-operator + app.kubernetes.io/managed-by: kustomize + name: terdutserver-sample +spec: + # An already-running terdut-server this TerdutServer represents — Stage 1 + # never creates or manages a Deployment/Service for it (ROADMAP.md). + endpoint: "http://terdut.oncall.svc.cluster.local:8080" + # Bring-your-own instance credential (DESIGN.md §6): mint this once, + # manually, with your own admin session -- + # curl -H "Authorization: Bearer " \ + # -X POST "$ENDPOINT/api/service-accounts" \ + # -d '{"name":"terdut-operator","scope":"instance"}' + # -- then create this Secret, in the OPERATOR's own namespace, from the + # "key" field of that response: + # kubectl create secret generic terdutserver-sample-creds \ + # -n --from-literal=token= + credentialsSecretRef: + name: terdutserver-sample-creds + key: token diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go new file mode 100644 index 0000000..2eece3b --- /dev/null +++ b/internal/controller/suite_test.go @@ -0,0 +1,100 @@ +package controller + +import ( + "context" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = terdutv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/internal/controller/terdutserver_controller.go b/internal/controller/terdutserver_controller.go new file mode 100644 index 0000000..2fdafd2 --- /dev/null +++ b/internal/controller/terdutserver_controller.go @@ -0,0 +1,180 @@ +package controller + +import ( + "context" + "fmt" + "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" + 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" +) + +// resyncInterval is the periodic requeue on a successful reconcile (DESIGN.md +// §5's general rule) — it exists to catch drift from someone changing state +// directly against the server's API/UI, not from a missed watch event. +const resyncInterval = 5 * time.Minute + +// waitInterval is the requeue while waiting on an external condition to +// resolve (the credential Secret not existing yet, the server being +// unreachable) — shorter than resyncInterval, since these are expected to +// change sooner than "someone edited something out of band." +const waitInterval = 30 * time.Second + +// TerdutServerReconciler reconciles a TerdutServer object. +// +// Stage 1 (ROADMAP.md): bootstrap/credentials only, bring-your-own path +// only. It never creates, updates, or deletes anything server-side or any +// Deployment/Service — it only reads a Secret the operator's own namespace +// already has and probes the server's /api/version. No finalizer: this +// controller owns nothing that needs cleaning up on delete (it never creates +// the credentials Secret itself, only ever adopts one a human already +// made) — revisit once self-registration (Stage 5) generates its own. +type TerdutServerReconciler struct { + client.Client + Scheme *runtime.Scheme + + // OperatorNamespace is where every credentials Secret this controller + // reads or writes lives (DESIGN.md §6) — never the TerdutServer's own + // namespace. Set from the POD_NAMESPACE downward-API env var in + // production (cmd/main.go); tests set it directly. + OperatorNamespace string + + // Recorder emits the Kubernetes Events DESIGN.md §12 asks for on every + // externally-visible outcome. The events.k8s.io/v1 API, not the + // deprecated core/v1 one GetEventRecorderFor still returns. + Recorder recorder.EventRecorder + + // NewClient builds the terdut-server API client for a given endpoint. + // A field, not a direct tdclient.New call, so tests can substitute an + // httptest.Server's client without a real network round trip. Defaults + // to tdclient.New via SetupWithManager. + NewClient func(endpoint string) *tdclient.Client +} + +// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers/finalizers,verbs=update +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch + +func (r *TerdutServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + var srv terdutv1alpha1.TerdutServer + if err := r.Get(ctx, req.NamespacedName, &srv); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if srv.Spec.CredentialsSecretRef == nil { + return r.setNotReady(ctx, &srv, + terdutv1alpha1.ReasonCredentialsSecretRefRequired, + "spec.credentialsSecretRef is unset; self-registration bootstrap isn't "+ + "implemented yet (Stage 5) — mint an instance-scoped service account "+ + "with your own admin session (POST /api/service-accounts) and set "+ + "this field to a Secret holding its key (DESIGN.md §6)") + } + ref := srv.Spec.CredentialsSecretRef + + var secret corev1.Secret + secretKey := client.ObjectKey{Namespace: r.OperatorNamespace, Name: ref.Name} + if err := r.Get(ctx, secretKey, &secret); err != nil { + if apierrors.IsNotFound(err) { + return r.setNotReady(ctx, &srv, + terdutv1alpha1.ReasonCredentialsSecretNotFound, + fmt.Sprintf("Secret %q not found in namespace %q", ref.Name, r.OperatorNamespace)) + } + return ctrl.Result{}, err + } + + raw, ok := secret.Data[ref.Key] + if !ok || len(raw) == 0 { + return r.setNotReady(ctx, &srv, + terdutv1alpha1.ReasonCredentialsSecretInvalid, + fmt.Sprintf("Secret %q has no data under key %q", ref.Name, ref.Key)) + } + + newClient := r.NewClient + if newClient == nil { + newClient = tdclient.New + } + if _, err := newClient(srv.Spec.Endpoint).Version(ctx); err != nil { + return r.setNotReady(ctx, &srv, + terdutv1alpha1.ReasonServerUnreachable, + fmt.Sprintf("GET %s/api/version: %v", srv.Spec.Endpoint, err)) + } + + srv.Status.CredentialsSecretRef = ref.DeepCopy() + meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{ + Type: terdutv1alpha1.ConditionBootstrapped, + Status: metav1.ConditionTrue, + Reason: terdutv1alpha1.ReasonAdopted, + Message: fmt.Sprintf("adopted spec.credentialsSecretRef (Secret %q, key %q)", ref.Name, ref.Key), + }) + meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{ + Type: terdutv1alpha1.ConditionReady, + Status: metav1.ConditionTrue, + Reason: terdutv1alpha1.ReasonAdopted, + Message: "credentials adopted, server reachable", + }) + srv.Status.ObservedGeneration = srv.Generation + if err := r.Status().Update(ctx, &srv); err != nil { + return ctrl.Result{}, err + } + if r.Recorder != nil { + r.Recorder.Eventf(&srv, nil, corev1.EventTypeNormal, + terdutv1alpha1.ReasonAdopted, terdutv1alpha1.ReasonAdopted, + "credentials adopted, server reachable") + } + log.Info("TerdutServer ready", "endpoint", srv.Spec.Endpoint) + + return ctrl.Result{RequeueAfter: resyncInterval}, nil +} + +// setNotReady records Ready: False with reason/message on both conditions, +// fires a Warning event, and requeues after waitInterval — every "waiting on +// something external" exit from Reconcile goes through here so the +// condition/event/requeue shape can't drift between them. +func (r *TerdutServerReconciler) setNotReady( + ctx context.Context, srv *terdutv1alpha1.TerdutServer, reason, message string, +) (ctrl.Result, error) { + meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{ + Type: terdutv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: reason, + Message: message, + }) + srv.Status.ObservedGeneration = srv.Generation + if err := r.Status().Update(ctx, srv); err != nil { + return ctrl.Result{}, err + } + if r.Recorder != nil { + r.Recorder.Eventf(srv, nil, corev1.EventTypeWarning, reason, reason, message) + } + return ctrl.Result{RequeueAfter: waitInterval}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *TerdutServerReconciler) SetupWithManager(mgr ctrl.Manager) error { + if r.NewClient == nil { + r.NewClient = tdclient.New + } + if r.Recorder == nil { + r.Recorder = mgr.GetEventRecorder("terdutserver-controller") + } + return ctrl.NewControllerManagedBy(mgr). + For(&terdutv1alpha1.TerdutServer{}). + Named("terdutserver"). + Complete(r) +} diff --git a/internal/controller/terdutserver_controller_test.go b/internal/controller/terdutserver_controller_test.go new file mode 100644 index 0000000..2251d91 --- /dev/null +++ b/internal/controller/terdutserver_controller_test.go @@ -0,0 +1,216 @@ +package controller + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + 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/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1" +) + +const ( + // unusedEndpoint is a syntactically valid URL no test here ever expects + // to actually be dialed (the cases using it fail before reaching the + // server-reachability check). + unusedEndpoint = "http://unused.invalid" + // tokenKey is the data key every test's credentials Secret uses. + tokenKey = "token" +) + +// fakeTerdutServer is an httptest.Server standing in for terdut-server's +// GET /api/version, per DESIGN.md §11 ("terdut-server's REST API is faked +// with a small httptest.Server per controller test ... no real Postgres or +// real terdut-server binary needed for controller unit tests"). +func fakeTerdutServer(version string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/version" { + w.WriteHeader(http.StatusNotFound) + return + } + fmt.Fprintf(w, `{"version":%q}`, version) //nolint:errcheck + })) +} + +var _ = Describe("TerdutServer Controller", func() { + const operatorNamespace = "default" + + var ( + reconciler *TerdutServerReconciler + name string + objKey types.NamespacedName + ) + + BeforeEach(func() { + reconciler = &TerdutServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + OperatorNamespace: operatorNamespace, + } + name = fmt.Sprintf("test-server-%d-%d", GinkgoRandomSeed(), GinkgoParallelProcess()) + objKey = types.NamespacedName{Name: name, Namespace: operatorNamespace} + }) + + AfterEach(func(ctx SpecContext) { + srv := &terdutv1alpha1.TerdutServer{} + if err := k8sClient.Get(ctx, objKey, srv); err == nil { + Expect(k8sClient.Delete(ctx, srv)).To(Succeed()) + } + }) + + createServer := func(ctx context.Context, spec terdutv1alpha1.TerdutServerSpec) { + srv := &terdutv1alpha1.TerdutServer{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: operatorNamespace}, + Spec: spec, + } + Expect(k8sClient.Create(ctx, srv)).To(Succeed()) + } + + reconcileOnce := func(ctx context.Context) { + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) + Expect(err).NotTo(HaveOccurred()) + } + + readyCondition := func(ctx context.Context) metav1.Condition { + srv := &terdutv1alpha1.TerdutServer{} + Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed()) + c := meta.FindStatusCondition(srv.Status.Conditions, terdutv1alpha1.ConditionReady) + Expect(c).NotTo(BeNil(), "Ready condition should always be set after a reconcile") + return *c + } + + When("spec.credentialsSecretRef is unset", func() { + It("reports Ready: False, reason CredentialsSecretRefRequired, and makes no API call", func(ctx SpecContext) { + createServer(ctx, terdutv1alpha1.TerdutServerSpec{Endpoint: unusedEndpoint}) + reconcileOnce(ctx) + + cond := readyCondition(ctx) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonCredentialsSecretRefRequired)) + }) + }) + + When("the referenced Secret does not exist", func() { + It("reports Ready: False, reason CredentialsSecretNotFound", func(ctx SpecContext) { + createServer(ctx, terdutv1alpha1.TerdutServerSpec{ + Endpoint: unusedEndpoint, + CredentialsSecretRef: &terdutv1alpha1.SecretKeyRef{Name: "does-not-exist", Key: tokenKey}, + }) + reconcileOnce(ctx) + + cond := readyCondition(ctx) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonCredentialsSecretNotFound)) + }) + }) + + When("the referenced Secret exists but has no data under the given key", func() { + It("reports Ready: False, reason CredentialsSecretInvalid", func(ctx SpecContext) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name + "-creds", Namespace: operatorNamespace}, + Data: map[string][]byte{"wrong-key": []byte("tdsa_something")}, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + defer func() { _ = k8sClient.Delete(ctx, secret) }() + + createServer(ctx, terdutv1alpha1.TerdutServerSpec{ + Endpoint: unusedEndpoint, + CredentialsSecretRef: &terdutv1alpha1.SecretKeyRef{Name: secret.Name, Key: tokenKey}, + }) + reconcileOnce(ctx) + + cond := readyCondition(ctx) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonCredentialsSecretInvalid)) + }) + }) + + When("the Secret is valid but the server is unreachable", func() { + It("reports Ready: False, reason ServerUnreachable", func(ctx SpecContext) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name + "-creds", Namespace: operatorNamespace}, + Data: map[string][]byte{tokenKey: []byte("tdsa_something")}, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + defer func() { _ = k8sClient.Delete(ctx, secret) }() + + createServer(ctx, terdutv1alpha1.TerdutServerSpec{ + // Port 1 is never listening (closed cleanly and immediately, + // unlike an unroutable address which would hang on a + // connect timeout) -- keeps the test fast. + Endpoint: "http://127.0.0.1:1", + CredentialsSecretRef: &terdutv1alpha1.SecretKeyRef{Name: secret.Name, Key: tokenKey}, + }) + reconcileOnce(ctx) + + cond := readyCondition(ctx) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonServerUnreachable)) + }) + }) + + When("the Secret is valid and the server answers /api/version", func() { + It("reports Ready: True, Bootstrapped: True, and mirrors credentialsSecretRef into status", func(ctx SpecContext) { + fake := fakeTerdutServer("v0.20.0") + DeferCleanup(fake.Close) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name + "-creds", Namespace: operatorNamespace}, + Data: map[string][]byte{tokenKey: []byte("tdsa_something")}, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + defer func() { _ = k8sClient.Delete(ctx, secret) }() + + createServer(ctx, terdutv1alpha1.TerdutServerSpec{ + Endpoint: fake.URL, + CredentialsSecretRef: &terdutv1alpha1.SecretKeyRef{Name: secret.Name, Key: tokenKey}, + }) + reconcileOnce(ctx) + + srv := &terdutv1alpha1.TerdutServer{} + Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed()) + + ready := meta.FindStatusCondition(srv.Status.Conditions, terdutv1alpha1.ConditionReady) + Expect(ready).NotTo(BeNil()) + Expect(ready.Status).To(Equal(metav1.ConditionTrue)) + Expect(ready.Reason).To(Equal(terdutv1alpha1.ReasonAdopted)) + + bootstrapped := meta.FindStatusCondition(srv.Status.Conditions, terdutv1alpha1.ConditionBootstrapped) + Expect(bootstrapped).NotTo(BeNil()) + Expect(bootstrapped.Status).To(Equal(metav1.ConditionTrue)) + + Expect(srv.Status.CredentialsSecretRef).NotTo(BeNil()) + Expect(srv.Status.CredentialsSecretRef.Name).To(Equal(secret.Name)) + Expect(srv.Status.CredentialsSecretRef.Key).To(Equal(tokenKey)) + Expect(srv.Status.ObservedGeneration).To(Equal(srv.Generation)) + }) + }) + + When("the TerdutServer object no longer exists", func() { + It("returns no error (deleted between enqueue and reconcile)", func(ctx SpecContext) { + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: "never-created", Namespace: operatorNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) + +var _ = Describe("TerdutServerReconciler sanity", func() { + It("treats a real apierrors.IsNotFound the same as any other caller would", func() { + // Guards against a refactor accidentally swapping in a different + // not-found check that stops matching what client.Client actually + // returns. + Expect(apierrors.IsNotFound(apierrors.NewNotFound( + terdutv1alpha1.GroupVersion.WithResource("terdutservers").GroupResource(), "x"))).To(BeTrue()) + }) +}) diff --git a/internal/tdclient/client.go b/internal/tdclient/client.go new file mode 100644 index 0000000..1d523fb --- /dev/null +++ b/internal/tdclient/client.go @@ -0,0 +1,115 @@ +// Package tdclient is a minimal terdut-server API client for the operator's +// own controllers. It mirrors the shape of terdut-tui's +// internal/api/client.go (baseURL/httpClient fields, a shared do/statusError +// helper, per terdut/CLAUDE.md's "any change to a server endpoint or JSON +// shape must be mirrored" convention) but authorizes with a Bearer API key +// rather than a session cookie — the operator never signs in as a human +// (DESIGN.md §6). +// +// Deliberately minimal for Stage 1 (ROADMAP.md): only Version, the +// reachability probe TerdutServer's controller needs. Bootstrap and the +// service-account endpoints land here once self-registration does (Stage 5). +package tdclient + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// 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). +type Client struct { + baseURL string + httpClient *http.Client + token string +} + +// New creates a Client against baseURL, with no credential set. +func New(baseURL string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +// WithToken returns a copy of c that authorizes every request as a Bearer +// credential — a user's own API key or a service-account key +// (SERVICE-ACCOUNTS.md), the server resolves either the same way. +func (c *Client) WithToken(token string) *Client { + cp := *c + cp.token = token + return &cp +} + +// StatusError is a non-2xx response — the server's {"error": "..."} body +// decoded into Message, same shape terdut-tui's client uses. +type StatusError struct { + Code int + Message string +} + +func (e *StatusError) Error() string { + if e.Message != "" { + return fmt.Sprintf("server returned %d: %s", e.Code, e.Message) + } + return fmt.Sprintf("server returned %d", e.Code) +} + +func statusError(resp *http.Response) error { + var e struct { + Error string `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&e) + return &StatusError{Code: resp.StatusCode, Message: e.Error} +} + +func (c *Client) newRequest(ctx context.Context, method, path string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + return req, nil +} + +func (c *Client) do(req *http.Request, out any) error { + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 400 { + return statusError(resp) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +// Version calls GET /api/version — unauthenticated, per terdut-server's own +// router.go comment ("a client deciding whether it can talk to this server — +// terdut-tui, terdut-operator — needs to ask before it holds a credential +// for it"). Used here purely as a reachability probe: a bad endpoint fails +// here, clearly, rather than on whatever the controller tries first. +func (c *Client) Version(ctx context.Context) (string, error) { + req, err := c.newRequest(ctx, http.MethodGet, "/api/version") + if err != nil { + return "", err + } + var v struct { + Version string `json:"version"` + } + if err := c.do(req, &v); err != nil { + return "", err + } + return v.Version, nil +} diff --git a/internal/tdclient/client_test.go b/internal/tdclient/client_test.go new file mode 100644 index 0000000..f36de13 --- /dev/null +++ b/internal/tdclient/client_test.go @@ -0,0 +1,91 @@ +package tdclient + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestVersion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/version" { + t.Errorf("unexpected path %q", r.URL.Path) + } + // Unauthenticated per terdut-server's own router.go comment: no + // Authorization header should be required, and none is sent here. + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"version":"v0.20.0"}`)) //nolint:errcheck + })) + defer srv.Close() + + got, err := New(srv.URL).Version(context.Background()) + if err != nil { + t.Fatalf("Version() error = %v", err) + } + if got != "v0.20.0" { + t.Errorf("Version() = %q, want %q", got, "v0.20.0") + } +} + +func TestVersionUnreachable(t *testing.T) { + // A closed server: connection refused, the same shape a bad + // spec.endpoint produces against a real cluster. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() + + if _, err := New(srv.URL).Version(context.Background()); err == nil { + t.Fatal("Version() error = nil, want a connection error") + } +} + +func TestVersionErrorStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"internal error"}`)) //nolint:errcheck + })) + defer srv.Close() + + _, err := New(srv.URL).Version(context.Background()) + if err == nil { + t.Fatal("Version() error = nil, want a StatusError") + } + var statusErr *StatusError + if !asStatusError(err, &statusErr) { + t.Fatalf("Version() error = %v (%T), want *StatusError", err, err) + } + if statusErr.Code != http.StatusInternalServerError { + t.Errorf("StatusError.Code = %d, want %d", statusErr.Code, http.StatusInternalServerError) + } + if statusErr.Message != "internal error" { + t.Errorf("StatusError.Message = %q, want %q", statusErr.Message, "internal error") + } +} + +// asStatusError is errors.As without importing errors twice in a tiny test +// file — kept local since no other test here needs it. +func asStatusError(err error, target **StatusError) bool { + se, ok := err.(*StatusError) + if !ok { + return false + } + *target = se + return true +} + +func TestWithTokenSetsAuthorizationHeader(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Write([]byte(`{"version":"v0.20.0"}`)) //nolint:errcheck + })) + defer srv.Close() + + c := New(srv.URL).WithToken("tdsa_abc123") + if _, err := c.Version(context.Background()); err != nil { + t.Fatalf("Version() error = %v", err) + } + if want := "Bearer tdsa_abc123"; gotAuth != want { + t.Errorf("Authorization header = %q, want %q", gotAuth, want) + } +}