6a699d4341
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.
276 lines
11 KiB
Go
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)
|
|
}
|