1c45b7e80b
CI / test (push) Has been cancelled
Ran a full kind end-to-end pass per ROADMAP.md's open item: real kind cluster, real disposable Postgres, the real terdut-server v0.33.0 image, the operator built into a real image and deployed as a real Pod (not `go run` against the cluster -- that was tried first and correctly failed on cluster DNS not resolving from outside the cluster network, which is expected, not a bug). Result: TerdutServer went Ready, the generated credentials Secret held a real tdsa_-prefixed service-account key, and that key successfully authenticated and exercised its real intended capability against the actual server (GET/POST /api/teams -> 200/201) -- confirmed from terdut-server's own access log, not just our side. Stage 1's actual goal (ROADMAP.md) is proven, not just asserted. Two real bugs surfaced that no envtest suite could have caught, since envtest's client bypasses RBAC entirely: - .dockerignore's `!**/*.go` doesn't work under podman (the scaffold's own comment already named this exact gotcha, buildah/containers#6417, and pointed at the fix) -- `docker build` was silently building from an empty source tree ("package cmd/main.go is not in std") until this was pinned down. Fixed by re-including cmd/api/internal by name, as that comment suggested doing if this happened. - The controller had no RBAC for events.k8s.io (the new events API GetEventRecorder uses, unlike the deprecated GetEventRecorderFor) -- every Event emission failed server-side ("Server rejected event (will not retry!)"), silently, since event-recording failure doesn't fail reconciliation. Reconciliation itself was never affected, but DESIGN.md §12's observability goal (every externally-visible action emits an Event) silently wasn't being met in any real deployment. Added +kubebuilder:rbac for events.k8s.io/events (create, patch); confirmed fixed by restarting the operator and checking `kubectl describe terdutserver` actually shows the Event afterward, not just that the log line stopped. Also noted, not fixed here (a different repo's bug): terdut-server's own GET /api/me 500s for a service-account caller rather than a clean 4xx -- that endpoint assumes a human user in context. Worth a terdut-server issue, not an operator concern.
270 lines
10 KiB
Go
270 lines
10 KiB
Go
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)
|
|
}
|