e1103f2b7d
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
176 lines
6.5 KiB
Go
176 lines
6.5 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
|
|
"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"
|
|
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
|
"sigs.k8s.io/controller-runtime/pkg/recorder"
|
|
|
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
|
)
|
|
|
|
// 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
|
|
|
|
// 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
|
|
|
|
// Recorder emits the Kubernetes Events DESIGN.md §12 asks for on every
|
|
// externally-visible outcome.
|
|
Recorder recorder.EventRecorder
|
|
}
|
|
|
|
// +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
|
|
}
|
|
|
|
dbEnv, dbErr := r.resolveDatabaseEnv(ctx, &srv)
|
|
if dbErr != nil {
|
|
return r.setNotReady(ctx, &srv, dbErr.reason, dbErr.message, waitInterval)
|
|
}
|
|
|
|
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
|
|
}
|
|
if err := r.reconcileService(ctx, &srv); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
if err := r.reconcilePodDisruptionBudget(ctx, &srv); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
if deploy.Status.ReadyReplicas < 1 {
|
|
return r.setNotReady(ctx, &srv,
|
|
terdutv1alpha1.ReasonWaitingForDeployment,
|
|
fmt.Sprintf("Deployment %q has no ready replica yet", deploy.Name),
|
|
waitInterval)
|
|
}
|
|
|
|
meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{
|
|
Type: terdutv1alpha1.ConditionReady,
|
|
Status: metav1.ConditionTrue,
|
|
Reason: terdutv1alpha1.ReasonAdopted,
|
|
Message: "deployment ready, database resolved, operator key seeded",
|
|
})
|
|
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
|
|
}
|
|
|
|
// SetupWithManager sets up the controller with the Manager.
|
|
func (r *TerdutServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|
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{}).
|
|
Owns(&corev1.Secret{}).
|
|
Named("terdutserver").
|
|
Complete(r)
|
|
}
|
|
|
|
// --- naming ---
|
|
|
|
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)
|
|
}
|