Skip to content

fix(operator): block duplicate network expose endpoints across packages#2783

Open
chance-coleman wants to merge 3 commits into
mainfrom
chance/core-600
Open

fix(operator): block duplicate network expose endpoints across packages#2783
chance-coleman wants to merge 3 commits into
mainfrom
chance/core-600

Conversation

@chance-coleman

Copy link
Copy Markdown
Contributor

Description

Two UDSPackage CRs in different namespaces can expose the same ingress endpoint with no warning. Whichever VirtualService Istio resolves last wins and the other silently drops traffic. This was discovered while working on UDS Connect where deploying the same app into two namespaces created two packages both claiming doom.uds.dev.

Adds admission-time collision detection to the Pepr operator. If a package tries to expose an endpoint already owned by a package in another namespace, Pepr denies the request immediately with a clear error naming the owning namespace.

The index key is gateway:fqdn so tenant and passthrough entries with the same hostname are correctly treated as distinct routes and don't false-collide. Follows the same in-memory index pattern used by the existing SSO clientId collision check.

Related Issue

Fixes #CORE-600

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Other (security config, docs update, etc)

Steps to Validate

  1. Deploy any UDS package into a namespace
  2. Deploy the same package with a namespace override so it lands in a second namespace with the same exposed host
  3. Confirm Pepr denies the second UDSPackage CR at admission with an error message containing the FQDN and the owning namespace

Checklist before merging

@chance-coleman chance-coleman self-assigned this Jul 2, 2026
@chance-coleman

Copy link
Copy Markdown
Contributor Author

@greptileai review

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds admission-time collision detection to the Pepr operator to prevent two UDSPackage CRs in different namespaces from silently competing for the same ingress endpoint. When a package tries to expose a gateway+FQDN already owned by another namespace, the webhook now denies the request with a clear error naming the owner.

  • Introduces a fqdnIndex map ("gateway:fqdn" → namespace) in PackageStore, with correct stale-entry cleanup on both update (add()) and deletion (remove()), and exposes it via findNamespaceForExpose().
  • Adds getExposureKey() in domain-utils.ts to compute compound keys, ensuring tenant, admin, and passthrough entries sharing the same hostname are treated as distinct routes and never false-collide.
  • Wires the collision check into the validator loop immediately after VS-name uniqueness, and follows the same index pattern as the existing SSO clientId collision check; comprehensive unit and integration tests cover add/remove/update, cross-package isolation, and gateway-distinction scenarios.

Confidence Score: 5/5

Safe to merge — the change is additive and contained to the in-memory index and admission webhook.

The collision detection follows the identical pattern as the existing SSO clientId check, the fqdnIndex cleanup is symmetric across add/update/remove, and the test suite covers the full matrix of scenarios including self-update, gateway distinction, and cross-namespace denial.

No files require special attention.

Important Files Changed

Filename Overview
src/pepr/operator/controllers/domain-utils.ts Adds getExposureKey() helper that computes a compound "gateway:fqdn" index key, correctly distinguishing tenant/admin/passthrough entries with the same host.
src/pepr/operator/controllers/packages/package-store.ts Introduces fqdnIndex map and findNamespaceForExpose(); correctly cleans up stale index entries on update and uses storedPkg for both FQDN and SSO cleanup on removal.
src/pepr/operator/crd/validators/package-validator.ts Wires collision detection into the expose validation loop; correctly allows self-updates while denying cross-namespace FQDN theft.
src/pepr/operator/controllers/packages/package-store.spec.ts Adds comprehensive unit tests for findNamespaceForExpose covering add, remove, update, cross-package isolation, and gateway-distinction cases.
src/pepr/operator/crd/validators/package-validator.spec.ts Adds integration-level validator tests for cross-namespace FQDN collision; correctly seeds the store and verifies both deny and approve paths including self-update and gateway-distinction cases.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant K8s as Kubernetes API
    participant PW as Pepr Webhook (validator)
    participant PS as PackageStore (in-memory)
    participant FI as fqdnIndex

    Note over PS,FI: Package A (ns-a) already admitted
    PS->>FI: "fqdnIndex[tenant:doom.uds.dev] = ns-a"

    K8s->>PW: Admit UDSPackage (ns-b, expose: doom.uds.dev)
    PW->>PS: "findNamespaceForExpose({host:doom})"
    PS->>FI: get(tenant:doom.uds.dev)
    FI-->>PS: ns-a
    PS-->>PW: "ownerNs = ns-a"
    PW->>PW: "ownerNs(ns-a) != ns(ns-b)"
    PW-->>K8s: Deny - endpoint already owned by ns-a

    K8s->>PW: Admit UDSPackage (ns-a update, expose: doom.uds.dev)
    PW->>PS: "findNamespaceForExpose({host:doom})"
    PS->>FI: get(tenant:doom.uds.dev)
    FI-->>PS: ns-a
    PS-->>PW: "ownerNs = ns-a"
    PW->>PW: "ownerNs(ns-a) == ns(ns-a)"
    PW-->>K8s: Approve (self-update allowed)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant K8s as Kubernetes API
    participant PW as Pepr Webhook (validator)
    participant PS as PackageStore (in-memory)
    participant FI as fqdnIndex

    Note over PS,FI: Package A (ns-a) already admitted
    PS->>FI: "fqdnIndex[tenant:doom.uds.dev] = ns-a"

    K8s->>PW: Admit UDSPackage (ns-b, expose: doom.uds.dev)
    PW->>PS: "findNamespaceForExpose({host:doom})"
    PS->>FI: get(tenant:doom.uds.dev)
    FI-->>PS: ns-a
    PS-->>PW: "ownerNs = ns-a"
    PW->>PW: "ownerNs(ns-a) != ns(ns-b)"
    PW-->>K8s: Deny - endpoint already owned by ns-a

    K8s->>PW: Admit UDSPackage (ns-a update, expose: doom.uds.dev)
    PW->>PS: "findNamespaceForExpose({host:doom})"
    PS->>FI: get(tenant:doom.uds.dev)
    FI-->>PS: ns-a
    PS-->>PW: "ownerNs = ns-a"
    PW->>PW: "ownerNs(ns-a) == ns(ns-a)"
    PW-->>K8s: Approve (self-update allowed)
Loading

Reviews (2): Last reviewed commit: "Merge branch 'main' into chance/core-600" | Re-trigger Greptile

Comment thread src/pepr/operator/controllers/packages/package-store.ts Outdated
Comment thread src/pepr/operator/controllers/packages/package-store.ts Outdated
@chance-coleman
chance-coleman marked this pull request as ready for review July 2, 2026 19:00
@chance-coleman
chance-coleman requested a review from a team as a code owner July 2, 2026 19:00
@eoghanriley

Copy link
Copy Markdown
Contributor

LGTM 🚀

@mjnagel mjnagel 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.

Haven't done any code review but do want to call out that this is a breaking change and should be reflected as such in release notes at a minimum. I believe there is also technically a use-case for this when using advanced http match in an expose entry - you could have certain paths route to services in different namespaces (for the same endpoint). Whether that is something that is currently being used and/or should be supported I'm not sure.

@joelmccoy

Copy link
Copy Markdown
Contributor

Haven't done any code review but do want to call out that this is a breaking change and should be reflected as such in release notes at a minimum. I believe there is also technically a use-case for this when using advanced http match in an expose entry - you could have certain paths route to services in different namespaces (for the same endpoint). Whether that is something that is currently being used and/or should be supported I'm not sure.

Yeah this is a good callout. I think it wouldn't be a breaking change if we prevent expose entries that technically cause a conflict? If they have the same spec one of the expose entries will just not work (and I would consider that as a bug fix?). Maybe we take into account more than just the expose fqdn to make sure there aren't conflicts.

@chance-coleman

Copy link
Copy Markdown
Contributor Author

On the breaking change: i agree that any existing overlap is already silently broken at the Istio level (last VirtualService wins, undefined behavior). The admission deny just makes it visible. That said, i think it makes sense to call this change in behavior out. so I'll add a release note calling it out.

On advancedHTTP.match: The current check uses gateway:fqdn only and would incorrectly deny two packages routing different paths on the same host. I'm thinking i will just implement a fix for this now, if advancedHTTP.match is set then skip the collision check for it.

@joelmccoy

Copy link
Copy Markdown
Contributor

On advancedHTTP.match: The current check uses gateway:fqdn only and would incorrectly deny two packages routing different paths on the same host. I'm thinking i will just implement a fix for this now, if advancedHTTP.match is set then skip the collision check for it.

I need to think through this... But would it instead be worth it to just take a hash or string combination of all the fields that make an expose route unique/work? i.e. FQDN + advancedHTTP + any other fields?

@chance-coleman

Copy link
Copy Markdown
Contributor Author

I need to think through this... But would it instead be worth it to just take a hash or string combination of all the fields that make an expose route unique/work? i.e. FQDN + advancedHTTP + any other fields?

the hash approach is better than skip for catching exact duplicates, but neither solves actual path overlap detection. Whether that extra coverage is worth the added implementation complexity is the real question.

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.

4 participants