From ba64c4cbb133ee21b8d5ee7d8f7f743485650136 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Sat, 26 Sep 2026 13:59:08 -0500 Subject: [PATCH] feat: Carry an opaque attached-to reference to the edge Edge Envoy access logs record which POP served a request but nothing about the upstream it was proxied to, so an operator debugging a 503 cannot tell what backend the request reached. This adds an optional, immutable, consumer-authored attachedTo reference to NetworkInterfaceClaim, projects it onto the bound NetworkInterface, and carries it forward through the generated EndpointSlice and into the Envoy cluster metadata the extension server programs. The reference is opaque: the networking operator copies apiGroup, kind, and name without importing any consumer type or branching on a kind. Key changes: - add AttachedToRef and attachedTo on the claim and interface specs - project attachedTo from claim to interface on bind, create, and sync - set endpoint targetRef and stamp attached-to slice labels only when every member of a network service agrees on one reference - read the slice labels in the extension server policy index and write upstream_apigroup, upstream_kind, and upstream_name into the datum-gateway cluster filter_metadata Co-Authored-By: Claude Opus 4.8 --- api/v1alpha/networkinterface_types.go | 43 +++++++ api/v1alpha/networkinterfaceclaim_types.go | 15 +++ api/v1alpha/zz_generated.deepcopy.go | 25 ++++ ....datumapis.com_networkinterfaceclaims.yaml | 38 ++++++ ...rking.datumapis.com_networkinterfaces.yaml | 31 +++++ docs/api/networkinterfaceclaims.md | 74 ++++++++++- docs/api/networkinterfaces.md | 66 +++++++++- .../controller/httpproxy_networkservice.go | 63 ++++++++- ...ttpproxy_networkservice_attachedto_test.go | 88 +++++++++++++ .../networkinterfaceclaim_controller.go | 7 + .../networkinterfaceclaim_controller_test.go | 25 ++++ internal/extensionserver/cache/index.go | 26 +++- internal/extensionserver/cache/index_test.go | 66 ++++++++++ internal/extensionserver/cache/types.go | 52 ++++++++ .../extensionserver/mutate/upstreammeta.go | 92 ++++++++++++++ .../mutate/upstreammeta_test.go | 120 ++++++++++++++++++ internal/extensionserver/server/server.go | 11 ++ 17 files changed, 828 insertions(+), 14 deletions(-) create mode 100644 internal/controller/httpproxy_networkservice_attachedto_test.go create mode 100644 internal/extensionserver/mutate/upstreammeta.go create mode 100644 internal/extensionserver/mutate/upstreammeta_test.go diff --git a/api/v1alpha/networkinterface_types.go b/api/v1alpha/networkinterface_types.go index 14552ffa..d5986194 100644 --- a/api/v1alpha/networkinterface_types.go +++ b/api/v1alpha/networkinterface_types.go @@ -260,6 +260,38 @@ type NetworkInterfaceAttachmentRef struct { Name string `json:"name"` } +// AttachedToRef names the consumer resource an interface is attached to, such +// as a compute Instance. It is authored by whoever creates the claim, carried +// onto the bound interface, and never interpreted here: the networking operator +// has no idea what an Instance is, and the reference is opaque to it. +// +// It is distinct from NetworkInterfaceAttachmentRef, which the provider writes +// to record the data-plane resource realizing the interface. This one names the +// consumer-side thing the interface belongs to, so an operator reading an access +// log can tell which backend served a request. +type AttachedToRef struct { + // apiGroup is the API group of the referent, such as compute.datumapis.com. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + APIGroup string `json:"apiGroup"` + + // kind is the kind of the referent, such as Instance. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + Kind string `json:"kind"` + + // name is the name of the referent. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Name string `json:"name"` +} + // NetworkInterfaceSpec defines the desired state of NetworkInterface. It is // written by the operator when a claim is fulfilled, and it carries everything // a provider needs to configure a NIC without reading any other resource. @@ -336,6 +368,17 @@ type NetworkInterfaceSpec struct { // +kubebuilder:validation:Optional // +kubebuilder:default="Delete" ReclaimPolicy NetworkInterfaceReclaimPolicy `json:"reclaimPolicy,omitempty"` + + // attachedTo names the consumer resource this interface is attached to, such + // as a compute Instance. It comes from the claim, and the operator carries it + // without interpreting it, the same way the held-by label and the + // HolderAvailable condition name the holder without knowing what a holder is. + // The holder surface says a holder exists and whether it serves; this says + // what the holder is, so a reader tracing traffic to a member can name the + // backend behind it. + // + // +kubebuilder:validation:Optional + AttachedTo *AttachedToRef `json:"attachedTo,omitempty"` } // NetworkInterfaceStatus defines the observed state of NetworkInterface: which diff --git a/api/v1alpha/networkinterfaceclaim_types.go b/api/v1alpha/networkinterfaceclaim_types.go index 8fa2e34e..731abfd8 100644 --- a/api/v1alpha/networkinterfaceclaim_types.go +++ b/api/v1alpha/networkinterfaceclaim_types.go @@ -102,6 +102,7 @@ type NetworkInterfaceAddressRequest struct { // // +kubebuilder:validation:XValidation:message="networkInterfaceName is immutable and cannot be set, changed, or cleared after creation",rule="has(self.networkInterfaceName) == has(oldSelf.networkInterfaceName) && (!has(self.networkInterfaceName) || self.networkInterfaceName == oldSelf.networkInterfaceName)" // +kubebuilder:validation:XValidation:message="addresses is immutable and cannot be set, changed, or cleared after creation",rule="has(self.addresses) == has(oldSelf.addresses) && (!has(self.addresses) || self.addresses == oldSelf.addresses)" +// +kubebuilder:validation:XValidation:message="attachedTo is immutable and cannot be set, changed, or cleared after creation",rule="has(self.attachedTo) == has(oldSelf.attachedTo) && (!has(self.attachedTo) || self.attachedTo == oldSelf.attachedTo)" type NetworkInterfaceClaimSpec struct { // network is the network the interface attaches to. The network must already // exist in the same namespace as the claim. @@ -215,6 +216,20 @@ type NetworkInterfaceClaimSpec struct { // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=253 NetworkInterfaceName string `json:"networkInterfaceName,omitempty"` + + // attachedTo names the consumer resource this interface is attached to, such + // as a compute Instance. It is set by whoever creates the claim. + // + // It is copied to the bound interface and never interpreted here. The + // networking operator has no idea what an Instance is; it carries the + // reference so a reader tracing traffic to a member can name the backend + // behind it. + // + // Immutable, because a bound interface's attachment does not move to a + // different consumer resource. + // + // +kubebuilder:validation:Optional + AttachedTo *AttachedToRef `json:"attachedTo,omitempty"` } // NetworkInterfaceClaimStatus defines the observed state of diff --git a/api/v1alpha/zz_generated.deepcopy.go b/api/v1alpha/zz_generated.deepcopy.go index a4f1d80e..4c879e35 100644 --- a/api/v1alpha/zz_generated.deepcopy.go +++ b/api/v1alpha/zz_generated.deepcopy.go @@ -30,6 +30,21 @@ func (in *AbuseContact) DeepCopy() *AbuseContact { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AttachedToRef) DeepCopyInto(out *AttachedToRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AttachedToRef. +func (in *AttachedToRef) DeepCopy() *AttachedToRef { + if in == nil { + return nil + } + out := new(AttachedToRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConnectorReference) DeepCopyInto(out *ConnectorReference) { *out = *in @@ -1487,6 +1502,11 @@ func (in *NetworkInterfaceClaimSpec) DeepCopyInto(out *NetworkInterfaceClaimSpec *out = make([]NetworkInterfaceAddressRequest, len(*in)) copy(*out, *in) } + if in.AttachedTo != nil { + in, out := &in.AttachedTo, &out.AttachedTo + *out = new(AttachedToRef) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkInterfaceClaimSpec. @@ -1619,6 +1639,11 @@ func (in *NetworkInterfaceSpec) DeepCopyInto(out *NetworkInterfaceSpec) { *out = make([]NetworkInterfaceExternalAddress, len(*in)) copy(*out, *in) } + if in.AttachedTo != nil { + in, out := &in.AttachedTo, &out.AttachedTo + *out = new(AttachedToRef) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkInterfaceSpec. diff --git a/config/crd/bases/networking.datumapis.com_networkinterfaceclaims.yaml b/config/crd/bases/networking.datumapis.com_networkinterfaceclaims.yaml index d8789a81..54ff2ea0 100644 --- a/config/crd/bases/networking.datumapis.com_networkinterfaceclaims.yaml +++ b/config/crd/bases/networking.datumapis.com_networkinterfaceclaims.yaml @@ -108,6 +108,40 @@ spec: x-kubernetes-validations: - message: Each address class may be requested at most once rule: self.all(a, self.exists_one(b, b.class == a.class)) + attachedTo: + description: |- + attachedTo names the consumer resource this interface is attached to, such + as a compute Instance. It is set by whoever creates the claim. + + It is copied to the bound interface and never interpreted here. The + networking operator has no idea what an Instance is; it carries the + reference so a reader tracing traffic to a member can name the backend + behind it. + + Immutable, because a bound interface's attachment does not move to a + different consumer resource. + properties: + apiGroup: + description: apiGroup is the API group of the referent, such as + compute.datumapis.com. + maxLength: 253 + minLength: 1 + type: string + kind: + description: kind is the kind of the referent, such as Instance. + maxLength: 63 + minLength: 1 + type: string + name: + description: name is the name of the referent. + maxLength: 253 + minLength: 1 + type: string + required: + - apiGroup + - kind + - name + type: object attachmentMode: default: Netns description: |- @@ -249,6 +283,10 @@ spec: after creation rule: has(self.addresses) == has(oldSelf.addresses) && (!has(self.addresses) || self.addresses == oldSelf.addresses) + - message: attachedTo is immutable and cannot be set, changed, or cleared + after creation + rule: has(self.attachedTo) == has(oldSelf.attachedTo) && (!has(self.attachedTo) + || self.attachedTo == oldSelf.attachedTo) status: default: conditions: diff --git a/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml b/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml index 6d91187a..7999812a 100644 --- a/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml +++ b/config/crd/bases/networking.datumapis.com_networkinterfaces.yaml @@ -142,6 +142,37 @@ spec: == 1 - message: Only one address may be held per address family rule: self.all(a, self.exists_one(b, b.family == a.family)) + attachedTo: + description: |- + attachedTo names the consumer resource this interface is attached to, such + as a compute Instance. It comes from the claim, and the operator carries it + without interpreting it, the same way the held-by label and the + HolderAvailable condition name the holder without knowing what a holder is. + The holder surface says a holder exists and whether it serves; this says + what the holder is, so a reader tracing traffic to a member can name the + backend behind it. + properties: + apiGroup: + description: apiGroup is the API group of the referent, such as + compute.datumapis.com. + maxLength: 253 + minLength: 1 + type: string + kind: + description: kind is the kind of the referent, such as Instance. + maxLength: 63 + minLength: 1 + type: string + name: + description: name is the name of the referent. + maxLength: 253 + minLength: 1 + type: string + required: + - apiGroup + - kind + - name + type: object attachmentMode: default: Netns description: |- diff --git a/docs/api/networkinterfaceclaims.md b/docs/api/networkinterfaceclaims.md index c97123b1..7a714f69 100644 --- a/docs/api/networkinterfaceclaims.md +++ b/docs/api/networkinterfaceclaims.md @@ -74,7 +74,7 @@ it. To change one of those fields, delete the claim and create a new one, accepting that the workload gets new addresses unless the interface is retained.

- Validations:
  • has(self.networkInterfaceName) == has(oldSelf.networkInterfaceName) && (!has(self.networkInterfaceName) || self.networkInterfaceName == oldSelf.networkInterfaceName): networkInterfaceName is immutable and cannot be set, changed, or cleared after creation
  • has(self.addresses) == has(oldSelf.addresses) && (!has(self.addresses) || self.addresses == oldSelf.addresses): addresses is immutable and cannot be set, changed, or cleared after creation
  • + Validations:
  • has(self.networkInterfaceName) == has(oldSelf.networkInterfaceName) && (!has(self.networkInterfaceName) || self.networkInterfaceName == oldSelf.networkInterfaceName): networkInterfaceName is immutable and cannot be set, changed, or cleared after creation
  • has(self.addresses) == has(oldSelf.addresses) && (!has(self.addresses) || self.addresses == oldSelf.addresses): addresses is immutable and cannot be set, changed, or cleared after creation
  • has(self.attachedTo) == has(oldSelf.attachedTo) && (!has(self.attachedTo) || self.attachedTo == oldSelf.attachedTo): attachedTo is immutable and cannot be set, changed, or cleared after creation
  • true @@ -142,6 +142,22 @@ Omit this field for ordinary private addressing, which is the common case.
    Validations:
  • self.all(a, self.exists_one(b, b.class == a.class)): Each address class may be requested at most once
  • false + + attachedTo + object + + attachedTo names the consumer resource this interface is attached to, such +as a compute Instance. It is set by whoever creates the claim. + +It is copied to the bound interface and never interpreted here. The +networking operator has no idea what an Instance is; it carries the +reference so a reader tracing traffic to a member can name the backend +behind it. + +Immutable, because a bound interface's attachment does not move to a +different consumer resource.
    + + false attachmentMode enum @@ -149,7 +165,9 @@ Omit this field for ordinary private addressing, which is the common case.
    attachmentMode is how the guest consumes this interface. Netns places it in the workload's network namespace, which is what an ordinary container expects. Hypervisor hands it to a hypervisor as a device, which is what a -virtual machine or microVM guest needs. +virtual machine or microVM guest needs. HypervisorDeclared also hands it +to a hypervisor, and additionally has the realizer state the device to +that hypervisor instead of letting it discover the device from the node. It is copied to the bound interface and never interpreted here. Whoever realizes the interface decides what each mode means on its data plane. @@ -157,7 +175,7 @@ realizes the interface decides what each mode means on its data plane. Immutable, because the guest and the attachment are both built against it.

    Validations:
  • self == oldSelf: attachmentMode is immutable and cannot be changed after creation
  • - Enum: Netns, Hypervisor
    + Enum: Netns, Hypervisor, HypervisorDeclared
    Default: Netns
    false @@ -309,6 +327,56 @@ CIDR, so a class cannot be used to ask for a particular address.
    +### NetworkInterfaceClaim.spec.attachedTo +[↩ Parent](#networkinterfaceclaimspec) + + + +attachedTo names the consumer resource this interface is attached to, such +as a compute Instance. It is set by whoever creates the claim. + +It is copied to the bound interface and never interpreted here. The +networking operator has no idea what an Instance is; it carries the +reference so a reader tracing traffic to a member can name the backend +behind it. + +Immutable, because a bound interface's attachment does not move to a +different consumer resource. + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiGroupstring + apiGroup is the API group of the referent, such as compute.datumapis.com.
    +
    true
    kindstring + kind is the kind of the referent, such as Instance.
    +
    true
    namestring + name is the name of the referent.
    +
    true
    + + ### NetworkInterfaceClaim.status [↩ Parent](#networkinterfaceclaim) diff --git a/docs/api/networkinterfaces.md b/docs/api/networkinterfaces.md index d949e2e5..99da86d0 100644 --- a/docs/api/networkinterfaces.md +++ b/docs/api/networkinterfaces.md @@ -122,6 +122,19 @@ length and, once the location has a subnet, the gateway to route through.
    Validations:
  • size(self) == 0 || self.filter(a, has(a.primary) && a.primary).size() == 1: Exactly one address must be primary
  • self.all(a, self.exists_one(b, b.family == a.family)): Only one address may be held per address family
  • false + + attachedTo + object + + attachedTo names the consumer resource this interface is attached to, such +as a compute Instance. It comes from the claim, and the operator carries it +without interpreting it, the same way the held-by label and the +HolderAvailable condition name the holder without knowing what a holder is. +The holder surface says a holder exists and whether it serves; this says +what the holder is, so a reader tracing traffic to a member can name the +backend behind it.
    + + false attachmentMode enum @@ -131,9 +144,11 @@ claim, and the operator carries it without interpreting it. Netns places the interface in the workload's network namespace. Hypervisor hands it to a hypervisor as a device, which is what a virtual machine or -microVM guest needs.
    +microVM guest needs. HypervisorDeclared also hands it to a hypervisor, and +additionally has the realizer state the device to that hypervisor instead +of letting it discover the device from the node.

    - Enum: Netns, Hypervisor
    + Enum: Netns, Hypervisor, HypervisorDeclared
    Default: Netns
    false @@ -298,6 +313,53 @@ spec.ipFamilies.
    +### NetworkInterface.spec.attachedTo +[↩ Parent](#networkinterfacespec) + + + +attachedTo names the consumer resource this interface is attached to, such +as a compute Instance. It comes from the claim, and the operator carries it +without interpreting it, the same way the held-by label and the +HolderAvailable condition name the holder without knowing what a holder is. +The holder surface says a holder exists and whether it serves; this says +what the holder is, so a reader tracing traffic to a member can name the +backend behind it. + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiGroupstring + apiGroup is the API group of the referent, such as compute.datumapis.com.
    +
    true
    kindstring + kind is the kind of the referent, such as Instance.
    +
    true
    namestring + name is the name of the referent.
    +
    true
    + + ### NetworkInterface.spec.claimRef [↩ Parent](#networkinterfacespec) diff --git a/internal/controller/httpproxy_networkservice.go b/internal/controller/httpproxy_networkservice.go index 8e6dbe9c..3b448963 100644 --- a/internal/controller/httpproxy_networkservice.go +++ b/internal/controller/httpproxy_networkservice.go @@ -28,6 +28,17 @@ const maxEndpointsPerSlice = 100 // EndpointSlice was generated from. const NetworkServiceBackendLabel = "networking.datumapis.com/network-service" +// AttachedToGroupLabel, AttachedToKindLabel, and AttachedToNameLabel carry the +// consumer resource every member of an EndpointSlice is attached to, taken from +// each member's NetworkInterface spec.attachedTo. They are stamped only when +// every member agrees on the same reference. A slice whose members disagree +// carries none of the three, and a reader treats their absence as unknown. +const ( + AttachedToGroupLabel = "networking.datumapis.com/attached-to-group" + AttachedToKindLabel = "networking.datumapis.com/attached-to-kind" + AttachedToNameLabel = "networking.datumapis.com/attached-to-name" +) + // errNetworkServiceBackendNotFound is returned when a networkService backend // names a NetworkService that does not exist, or a port that service does not // declare. @@ -193,9 +204,44 @@ func networkServiceEndpoint(member *networkingv1alpha.NetworkInterface, address endpoint.Zone = ptr.To(zone) } + if ref := member.Spec.AttachedTo; ref != nil { + endpoint.TargetRef = &corev1.ObjectReference{ + APIVersion: ref.APIGroup, + Kind: ref.Kind, + Name: ref.Name, + Namespace: member.Namespace, + } + } + return endpoint } +// agreedAttachedTo returns the one consumer reference every endpoint is +// attached to, or nil when the set is empty, any endpoint carries none, or two +// endpoints carry different ones. A slice-level label is only correct when the +// whole slice speaks with one voice, so a mixed set is reported as no agreement +// and the reader treats the absent labels as unknown. +func agreedAttachedTo(endpoints []discoveryv1.Endpoint) *corev1.ObjectReference { + if len(endpoints) == 0 { + return nil + } + var agreed *corev1.ObjectReference + for i := range endpoints { + ref := endpoints[i].TargetRef + if ref == nil { + return nil + } + if agreed == nil { + agreed = ref + continue + } + if ref.APIVersion != agreed.APIVersion || ref.Kind != agreed.Kind || ref.Name != agreed.Name { + return nil + } + } + return agreed +} + // networkServiceEndpointSlices shards a service's endpoints across as many // EndpointSlices as the per-slice limit requires. The first shard keeps the // name the rule's backendRef points at; every shard carries the same @@ -216,20 +262,29 @@ func networkServiceEndpointSlices( }, } + agreed := agreedAttachedTo(resolved.endpoints) + newSlice := func(shard int, endpoints []discoveryv1.Endpoint) *discoveryv1.EndpointSlice { name := baseName if shard > 0 { name = fmt.Sprintf("%s-%d", baseName, shard) } + labels := map[string]string{ + discoveryv1.LabelServiceName: baseName, + NetworkServiceBackendLabel: serviceName, + } + if agreed != nil { + labels[AttachedToGroupLabel] = agreed.APIVersion + labels[AttachedToKindLabel] = agreed.Kind + labels[AttachedToNameLabel] = agreed.Name + } + return &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{ Namespace: namespace, Name: name, - Labels: map[string]string{ - discoveryv1.LabelServiceName: baseName, - NetworkServiceBackendLabel: serviceName, - }, + Labels: labels, }, AddressType: resolved.addressType, Endpoints: endpoints, diff --git a/internal/controller/httpproxy_networkservice_attachedto_test.go b/internal/controller/httpproxy_networkservice_attachedto_test.go new file mode 100644 index 00000000..85cc8955 --- /dev/null +++ b/internal/controller/httpproxy_networkservice_attachedto_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + discoveryv1 "k8s.io/api/discovery/v1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +func memberWithAttachedTo(name string, ref *networkingv1alpha.AttachedToRef) *networkingv1alpha.NetworkInterface { + iface := &networkingv1alpha.NetworkInterface{} + iface.Namespace = "proj" + iface.Name = name + iface.Spec.AttachedTo = ref + return iface +} + +func TestNetworkServiceEndpoint_SetsTargetRefFromAttachedTo(t *testing.T) { + member := memberWithAttachedTo("web-0", &networkingv1alpha.AttachedToRef{ + APIGroup: "compute.datumapis.com", + Kind: "Instance", + Name: "web-0", + }) + + ep := networkServiceEndpoint(member, "10.0.0.5") + + require.NotNil(t, ep.TargetRef) + assert.Equal(t, "compute.datumapis.com", ep.TargetRef.APIVersion) + assert.Equal(t, "Instance", ep.TargetRef.Kind) + assert.Equal(t, "web-0", ep.TargetRef.Name) + assert.Equal(t, "proj", ep.TargetRef.Namespace) +} + +func TestNetworkServiceEndpoint_NoTargetRefWithoutAttachedTo(t *testing.T) { + ep := networkServiceEndpoint(memberWithAttachedTo("web-0", nil), "10.0.0.5") + assert.Nil(t, ep.TargetRef) +} + +func sliceLabels(t *testing.T, refs []*networkingv1alpha.AttachedToRef) map[string]string { + t.Helper() + + resolved := &resolvedNetworkService{addressType: discoveryv1.AddressTypeIPv4} + for i, ref := range refs { + member := memberWithAttachedTo("m", ref) + resolved.endpoints = append(resolved.endpoints, networkServiceEndpoint(member, "10.0.0."+string(rune('1'+i)))) + } + + slices := networkServiceEndpointSlices("proj", "svc-0-0", "http", "svc", resolved) + require.Len(t, slices, 1) + return slices[0].Labels +} + +func TestNetworkServiceEndpointSlices_StampsWhenMembersAgree(t *testing.T) { + ref := &networkingv1alpha.AttachedToRef{APIGroup: "compute.datumapis.com", Kind: "Instance", Name: "web-0"} + labels := sliceLabels(t, []*networkingv1alpha.AttachedToRef{ref, ref}) + + assert.Equal(t, "compute.datumapis.com", labels[AttachedToGroupLabel]) + assert.Equal(t, "Instance", labels[AttachedToKindLabel]) + assert.Equal(t, "web-0", labels[AttachedToNameLabel]) +} + +func TestNetworkServiceEndpointSlices_OmitsWhenMembersDisagree(t *testing.T) { + labels := sliceLabels(t, []*networkingv1alpha.AttachedToRef{ + {APIGroup: "compute.datumapis.com", Kind: "Instance", Name: "web-0"}, + {APIGroup: "compute.datumapis.com", Kind: "Instance", Name: "web-1"}, + }) + + assert.NotContains(t, labels, AttachedToGroupLabel) + assert.NotContains(t, labels, AttachedToKindLabel) + assert.NotContains(t, labels, AttachedToNameLabel) +} + +func TestNetworkServiceEndpointSlices_OmitsWhenMemberLacksAttachedTo(t *testing.T) { + ref := &networkingv1alpha.AttachedToRef{APIGroup: "compute.datumapis.com", Kind: "Instance", Name: "web-0"} + labels := sliceLabels(t, []*networkingv1alpha.AttachedToRef{ref, nil}) + + assert.NotContains(t, labels, AttachedToGroupLabel) +} + +func TestNetworkServiceEndpointSlices_OmitsWhenNoMembers(t *testing.T) { + labels := sliceLabels(t, nil) + assert.NotContains(t, labels, AttachedToGroupLabel) +} diff --git a/internal/controller/networkinterfaceclaim_controller.go b/internal/controller/networkinterfaceclaim_controller.go index c4d56e34..5b19622c 100644 --- a/internal/controller/networkinterfaceclaim_controller.go +++ b/internal/controller/networkinterfaceclaim_controller.go @@ -14,6 +14,7 @@ import ( "time" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -542,6 +543,7 @@ func (r *NetworkInterfaceClaimReconciler) bindInterface( } existing.Spec.ClaimRef = &networkingv1alpha.NetworkInterfaceClaimRef{Name: claim.Name} + existing.Spec.AttachedTo = claim.Spec.AttachedTo controllerutil.AddFinalizer(&existing, networkInterfaceFinalizer) if err := cl.Update(ctx, &existing); err != nil { return nil, fmt.Errorf("failed binding network interface: %w", err) @@ -572,6 +574,7 @@ func (r *NetworkInterfaceClaimReconciler) bindInterface( AttachmentMode: claim.Spec.AttachmentMode, MTU: networkContext.Spec.MTU, ReclaimPolicy: claim.Spec.ReclaimPolicy, + AttachedTo: claim.Spec.AttachedTo, } for _, entry := range allocated { @@ -1028,6 +1031,10 @@ func (r *NetworkInterfaceClaimReconciler) syncInterface( iface.Spec.AttachmentMode = claim.Spec.AttachmentMode changed = true } + if !equality.Semantic.DeepEqual(iface.Spec.AttachedTo, claim.Spec.AttachedTo) { + iface.Spec.AttachedTo = claim.Spec.AttachedTo + changed = true + } for i := range iface.Spec.Addresses { primary := iface.Spec.Addresses[i].Family == claim.Spec.IPFamilies[0] if iface.Spec.Addresses[i].Primary != primary { diff --git a/internal/controller/networkinterfaceclaim_controller_test.go b/internal/controller/networkinterfaceclaim_controller_test.go index 6e7a72e8..62ce2495 100644 --- a/internal/controller/networkinterfaceclaim_controller_test.go +++ b/internal/controller/networkinterfaceclaim_controller_test.go @@ -686,6 +686,31 @@ func TestNetworkInterfaceClaimBindsDualStack(t *testing.T) { "Ready requires Prepared and Programmed, which nothing reports yet") } +func TestNetworkInterfaceClaimProjectsAttachedTo(t *testing.T) { + s := newScenario(t, true, + []networkingv1alpha.IPFamily{networkingv1alpha.IPv4Protocol}, + publicV4Class()) + + attachedTo := &networkingv1alpha.AttachedToRef{ + APIGroup: "compute.datumapis.com", + Kind: "Instance", + Name: "web-0", + } + + claim := s.createClaim("web-0-eth0", networkingv1alpha.NetworkInterfaceClaimSpec{ + InterfaceName: "eth0", + IPFamilies: []networkingv1alpha.IPFamily{networkingv1alpha.IPv4Protocol}, + ReclaimPolicy: networkingv1alpha.NetworkInterfaceReclaimPolicyDelete, + AttachedTo: attachedTo, + }) + s.reconcile(claim) + + iface, err := s.getInterface("web-0-eth0") + require.NoError(t, err) + require.Equal(t, attachedTo, iface.Spec.AttachedTo, + "the claim's attachedTo is projected onto the bound interface") +} + func TestNetworkInterfaceClaimFailsClosedWithoutProject(t *testing.T) { s := newScenario(t, false, []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol}) diff --git a/internal/extensionserver/cache/index.go b/internal/extensionserver/cache/index.go index fb0965ef..97055272 100644 --- a/internal/extensionserver/cache/index.go +++ b/internal/extensionserver/cache/index.go @@ -39,6 +39,7 @@ func BuildPolicyIndexFromClient(ctx context.Context, cl client.Client, baseDirec TPPs: make(map[string][]TPPInfo), Connectors: make(map[ConnectorKey]ConnectorInfo), VPCPods: make(map[VPCPodKey]VPCPodInfo), + UpstreamRefs: make(map[UpstreamRefKey]UpstreamRef), } if err := populateFromClient(ctx, cl, idx, baseDirectives); err != nil { return nil, err @@ -121,7 +122,7 @@ func populateFromClient(ctx context.Context, cl client.Client, idx *PolicyIndex, if err := cl.List(ctx, &sliceList); err != nil { return fmt.Errorf("list EndpointSlices: %w", err) } - tenantByAddress, addressesByOwner := endpointSliceAddressMaps(&sliceList) + tenantByAddress, addressesByOwner, attachedToByOwner := endpointSliceAddressMaps(&sliceList) // --- HTTPProxies → ConnectorInfo --- var proxyList networkingv1alpha.HTTPProxyList @@ -202,6 +203,10 @@ func populateFromClient(ctx context.Context, cl client.Client, idx *PolicyIndex, idx.VPCPods[key] = VPCPodInfo{TenantID: endpointSlice.Labels[VPCPodTenantIDLabel]} + if ref, ok := UpstreamRefFromLabels(endpointSlice.Labels); ok { + idx.UpstreamRefs[UpstreamRefKey(key)] = ref + } + case backend.NetworkService != nil: key := VPCPodKey{ UpstreamNS: effectiveNS, @@ -223,6 +228,10 @@ func populateFromClient(ctx context.Context, cl client.Client, idx *PolicyIndex, idx.VPCPods[key] = VPCPodInfo{ TenantID: tenantForAddresses(addressesByOwner[owner], tenantByAddress), } + + if ref, ok := attachedToByOwner[owner]; ok { + idx.UpstreamRefs[UpstreamRefKey(key)] = ref + } } } } @@ -230,9 +239,9 @@ func populateFromClient(ctx context.Context, cl client.Client, idx *PolicyIndex, return nil } -// endpointSliceAddressMaps builds the two lookups a networkService backend's -// VRF binding is resolved through, in one pass over the cluster's -// EndpointSlices. +// endpointSliceAddressMaps builds the lookups a networkService backend's VRF +// binding and upstream reference are resolved through, in one pass over the +// cluster's EndpointSlices. // // tenantByAddress maps a member address to the tenant galactic labelled that // address's own slice with. An edge holds both the slices galactic publishes @@ -249,9 +258,11 @@ func populateFromClient(ctx context.Context, cl client.Client, idx *PolicyIndex, func endpointSliceAddressMaps(sliceList *discoveryv1.EndpointSliceList) ( tenantByAddress map[string]string, addressesByOwner map[client.ObjectKey][]string, + attachedToByOwner map[client.ObjectKey]UpstreamRef, ) { tenantByAddress = make(map[string]string) addressesByOwner = make(map[client.ObjectKey][]string) + attachedToByOwner = make(map[client.ObjectKey]UpstreamRef) for i := range sliceList.Items { slice := &sliceList.Items[i] @@ -262,6 +273,11 @@ func endpointSliceAddressMaps(sliceList *discoveryv1.EndpointSliceList) ( } ownerKey := client.ObjectKey{Namespace: slice.Namespace, Name: owner} + if owner != "" { + if ref, ok := UpstreamRefFromLabels(slice.Labels); ok { + attachedToByOwner[ownerKey] = ref + } + } for _, endpoint := range slice.Endpoints { for _, rawAddress := range endpoint.Addresses { address := canonicalAddress(rawAddress) @@ -278,7 +294,7 @@ func endpointSliceAddressMaps(sliceList *discoveryv1.EndpointSliceList) ( } } - return tenantByAddress, addressesByOwner + return tenantByAddress, addressesByOwner, attachedToByOwner } // canonicalAddress normalises an endpoint address so two spellings of one diff --git a/internal/extensionserver/cache/index_test.go b/internal/extensionserver/cache/index_test.go index bd5fcdd3..89f9696f 100644 --- a/internal/extensionserver/cache/index_test.go +++ b/internal/extensionserver/cache/index_test.go @@ -951,6 +951,72 @@ func TestBuildPolicyIndexFromClient_VPCPodResolution_TenantIDFromLabel(t *testin assert.Equal(t, tenantID, info.TenantID) } +func TestBuildPolicyIndexFromClient_UpstreamRefFromInstanceSliceLabels(t *testing.T) { + const ( + upstreamNS = "test-project" + proxyName = "my-proxy" + podSlice = "vpc-pod-1" + ) + scheme := indexTestScheme(t) + + proxy := newVPCPodHTTPProxy(upstreamNS, podSlice) + endpointSlice := &discoveryv1.EndpointSlice{ + ObjectMeta: metav1.ObjectMeta{ + Name: podSlice, + Namespace: upstreamNS, + Labels: map[string]string{ + AttachedToGroupLabel: "compute.datumapis.com", + AttachedToKindLabel: "Instance", + AttachedToNameLabel: "web-0", + }, + }, + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(proxy, endpointSlice). + Build() + + idx, err := BuildPolicyIndexFromClient(context.Background(), cl, nil) + require.NoError(t, err) + + ref, ok := idx.UpstreamRefs[UpstreamRefKey{UpstreamNS: upstreamNS, HTTPProxyName: proxyName, RuleIndex: 0}] + require.True(t, ok, "attached-to labels must populate UpstreamRefs") + assert.Equal(t, UpstreamRef{APIGroup: "compute.datumapis.com", Kind: "Instance", Name: "web-0"}, ref) +} + +func TestBuildPolicyIndexFromClient_UpstreamRefAbsentWhenLabelsIncomplete(t *testing.T) { + const ( + upstreamNS = "other-project" + proxyName = "my-proxy" + podSlice = "vpc-pod-1" + ) + scheme := indexTestScheme(t) + + proxy := newVPCPodHTTPProxy(upstreamNS, podSlice) + endpointSlice := &discoveryv1.EndpointSlice{ + ObjectMeta: metav1.ObjectMeta{ + Name: podSlice, + Namespace: upstreamNS, + Labels: map[string]string{ + AttachedToGroupLabel: "compute.datumapis.com", + AttachedToKindLabel: "Instance", + }, + }, + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(proxy, endpointSlice). + Build() + + idx, err := BuildPolicyIndexFromClient(context.Background(), cl, nil) + require.NoError(t, err) + + _, ok := idx.UpstreamRefs[UpstreamRefKey{UpstreamNS: upstreamNS, HTTPProxyName: proxyName, RuleIndex: 0}] + assert.False(t, ok, "an incomplete label set names no upstream reference") +} + func TestBuildPolicyIndexFromClient_VPCPodResolution_MissingEndpointSlice_EmptyTenantID(t *testing.T) { // HTTPProxy references a vpcPod EndpointSlice that doesn't exist. Production // behavior: cl.Get returns NotFound → VPCPodInfo{} (empty TenantID), so diff --git a/internal/extensionserver/cache/types.go b/internal/extensionserver/cache/types.go index 38fed343..e53850ea 100644 --- a/internal/extensionserver/cache/types.go +++ b/internal/extensionserver/cache/types.go @@ -62,6 +62,14 @@ type PolicyIndex struct { // populated for HTTPProxy rules that have a vpcPod backend. Accumulated // across all engaged clusters, same shape as Connectors. VPCPods map[VPCPodKey]VPCPodInfo + + // UpstreamRefs maps (upstreamNS, httpProxyName, ruleIndex) to the consumer + // resource the rule's backend is attached to, read from the attached-to + // labels the HTTPProxy controller stamps on the rule's EndpointSlice when its + // members agree. Only populated when the slice carries all three labels; + // absent when members disagree or nothing set the labels, which the mutation + // layer reads as unknown. + UpstreamRefs map[UpstreamRefKey]UpstreamRef } // TPPInfo holds the fields of a TrafficProtectionPolicy needed by the @@ -149,3 +157,47 @@ type VPCPodKey struct { HTTPProxyName string RuleIndex int } + +// AttachedTo label keys, duplicated from +// internal/controller.AttachedToGroupLabel and its siblings rather than +// imported, to keep the extension server and controller packages decoupled the +// same way VPCPodTenantIDLabel is. +const ( + AttachedToGroupLabel = "networking.datumapis.com/attached-to-group" + AttachedToKindLabel = "networking.datumapis.com/attached-to-kind" + AttachedToNameLabel = "networking.datumapis.com/attached-to-name" +) + +// UpstreamRef names the consumer resource an HTTPProxy rule's backend is +// attached to. It is read from the attached-to labels on the rule's +// EndpointSlice and written into the Envoy cluster's datum-gateway +// filter_metadata so the access log can report the upstream a request was +// proxied to. +type UpstreamRef struct { + APIGroup string + Kind string + Name string +} + +// UpstreamRefKey uniquely identifies an HTTPProxy rule whose EndpointSlice +// carries attached-to labels. Same shape and namespace-keying rationale as +// ConnectorKey. +type UpstreamRefKey struct { + UpstreamNS string + HTTPProxyName string + RuleIndex int +} + +// UpstreamRefFromLabels reads the three attached-to labels and returns the +// reference they name, or ok=false unless all three are present. Members that +// disagree leave the labels off entirely, so a partial set is treated the same +// as none. +func UpstreamRefFromLabels(labels map[string]string) (UpstreamRef, bool) { + group := labels[AttachedToGroupLabel] + kind := labels[AttachedToKindLabel] + name := labels[AttachedToNameLabel] + if group == "" || kind == "" || name == "" { + return UpstreamRef{}, false + } + return UpstreamRef{APIGroup: group, Kind: kind, Name: name}, true +} diff --git a/internal/extensionserver/mutate/upstreammeta.go b/internal/extensionserver/mutate/upstreammeta.go new file mode 100644 index 00000000..3bbfe8b0 --- /dev/null +++ b/internal/extensionserver/mutate/upstreammeta.go @@ -0,0 +1,92 @@ +package mutate + +import ( + clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + "google.golang.org/protobuf/types/known/structpb" + + extcache "go.datum.net/network-services-operator/internal/extensionserver/cache" +) + +// upstream_* metadata field names written into a cluster's datum-gateway +// filter_metadata. The access log format reads them back with +// %CLUSTER_METADATA(datum-gateway:upstream_kind)% and its siblings. +const ( + upstreamAPIGroupField = "upstream_apigroup" + upstreamKindField = "upstream_kind" + upstreamNameField = "upstream_name" +) + +// ApplyUpstreamMetadata writes the consumer resource a rule's backend is +// attached to into that rule's Envoy cluster metadata, so the access log can +// report the upstream a request was proxied to. The reference is read from the +// attached-to labels the HTTPProxy controller stamps on the rule's +// EndpointSlice, carried through the policy index, and stamped into +// cluster.Metadata.FilterMetadata["datum-gateway"] here. +// +// Clusters are matched by name using the same +// "httproute///rule/" pattern the connector and vpcPod +// families rely on. A rule whose members disagree on their upstream, or that no +// label set reached, has no index entry and is left unstamped: the access log +// then renders the field empty rather than naming one member's upstream for a +// request another member served. +// +// Returns the number of clusters mutated. +func ApplyUpstreamMetadata(clusters []*clusterv3.Cluster, idx *extcache.PolicyIndex) (mutated int) { + for _, cl := range clusters { + dsNS, proxyName, ruleIndex, ok := parseConnectorClusterName(cl.GetName()) + if !ok { + continue + } + + upstreamNS, ok := idx.DStoUS[dsNS] + if !ok { + continue + } + + ref, ok := idx.UpstreamRefs[extcache.UpstreamRefKey{ + UpstreamNS: upstreamNS, + HTTPProxyName: proxyName, + RuleIndex: ruleIndex, + }] + if !ok { + continue + } + + injectClusterUpstreamMetadata(cl, ref) + mutated++ + } + return mutated +} + +// injectClusterUpstreamMetadata writes the three upstream_* fields into a +// cluster's datum-gateway filter_metadata, creating the metadata and the +// namespace struct when absent and leaving every other field untouched. It +// mirrors injectProjectNameMetadata in tpp.go, which does the same for a route. +func injectClusterUpstreamMetadata(cl *clusterv3.Cluster, ref extcache.UpstreamRef) { + if cl.Metadata == nil { + cl.Metadata = &corev3.Metadata{} + } + if cl.Metadata.FilterMetadata == nil { + cl.Metadata.FilterMetadata = make(map[string]*structpb.Struct) + } + + fields := map[string]any{ + upstreamAPIGroupField: ref.APIGroup, + upstreamKindField: ref.Kind, + upstreamNameField: ref.Name, + } + + existing := cl.Metadata.FilterMetadata[datumGatewayMetadataKey] + if existing == nil { + s, _ := structpb.NewStruct(fields) + cl.Metadata.FilterMetadata[datumGatewayMetadataKey] = s + return + } + if existing.Fields == nil { + existing.Fields = make(map[string]*structpb.Value) + } + for k, v := range fields { + existing.Fields[k] = structpb.NewStringValue(v.(string)) + } +} diff --git a/internal/extensionserver/mutate/upstreammeta_test.go b/internal/extensionserver/mutate/upstreammeta_test.go new file mode 100644 index 00000000..03b19482 --- /dev/null +++ b/internal/extensionserver/mutate/upstreammeta_test.go @@ -0,0 +1,120 @@ +package mutate + +import ( + "testing" + + clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" + + extcache "go.datum.net/network-services-operator/internal/extensionserver/cache" +) + +func upstreamRefIndex(ref extcache.UpstreamRef) *extcache.PolicyIndex { + return &extcache.PolicyIndex{ + DStoUS: map[string]string{testDSNS: testUpstreamNS}, + UpstreamRefs: map[extcache.UpstreamRefKey]extcache.UpstreamRef{ + { + UpstreamNS: testUpstreamNS, + HTTPProxyName: testProxyName, + RuleIndex: 0, + }: ref, + }, + } +} + +func datumGatewayFields(t *testing.T, cl *clusterv3.Cluster) map[string]*structpb.Value { + t.Helper() + require.NotNil(t, cl.Metadata) + s := cl.Metadata.FilterMetadata[datumGatewayMetadataKey] + require.NotNil(t, s, "datum-gateway filter_metadata must be present") + return s.Fields +} + +func TestApplyUpstreamMetadata_StampsMatchingCluster(t *testing.T) { + idx := upstreamRefIndex(extcache.UpstreamRef{ + APIGroup: "compute.datumapis.com", + Kind: "Instance", + Name: "web-0", + }) + + clusters := []*clusterv3.Cluster{ + {Name: testClusterName()}, + {Name: "infra-cluster"}, + } + + mutated := ApplyUpstreamMetadata(clusters, idx) + assert.Equal(t, 1, mutated) + + fields := datumGatewayFields(t, clusters[0]) + assert.Equal(t, "compute.datumapis.com", fields[upstreamAPIGroupField].GetStringValue()) + assert.Equal(t, "Instance", fields[upstreamKindField].GetStringValue()) + assert.Equal(t, "web-0", fields[upstreamNameField].GetStringValue()) + + assert.Nil(t, clusters[1].Metadata, "non-matching cluster must be untouched") +} + +func TestApplyUpstreamMetadata_PreservesExistingDatumGatewayFields(t *testing.T) { + idx := upstreamRefIndex(extcache.UpstreamRef{ + APIGroup: "compute.datumapis.com", + Kind: "Instance", + Name: "web-0", + }) + + existing, err := structpb.NewStruct(map[string]any{"project_name": "acme"}) + require.NoError(t, err) + + clusters := []*clusterv3.Cluster{{ + Name: testClusterName(), + Metadata: &corev3.Metadata{ + FilterMetadata: map[string]*structpb.Struct{ + datumGatewayMetadataKey: existing, + }, + }, + }} + + mutated := ApplyUpstreamMetadata(clusters, idx) + assert.Equal(t, 1, mutated) + + fields := datumGatewayFields(t, clusters[0]) + assert.Equal(t, "acme", fields["project_name"].GetStringValue()) + assert.Equal(t, "Instance", fields[upstreamKindField].GetStringValue()) +} + +func TestApplyUpstreamMetadata_SkipsWhenNoIndexEntry(t *testing.T) { + idx := &extcache.PolicyIndex{ + DStoUS: map[string]string{testDSNS: testUpstreamNS}, + UpstreamRefs: map[extcache.UpstreamRefKey]extcache.UpstreamRef{}, + } + + clusters := []*clusterv3.Cluster{{Name: testClusterName()}} + + mutated := ApplyUpstreamMetadata(clusters, idx) + assert.Equal(t, 0, mutated) + assert.Nil(t, clusters[0].Metadata, "a rule with no upstream reference is left unstamped") +} + +func TestUpstreamRefFromLabels(t *testing.T) { + ref, ok := extcache.UpstreamRefFromLabels(map[string]string{ + extcache.AttachedToGroupLabel: "compute.datumapis.com", + extcache.AttachedToKindLabel: "Instance", + extcache.AttachedToNameLabel: "web-0", + }) + require.True(t, ok) + assert.Equal(t, extcache.UpstreamRef{ + APIGroup: "compute.datumapis.com", + Kind: "Instance", + Name: "web-0", + }, ref) + + _, ok = extcache.UpstreamRefFromLabels(map[string]string{ + extcache.AttachedToGroupLabel: "compute.datumapis.com", + extcache.AttachedToKindLabel: "Instance", + }) + assert.False(t, ok, "a partial label set names no reference") + + _, ok = extcache.UpstreamRefFromLabels(nil) + assert.False(t, ok) +} diff --git a/internal/extensionserver/server/server.go b/internal/extensionserver/server/server.go index a3268d6c..12c9c2bc 100644 --- a/internal/extensionserver/server/server.go +++ b/internal/extensionserver/server/server.go @@ -308,6 +308,16 @@ func (s *Server) PostTranslateModify( } extmetrics.VPCPodSocketBindTotal.Add(float64(vpcPodCount)) + // --- Upstream metadata family --- + // Stamps the consumer resource a rule's backend is attached to into that + // rule's cluster metadata, so the edge access log can report the upstream a + // request was proxied to. Independent of the families above; a cluster may + // carry this alongside a connector or vpcPod mutation. + _, upstreamMetaSpan := tr.Start(mctx, "upstreammeta.clusters") + upstreamMetaCount := mutate.ApplyUpstreamMetadata(clusters, idx) + upstreamMetaSpan.SetAttributes(attribute.Int("clusters.upstream_meta_stamped", upstreamMetaCount)) + upstreamMetaSpan.End() + mspan.End() extmetrics.PhaseDuration.WithLabelValues("mutate").Observe(time.Since(mutStart).Seconds()) @@ -390,6 +400,7 @@ func (s *Server) PostTranslateModify( "vhosts_connector_applied", vhCount, "connector_offline_routes", offlineRtCount, "clusters_vpcpod_bound", vpcPodCount, + "clusters_upstream_meta_stamped", upstreamMetaCount, ) return &pb.PostTranslateModifyResponse{