1be7cf2b7f
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.
181 lines
6.9 KiB
Go
181 lines
6.9 KiB
Go
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)
|
|
}
|