Skip to content

Commit 18d3516

Browse files
scotwellsclaude
andcommitted
fix(connector): re-translate at the edge on connector liveness change (#209)
When a connector comes online (Ready False→True), its liveness reaches the edge via the upstream-status annotation, but Envoy Gateway did not re-translate against it. EG watches Connector with a generation-only predicate, so the annotation change is ignored, and the previous project-side Gateway annotation touch raced the annotation's hub→edge propagation. EG could translate while the edge still saw the connector offline, serving 503 with no recovery. Add an edge-local controller in the extension-server process that watches the replicated Connector and touches the owning Gateway when liveness changes — after the new liveness is already in the shared cache — forcing EG to re-translate against fresh data. Remove the racy project-side touch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e5e0e4a commit 18d3516

9 files changed

Lines changed: 510 additions & 440 deletions

File tree

config/extension-server/rbac/role.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,13 @@ rules:
3434
- connectors/status
3535
verbs:
3636
- get
37+
# The edge re-translation controller patches a trigger annotation onto
38+
# Gateways to make Envoy Gateway re-translate when a Connector's liveness
39+
# changes.
40+
- apiGroups:
41+
- gateway.networking.k8s.io
42+
resources:
43+
- gateways
44+
verbs:
45+
- get
46+
- patch

internal/cmd/manager/manager.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -437,8 +437,7 @@ func NewCommand(build BuildInfo) *cobra.Command {
437437
}
438438

439439
if err := (&controller.ConnectorReconciler{
440-
Config: serverConfig,
441-
DownstreamCluster: downstreamCluster,
440+
Config: serverConfig,
442441
}).SetupWithManager(mgr); err != nil {
443442
setupLog.Error(err, "unable to create controller", "controller", "Connector")
444443
os.Exit(1)

internal/controller/connector_controller.go

Lines changed: 0 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -20,42 +20,20 @@ import (
2020
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
2121
"sigs.k8s.io/controller-runtime/pkg/handler"
2222
"sigs.k8s.io/controller-runtime/pkg/log"
23-
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
2423
mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder"
2524
mchandler "sigs.k8s.io/multicluster-runtime/pkg/handler"
2625
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
2726
"sigs.k8s.io/multicluster-runtime/pkg/multicluster"
2827
mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"
2928

30-
networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
3129
networkingv1alpha1 "go.datum.net/network-services-operator/api/v1alpha1"
3230
"go.datum.net/network-services-operator/internal/config"
33-
downstreamclient "go.datum.net/network-services-operator/internal/downstreamclient"
3431
)
3532

36-
// connectorReadyAnnotationKey is patched onto downstream Gateways when a
37-
// Connector's Ready condition flips online↔offline. Envoy Gateway's
38-
// AnnotationChangedPredicate on Gateway fires a full re-translation so the
39-
// extension server can re-apply the correct routing config from its cache.
40-
//
41-
// This is the Mode-B (eppEmissionEnabled:false) replacement for the trigger
42-
// that EnvoyPatchPolicy objects provided in Mode A: the EPP itself was the
43-
// EG-watched resource; in Mode B we touch a Gateway annotation instead.
44-
// Only used when r.DownstreamCluster is set and EPP emission is disabled.
45-
const connectorReadyAnnotationKey = "networking.datumapis.com/connector-ready-generation"
46-
4733
// ConnectorReconciler reconciles a Connector object
4834
type ConnectorReconciler struct {
4935
mgr mcmanager.Manager
5036
Config config.NetworkServicesOperator
51-
52-
// DownstreamCluster is the edge/infra cluster where Gateways are
53-
// materialized and where Envoy Gateway runs. When non-nil and EPP
54-
// emission is disabled (Mode B), the reconciler touches a trigger
55-
// annotation on affected downstream Gateways whenever a Connector's
56-
// Ready condition flips, causing EG to re-translate via its
57-
// AnnotationChangedPredicate on the Gateway resource.
58-
DownstreamCluster cluster.Cluster
5937
}
6038

6139
// +kubebuilder:rbac:groups=networking.datumapis.com,resources=connectors,verbs=get;list;watch;create;update;patch;delete
@@ -89,9 +67,6 @@ func (r *ConnectorReconciler) Reconcile(ctx context.Context, req mcreconcile.Req
8967
defer logger.Info("reconcile complete")
9068

9169
originalStatus := connector.Status.DeepCopy()
92-
// Capture the Ready condition BEFORE any mutations so we can detect a
93-
// status→Ready flip at the end of this reconcile (Mode B only).
94-
prevReadyCondition := apimeta.FindStatusCondition(originalStatus.Conditions, networkingv1alpha1.ConnectorConditionReady)
9570

9671
readyCondition := apimeta.FindStatusCondition(connector.Status.Conditions, networkingv1alpha1.ConnectorConditionReady)
9772
if readyCondition == nil {
@@ -192,137 +167,12 @@ func (r *ConnectorReconciler) Reconcile(ctx context.Context, req mcreconcile.Req
192167
}
193168
}
194169

195-
// Mode B (extension server): when the Connector's Ready condition flips
196-
// (Lease expiry → offline, Lease renewal → online), touch a trigger
197-
// annotation on each affected downstream Gateway so EG's
198-
// AnnotationChangedPredicate fires a full re-translation. This replaces
199-
// the trigger that EPP objects provided in Mode A.
200-
//
201-
// The extensionManager.resources watch (TPP + Connector) already handles
202-
// spec-change triggers via GenerationChangedPredicate; status-only
203-
// transitions (this path) require the Gateway annotation touch because
204-
// status writes do NOT increment metadata.generation.
205-
if !r.Config.Gateway.IsEPPEmissionEnabled() && r.DownstreamCluster != nil {
206-
newReadyCondition := apimeta.FindStatusCondition(connector.Status.Conditions, networkingv1alpha1.ConnectorConditionReady)
207-
if connectorReadyStatusChanged(prevReadyCondition, newReadyCondition) {
208-
if annotationErr := r.touchDownstreamGatewayAnnotations(ctx, cl.GetClient(), string(req.ClusterName), &connector); annotationErr != nil {
209-
// Log and continue — annotation touch is best-effort; a
210-
// missed touch means Envoy holds last-known-good config
211-
// until the next EG rebuild rather than crashing.
212-
logger.Error(annotationErr, "failed to touch downstream gateway annotations for connector ready state transition")
213-
}
214-
}
215-
}
216-
217170
if leaseStatus.requeueAfter != nil {
218171
return ctrl.Result{RequeueAfter: *leaseStatus.requeueAfter}, nil
219172
}
220173
return ctrl.Result{}, nil
221174
}
222175

223-
// connectorReadyStatusChanged returns true when the Ready condition's Status
224-
// has changed between the previous and current condition. Nil → non-nil (first
225-
// time the condition is set) is also treated as a change.
226-
func connectorReadyStatusChanged(prev, next *metav1.Condition) bool {
227-
if prev == nil && next == nil {
228-
return false
229-
}
230-
if prev == nil || next == nil {
231-
return true
232-
}
233-
return prev.Status != next.Status
234-
}
235-
236-
// connectorReadyAnnotationValue returns a deterministic string encoding of
237-
// the Connector's Ready condition for use as the annotation value.
238-
// The value changes only when the Ready Status changes (True ↔ False), so
239-
// patching it onto a downstream Gateway is idempotent across reconciles that
240-
// leave Ready in the same state.
241-
func connectorReadyAnnotationValue(readyCondition *metav1.Condition) string {
242-
if readyCondition == nil {
243-
return "Unknown/Unknown"
244-
}
245-
return fmt.Sprintf("%s/%s", readyCondition.Status, readyCondition.Reason)
246-
}
247-
248-
// touchDownstreamGatewayAnnotations patches a trigger annotation on every
249-
// downstream Gateway backed by an HTTPProxy that references this Connector.
250-
// The annotation change fires EG's AnnotationChangedPredicate on the Gateway,
251-
// which enqueues a full re-translation so the extension server can re-apply
252-
// the correct online/offline routing config from its informer cache.
253-
func (r *ConnectorReconciler) touchDownstreamGatewayAnnotations(
254-
ctx context.Context,
255-
upstreamClient client.Client,
256-
clusterName string,
257-
connector *networkingv1alpha1.Connector,
258-
) error {
259-
logger := log.FromContext(ctx)
260-
261-
// List all HTTPProxies in the Connector's namespace; filter to those that
262-
// reference this Connector. The HTTPProxy controller creates one Gateway
263-
// per HTTPProxy with the same name and namespace, so Gateway affinity
264-
// follows directly from the HTTPProxy→Connector reference.
265-
var httpProxies networkingv1alpha.HTTPProxyList
266-
if err := upstreamClient.List(ctx, &httpProxies, client.InNamespace(connector.Namespace)); err != nil {
267-
return fmt.Errorf("list HTTPProxies in %s: %w", connector.Namespace, err)
268-
}
269-
270-
// Resolve the downstream namespace once for the whole batch.
271-
strategy := downstreamclient.NewMappedNamespaceResourceStrategy(
272-
clusterName,
273-
upstreamClient,
274-
r.DownstreamCluster.GetClient(),
275-
)
276-
downstreamNS, err := strategy.GetDownstreamNamespaceNameForUpstreamNamespace(ctx, connector.Namespace)
277-
if err != nil {
278-
return fmt.Errorf("get downstream namespace for %s: %w", connector.Namespace, err)
279-
}
280-
281-
readyCondition := apimeta.FindStatusCondition(connector.Status.Conditions, networkingv1alpha1.ConnectorConditionReady)
282-
annotationValue := connectorReadyAnnotationValue(readyCondition)
283-
downstreamCl := r.DownstreamCluster.GetClient()
284-
285-
for i := range httpProxies.Items {
286-
hp := &httpProxies.Items[i]
287-
if !httpProxyReferencesConnector(hp, connector.Name) {
288-
continue
289-
}
290-
291-
// Gateway name == HTTPProxy name (see HTTPProxyReconciler.collectDesiredResources).
292-
gwKey := client.ObjectKey{Namespace: downstreamNS, Name: hp.Name}
293-
var gw gatewayv1.Gateway
294-
if err := downstreamCl.Get(ctx, gwKey, &gw); err != nil {
295-
if apierrors.IsNotFound(err) {
296-
// Gateway not yet created or already deleted — skip.
297-
logger.V(1).Info("downstream gateway not found; skipping annotation touch",
298-
"gateway", gwKey)
299-
continue
300-
}
301-
return fmt.Errorf("get downstream gateway %s: %w", gwKey, err)
302-
}
303-
304-
// Idempotent: skip Patch if the value hasn't changed.
305-
if gw.Annotations[connectorReadyAnnotationKey] == annotationValue {
306-
continue
307-
}
308-
309-
patch := client.MergeFrom(gw.DeepCopy())
310-
if gw.Annotations == nil {
311-
gw.Annotations = make(map[string]string)
312-
}
313-
gw.Annotations[connectorReadyAnnotationKey] = annotationValue
314-
if err := downstreamCl.Patch(ctx, &gw, patch); err != nil {
315-
return fmt.Errorf("patch downstream gateway %s annotation: %w", gwKey, err)
316-
}
317-
logger.Info("touched downstream gateway annotation for connector ready state change",
318-
"gateway", gwKey,
319-
"connector", connector.Name,
320-
"annotationValue", annotationValue)
321-
}
322-
323-
return nil
324-
}
325-
326176
func (r *ConnectorReconciler) connectorLeaseDurationSeconds() int32 {
327177
if r.Config.Connector.LeaseDurationSeconds > 0 {
328178
return r.Config.Connector.LeaseDurationSeconds

0 commit comments

Comments
 (0)