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
+100
View File
@@ -0,0 +1,100 @@
package controller
import (
"context"
"os"
"path/filepath"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
// +kubebuilder:scaffold:imports
)
// These tests use Ginkgo (BDD-style Go testing framework). Refer to
// http://onsi.github.io/ginkgo/ to learn more about Ginkgo.
var (
ctx context.Context
cancel context.CancelFunc
testEnv *envtest.Environment
cfg *rest.Config
k8sClient client.Client
)
func TestControllers(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Controller Suite")
}
var _ = BeforeSuite(func() {
logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
ctx, cancel = context.WithCancel(context.TODO())
var err error
err = terdutv1alpha1.AddToScheme(scheme.Scheme)
Expect(err).NotTo(HaveOccurred())
// +kubebuilder:scaffold:scheme
By("bootstrapping test environment")
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
ErrorIfCRDPathMissing: true,
}
// Retrieve the first found binary directory to allow running tests from IDEs
if getFirstFoundEnvTestBinaryDir() != "" {
testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir()
}
// cfg is defined in this file globally.
cfg, err = testEnv.Start()
Expect(err).NotTo(HaveOccurred())
Expect(cfg).NotTo(BeNil())
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient).NotTo(BeNil())
})
var _ = AfterSuite(func() {
By("tearing down the test environment")
cancel()
err := testEnv.Stop()
Expect(err).NotTo(HaveOccurred())
})
// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path.
// ENVTEST-based tests depend on specific binaries, usually located in paths set by
// controller-runtime. When running tests directly (e.g., via an IDE) without using
// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured.
//
// This function streamlines the process by finding the required binaries, similar to
// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are
// properly set up, run 'make setup-envtest' beforehand.
func getFirstFoundEnvTestBinaryDir() string {
basePath := filepath.Join("..", "..", "bin", "k8s")
entries, err := os.ReadDir(basePath)
if err != nil {
logf.Log.Error(err, "Failed to read directory", "path", basePath)
return ""
}
for _, entry := range entries {
if entry.IsDir() {
return filepath.Join(basePath, entry.Name())
}
}
return ""
}
@@ -0,0 +1,180 @@
package controller
import (
"context"
"fmt"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/recorder"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
)
// resyncInterval is the periodic requeue on a successful reconcile (DESIGN.md
// §5's general rule) — it exists to catch drift from someone changing state
// directly against the server's API/UI, not from a missed watch event.
const resyncInterval = 5 * time.Minute
// waitInterval is the requeue while waiting on an external condition to
// resolve (the credential Secret not existing yet, the server being
// unreachable) — shorter than resyncInterval, since these are expected to
// change sooner than "someone edited something out of band."
const waitInterval = 30 * time.Second
// TerdutServerReconciler reconciles a TerdutServer object.
//
// Stage 1 (ROADMAP.md): bootstrap/credentials only, bring-your-own path
// only. It never creates, updates, or deletes anything server-side or any
// Deployment/Service — it only reads a Secret the operator's own namespace
// already has and probes the server's /api/version. No finalizer: this
// controller owns nothing that needs cleaning up on delete (it never creates
// the credentials Secret itself, only ever adopts one a human already
// made) — revisit once self-registration (Stage 5) generates its own.
type TerdutServerReconciler struct {
client.Client
Scheme *runtime.Scheme
// OperatorNamespace is where every credentials Secret this controller
// reads or writes lives (DESIGN.md §6) — never the TerdutServer's own
// namespace. Set from the POD_NAMESPACE downward-API env var in
// production (cmd/main.go); tests set it directly.
OperatorNamespace string
// Recorder emits the Kubernetes Events DESIGN.md §12 asks for on every
// externally-visible outcome. The events.k8s.io/v1 API, not the
// deprecated core/v1 one GetEventRecorderFor still returns.
Recorder recorder.EventRecorder
// NewClient builds the terdut-server API client for a given endpoint.
// A field, not a direct tdclient.New call, so tests can substitute an
// httptest.Server's client without a real network round trip. Defaults
// to tdclient.New via SetupWithManager.
NewClient func(endpoint string) *tdclient.Client
}
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers/finalizers,verbs=update
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
func (r *TerdutServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logf.FromContext(ctx)
var srv terdutv1alpha1.TerdutServer
if err := r.Get(ctx, req.NamespacedName, &srv); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
if srv.Spec.CredentialsSecretRef == nil {
return r.setNotReady(ctx, &srv,
terdutv1alpha1.ReasonCredentialsSecretRefRequired,
"spec.credentialsSecretRef is unset; self-registration bootstrap isn't "+
"implemented yet (Stage 5) — mint an instance-scoped service account "+
"with your own admin session (POST /api/service-accounts) and set "+
"this field to a Secret holding its key (DESIGN.md §6)")
}
ref := srv.Spec.CredentialsSecretRef
var secret corev1.Secret
secretKey := client.ObjectKey{Namespace: r.OperatorNamespace, Name: ref.Name}
if err := r.Get(ctx, secretKey, &secret); err != nil {
if apierrors.IsNotFound(err) {
return r.setNotReady(ctx, &srv,
terdutv1alpha1.ReasonCredentialsSecretNotFound,
fmt.Sprintf("Secret %q not found in namespace %q", ref.Name, r.OperatorNamespace))
}
return ctrl.Result{}, err
}
raw, ok := secret.Data[ref.Key]
if !ok || len(raw) == 0 {
return r.setNotReady(ctx, &srv,
terdutv1alpha1.ReasonCredentialsSecretInvalid,
fmt.Sprintf("Secret %q has no data under key %q", ref.Name, ref.Key))
}
newClient := r.NewClient
if newClient == nil {
newClient = tdclient.New
}
if _, err := newClient(srv.Spec.Endpoint).Version(ctx); err != nil {
return r.setNotReady(ctx, &srv,
terdutv1alpha1.ReasonServerUnreachable,
fmt.Sprintf("GET %s/api/version: %v", srv.Spec.Endpoint, err))
}
srv.Status.CredentialsSecretRef = ref.DeepCopy()
meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{
Type: terdutv1alpha1.ConditionBootstrapped,
Status: metav1.ConditionTrue,
Reason: terdutv1alpha1.ReasonAdopted,
Message: fmt.Sprintf("adopted spec.credentialsSecretRef (Secret %q, key %q)", ref.Name, ref.Key),
})
meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{
Type: terdutv1alpha1.ConditionReady,
Status: metav1.ConditionTrue,
Reason: terdutv1alpha1.ReasonAdopted,
Message: "credentials adopted, server reachable",
})
srv.Status.ObservedGeneration = srv.Generation
if err := r.Status().Update(ctx, &srv); err != nil {
return ctrl.Result{}, err
}
if r.Recorder != nil {
r.Recorder.Eventf(&srv, nil, corev1.EventTypeNormal,
terdutv1alpha1.ReasonAdopted, terdutv1alpha1.ReasonAdopted,
"credentials adopted, server reachable")
}
log.Info("TerdutServer ready", "endpoint", srv.Spec.Endpoint)
return ctrl.Result{RequeueAfter: resyncInterval}, nil
}
// setNotReady records Ready: False with reason/message on both conditions,
// fires a Warning event, and requeues after waitInterval — every "waiting on
// something external" exit from Reconcile goes through here so the
// condition/event/requeue shape can't drift between them.
func (r *TerdutServerReconciler) setNotReady(
ctx context.Context, srv *terdutv1alpha1.TerdutServer, reason, message string,
) (ctrl.Result, error) {
meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{
Type: terdutv1alpha1.ConditionReady,
Status: metav1.ConditionFalse,
Reason: reason,
Message: message,
})
srv.Status.ObservedGeneration = srv.Generation
if err := r.Status().Update(ctx, srv); err != nil {
return ctrl.Result{}, err
}
if r.Recorder != nil {
r.Recorder.Eventf(srv, nil, corev1.EventTypeWarning, reason, reason, message)
}
return ctrl.Result{RequeueAfter: waitInterval}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *TerdutServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
if r.NewClient == nil {
r.NewClient = tdclient.New
}
if r.Recorder == nil {
r.Recorder = mgr.GetEventRecorder("terdutserver-controller")
}
return ctrl.NewControllerManagedBy(mgr).
For(&terdutv1alpha1.TerdutServer{}).
Named("terdutserver").
Complete(r)
}
@@ -0,0 +1,216 @@
package controller
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
)
const (
// unusedEndpoint is a syntactically valid URL no test here ever expects
// to actually be dialed (the cases using it fail before reaching the
// server-reachability check).
unusedEndpoint = "http://unused.invalid"
// tokenKey is the data key every test's credentials Secret uses.
tokenKey = "token"
)
// fakeTerdutServer is an httptest.Server standing in for terdut-server's
// GET /api/version, per DESIGN.md §11 ("terdut-server's REST API is faked
// with a small httptest.Server per controller test ... no real Postgres or
// real terdut-server binary needed for controller unit tests").
func fakeTerdutServer(version string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/version" {
w.WriteHeader(http.StatusNotFound)
return
}
fmt.Fprintf(w, `{"version":%q}`, version) //nolint:errcheck
}))
}
var _ = Describe("TerdutServer Controller", func() {
const operatorNamespace = "default"
var (
reconciler *TerdutServerReconciler
name string
objKey types.NamespacedName
)
BeforeEach(func() {
reconciler = &TerdutServerReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
OperatorNamespace: operatorNamespace,
}
name = fmt.Sprintf("test-server-%d-%d", GinkgoRandomSeed(), GinkgoParallelProcess())
objKey = types.NamespacedName{Name: name, Namespace: operatorNamespace}
})
AfterEach(func(ctx SpecContext) {
srv := &terdutv1alpha1.TerdutServer{}
if err := k8sClient.Get(ctx, objKey, srv); err == nil {
Expect(k8sClient.Delete(ctx, srv)).To(Succeed())
}
})
createServer := func(ctx context.Context, spec terdutv1alpha1.TerdutServerSpec) {
srv := &terdutv1alpha1.TerdutServer{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: operatorNamespace},
Spec: spec,
}
Expect(k8sClient.Create(ctx, srv)).To(Succeed())
}
reconcileOnce := func(ctx context.Context) {
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey})
Expect(err).NotTo(HaveOccurred())
}
readyCondition := func(ctx context.Context) metav1.Condition {
srv := &terdutv1alpha1.TerdutServer{}
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
c := meta.FindStatusCondition(srv.Status.Conditions, terdutv1alpha1.ConditionReady)
Expect(c).NotTo(BeNil(), "Ready condition should always be set after a reconcile")
return *c
}
When("spec.credentialsSecretRef is unset", func() {
It("reports Ready: False, reason CredentialsSecretRefRequired, and makes no API call", func(ctx SpecContext) {
createServer(ctx, terdutv1alpha1.TerdutServerSpec{Endpoint: unusedEndpoint})
reconcileOnce(ctx)
cond := readyCondition(ctx)
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonCredentialsSecretRefRequired))
})
})
When("the referenced Secret does not exist", func() {
It("reports Ready: False, reason CredentialsSecretNotFound", func(ctx SpecContext) {
createServer(ctx, terdutv1alpha1.TerdutServerSpec{
Endpoint: unusedEndpoint,
CredentialsSecretRef: &terdutv1alpha1.SecretKeyRef{Name: "does-not-exist", Key: tokenKey},
})
reconcileOnce(ctx)
cond := readyCondition(ctx)
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonCredentialsSecretNotFound))
})
})
When("the referenced Secret exists but has no data under the given key", func() {
It("reports Ready: False, reason CredentialsSecretInvalid", func(ctx SpecContext) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name + "-creds", Namespace: operatorNamespace},
Data: map[string][]byte{"wrong-key": []byte("tdsa_something")},
}
Expect(k8sClient.Create(ctx, secret)).To(Succeed())
defer func() { _ = k8sClient.Delete(ctx, secret) }()
createServer(ctx, terdutv1alpha1.TerdutServerSpec{
Endpoint: unusedEndpoint,
CredentialsSecretRef: &terdutv1alpha1.SecretKeyRef{Name: secret.Name, Key: tokenKey},
})
reconcileOnce(ctx)
cond := readyCondition(ctx)
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonCredentialsSecretInvalid))
})
})
When("the Secret is valid but the server is unreachable", func() {
It("reports Ready: False, reason ServerUnreachable", func(ctx SpecContext) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name + "-creds", Namespace: operatorNamespace},
Data: map[string][]byte{tokenKey: []byte("tdsa_something")},
}
Expect(k8sClient.Create(ctx, secret)).To(Succeed())
defer func() { _ = k8sClient.Delete(ctx, secret) }()
createServer(ctx, terdutv1alpha1.TerdutServerSpec{
// Port 1 is never listening (closed cleanly and immediately,
// unlike an unroutable address which would hang on a
// connect timeout) -- keeps the test fast.
Endpoint: "http://127.0.0.1:1",
CredentialsSecretRef: &terdutv1alpha1.SecretKeyRef{Name: secret.Name, Key: tokenKey},
})
reconcileOnce(ctx)
cond := readyCondition(ctx)
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonServerUnreachable))
})
})
When("the Secret is valid and the server answers /api/version", func() {
It("reports Ready: True, Bootstrapped: True, and mirrors credentialsSecretRef into status", func(ctx SpecContext) {
fake := fakeTerdutServer("v0.20.0")
DeferCleanup(fake.Close)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name + "-creds", Namespace: operatorNamespace},
Data: map[string][]byte{tokenKey: []byte("tdsa_something")},
}
Expect(k8sClient.Create(ctx, secret)).To(Succeed())
defer func() { _ = k8sClient.Delete(ctx, secret) }()
createServer(ctx, terdutv1alpha1.TerdutServerSpec{
Endpoint: fake.URL,
CredentialsSecretRef: &terdutv1alpha1.SecretKeyRef{Name: secret.Name, Key: tokenKey},
})
reconcileOnce(ctx)
srv := &terdutv1alpha1.TerdutServer{}
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
ready := meta.FindStatusCondition(srv.Status.Conditions, terdutv1alpha1.ConditionReady)
Expect(ready).NotTo(BeNil())
Expect(ready.Status).To(Equal(metav1.ConditionTrue))
Expect(ready.Reason).To(Equal(terdutv1alpha1.ReasonAdopted))
bootstrapped := meta.FindStatusCondition(srv.Status.Conditions, terdutv1alpha1.ConditionBootstrapped)
Expect(bootstrapped).NotTo(BeNil())
Expect(bootstrapped.Status).To(Equal(metav1.ConditionTrue))
Expect(srv.Status.CredentialsSecretRef).NotTo(BeNil())
Expect(srv.Status.CredentialsSecretRef.Name).To(Equal(secret.Name))
Expect(srv.Status.CredentialsSecretRef.Key).To(Equal(tokenKey))
Expect(srv.Status.ObservedGeneration).To(Equal(srv.Generation))
})
})
When("the TerdutServer object no longer exists", func() {
It("returns no error (deleted between enqueue and reconcile)", func(ctx SpecContext) {
_, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{Name: "never-created", Namespace: operatorNamespace},
})
Expect(err).NotTo(HaveOccurred())
})
})
})
var _ = Describe("TerdutServerReconciler sanity", func() {
It("treats a real apierrors.IsNotFound the same as any other caller would", func() {
// Guards against a refactor accidentally swapping in a different
// not-found check that stops matching what client.Client actually
// returns.
Expect(apierrors.IsNotFound(apierrors.NewNotFound(
terdutv1alpha1.GroupVersion.WithResource("terdutservers").GroupResource(), "x"))).To(BeTrue())
})
})
+115
View File
@@ -0,0 +1,115 @@
// Package tdclient is a minimal terdut-server API client for the operator's
// own controllers. It mirrors the shape of terdut-tui's
// internal/api/client.go (baseURL/httpClient fields, a shared do/statusError
// helper, per terdut/CLAUDE.md's "any change to a server endpoint or JSON
// shape must be mirrored" convention) but authorizes with a Bearer API key
// rather than a session cookie — the operator never signs in as a human
// (DESIGN.md §6).
//
// Deliberately minimal for Stage 1 (ROADMAP.md): only Version, the
// reachability probe TerdutServer's controller needs. Bootstrap and the
// service-account endpoints land here once self-registration does (Stage 5).
package tdclient
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// Client talks to one terdut-server install, optionally as a service
// account. A zero-value token works for endpoints that don't need one
// (Version).
type Client struct {
baseURL string
httpClient *http.Client
token string
}
// New creates a Client against baseURL, with no credential set.
func New(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// WithToken returns a copy of c that authorizes every request as a Bearer
// credential — a user's own API key or a service-account key
// (SERVICE-ACCOUNTS.md), the server resolves either the same way.
func (c *Client) WithToken(token string) *Client {
cp := *c
cp.token = token
return &cp
}
// StatusError is a non-2xx response — the server's {"error": "..."} body
// decoded into Message, same shape terdut-tui's client uses.
type StatusError struct {
Code int
Message string
}
func (e *StatusError) Error() string {
if e.Message != "" {
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
}
return fmt.Sprintf("server returned %d", e.Code)
}
func statusError(resp *http.Response) error {
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
return &StatusError{Code: resp.StatusCode, Message: e.Error}
}
func (c *Client) newRequest(ctx context.Context, method, path string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
return req, nil
}
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
return statusError(resp)
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// Version calls GET /api/version — unauthenticated, per terdut-server's own
// router.go comment ("a client deciding whether it can talk to this server —
// terdut-tui, terdut-operator — needs to ask before it holds a credential
// for it"). Used here purely as a reachability probe: a bad endpoint fails
// here, clearly, rather than on whatever the controller tries first.
func (c *Client) Version(ctx context.Context) (string, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/api/version")
if err != nil {
return "", err
}
var v struct {
Version string `json:"version"`
}
if err := c.do(req, &v); err != nil {
return "", err
}
return v.Version, nil
}
+91
View File
@@ -0,0 +1,91 @@
package tdclient
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestVersion(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/version" {
t.Errorf("unexpected path %q", r.URL.Path)
}
// Unauthenticated per terdut-server's own router.go comment: no
// Authorization header should be required, and none is sent here.
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"version":"v0.20.0"}`)) //nolint:errcheck
}))
defer srv.Close()
got, err := New(srv.URL).Version(context.Background())
if err != nil {
t.Fatalf("Version() error = %v", err)
}
if got != "v0.20.0" {
t.Errorf("Version() = %q, want %q", got, "v0.20.0")
}
}
func TestVersionUnreachable(t *testing.T) {
// A closed server: connection refused, the same shape a bad
// spec.endpoint produces against a real cluster.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
srv.Close()
if _, err := New(srv.URL).Version(context.Background()); err == nil {
t.Fatal("Version() error = nil, want a connection error")
}
}
func TestVersionErrorStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"internal error"}`)) //nolint:errcheck
}))
defer srv.Close()
_, err := New(srv.URL).Version(context.Background())
if err == nil {
t.Fatal("Version() error = nil, want a StatusError")
}
var statusErr *StatusError
if !asStatusError(err, &statusErr) {
t.Fatalf("Version() error = %v (%T), want *StatusError", err, err)
}
if statusErr.Code != http.StatusInternalServerError {
t.Errorf("StatusError.Code = %d, want %d", statusErr.Code, http.StatusInternalServerError)
}
if statusErr.Message != "internal error" {
t.Errorf("StatusError.Message = %q, want %q", statusErr.Message, "internal error")
}
}
// asStatusError is errors.As without importing errors twice in a tiny test
// file — kept local since no other test here needs it.
func asStatusError(err error, target **StatusError) bool {
se, ok := err.(*StatusError)
if !ok {
return false
}
*target = se
return true
}
func TestWithTokenSetsAuthorizationHeader(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Write([]byte(`{"version":"v0.20.0"}`)) //nolint:errcheck
}))
defer srv.Close()
c := New(srv.URL).WithToken("tdsa_abc123")
if _, err := c.Version(context.Background()); err != nil {
t.Fatalf("Version() error = %v", err)
}
if want := "Bearer tdsa_abc123"; gotAuth != want {
t.Errorf("Authorization header = %q, want %q", gotAuth, want)
}
}