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
+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
}