Authenticate with a seeded operator key; fold escalation and switches into TerdutTeam
Credentials: the TerdutServer controller generates <name>-operator-key in the server's own namespace (owned by it) and hands it to the pods as TERDUT_OPERATOR_KEY; the server creates its instance-scoped account from it at every start. A replaced Secret rolls the pods. The bootstrap handshake, the checkpoint Secret, per-team service accounts and credentials Secrets, BootstrapStateLost and credentials.deletionPolicy are gone. CRDs: TerdutServer, TerdutTeam and TerdutAlertSource. TerdutEscalationRule and TerdutDeadmanSwitch become spec.escalation and spec.deadmanSwitches[] on the team (matched by name, extras removed); team invites are removed. A team is created under the identity <namespace>/<name> (external_id), so a retry, a lost status or a deleted team heal by repeating the same call, and a display name owned by another team is TeamNameTaken instead of an adoption. The server resolves escalation usernames (UnknownUser condition). OIDC claim names and trustEmail are spec fields. Fixes: query values are URL-escaped; every delete treats 404 as success; deleting a team no longer depends on allowedTeams consent; a switch or integration deleted on the server is recreated; unnamed switches take the CR's name. Cleanup: scaffold e2e test, AGENTS.md, devcontainer, unused config/ pieces and Client.Version() removed; DESIGN.md, README, ROADMAP and the demo (run-demo.sh, manifests) rewritten for the new design. Secret RBAC stays cluster-wide, now stated in DESIGN.md section 9. Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
@@ -2,7 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -16,12 +16,10 @@ import (
|
||||
"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
|
||||
@@ -39,27 +37,6 @@ const resyncInterval = 5 * time.Minute
|
||||
// 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
|
||||
@@ -75,21 +52,9 @@ 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
|
||||
@@ -113,26 +78,18 @@ func (r *TerdutServerReconciler) Reconcile(ctx context.Context, req ctrl.Request
|
||||
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)
|
||||
operatorKey, err := r.reconcileOperatorKey(ctx, &srv)
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
srv.Status.CredentialsSecretRef = operatorKeyRef(&srv)
|
||||
|
||||
deploy, err := r.reconcileDeployment(ctx, &srv, dbEnv, operatorKeyHash(operatorKey))
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
@@ -143,13 +100,6 @@ func (r *TerdutServerReconciler) Reconcile(ctx context.Context, req ctrl.Request
|
||||
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,
|
||||
@@ -157,28 +107,12 @@ func (r *TerdutServerReconciler) Reconcile(ctx context.Context, req ctrl.Request
|
||||
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",
|
||||
Message: "deployment ready, database resolved, operator key seeded",
|
||||
})
|
||||
srv.Status.ServiceName = srv.Name
|
||||
srv.Status.ObservedGeneration = srv.Generation
|
||||
if err := r.Status().Update(ctx, &srv); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
@@ -215,48 +149,8 @@ func (r *TerdutServerReconciler) setNotReady(
|
||||
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")
|
||||
}
|
||||
@@ -265,20 +159,13 @@ func (r *TerdutServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
Owns(&appsv1.Deployment{}).
|
||||
Owns(&corev1.Service{}).
|
||||
Owns(&policyv1.PodDisruptionBudget{}).
|
||||
Owns(&corev1.Secret{}).
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user