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