Stage 1: TerdutServer, bring-your-own bootstrap credentials
CI / test (push) Successful in 1m41s

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.
This commit is contained in:
Niklas Ye
2026-09-30 22:30:22 +02:00
parent ffc2e6441e
commit 1be7cf2b7f
22 changed files with 1575 additions and 7 deletions
+10
View File
@@ -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"
+28
View File
@@ -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
)
+182
View File
@@ -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
})
}
+168
View File
@@ -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
}
+23
View File
@@ -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 {
@@ -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: {}
+16
View File
@@ -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
+12
View File
@@ -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
+1 -1
View File
@@ -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
+9
View File
@@ -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
+8
View File
@@ -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
+35 -6
View File
@@ -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
+27
View File
@@ -0,0 +1,27 @@
# This rule is not used by the project terdut-operator itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants full permissions ('*') over terdut.ryuvia.com.
# This role is intended for users authorized to modify roles and bindings within the cluster,
# enabling them to delegate specific permissions to other users or groups as needed.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: terdut-operator
app.kubernetes.io/managed-by: kustomize
name: terdutserver-admin-role
rules:
- apiGroups:
- terdut.ryuvia.com
resources:
- terdutservers
verbs:
- '*'
- apiGroups:
- terdut.ryuvia.com
resources:
- terdutservers/status
verbs:
- get
+33
View File
@@ -0,0 +1,33 @@
# This rule is not used by the project terdut-operator itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants permissions to create, update, and delete resources within the terdut.ryuvia.com.
# This role is intended for users who need to manage these resources
# but should not control RBAC or manage permissions for others.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: terdut-operator
app.kubernetes.io/managed-by: kustomize
name: 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
+29
View File
@@ -0,0 +1,29 @@
# This rule is not used by the project terdut-operator itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants read-only access to terdut.ryuvia.com resources.
# This role is intended for users who need visibility into these resources
# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: terdut-operator
app.kubernetes.io/managed-by: kustomize
name: terdutserver-viewer-role
rules:
- apiGroups:
- terdut.ryuvia.com
resources:
- terdutservers
verbs:
- get
- list
- watch
- apiGroups:
- terdut.ryuvia.com
resources:
- terdutservers/status
verbs:
- get
+4
View File
@@ -0,0 +1,4 @@
## Append samples of your project ##
resources:
- terdut_v1alpha1_terdutserver.yaml
# +kubebuilder:scaffold:manifestskustomizesamples
@@ -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 <your-own-api-key>" \
# -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 <operator namespace> --from-literal=token=<the key>
credentialsSecretRef:
name: terdutserver-sample-creds
key: token
+100
View File
@@ -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 ""
}
@@ -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)
}
@@ -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())
})
})
+115
View File
@@ -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
}
+91
View File
@@ -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)
}
}