package controller import ( "context" "errors" "fmt" "time" appsv1 "k8s.io/api/apps/v1" 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" "k8s.io/apimachinery/pkg/runtime/schema" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 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. It // also doubles as the eventual-rotation-detection interval for the Zalando // database path (§8 asks for a live Secret watch; this controller doesn't // have one yet, so a rotated credential is noticed on the next resync // rather than immediately — a known simplification, not a design decision). const resyncInterval = 5 * time.Minute // waitInterval is the requeue while waiting on an external condition to // resolve (the Deployment not ready yet, a Zalando cluster not found yet) — // shorter than resyncInterval, since these are expected to change sooner // than "someone edited something out of band." const waitInterval = 15 * time.Second // finalizerName cleans up the credentials Secret(s) this controller // generates in the operator's own namespace on delete — the Deployment and // Service are owned (OwnerReference, DESIGN.md §7) and need no finalizer of // their own. const finalizerName = "terdut.ryuvia.com/terdutserver" // serviceAccountName is the name the operator registers itself under // server-side (DESIGN.md §6) — a fixed, repo-wide constant, not a spec // field: it names the automation, not anything about this one TerdutServer. const serviceAccountName = "terdut-operator" // bootstrapUsername/bootstrapEmail found the one human-shaped user every // fresh install needs (terdut-server's handleBootstrap requires both). // Nobody signs in as this user afterward — its only purpose is minting the // admin key the controller immediately trades for a real service-account // key — so these are fixed, not spec fields. const ( bootstrapUsername = "terdut-operator-bootstrap" bootstrapEmail = "bootstrap@terdut-operator.local" ) // postgresqlGVK is the Zalando postgres-operator's CR (DESIGN.md §8). // Resolved via unstructured rather than vendoring Zalando's own client, to // keep this operator's dependency on it to "an optional CRD read" rather // than a library — matches §9's "degrade gracefully if the CRD isn't // installed" stance. var postgresqlGVK = schema.GroupVersionKind{Group: "acid.zalan.do", Version: "v1", Kind: "postgresql"} // TerdutServerReconciler reconciles a TerdutServer object. // // Stage 1 (ROADMAP.md): full lifecycle. The operator creates and owns every // TerdutServer it manages (DESIGN.md §1) — there is no adopt path. type TerdutServerReconciler struct { client.Client Scheme *runtime.Scheme // OperatorNamespace is where every credentials Secret this controller // generates 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. 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;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=acid.zalan.do,resources=postgresqls,verbs=get;list;watch // +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch 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.DeletionTimestamp.IsZero() { return r.reconcileDelete(ctx, &srv) } if !controllerutil.ContainsFinalizer(&srv, finalizerName) { controllerutil.AddFinalizer(&srv, finalizerName) if err := r.Update(ctx, &srv); err != nil { return ctrl.Result{}, err } // The Update above re-triggers a reconcile via the watch; nothing // further to do on this pass. return ctrl.Result{}, nil } dbEnv, dbErr := r.resolveDatabaseEnv(ctx, &srv) if dbErr != nil { return r.setNotReady(ctx, &srv, dbErr.reason, dbErr.message, waitInterval) } deploy, err := r.reconcileDeployment(ctx, &srv, dbEnv) if err != nil { return ctrl.Result{}, err } if err := r.reconcileService(ctx, &srv); err != nil { return ctrl.Result{}, err } meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{ Type: terdutv1alpha1.ConditionDatabaseReady, Status: metav1.ConditionTrue, Reason: terdutv1alpha1.ReasonAdopted, Message: "database resolved", }) if deploy.Status.ReadyReplicas < 1 { return r.setNotReady(ctx, &srv, terdutv1alpha1.ReasonWaitingForDeployment, fmt.Sprintf("Deployment %q has no ready replica yet", deploy.Name), waitInterval) } if srv.Status.CredentialsSecretRef == nil { if err := r.reconcileBootstrap(ctx, &srv); err != nil { if pending, ok := errors.AsType[*bootstrapStateLostError](err); ok { return r.setNotReady(ctx, &srv, terdutv1alpha1.ReasonBootstrapStateLost, pending.Error(), waitInterval) } return ctrl.Result{}, err } } meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{ Type: terdutv1alpha1.ConditionBootstrapped, Status: metav1.ConditionTrue, Reason: terdutv1alpha1.ReasonAdopted, Message: fmt.Sprintf("credentials in Secret %q", srv.Status.CredentialsSecretRef.Name), }) meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{ Type: terdutv1alpha1.ConditionReady, Status: metav1.ConditionTrue, Reason: terdutv1alpha1.ReasonAdopted, Message: "deployment ready, database resolved, credentials bootstrapped", }) srv.Status.ServiceName = srv.Name 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, "terdut-server ready") } log.Info("TerdutServer ready", "name", srv.Name) return ctrl.Result{RequeueAfter: resyncInterval}, nil } // setNotReady records Ready: False with reason/message, fires a Warning // event, and requeues after d. Every "waiting on something" 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, d time.Duration, ) (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: d}, nil } // reconcileDelete cleans up the credentials Secret(s) this controller // generated in the operator's own namespace. The Deployment and Service are // owned (OwnerReference, DESIGN.md §7) and need no attention here — normal // GC handles them. There is no server-side "delete this install" call to // make: bootstrap created a user and a service account, and terdut-server's // API has no way to delete either (only to revoke individual keys), so // there is nothing meaningful to undo there either. func (r *TerdutServerReconciler) reconcileDelete(ctx context.Context, srv *terdutv1alpha1.TerdutServer) (ctrl.Result, error) { if !controllerutil.ContainsFinalizer(srv, finalizerName) { return ctrl.Result{}, nil } for _, name := range []string{checkpointSecretName(srv), credentialsSecretName(srv)} { sec := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: r.OperatorNamespace}} if err := r.Delete(ctx, sec); err != nil && !apierrors.IsNotFound(err) { return ctrl.Result{}, err } } controllerutil.RemoveFinalizer(srv, finalizerName) if err := r.Update(ctx, srv); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, 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{}). Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). Named("terdutserver"). Complete(r) } // --- naming --- func checkpointSecretName(srv *terdutv1alpha1.TerdutServer) string { return fmt.Sprintf("%s.%s-bootstrap-admin", srv.Namespace, srv.Name) } func credentialsSecretName(srv *terdutv1alpha1.TerdutServer) string { return fmt.Sprintf("%s.%s-instance-credentials", srv.Namespace, srv.Name) } func serviceURL(srv *terdutv1alpha1.TerdutServer) string { port := srv.Spec.Networking.ServicePort if port == 0 { port = 8080 } return fmt.Sprintf("http://%s.%s.svc:%d", srv.Name, srv.Namespace, port) }