Skip to content

Fix gRPC-tunnel targets never reaching gNMIc, and Prometheus output Service port drift#106

Open
zentavr wants to merge 4 commits into
gnmic:mainfrom
zentavr:zentavr/fix-cluster-tunnel-mode
Open

Fix gRPC-tunnel targets never reaching gNMIc, and Prometheus output Service port drift#106
zentavr wants to merge 4 commits into
gnmic:mainfrom
zentavr:zentavr/fix-cluster-tunnel-mode

Conversation

@zentavr

@zentavr zentavr commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #102

Summary

A TunnelTargetPolicy-based tunnel deployment (no static Targets) never worked: the
switch/router's tunnel registration was always rejected, and even after fixing that,
the Prometheus output's Service pointed at the wrong port. Four issues, found in order
while tracing the whole path from TunnelTargetPolicy CRD to gNMIc's running config:

  1. TunnelTargetPolicy match rules were computed but never applied. The operator
    resolved TunnelTargetPolicy -> TunnelTargetMatch but nothing in the reconcile
    path pushed the result anywhere gNMIc could see it.
  2. Empty .spec.match (match-all) rendered as literal empty strings, not a wildcard.
    The final config.yaml is rendered via gopkg.in/yaml.v2, which does not honor json
    struct tags at all - TunnelTargetMatch only had json tags, so yaml.v2 serialized
    every field regardless of zero-value, turning "match everything" into type: "" /
    id: "", which gNMIc does not treat as a wildcard.
  3. Bulk /api/v1/config/apply silently drops tunnel-target-matches. gNMIc's own
    mapstructure-based decoder for that endpoint is hyphen-blind on this field. Switched
    to POSTing each TunnelTargetMatch individually to
    /api/v1/config/tunnel-target-matches, which decodes correctly.
  4. applyConfigToPods skipped every pod when there were zero static Targets.
    DistributeTargets/boundedLoadRendezvousHash returns an empty PerPodPlans map
    when len(Targets) == 0 (true for any tunnel-only deployment), and the per-pod loop
    did continue on a missing entry -- silently skipping sendApplyRequest and the
    tunnel-target-matches push for every pod, every reconcile. This meant fixes point 1- point 3
    never actually reached a running pod. Build the same fallback shape
    DistributeTargets would have produced (empty Targets, everything else from the
    plan) so subscriptions/outputs/processors/tunnel-target-matches still reach every pod.
  5. Prometheus output Service port could disagree with gNMIc's real listen port.
    parseListenPortAndPath defaulted a Prometheus Output with no explicit listen field
    to a fabricated port 9804, which reconcilePrometheusServices then treated as an
    explicit user override for the generated Service (and its prometheus.io/port
    annotation). The port gNMIc actually binds to is computed independently and
    unconditionally by assignPrometheusOutputPorts -- a hash-based assignment across a
    1009-slot pool starting at 9804, so that multiple Prometheus outputs sharing one
    gNMIc process never collide. Whenever the hash didn't happen to land on 9804 itself,
    the Service and the container disagreed and scraping silently failed. Return port 0
    when listen isn't set so the caller falls back to the port the plan actually
    assigned.

Testing

  • Reproduced end-to-end against a real Juniper QFX5110 Virtual Chassis dialing in over
    gRPC tunnel; confirmed via operator + gNMIc pod logs that registration went from
    "target ignored, not matching any rule" (every ~5 min, indefinitely) to a successful
    match and running subscriptions/outputs.
  • Added/updated unit tests (helpers_test.go) covering the Prometheus listen-port
    parsing edge cases (config present with no listen field, and {}).
  • go build ./... and go vet ./... clean.

@karimra

karimra commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

The prometheus port drift is nice catch, thanks for that.

But I think the operator should not configure the tunnel target matches statically using the config file. gNMIc was missing mapstructure tags for the tunnel-target-matches field which I added here.
So when using the next gNMIc release (v0.47.0) the operator will be able to dynamically add, update and remove tunnel target matches.

@zentavr

zentavr commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

@karimra so what's the plan? just wait for gNMIc release (v0.47.0) and that would be self-fixed?

Comment on lines 309 to +495
@@ -486,6 +485,14 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
r.plans[cluster.Namespace+"/"+cluster.Name] = applyPlan
r.m.Unlock()

// reconcile statefulset (needs applyPlan.TunnelTargetMatches for tunnel-server.targets)
statefulSet, err := r.reconcileStatefulSet(ctx, &cluster, applyPlan.TunnelTargetMatches)
if err != nil {
return ctrl.Result{}, err
}

logger.Info("reconciled cluster statefulset", "replicas", ptr.Deref(statefulSet.Spec.Replicas, 0), "image", statefulSet.Spec.Template.Spec.Containers[0].Image)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no need to swap these steps order once gnmic properly accepts the tunnel-target-matches (v0.47.0).
Also no need to include tunnel-server.targets in the static config.

Comment on lines 715 to +733
podPlan, ok := distResult.PerPodPlans[podIndex]
if !ok {
continue
// DistributeTargets/boundedLoadRendezvousHash returns a
// completely empty PerPodPlans map when there are zero static
// Targets to distribute (tunnel-only deployments have none) --
// `continue`-ing here used to skip sendApplyRequest and
// applyTunnelTargetMatches for every single pod, every single
// reconcile, silently. Subscriptions/outputs/processors/
// tunnel-target-matches apply to every pod regardless of
// static target distribution, so build the same fallback shape
// DistributeTargets would have, just with no targets assigned.
podPlan = &gnmic.ApplyPlan{
Targets: make(map[string]*gapi.TargetConfig),
Subscriptions: plan.Subscriptions,
Outputs: plan.Outputs,
Inputs: plan.Inputs,
Processors: plan.Processors,
TunnelTargetMatches: plan.TunnelTargetMatches,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main (operator-side) bug that prevented tunnel targets from being applied.
Let's keep it, but it would be good to wrap it in separate function.

Comment on lines +750 to +769
// tunnel-target-matches are pushed separately via
// /api/v1/config/tunnel-target-matches, not through the bulk
// /api/v1/config/apply request above: gnmic's ConfigApplyRequest.
// TunnelTargetMatches field has no `mapstructure` tag (only `json`),
// and the apply handler decodes the request body through
// mapstructure (not encoding/json) -- mapstructure's default
// field matching is case-insensitive but hyphen-blind, so the
// "tunnel-target-matches" JSON key never matches the
// TunnelTargetMatches field name and is silently dropped on every
// request regardless of payload content (confirmed by reading
// gnmic's pkg/collector/api/server/apply.go and
// decodeRequestMap()). The per-resource endpoint decodes the
// body directly into config.TunnelTargetMatch, whose own fields
// (type/id/config) DO have mapstructure tags and aren't
// hyphenated, so it works correctly.
if err := r.applyTunnelTargetMatches(ctx, podBaseURL, plan.TunnelTargetMatches, httpClient); err != nil {
return 0, fmt.Errorf("failed to apply tunnel-target-matches to pod %d: %w", podIndex, err)
}

logger.Info("config applied to pod", "pod", podIndex, "targets", len(podPlan.Targets), "tunnelTargetMatches", len(plan.TunnelTargetMatches))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this won't be needed anymore since tunnel-target-matches will be applied by r.sendApplyRequest (gnmic v0.47.0)

@karimra

karimra commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

I put comments on the parts that need to change. The goal is to only use the /config/apply endpoint from the operator so that everything gets applied at the same time, the config stays dynamic (including tunnel-targets) and is applied atomically in each pod.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Seems like tunnel Cluster not works

2 participants