62664c93ff
Deleting a TerdutServer removed the credential Secrets but never touched the database, so a recreated one found a server that was already bootstrapped and no key for it: /api/bootstrap answered 403 and the operator stopped at BootstrapStateLost, whose message and DESIGN.md both said "delete and recreate". That is how the terdut-demo install on the cluster got stuck on 2026-10-03: Helm's cleanupOnFail deleted its TerdutServer after a failed upgrade, the recreate found the bootstrapped database, and it sat at Ready: False for five days until the database was reset by hand. Recreating cannot fix it, because the finalizer clears Secrets and the database is not its to reset, so "a fresh create starts clean" was only ever true when the database went with it. spec.credentials.deletionPolicy is Retain by default: the finalizer keeps the instance credential Secret (Delete removes it, as before). The bootstrap checkpoint is always removed. Before calling /api/bootstrap, reconcile now looks for the retained Secret and asks the server for the operator's own service account with its token. Accepted: adopt it and skip bootstrap. Rejected with 401/403: the Secret outlived a database reset, so ignore it and bootstrap like a first install, which replaces it. Any other error retries. terdut-server's own tests already call that endpoint with an instance-scoped key, so the permission is not new. BootstrapStateLost is still the answer when the server is bootstrapped and no credential it accepts survives, but its message now names the Secret to restore and says that recreating does not clear the database. DESIGN.md §6 says the same, and the chart passes the setting through as terdutServer.credentials.deletionPolicy. A retained Secret of a TerdutServer that is gone for good is an orphan to delete by hand. It is inert: nothing adopts it unless the server accepts the token. Checked on the kind demo with a locally built image against the real terdut-server v0.43.0: deleting the TerdutServer kept the Secret, recreating it reached Ready with the same credential (identical hash) and both TerdutTeams came back Ready with their original ids. The controller specs cover adoption, a rejected token after a reset, the bootstrapped-and-rejected failure, and both deletion policies. Co-authored-by: Claude <noreply@anthropic.com>
289 lines
11 KiB
Go
289 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 Secret(s) this controller generates in the
|
|
// operator's own namespace on delete (which of them, spec.credentials.
|
|
// deletionPolicy decides) — 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 Secrets 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, and the database is
|
|
// never touched.
|
|
//
|
|
// That is why the instance credential is kept by default
|
|
// (spec.credentials.deletionPolicy: Retain). Everything it logs in to
|
|
// outlives the TerdutServer, so a recreated one finds a bootstrapped server
|
|
// it has no key for -- unless the key is still here to be adopted (see
|
|
// adoptRetainedCredentials). The bootstrap checkpoint is always removed: it
|
|
// is a short-lived admin key, and the instance credential is all that is
|
|
// needed afterwards.
|
|
func (r *TerdutServerReconciler) reconcileDelete(ctx context.Context, srv *terdutv1alpha1.TerdutServer) (ctrl.Result, error) {
|
|
if !controllerutil.ContainsFinalizer(srv, finalizerName) {
|
|
return ctrl.Result{}, nil
|
|
}
|
|
names := []string{checkpointSecretName(srv)}
|
|
if srv.Spec.Credentials.DeletionPolicy == terdutv1alpha1.CredentialsDelete {
|
|
names = append(names, credentialsSecretName(srv))
|
|
}
|
|
for _, name := range names {
|
|
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)
|
|
}
|