Files
Niklas Ye 6a699d4341 Let TerdutServer customize its pod, and never manage its own ingress
spec.pod (api/v1alpha1/terdutserver_types.go): annotations, nodeSelector,
tolerations, affinity, topologySpreadConstraints, resources, pod and
container securityContext, serviceAccountName, extraEnv/extraEnvFrom,
extraVolumes/extraVolumeMounts, imagePullSecrets, and an optional
disruptionBudget. All direct corev1 passthrough -- no wrapper types buy
anything for any of these, matching how CloudNativePG and the Zalando
postgres-operator both expose the same knobs, and matching this repo's
own SweeperSpec precedent ("wrap only when a round-trip through a
different type buys something"). affinity is pure user-supplied
passthrough, not a toggle-plus-generated-default the way a multi-replica
cluster operator's pod anti-affinity usually is: this operator never
auto-generates one, since spec.replicas above 1 isn't a supported
topology (the sweeper/notifier singleton constraint). Considered and
declined for this round: priorityClassName, pod labels beyond
annotations, and a HorizontalPodAutoscaler -- the last of those would
directly contradict the singleton constraint above.

disruptionBudget is the one field here that isn't a plain PodTemplateSpec
knob: when set, the controller now reconciles a PodDisruptionBudget
selecting the TerdutServer's own pods (new terdutserver_pdb.go); clearing
it deletes any it previously created. New RBAC marker on
poddisruptionbudgets to match.

Driven by a public-release pass: looking past this project's own use case
at what a mature, general-purpose operator CRD exposes here (researched
against Zalando postgres-operator and CloudNativePG specifically), not
just the fields this install happened to need.

Separately, and found while answering a question about exposing
TerdutServer through Istio instead of Gateway API: spec.networking's own
doc comment quietly promised a Gateway API HTTPRoute this operator would
build eventually ("a near-term follow-up, not deferred"). That promise is
wrong for a public release -- an operator managing someone's ingress
mechanism for them is a worse default than not touching it at all, and a
surprise HTTPRoute appearing once that follow-up eventually landed would
have been exactly backwards for an Istio (or plain-Ingress, or
intentionally-unexposed) install. Made the non-goal explicit and
permanent instead (DESIGN.md §1), removed the dead `gatewayListener`
field it was the only consumer of (zero runtime call sites anywhere --
setting it already had no effect, so this is a schema cleanup, not a
behavior change), and corrected ROADMAP.md's framing. hostname/servicePort
stay: both are live (TERDUT_PUBLIC_URL, container/Service port), this
operator just never acts on hostname for exposure. Added
examples/networking (Gateway API HTTPRoute, Istio VirtualService) showing
how to expose the plain ClusterIP Service the operator already creates --
outside the operator itself, as illustrations, not as something
examples/demo applies automatically.

No new terdut-server version requirement: both changes are CRD/controller-
only, nothing about the API this operator's bootstrap flow depends on
changed.
2026-10-02 18:50:38 +02:00

276 lines
11 KiB
Go

package controller
import (
"context"
"errors"
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
policyv1 "k8s.io/api/policy/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=policy,resources=poddisruptionbudgets,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
}
if err := r.reconcilePodDisruptionBudget(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{}).
Owns(&policyv1.PodDisruptionBudget{}).
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)
}