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.
135 lines
5.6 KiB
Go
135 lines
5.6 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
|
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
|
)
|
|
|
|
// databaseError carries a condition reason/message out of resolveDatabaseEnv
|
|
// — distinct from a plain error, since "the database isn't ready yet" is an
|
|
// expected, requeue-and-retry outcome (DESIGN.md §8), not a reconcile
|
|
// failure.
|
|
type databaseError struct {
|
|
reason string
|
|
message string
|
|
}
|
|
|
|
func (e *databaseError) Error() string { return e.message }
|
|
|
|
// classifyClusterGetError turns a failed Get of the postgresql.acid.zalan.do
|
|
// CR into the right condition — distinct from a plain reconcile error, and
|
|
// its own function (not inlined) so it's unit-testable against a
|
|
// hand-constructed error without needing a real client at all: a
|
|
// *meta.NoKindMatchError means the CRD itself isn't installed
|
|
// (ReasonPostgresOperatorCRDNotInstalled, §8/§9's graceful-degradation
|
|
// case), apierrors.IsNotFound means the CRD exists but this particular
|
|
// object doesn't (yet) (ReasonPostgresClusterNotFound).
|
|
func classifyClusterGetError(err error, clusterName, namespace string) *databaseError {
|
|
switch {
|
|
case meta.IsNoMatchError(err):
|
|
return &databaseError{
|
|
reason: terdutv1alpha1.ReasonPostgresOperatorCRDNotInstalled,
|
|
message: "spec.database.postgresClusterRef is set but the postgresql.acid.zalan.do CRD isn't installed in this cluster",
|
|
}
|
|
case apierrors.IsNotFound(err):
|
|
return &databaseError{
|
|
reason: terdutv1alpha1.ReasonPostgresClusterNotFound,
|
|
message: fmt.Sprintf("postgresql.acid.zalan.do %q not found in namespace %q", clusterName, namespace),
|
|
}
|
|
default:
|
|
// Any other error (RBAC, transient API failure) is a real
|
|
// reconcile error, not a condition to report and wait out --
|
|
// callers still surface it as a databaseError so resolveDatabaseEnv
|
|
// has one return shape, but Reconcile treats every databaseError as
|
|
// a wait-and-retry outcome today. Revisit if that ever matters in
|
|
// practice (a persistent RBAC misconfiguration looks identical to
|
|
// "not ready yet" until someone reads the condition message).
|
|
return &databaseError{reason: terdutv1alpha1.ReasonPostgresClusterNotFound, message: err.Error()}
|
|
}
|
|
}
|
|
|
|
// resolveDatabaseEnv implements DESIGN.md §8's two Postgres paths. It never
|
|
// reads a password's value — only ever wires a secretKeyRef into the
|
|
// Deployment's env, the same way the chart does — so it needs no RBAC on
|
|
// Secret *data* for this, only on the postgresql.acid.zalan.do CR itself
|
|
// (the XValidation rule on TerdutServerSpec.Database guarantees exactly one
|
|
// of the two fields below is set, so there's no third case to handle).
|
|
func (r *TerdutServerReconciler) resolveDatabaseEnv(ctx context.Context, srv *terdutv1alpha1.TerdutServer) ([]corev1.EnvVar, *databaseError) {
|
|
db := srv.Spec.Database
|
|
|
|
if db.PostgresClusterRef != nil {
|
|
return r.resolveZalandoDatabaseEnv(ctx, srv, db.PostgresClusterRef.Name)
|
|
}
|
|
|
|
env := []corev1.EnvVar{{Name: "TERDUT_DB_DSN", Value: db.DSN}}
|
|
if db.PasswordSecretRef != nil {
|
|
env = append(env, corev1.EnvVar{
|
|
Name: "PGPASSWORD",
|
|
ValueFrom: &corev1.EnvVarSource{
|
|
SecretKeyRef: &corev1.SecretKeySelector{
|
|
LocalObjectReference: corev1.LocalObjectReference{Name: db.PasswordSecretRef.Name},
|
|
Key: db.PasswordSecretRef.Key,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
return env, nil
|
|
}
|
|
|
|
// resolveZalandoDatabaseEnv resolves a Zalando postgres-operator
|
|
// `postgresql` CR into a DSN + PGPASSWORD secretKeyRef, without reading the
|
|
// generated credentials Secret's value — only confirming it exists, the
|
|
// same "exists, don't read" posture the DSN path takes toward its own
|
|
// password Secret.
|
|
//
|
|
// By convention (DESIGN.md §8, PostgresClusterRef's own doc comment) the
|
|
// database and role are both named "terdut", and the primary Service is
|
|
// named after the cluster CR itself — Zalando's own naming convention, not
|
|
// something its status exposes as a field to read.
|
|
func (r *TerdutServerReconciler) resolveZalandoDatabaseEnv(
|
|
ctx context.Context, srv *terdutv1alpha1.TerdutServer, clusterName string,
|
|
) ([]corev1.EnvVar, *databaseError) {
|
|
cluster := &unstructured.Unstructured{}
|
|
cluster.SetGroupVersionKind(postgresqlGVK)
|
|
if err := r.Get(ctx, client.ObjectKey{Namespace: srv.Namespace, Name: clusterName}, cluster); err != nil {
|
|
return nil, classifyClusterGetError(err, clusterName, srv.Namespace)
|
|
}
|
|
|
|
credsSecretName := fmt.Sprintf("terdut.%s.credentials.postgresql.acid.zalan.do", clusterName)
|
|
var secret corev1.Secret
|
|
if err := r.Get(ctx, client.ObjectKey{Namespace: srv.Namespace, Name: credsSecretName}, &secret); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
return nil, &databaseError{
|
|
reason: terdutv1alpha1.ReasonPostgresClusterNotFound,
|
|
message: fmt.Sprintf(
|
|
"postgresql.acid.zalan.do %q found, but its generated credentials Secret %q doesn't exist yet",
|
|
clusterName, credsSecretName),
|
|
}
|
|
}
|
|
return nil, &databaseError{reason: terdutv1alpha1.ReasonPostgresClusterNotFound, message: err.Error()}
|
|
}
|
|
|
|
host := fmt.Sprintf("%s.%s.svc", clusterName, srv.Namespace)
|
|
dsn := fmt.Sprintf("postgres://terdut@%s:5432/terdut?sslmode=require", host)
|
|
return []corev1.EnvVar{
|
|
{Name: "TERDUT_DB_DSN", Value: dsn},
|
|
{
|
|
Name: "PGPASSWORD",
|
|
ValueFrom: &corev1.EnvVarSource{
|
|
SecretKeyRef: &corev1.SecretKeySelector{
|
|
LocalObjectReference: corev1.LocalObjectReference{Name: credsSecretName},
|
|
Key: "password",
|
|
},
|
|
},
|
|
},
|
|
}, nil
|
|
}
|