Skip to content

feat: implement VPC ingress control-plane backend (#856) - #387

Merged
privateip merged 7 commits into
mainfrom
feat/vpc-ingress-backend
Aug 21, 2026
Merged

feat: implement VPC ingress control-plane backend (#856)#387
privateip merged 7 commits into
mainfrom
feat/vpc-ingress-backend

Conversation

@privateip

Copy link
Copy Markdown
Contributor

Summary

Implements the plan in #342 for NSO's slice of VPC ingress (enhancements#856, accepted design enhancements#851): let a tenant's HTTPProxy reference the EndpointSlice galactic-cni publishes for a VPC pod, skip synthesizing a downstream Service for it so Envoy gets a real pod address, and bind the resulting Envoy cluster to the tenant's VRF device via SO_BINDTODEVICE — the mechanism the shared multi-tenant Envoy fleet needs to disambiguate address space that collides across tenants.

What changed

  • API: new HTTPProxyRuleBackend.VPCPod field, mutually exclusive with endpoint/connector.
  • HTTPProxy controller: a vpcPod backend references the existing EndpointSlice instead of synthesizing one; a missing reference surfaces as Programmed=False/VPCPodBackendNotFound with a short requeue instead of a bare generic one.
  • Gateway controller: a tenant-labeled EndpointSlice backendRef skips Service/EndpointSlice/BackendTLSPolicy synthesis and resolves the CNI-published EndpointSlice directly off the downstream cluster.
  • Extension server: watches the CNI-published EndpointSlice and patches the resulting cluster's upstream_bind_config with a SO_BINDTODEVICE socket option naming the tenant's VRF device.
  • Sidecar manifest: wires the #855 VRF/SRv6 sidecar into both EnvoyProxy CRs via a StrategicMerge patch — there's no native EnvoyDeployment field for a long-running sidecar, only for the main container and init containers — plus CAP_NET_RAW on the Envoy container for its own bind call.

Known placeholders (all flagged inline as TODO(#856))

  • Tenant-id label name/schema — unconfirmed with #854.
  • VRF device-naming convention — unconfirmed with #855; current scheme is SHA-256-based, bounded to IFNAMSIZ-1 (15 bytes).
  • Sidecar image — busybox:1.36/sleep infinity stand-in so the manifests stay deployable (task test-infra:up, make run) before #855 publishes a real one.
  • The tenant-id label has no provenance check yet — a real gap for a mechanism whose whole job is tenant isolation, since NSO's own manager already holds unrestricted downstream EndpointSlice RBAC.
  • The downstream-native EndpointSlice watch (listGatewaysForDownstreamVPCPodEndpointSlice) has no precedent elsewhere in this codebase and is a coarse first pass (re-reconciles every Gateway in the namespace), not a designed solution.

Test plan

  • make test (envtest, full suite — internal/controller 64.3% coverage, extensionserver/cache 83.7%, extensionserver/mutate 86.6%)
  • make lint
  • task validate-kustomizations + a rendered kustomize build of both touched EnvoyProxy manifests

Related to #342, #856

Implements steps 1-2 of the VPC ingress control-plane plan (#856):
a new backend kind that references a CNI-published EndpointSlice as
a Gateway API backend instead of synthesizing one, so the pod address
stays joined to the SID annotation the tenant-VRF/SRv6 mechanism on
the data plane depends on.

Key changes:
- Add HTTPProxyRuleBackend.VPCPod, mutually exclusive with endpoint
  and connector via a new CEL oneOf; relax endpoint to optional
- Bypass the unconditional endpoint URL-parsing and EndpointSlice
  synthesis in collectDesiredResources for vpcPod backends; build the
  backendRef directly from the referenced EndpointSlice's name/port
- Surface a missing referenced EndpointSlice as a Programmed=False/
  VPCPodBackendNotFound condition with a short requeue, instead of a
  bare generic requeue
- Extend httpproxy_validation.go with vpcPod field validation,
  splitting the endpoint-specific checks into their own function since
  vpcPod backends don't carry an endpoint URL at all

TODO(#856): the tenant-id label name/schema this backend implicitly
depends on downstream, and whether the referenced EndpointSlice lives
in the HTTPProxy's own namespace via the same client used here, are
both unconfirmed with #854. Revisit once that's settled.
Implements step 3 of the VPC ingress control-plane plan (#856): a
tenant-labeled EndpointSlice backendRef skips the usual downstream
Service + EndpointSlice synthesis and BackendTLSPolicy handling, and
resolves the CNI-published EndpointSlice directly off the downstream
cluster instead. Envoy needs a real pod address for the tenant-VRF/
SRv6 socket-bind mechanism to work, so a ClusterIP Service in the
middle defeats the whole point.

Key changes:
- Branch on a new VPCPodTenantIDLabel before the existing unconditional
  upstream Get + GC-finalizer stamp, not just before Service synthesis
  further down — a CNI-published EndpointSlice's lifecycle belongs to
  galactic-cni, not this controller, so it gets neither
- Add passThroughVPCPodBackendRef to resolve the downstream-native
  EndpointSlice and pass the backendRef through unmodified
- Add a placeholder downstream watch (listGatewaysForDownstreamVPCPodEndpointSlice)
  so a CNI-side pod add/remove re-triggers the owning Gateway; flagged
  inline as the one piece of this integration with no existing
  precedent in this codebase

TODO(#856): both the tenant-id label and the assumption that
galactic-cni publishes into the same mapped downstream namespace this
controller already computes are unconfirmed with #854. The watch
implementation is explicitly a placeholder pending isolated
prototyping, not a designed solution.
Implements step 4 of the VPC ingress control-plane plan (#856): the
extension server now watches the CNI-published EndpointSlice a vpcPod
HTTPProxy backend references and patches the resulting Envoy cluster
with a VRF socket-bind option, so the shared multi-tenant fleet routes
each backend through the right tenant's VRF/SRv6 device.

Key changes:
- Prime EndpointSlice into the edge cache and add a manual RBAC grant
  for it (config/extension-server/rbac/role.yaml is hand-maintained;
  make manifests does not cover it)
- Index vpcPod backends the same way Connector backends already are
  (upstreamNS, httpProxyName, ruleIndex) -> tenant ID, resolved from
  the referenced EndpointSlice's tenant-id label
- Add mutate.ApplyVPCPodSocketBind: patches upstream_bind_config with
  a generic SOL_SOCKET/SO_BINDTODEVICE socket option at STATE_PREBIND,
  matching clusters via the same httproute/<dsNS>/<proxy>/rule/<idx>
  name pattern connector clusters already use
- Bound the VRF device name to IFNAMSIZ-1 (15 bytes); SO_BINDTODEVICE
  silently fails to bind past that

Caught during testing: protojson.Unmarshal resets its destination
message before decoding rather than merging into it, so the bind
config is built into its own fresh message and assigned onto the
cluster's UpstreamBindConfig field directly, instead of unmarshaling
the patch straight into the existing cluster (which would have
silently wiped every field Envoy Gateway had already set).

TODO(#856): the VRF device-naming scheme is a placeholder pending
#855's actual convention. The tenant-id label and the assumption that
the referenced EndpointSlice lives in the same local namespace as the
HTTPProxy replica are both unconfirmed with #854.
Implements step 5 of the VPC ingress control-plane plan (#856), and
corrects a mechanism error the plan itself carried: EnvoyDeployment
has no field for adding a long-running sidecar container — 'container'
customizes only the main Envoy container, and 'initContainers' is
run-to-completion, not long-running. The sidecar has to go in via the
existing StrategicMerge patch on spec.template.spec.containers instead,
which Kubernetes appends to by container name rather than replacing.

Key changes:
- Add the #855 VRF/SRv6 sidecar via envoyDeployment.patch in both
  config/dev/downstream_resources/downstream-gateway.yaml and
  config/e2e-downstream/envoyproxy.yaml (the only two EnvoyProxy CR
  instances in this repo), with CAP_NET_ADMIN and no other privilege
- Add CAP_NET_RAW to the main Envoy container's securityContext —
  required for its own SO_BINDTODEVICE call (step 4), separate from
  and easy to miss alongside the sidecar's CAP_NET_ADMIN
- Point the raw-patch-fragility comment at Taskfile.test-infra.yml's
  ENVOY_GATEWAY_VERSION, the actually-deployed EG controller, not
  go.mod's unrelated Go API module version
- Use busybox/sleep as the sidecar image rather than a nonexistent
  #855 image reference: an unpullable image on a container sharing
  the Envoy pod would block the whole pod's readiness (ImagePullBackOff
  gates every container by default), breaking task test-infra:up and
  make run today. Swap for #855's real binary once it's published.

Verified via # Kustomize Validation Results

🔍 Validating: ./config/certmanager
✅ ./config/certmanager is valid
🔍 Validating: ./config/crd/gateway
✅ ./config/crd/gateway is valid
🔍 Validating: ./config/crd/downstream

❌ Error in './config/crd/downstream':
----------------------------------------
Error: accumulating resources: accumulation err='accumulating resources from '../bases/networking.datumapis.com_trafficprotectionpolicies.yaml': security; file '/home/sprygada/datum-cloud/network-services-operator/config/crd/bases/networking.datumapis.com_trafficprotectionpolicies.yaml' is not in or below '/home/sprygada/datum-cloud/network-services-operator/config/crd/downstream'': must build at directory: '/home/sprygada/datum-cloud/network-services-operator/config/crd/bases/networking.datumapis.com_trafficprotectionpolicies.yaml': file is not directory
----------------------------------------

🔍 Validating: ./config/crd
✅ ./config/crd is valid
🔍 Validating: ./config/default
✅ ./config/default is valid
🔍 Validating: ./config/dev/downstream_resources
✅ ./config/dev/downstream_resources is valid
🔍 Validating: ./config/dev
✅ ./config/dev is valid
🔍 Validating: ./config/e2e
✅ ./config/e2e is valid
🔍 Validating: ./config/iam
✅ ./config/iam is valid
🔍 Validating: ./config/iam/protected-resources
✅ ./config/iam/protected-resources is valid
🔍 Validating: ./config/iam/roles
✅ ./config/iam/roles is valid
🔍 Validating: ./config/manager
✅ ./config/manager is valid
🔍 Validating: ./config/milo/activity
✅ ./config/milo/activity is valid
🔍 Validating: ./config/milo/activity/policies
✅ ./config/milo/activity/policies is valid
🔍 Validating: ./config/milo
✅ ./config/milo is valid
🔍 Validating: ./config/network-policy
✅ ./config/network-policy is valid
🔍 Validating: ./config/prometheus
✅ ./config/prometheus is valid
🔍 Validating: ./config/quota/claim-policies
✅ ./config/quota/claim-policies is valid
🔍 Validating: ./config/quota
✅ ./config/quota is valid
🔍 Validating: ./config/quota/registrations
✅ ./config/quota/registrations is valid
🔍 Validating: ./config/rbac_deployment
✅ ./config/rbac_deployment is valid
🔍 Validating: ./config/rbac_downstream
✅ ./config/rbac_downstream is valid
🔍 Validating: ./config/resource-metrics-policies
✅ ./config/resource-metrics-policies is valid
🔍 Validating: ./config/resource-metrics
✅ ./config/resource-metrics is valid
🔍 Validating: ./config/telemetry/alerts
✅ ./config/telemetry/alerts is valid
🔍 Validating: ./config/tools/cert-manager
✅ ./config/tools/cert-manager is valid
🔍 Validating: ./config/tools/envoy-gateway
✅ ./config/tools/envoy-gateway is valid
🔍 Validating: ./config/tools/envoy-gateway/overlays/e2e
✅ ./config/tools/envoy-gateway/overlays/e2e is valid
🔍 Validating: ./config/tools/external-dns
✅ ./config/tools/external-dns is valid
🔍 Validating: ./config/tools/billing-usage-collector
✅ ./config/tools/billing-usage-collector is valid
🔍 Validating: ./config/tools/envoy-gateway-downstream
✅ ./config/tools/envoy-gateway-downstream is valid
🔍 Validating: ./config/upstream_resources
✅ ./config/upstream_resources is valid
🔍 Validating: ./config/webhook
✅ ./config/webhook is valid
🔍 Validating: ./config/components/service-catalog
✅ ./config/components/service-catalog is valid
🔍 Validating: ./config/components/cell-controllers
✅ ./config/components/cell-controllers is valid
🔍 Validating: ./config/e2e-downstream/eg-downstream
✅ ./config/e2e-downstream/eg-downstream is valid
🔍 Validating: ./config/e2e-downstream/extserver-base
✅ ./config/e2e-downstream/extserver-base is valid
🔍 Validating: ./config/e2e-downstream
✅ ./config/e2e-downstream is valid
🔍 Validating: ./config/extension-server-e2e
✅ ./config/extension-server-e2e is valid
🔍 Validating: ./config/extension-server/certmanager
✅ ./config/extension-server/certmanager is valid
🔍 Validating: ./config/extension-server
✅ ./config/extension-server is valid
🔍 Validating: ./config/extension-server/network-policy
✅ ./config/extension-server/network-policy is valid
🔍 Validating: ./config/extension-server/rbac
✅ ./config/extension-server/rbac is valid
🔍 Validating: ./config/cell
✅ ./config/cell is valid
🔍 Validating: ./config/dependencies/ipam/overlay
✅ ./config/dependencies/ipam/overlay is valid
🔍 Validating: ./config/dependencies/milo/overlay
✅ ./config/dependencies/milo/overlay is valid
🔍 Validating: ./config/dependencies/postgres-operator
✅ ./config/dependencies/postgres-operator is valid

🚨 One or more kustomizations failed validation. and a rendered
 of both manifests.

TODO(#856): sidecar image/binary is a placeholder pending #855.
The vpcPod not-found branch added in the earlier commit pushed
Reconcile's cyclomatic complexity to 61, past this repo's gocyclo
threshold of 60. Collapse both decisions (err != nil, errors.As) into
a single helper call so Reconcile keeps exactly one branch here, same
as before vpcPod existed.

Also drops a redundant int32->PortNumber conversion (PortNumber is a
plain type alias for int32) golangci-lint's unconvert flagged.

make lint is clean; make test still passes.
CI caught this: the vpcPod CEL rule required exactly one of
endpoint/connector/vpcPod, which rejected every existing connector
backend — connector backends have always required endpoint alongside
connector (endpoint is the tunnel's target address; connector says
which tunnel to route it through). Only vpcPod is actually mutually
exclusive with the other two.

Failing scenario: test/e2e-edge/connector-offline-503, which sets
both endpoint and connector on the same backend, exactly like every
other connector-backed HTTPProxy in this repo.

New rule: endpoint is required unless vpcPod is set; vpcPod excludes
both endpoint and connector.

Also adds test/crd/httpproxy_backend_test.go, an envtest-backed
regression test that exercises the CEL rule against a real apiserver
for all five backend shapes. This is the coverage gap that let the
bug ship: internal/validation's unit tests exercise the hand-rolled
Go validation, never the CRD's CEL rules, and the fake client used
elsewhere never enforces schema at all. Verified the new test fails
against the reverted (buggy) rule and passes against the fix.
@privateip
privateip requested review from 0xmc and scotwells August 20, 2026 16:45
scotwells
scotwells previously approved these changes Aug 20, 2026

@scotwells scotwells left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This approach is fine for now to verify the integration. I'd like to look at something that uses the EndpointSlice mechanism or similar so we can easily program envoy to spread load across multiple backends.

Comment thread api/v1alpha/httpproxy_types.go Outdated
scotwells flagged the vpcPod field/type naming on HTTPProxyRuleBackend
as review feedback: "instance" reads better in the consumer-facing
API than an implementation detail like "VPC pod".

Renames the public API surface only: the field, its json tag, the
InstanceBackendRef type, the CEL exclusivity rule, and the
Programmed=False condition reason (visible via kubectl describe).
Internal extension-server identifiers (VPCPodTenantIDLabel,
ApplyVPCPodSocketBind, the PolicyIndex cache) keep their existing
names since they describe the CNI/VRF mechanism, not the schema.

Key changes:
- HTTPProxyRuleBackend.VPCPod -> Instance (json: vpcPod -> instance)
- VPCPodBackendRef type -> InstanceBackendRef
- HTTPProxyReasonVPCPodBackendNotFound -> ...InstanceBackendNotFound
- errVPCPodBackendNotFound -> errInstanceBackendNotFound
- update controllers, validation, and tests for the new field name
- regenerate CRDs, deepcopy, and API docs
@privateip
privateip merged commit e5bdab3 into main Aug 21, 2026
14 checks passed
@privateip
privateip deleted the feat/vpc-ingress-backend branch August 21, 2026 01:19
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.

2 participants