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:
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user