Skip to content

fix(operator): reject conflicting cross-namespace expose routes - #2783

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

fix(operator): reject conflicting cross-namespace expose routes#2783
chance-coleman wants to merge 4 commits into
mainfrom
chance/core-600

Conversation

@chance-coleman

@chance-coleman chance-coleman commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Description

Adds admission-time collision detection for HTTP Package CR expose entries.

Two packages in different namespaces cannot claim the same gateway and FQDN when either route is a catch-all. Both routes remain allowed when they define advancedHTTP.match for path-based routing. Deprecated expose.match entries are treated as advanced matches.

The cross-namespace rule intentionally follows option 2 from the review discussion:

  • Deny when the gateway and FQDN match and either expose entry is a catch-all.
  • Allow when both expose entries define advancedHTTP.match.
  • Do not apply this cross-namespace check between expose entries in the same Package CR.
  • Keep gateway and FQDN in the index key so distinct gateways do not false-collide.
  • Remove stale exposure index entries during package updates and deletes.

This changes admission behavior for previously ambiguous configurations that already had undefined runtime routing behavior. The breaking-change release note is intentionally deferred to the 1.11 release preparation and is not added to the 1.9 notes.

Related Issue

Fixes #CORE-600

Type of change

  • Bug fix (behavioral validation change for previously ambiguous routes)
  • New feature (non-breaking change which adds functionality)
  • Other (security config, docs update, etc)

Steps to Validate

  1. Run npm run test:unit.
  2. Run npm run format:check.
  3. Confirm same gateway/FQDN catch-all exposes across namespaces are denied.
  4. Confirm routes are allowed when both entries define advancedHTTP.match.
  5. Confirm deprecated expose.match entries are treated as advanced matches.
  6. Confirm catch-all and advanced entries remain allowed within one Package CR.
  7. Confirm package updates and deletes remove stale collision-index entries.

Checklist before merging

  • Tests added or updated as needed
  • Release-note placement reviewed; entry deferred to 1.11 release preparation
  • Contributor Guide followed

@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

The PR adds admission-time detection of conflicting cross-namespace HTTP expose routes and keeps the supporting in-memory indexes synchronized during package updates and deletion.

  • Indexes HTTP exposes by normalized gateway and FQDN.
  • Allows shared endpoints only when both cross-namespace routes define path matching.
  • Cleans exposure, SSO, and UDP indexes using the package specification retained by the store.
  • Adds coverage for collision rules, gateway separation, casing, legacy matches, and stale-event cleanup.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; both previously reported issues are addressed by the current code.

Important Files Changed

Filename Overview
src/pepr/operator/controllers/domain-utils.ts Adds a normalized compound gateway/FQDN key shared by exposure indexing and lookup.
src/pepr/operator/controllers/packages/package-store.ts Adds the HTTP exposure index and correctly uses the stored package specification when removing all associated indexes.
src/pepr/operator/crd/validators/package-validator.ts Rejects cross-namespace endpoint collisions when either route is a catch-all while allowing two path-matched routes.
src/pepr/operator/controllers/packages/package-store.spec.ts Covers exposure lookup, replacement, cleanup, normalization, gateway separation, and match-aware collision behavior.
src/pepr/operator/crd/validators/package-validator.spec.ts Covers the new admission outcomes for catch-all, advanced, deprecated-match, same-package, and distinct-gateway routes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Package admission request] --> B[Normalize gateway and FQDN]
  B --> C[Look up indexed exposes]
  C --> D{Different namespace with same key?}
  D -- No --> G[Approve after remaining validation]
  D -- Yes --> E{Both routes have path matches?}
  E -- Yes --> G
  E -- No --> F[Deny conflicting endpoint]
  H[Package watch event] --> I{Added or Modified?}
  I -- Yes --> J[Replace stored package and indexes]
  I -- No, Deleted --> K[Load stored package before removal]
  K --> L[Remove exposure, SSO, and UDP indexes]
Loading

Reviews (3): 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 🚀

eoghanriley
eoghanriley previously approved these changes Jul 10, 2026

@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
Member

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
Member

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.

@chance-coleman
chance-coleman marked this pull request as draft July 20, 2026 18:22
@mjnagel

mjnagel commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Getting around to reviewing this again - the root of this issue is that we want to block Packages if their expose entry would match on the same endpoint (host + path) as another existing Package.

Reviewing the istio configuration here I think this could be a rough approach for handling:

Details
  • Package store maintains a lookup of host (example: foo.uds.dev) mapped to IndexedRoute:
    type MatchValue =
      | { kind: "any" }
      | { kind: "exact"; value: string }
      | { kind: "prefix"; value: string };
    
    type IndexedRoute = {
      namespace: string;
      packageName: string;
      uri: MatchValue;
      method: MatchValue;
    };
    
    type ExposureIndex = Map<string, IndexedRoute[]>;
    // key: `${gateway}:${fqdn}`
  • On package create/update, parse expose spec to add to the store (this parsing should be relatively straightforward based on expose spec). Examples:
        Examples:
      
        // No match block
        { uri: { kind: "any" }, method: { kind: "any" } }
      
        // match uri prefix: /bar
        { uri: { kind: "prefix", value: "/bar" }, method: { kind: "any" } }
      
        // match method: GET; uri prefix: /bar
        {
          uri: { kind: "prefix", value: "/bar" },
          method: { kind: "exact", value: "GET" },
        }
    
  • On validate check for a host match, then compare IndexedRoutes to identify if there are any conflicts:
    // Call this per existing `IndexedRoute`
    function routesOverlap(a: IndexedRoute, b: IndexedRoute) {
      return valuesOverlap(a.uri, b.uri) && valuesOverlap(a.method, b.method);
    }
    
    function valuesOverlap(a: MatchValue, b: MatchValue) {
      if (a.kind === "any" || b.kind === "any") return true;
    
      if (a.kind === "exact" && b.kind === "exact") {
        return a.value === b.value;
      }
    
      if (a.kind === "exact" && b.kind === "prefix") {
        return a.value.startsWith(b.value);
      }
    
      if (a.kind === "prefix" && b.kind === "exact") {
        return b.value.startsWith(a.value);
      }
    
      return a.value.startsWith(b.value) || b.value.startsWith(a.value);
    }

The main edge cases/missing pieces in the examples above are some of the more complex/annoying settings:

  • query params in a match
  • regex for method / URI
  • ignoreUriCase

I think we could handle any/all of these if we wanted, but it does start to increase the complexity more and more. I couldn't come up with a solid way to take a hash approach or more cleanly diff things - I think we'd really have to compare setting by setting for each of these.

After going through that exercise I'm not actually convinced it's the right approach - it starts to feel like we're coupling too closely to advanced Istio mechanisms. Thinking about this a bit more from our actual userbase... most users will probably not be touching advancedHttp. We likely want to err more on the side of "allow" than "deny", while still catching the obvious stuff (like what spawned the original issue). Two potential approaches that I think would prevent some obvious footguns, while being a bit permissive when people are using advancedHttp:

  1. Deny if hosts match and BOTH expose entries are "catchalls" (no advancedHttp.match)
  2. Deny if hosts match and at least ONE expose entry is a "catchall" (no advancedHttp.match)

Both of these would be a bit permissive when advancedHttp.match comes into play. Option 1 is the loosest but would allow things like our Keycloak config to work across namespaces. That's probably not a great thing in a multi-tenant environment though and I'd probably lean a bit towards option 2. Within a single Package CR we should be a bit more lenient I think (effectively option 1 if it's the same package CR, separate expose entries).

Curious on thoughts here @joelmccoy @chance-coleman - does this direction make sense?

@chance-coleman

Copy link
Copy Markdown
Contributor Author

Option 2 does feel better to me. it's not just safer default for operators but its also more honest what happens at runtime. for the cross namespace path routing use case to work both sides would have to set a advancedHTTP.match correctly which seems reasonable for that kind of advanced configuration. I'm trying to recall but i dont think we have enough information stored as is on the expose to know if it has a match, just something we'd have to think through before implementation.

@chance-coleman chance-coleman changed the title fix(operator): block duplicate network expose endpoints across packages fix(operator): reject conflicting cross-namespace expose routes Aug 4, 2026
@chance-coleman
chance-coleman marked this pull request as ready for review August 4, 2026 19:46
@chance-coleman
chance-coleman requested a review from a team August 12, 2026 15:23
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