/* Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package controller import ( "context" "errors" "fmt" "time" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" ) // ProxyReconciler reconciles Proxy objects as a state machine: every // reconcile derives exactly one action from (spec, status, provider Get), // performs it, and requeues. Status is written at most once per reconcile, // by the deferred patch in Reconcile. type ProxyReconciler struct { client.Client Scheme *runtime.Scheme // Providers maps spec.provider values to configured backends. Providers map[string]provider.Provider // Poll intervals are struct fields, never consts, so tests can shrink // them to milliseconds. ProvisioningPoll time.Duration // while waiting for an instance to reach Running DriftPoll time.Duration // between re-checks of a Running instance DeletionPoll time.Duration // while waiting for an instance to disappear QuotaRetry time.Duration // after ErrQuotaExceeded; slow, off the backoff curve RequeueNow time.Duration // "process the next state promptly" (Result.Requeue is deprecated) } // +kubebuilder:rbac:groups=crawl.example.com,resources=proxies,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=crawl.example.com,resources=proxies/status,verbs=get;update;patch // +kubebuilder:rbac:groups=crawl.example.com,resources=proxies/finalizers,verbs=update // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch // Reconcile fetches the Proxy named by req into p (r.Get fills the struct // through the pointer), dispatches to the delete/external/managed state // machines, and flushes any status change exactly once on the way out. func (r *ProxyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) { var p crawlv1alpha1.Proxy if err := r.Get(ctx, req.NamespacedName, &p); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } base := p.DeepCopy() defer func() { // NotFound is expected when this reconcile just removed the last // finalizer and the object is already gone. if perr := r.patchStatusIfChanged(ctx, base, &p); perr != nil && !apierrors.IsNotFound(perr) { err = errors.Join(err, perr) } }() switch { case !p.DeletionTimestamp.IsZero(): return r.reconcileDelete(ctx, &p) case p.Spec.Mode == crawlv1alpha1.ModeExternal: return r.reconcileExternal(ctx, &p) default: return r.reconcileManaged(ctx, &p) } } func (r *ProxyReconciler) reconcileManaged(ctx context.Context, p *crawlv1alpha1.Proxy) (ctrl.Result, error) { log := logf.FromContext(ctx) if controllerutil.AddFinalizer(p, crawlv1alpha1.FinalizerName) { // The Update event re-triggers reconciliation; provisioning starts // on the next pass, with the finalizer safely persisted first. return ctrl.Result{}, r.Update(ctx, p) } // Permanent-failure latch: once this generation has failed permanently, // stop calling the provider until the spec changes. if cond := apimeta.FindStatusCondition(p.Status.Conditions, crawlv1alpha1.ConditionProvisioned); cond != nil && cond.Status == metav1.ConditionFalse && cond.Reason == ReasonPermanentError && cond.ObservedGeneration == p.Generation { return ctrl.Result{}, nil } prov, ok := r.Providers[p.Spec.Provider] if !ok { setProvisioned(p, metav1.ConditionFalse, ReasonPermanentError, fmt.Sprintf("provider %q is not configured", p.Spec.Provider)) return ctrl.Result{}, nil } cloudInit, err := r.resolveCloudInit(ctx, p) if err != nil { setProvisioned(p, metav1.ConditionFalse, ReasonCloudInitError, err.Error()) return ctrl.Result{}, err } hash := specHash(p, cloudInit) if p.Status.ProviderID == "" { id, err := prov.Create(ctx, provider.CreateRequest{ Name: provider.NameFromUID(p.UID), UID: string(p.UID), Namespace: p.Namespace, ProxyName: p.Name, Placement: placementFrom(p.Spec.Placement), CloudInit: cloudInit, Port: p.EffectivePort(), }) if err != nil { return r.providerFailure(p, err) } log.Info("created instance", "provider", p.Spec.Provider, "providerID", id) if err := r.setSpecHash(ctx, p, hash); err != nil { return ctrl.Result{}, err } p.Status.ProviderID = id p.Status.IP = "" setProvisioned(p, metav1.ConditionFalse, ReasonProvisioning, "instance created; waiting for it to run") return ctrl.Result{RequeueAfter: r.ProvisioningPoll}, nil } if ann := p.Annotations[crawlv1alpha1.AnnotationSpecHash]; ann != hash { if ann == "" { // Adopt: an instance provisioned before the hash-input struct // gained a field (or by an older operator version) keeps its // instance; replacing the whole fleet on upgrade would be wrong. if err := r.setSpecHash(ctx, p, hash); err != nil { return ctrl.Result{}, err } return ctrl.Result{RequeueAfter: r.RequeueNow}, nil } return r.replaceInstance(ctx, p, prov, hash) } inst, err := prov.Get(ctx, p.Status.ProviderID) if provider.Class(err) == provider.ErrNotFound { p.Status.ProviderID = "" p.Status.IP = "" return ctrl.Result{RequeueAfter: r.RequeueNow}, nil } if err != nil { return r.providerFailure(p, err) } switch inst.State { case provider.StateProvisioning: p.Status.IP = "" setProvisioned(p, metav1.ConditionFalse, ReasonProvisioning, "waiting for the instance to run") return ctrl.Result{RequeueAfter: r.ProvisioningPoll}, nil case provider.StateRunning: p.Status.IP = inst.IP setProvisioned(p, metav1.ConditionTrue, ReasonCreated, "instance is running") return ctrl.Result{RequeueAfter: r.DriftPoll}, nil default: // Stopped, Terminated: cattle, not pets — delete and recreate. if err := prov.Delete(ctx, p.Status.ProviderID); err != nil { return r.providerFailure(p, err) } log.Info("deleting instance for recreation", "providerID", p.Status.ProviderID, "state", inst.State) p.Status.IP = "" setProvisioned(p, metav1.ConditionFalse, ReasonRecreating, fmt.Sprintf("instance is %s; deleting it for recreation", inst.State)) return ctrl.Result{RequeueAfter: r.DeletionPoll}, nil } } // replaceInstance handles a spec-hash mismatch. The replacement instance has // the same deterministic name as the old one (both derive from the CR UID), // so recreating before the old instance is fully gone would hit "already // exists" — hence: delete, poll to NotFound, only then advance the hash and // let the create branch run. func (r *ProxyReconciler) replaceInstance(ctx context.Context, p *crawlv1alpha1.Proxy, prov provider.Provider, hash string) (ctrl.Result, error) { _, err := prov.Get(ctx, p.Status.ProviderID) if provider.Class(err) == provider.ErrNotFound { // Old instance is gone. The Update inside setSpecHash refreshes p // from the server — including status — so the status clear must be // staged after it, or it would be silently overwritten. A crash // between the two writes recovers either way: the create branch's // Create is idempotent by name, and a stale ID resolves to NotFound // again. if err := r.setSpecHash(ctx, p, hash); err != nil { return ctrl.Result{}, err } p.Status.ProviderID = "" p.Status.IP = "" return ctrl.Result{RequeueAfter: r.RequeueNow}, nil } if err != nil { return r.providerFailure(p, err) } if err := prov.Delete(ctx, p.Status.ProviderID); err != nil { return r.providerFailure(p, err) } logf.FromContext(ctx).Info("replacing instance after spec change", "providerID", p.Status.ProviderID) p.Status.IP = "" setProvisioned(p, metav1.ConditionFalse, ReasonReplacing, "spec changed; deleting the old instance before recreating") return ctrl.Result{RequeueAfter: r.DeletionPoll}, nil } func (r *ProxyReconciler) reconcileDelete(ctx context.Context, p *crawlv1alpha1.Proxy) (ctrl.Result, error) { if !controllerutil.ContainsFinalizer(p, crawlv1alpha1.FinalizerName) { return ctrl.Result{}, nil } if p.Status.ProviderID == "" { // Nothing was ever recorded as created; orphan GC reaps any stray // instance a crashed create might have left behind. controllerutil.RemoveFinalizer(p, crawlv1alpha1.FinalizerName) return ctrl.Result{}, r.Update(ctx, p) } prov, ok := r.Providers[p.Spec.Provider] if !ok { return ctrl.Result{}, fmt.Errorf( "provider %q is not configured; cannot clean up instance %s", p.Spec.Provider, p.Status.ProviderID) } _, err := prov.Get(ctx, p.Status.ProviderID) if provider.Class(err) == provider.ErrNotFound { controllerutil.RemoveFinalizer(p, crawlv1alpha1.FinalizerName) return ctrl.Result{}, r.Update(ctx, p) } if err != nil { return r.deletionFailure(err) } if err := prov.Delete(ctx, p.Status.ProviderID); err != nil { return r.deletionFailure(err) } logf.FromContext(ctx).Info("deleting instance", "providerID", p.Status.ProviderID) setProvisioned(p, metav1.ConditionFalse, ReasonDeleting, "deleting the instance before removing the finalizer") return ctrl.Result{RequeueAfter: r.DeletionPoll}, nil } func (r *ProxyReconciler) reconcileExternal(_ context.Context, p *crawlv1alpha1.Proxy) (ctrl.Result, error) { if p.Spec.Endpoint == nil { // CEL guarantees an endpoint on any object that went through the API // server; tolerate its absence instead of panicking. setProvisioned(p, metav1.ConditionFalse, ReasonPermanentError, "external proxy has no endpoint") return ctrl.Result{}, nil } p.Status.IP = p.Spec.Endpoint.Host setProvisioned(p, metav1.ConditionTrue, ReasonExternalEndpoint, "tracking an external endpoint") return ctrl.Result{}, nil } // providerFailure translates a classified provider error into the // state-machine's reaction: transient errors ride the workqueue's // exponential backoff, quota errors back off slowly without counting as // errors, and permanent errors latch Failed and stop retrying. func (r *ProxyReconciler) providerFailure(p *crawlv1alpha1.Proxy, err error) (ctrl.Result, error) { switch provider.Class(err) { case provider.ErrQuotaExceeded: setProvisioned(p, metav1.ConditionFalse, ReasonQuotaExceeded, err.Error()) return ctrl.Result{RequeueAfter: r.QuotaRetry}, nil case provider.ErrPermanent: setProvisioned(p, metav1.ConditionFalse, ReasonPermanentError, err.Error()) return ctrl.Result{}, nil default: return ctrl.Result{}, err } } // deletionFailure is providerFailure for the finalizer path, where latching // a permanent failure would wedge the object forever with no retry — keep // retrying instead, visibly, until cleanup succeeds or an operator // intervenes. func (r *ProxyReconciler) deletionFailure(err error) (ctrl.Result, error) { if provider.Class(err) == provider.ErrQuotaExceeded { return ctrl.Result{RequeueAfter: r.QuotaRetry}, nil } return ctrl.Result{}, err } // resolveCloudInit returns the effective cloud-init user-data, reading the // referenced Secret if one is used. Both the spec hash and CreateRequest see // only resolved content, so rotating a Secret triggers replacement. func (r *ProxyReconciler) resolveCloudInit(ctx context.Context, p *crawlv1alpha1.Proxy) (string, error) { ci := p.Spec.CloudInit if ci == nil { return "", nil } if ci.Inline != "" { return ci.Inline, nil } if ci.SecretRef == nil { return "", nil } key := ci.SecretRef.Key if key == "" { key = crawlv1alpha1.DefaultCloudInitSecretKey } var sec corev1.Secret if err := r.Get(ctx, client.ObjectKey{Namespace: p.Namespace, Name: ci.SecretRef.Name}, &sec); err != nil { return "", fmt.Errorf("resolving cloudInit secret %q: %w", ci.SecretRef.Name, err) } data, ok := sec.Data[key] if !ok { return "", fmt.Errorf("cloudInit secret %q has no key %q", ci.SecretRef.Name, key) } return string(data), nil } // setSpecHash persists the spec-hash annotation. Status changes staged on p // are untouched by the Update (they live on the status subresource) and are // flushed by the deferred patch in Reconcile. func (r *ProxyReconciler) setSpecHash(ctx context.Context, p *crawlv1alpha1.Proxy, hash string) error { if p.Annotations[crawlv1alpha1.AnnotationSpecHash] == hash { return nil } if p.Annotations == nil { p.Annotations = map[string]string{} } p.Annotations[crawlv1alpha1.AnnotationSpecHash] = hash return r.Update(ctx, p) } func placementFrom(ps *crawlv1alpha1.PlacementSpec) provider.Placement { if ps == nil { return provider.Placement{} } return provider.Placement{ Region: ps.Region, Zone: ps.Zone, MachineType: ps.MachineType, Image: ps.Image, } } // proxiesForSecret maps a Secret event to the Proxies whose cloudInit // references it, so rotating a Secret re-triggers the replacement check. func (r *ProxyReconciler) proxiesForSecret(ctx context.Context, obj client.Object) []reconcile.Request { var list crawlv1alpha1.ProxyList if err := r.List(ctx, &list, client.InNamespace(obj.GetNamespace())); err != nil { logf.FromContext(ctx).Error(err, "listing proxies for secret event", "secret", obj.GetName()) return nil } var reqs []reconcile.Request for i := range list.Items { p := &list.Items[i] if ci := p.Spec.CloudInit; ci != nil && ci.SecretRef != nil && ci.SecretRef.Name == obj.GetName() { reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(p)}) } } return reqs } // SetupWithManager sets up the controller with the Manager. The Secret watch // only fires for Secrets the manager's cache holds; the composition root // (cmd/main.go) restricts that cache to labelled cloud-init Secrets. func (r *ProxyReconciler) SetupWithManager(mgr ctrl.Manager) error { r.applyDefaults() return ctrl.NewControllerManagedBy(mgr). For(&crawlv1alpha1.Proxy{}). Named("proxy"). Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.proxiesForSecret)). WithOptions(controller.Options{MaxConcurrentReconciles: 3}). Complete(r) } func (r *ProxyReconciler) applyDefaults() { if r.ProvisioningPoll == 0 { r.ProvisioningPoll = 10 * time.Second } if r.DriftPoll == 0 { r.DriftPoll = 2 * time.Minute } if r.DeletionPoll == 0 { r.DeletionPoll = 10 * time.Second } if r.QuotaRetry == 0 { r.QuotaRetry = 5 * time.Minute } if r.RequeueNow == 0 { r.RequeueNow = time.Second } }