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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+23
@@ -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: {}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user