8064876cb1
CI / test (push) Successful in 1m46s
paths, self-registration bootstrap)
Replaces the bring-your-own-only Stage 1 (commit 1be7cf2) wholesale, per
the redesign in the previous two commits: the operator creates every
server it manages, so self-registration (DESIGN.md §6) is the only
bootstrap path, and Deployment/Service/database management builds
together with it (ROADMAP.md Stage 1) rather than behind a separate
later stage.
Grounded in terdut-server's actual chart (charts/terdut-server/templates/
deployment.yaml, values.yaml), not reconstructed from DESIGN.md's
illustrative YAML alone -- env var names, the password-via-PGPASSWORD
convention, the Recreate deployment strategy, /healthz probes, and the
TERDUT_OPERATOR_MODE=true decision (always on here, unlike the chart's
default-off: every write this operator's own future controllers make
goes through a service account already) all match that source exactly.
- api/v1alpha1: full TerdutServerSpec (image, replicas, networking,
database, sweeper, deadman, notify, oidc, passwordLogin, allowedTeams).
spec.database is a oneOf (dsn xor postgresClusterRef) via CEL
XValidation. No spec.credentialsSecretRef -- removed entirely in the
prior redesign commit, not carried forward.
- internal/controller:
- terdutserver_deployment.go: Deployment + Service via CreateOrUpdate,
owned (OwnerReference), env built field-for-field against the chart.
- terdutserver_database.go: both §8 paths. The Zalando path resolves
the postgresql.acid.zalan.do CR by convention (database/role both
"terdut", matching every DESIGN.md example) and only ever confirms
its generated credentials Secret exists -- never reads the value,
same "wire a secretKeyRef, don't read it" posture the DSN path takes.
classifyClusterGetError is its own function specifically so the
CRD-not-installed case (meta.IsNoMatchError) is unit-testable without
a real client.
- terdutserver_bootstrap.go: self-registration, checkpointed against
both real crash windows (DESIGN.md §6 point 1) -- an admin-key
checkpoint Secret, and adopt-via-GET+mint-new-key on a 409 from
creating the service account. BootstrapStateLost is its own error
type so Reconcile can route it to a condition instead of an infinite
retry.
- terdutserver_controller.go: ties it together -- finalizer add, DB
resolution, Deployment/Service reconcile, wait for a ready replica,
bootstrap, Ready/Bootstrapped/DatabaseReady conditions. Finalizer on
delete only removes the generated Secrets: terdut-server's API can't
delete a user or service account, only revoke keys, so there's
nothing server-side to undo.
- internal/tdclient: added Bootstrap, CreateInstanceServiceAccount,
GetServiceAccountByName, CreateServiceAccountKey, matching
terdut-server's real handlers' request/response shapes (internal/api/
users.go, service_accounts.go in that repo) field-for-field.
- Tests: envtest suite covering the full DSN-path lifecycle end to end
(finalizer -> Deployment/Service -> simulated readiness -> real
bootstrap against an httptest.Server fake), the adopt-on-409 recovery
path, BootstrapStateLost, both Zalando outcomes (cluster not found;
cluster + Secret found -> real DSN -> Ready), and deletion. A minimal
test-only stub of the Zalando CRD (internal/controller/testdata) lets
envtest create fixture objects without a real postgres-operator
installed. 74.0%/44.7% coverage, 0 lint issues.
- Two things scoped down from §8's full ambition, called out in code and
ROADMAP.md rather than silently dropped: no live watch on the
Zalando-generated Secret for rotation (periodic resync notices
eventually, not immediately), no Gateway API HTTPRoute creation from
spec.networking (would add a new dependency; nothing about proving
bootstrap works depends on external ingress existing). Both are
near-term follow-ups.
Verified locally: make fmt lint test build all clean.
269 lines
10 KiB
Go
269 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
|
|
|
|
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)
|
|
}
|