From 0d950f92a203ec7cab1a3422b3f7c2abd67b96d9 Mon Sep 17 00:00:00 2001 From: iglin Date: Sun, 27 Sep 2026 15:35:47 +0500 Subject: [PATCH 01/11] docs: openspec for regex routes migration to istio --- openspec/changes/archive/.gitkeep | 0 .../.openspec.yaml | 2 + .../regex-routes-prefix-migration/design.md | 231 ++++++++++++++++++ .../regex-routes-prefix-migration/proposal.md | 59 +++++ .../specs/forbidden-route-policies/spec.md | 145 +++++++++++ .../specs/httproute-generation/spec.md | 168 +++++++++++++ .../specs/route-migration-validation/spec.md | 158 ++++++++++++ .../regex-routes-prefix-migration/tasks.md | 52 ++++ openspec/config.yaml | 32 +++ openspec/specs/.gitkeep | 0 10 files changed, 847 insertions(+) create mode 100644 openspec/changes/archive/.gitkeep create mode 100644 openspec/changes/regex-routes-prefix-migration/.openspec.yaml create mode 100644 openspec/changes/regex-routes-prefix-migration/design.md create mode 100644 openspec/changes/regex-routes-prefix-migration/proposal.md create mode 100644 openspec/changes/regex-routes-prefix-migration/specs/forbidden-route-policies/spec.md create mode 100644 openspec/changes/regex-routes-prefix-migration/specs/httproute-generation/spec.md create mode 100644 openspec/changes/regex-routes-prefix-migration/specs/route-migration-validation/spec.md create mode 100644 openspec/changes/regex-routes-prefix-migration/tasks.md create mode 100644 openspec/config.yaml create mode 100644 openspec/specs/.gitkeep diff --git a/openspec/changes/archive/.gitkeep b/openspec/changes/archive/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openspec/changes/regex-routes-prefix-migration/.openspec.yaml b/openspec/changes/regex-routes-prefix-migration/.openspec.yaml new file mode 100644 index 0000000000..0ca5fbe8ef --- /dev/null +++ b/openspec/changes/regex-routes-prefix-migration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-26 diff --git a/openspec/changes/regex-routes-prefix-migration/design.md b/openspec/changes/regex-routes-prefix-migration/design.md new file mode 100644 index 0000000000..9b99d7b393 --- /dev/null +++ b/openspec/changes/regex-routes-prefix-migration/design.md @@ -0,0 +1,231 @@ +# Design + +## Context + +For motivation, see `proposal.md` (Why) and `core-maven-plugins/httproutes-generator-maven-plugin/regex-routes-migration.md` (Option 1 was chosen). Unless stated otherwise, plugin paths and classes below are in `core-maven-plugins/httproutes-generator-maven-plugin`. Current state of the plugin: + +- `RouteScanner` builds a flat `Set` (`path`, `gatewayPath`, `type`, `timeout`) from `@Route`, Spring/JAX-RS mappings, `@Gateway` and `@GatewayRequestMapping`. It does not keep where each route came from. It skips classes that have no `@Route`. It never reads the `@Routes` container, `@Route` `gateways`/`hosts`, `@FacadeRoute`, `@FacadeGateway` or `@FacadeGatewayRequestMapping`. For `@Route(RouteType.FACADE)` it takes the gateway path from `@Gateway`/`@GatewayRequestMapping`. +- `HttpRouteRenderer` groups routes by `Type` into one HTTPRoute per type. The parentRefs follow the exposure chain: PUBLIC → public-gateway, private-gateway and Service internal-gateway-service; PRIVATE → private-gateway and internal-gateway-service; INTERNAL → internal-gateway-service; FACADE → Service `{{ .Values.SERVICE_NAME }}`. It turns `{var}` into `([^/]+)` inside a `RegularExpression` match. It always uses `ReplacePrefixMatch` with the full service path, which is invalid for regex. FACADE routes whose `path == gatewayPath` are skipped, even when the FACADE HTTPRoute exists, so Istio returns 404 for them. +- Legacy facade and composite routes (`ClassRoutesSet`): both have `RouteType.FACADE`. A facade route (`@Route(RouteType.FACADE)` or `@FacadeRoute` without `gateways`) goes to the facade gateway named after the microservice, and its gateway path comes from `@FacadeGateway`/`@FacadeGatewayRequestMapping`, or equals the service path when there is none. A composite route (`gateways` with any name other than a border gateway name) goes to that gateway, with the gateway path from `@Gateway`/`@GatewayRequestMapping` and optional `hosts`. A border name in `gateways` gives a border route of that type (`RouteType.fromGatewayName`). A method-level `@Route` list replaces the class-level one. +- Legacy registration (`route-registration-common` `RouteTransformer`) registers every border route on all three border gateways, with `allowed=false` on gateways wider than the route type. It prefers `allowed=true` when a gateway path is registered twice. Annotations carry no header matchers. `HttpRouteRenderer` puts the same single `backendRefs` entry (`backendRefVal`:`servicePort`) on every rule. So route "behavior" is only forbidden vs allowed, the upstream path (rewrite) and the timeout. The migration document's "different cluster" and "header matcher" overlaps (routes 3 and 4 of its example, both to `another-service`) can't come from this plugin. +- The plugin depends on `route-registration-common` `7.5.2-SNAPSHOT` from the same monorepo (`core-rest-libraries/route-registration/route-registration-common`). + +## Goals / Non-Goals + +**Goals:** +- One pipeline where generation and validation share the same route model, so that what we validate is exactly what we render. +- Validation that catches ordering interactions (cut, merge, Exact, DENY/notPaths) without a separate hand-written rule for each case in the migration document. +- Diagnostics precise enough that a developer can fix the build without reading the migration document. + +**Non-Goals:** +- Reproducing the Envoy string-prefix behavior of legacy prefixes without variables (legacy `/api/users` also matched `/api/usersX`). Both models match whole segments, and this difference is not reported. +- Routes of other services on the same gateway, and the gateway's own `/` fallback route. "Unrouted" means "not routed to this service". +- Fixing existing scanner deviations from legacy path resolution. For example, `buildClassGatewayRoutes` uses only the first class mapping. The legacy model is built from what the scanner produces. The annotation forms the scanner didn't read at all are read now (D13), and facade gateway paths follow legacy. +- Modeling facade or composite gateways, or comparing facade and composite routes with legacy routing. Istio has none of these gateways; the routes become service-bound rules (D15). +- A catch-all rule on the service-bound HTTPRoute (user decision). Paths that no facade or composite rule matches return 404 while that HTTPRoute exists. +- Generating a `VirtualService` fallback. Conflicts are reported, and any fallback is manual. +- Overlaps with routes that lead to another service (different legacy `cluster`) or that differ only by header matchers. The plugin can't produce them (see Context), so no model, planner step, finding kind or test covers them. There is no backend field in `HttpRoute`, the planned rules or the decisions, and no header matchers are modeled. +- A switch to turn validation errors into warnings. The opt-in `autoGenerateAuthorizationPolicies` (D12) removes the largest class of failures, exposures, by fixing them instead of hiding them. + +## Decisions + +### D1. Pipeline: scan → model → plan → validate → render +`GenerateRoutesMojo.execute()` becomes: + +1. `RouteScanner.collectDeclarations(reactorProjects)` returns a `RouteDeclarations` DTO (D14) with: + - `List`: an unchanged `HttpRoute` plus the sorted set of origins (`class#method` or `class`) that declared it; + - `List`: gateway path, set of target gateways, origin; + - scan-time findings (legacy-invalid `hosts`, D13; invalid `@ForbiddenRoute` usage, spec: Invalid ForbiddenRoute usage). +2. `LegacyRouteTable.build(declarations)`: per `Gateway` (a new enum PUBLIC, PRIVATE, INTERNAL with Istio ref data), a list of `LegacyEntry(pattern, allowed, route|forbidden, origin)`. FACADE routes are not added to any table. +3. `IstioRoutePlanner.plan(declarations, legacyTables)` produces an `IstioPlan`: + - HTTPRoute rules per route type, each rule with `MatchType`, value, rewrite kind and value, timeout, and source routes. FACADE rules are planned for the service-bound target (D15); + - `DenyRule`s per gateway, from `@ForbiddenRoute` (D8) and, when `autoGenerateAuthorizationPolicies` is `true`, automatic ones (D12); + - generation-time findings (unresolved same-match conflicts, legacy-invalid duplicates). +4. `MigrationValidator.validate(legacyTables, plan)` returns a list of `Finding`s. +5. If there is any finding with severity ERROR: log the report and throw `MojoFailureException`. Nothing is written. +6. Otherwise, `HttpRouteRenderer` renders the planned rules (it no longer takes raw `HttpRoute`s), and the new `AuthorizationPolicyRenderer` renders the deny rules. Both are written into one file in the existing header and guard. + +*Alternative*: keep the rendering-from-`Set` API and add checks around it. Rejected: then validation would re-derive the rendered rules and could drift from them. + +`MojoFailureException` (not `MojoExecutionException`) is used because the failure is caused by the project's configuration, not by the plugin crashing. `execute()` declares both exceptions. Scan, plan and validation run before `writeRoutesFile`, so its `catch (Exception e)` → `MojoExecutionException` wrapper can't turn a validation failure into an execution error. That wrapper stays only around rendering and file I/O. + +### D2. Validate by differential simulation, not pairwise overlap rules +Both models expose `Decision decide(Gateway, String requestPath)`. A decision is one of: +- `Routed(upstreamPath, timeout, source)`. There is no backend: every routed decision goes to this service; +- `Forbidden(source)` (legacy) or `Denied(rule)` (Istio); +- `Unrouted`. + +The validator runs both models on a generated set of probe paths and classifies each pair as the spec describes. The limitations in the migration document that the plugin can produce (cut conflicts between rewrites of this service, forbidden blocking longer routes, cut exposure, implicit 404s) all show up as a probe whose legacy and Istio decisions differ, so no special cases are needed. The header-matched root takeover (limitation 2 in the document) needs a header matcher and another service, so it can't happen here. Only its same-service form (different rewrite or timeout on the root) remains, and D7 handles it. + +*Alternative*: implement each limitation in the document as its own check. Rejected: those checks are ad hoc, easy to get incomplete (for example the literal-vs-variable case `/a/b/{id}/c` vs `/a/b/lit`), and they don't verify the generated DENY `notPaths` or `Exact` rules. + +### D3. Probe generation +Collect all patterns: legacy gateway paths, forbidden paths and generated match values. For each pattern, split it into segments. For each prefix `p` of the segment list (including the full pattern), emit these probes: +- `p` +- `p + "/"` +- `p + "/" + SAMPLE` + +Here `SAMPLE` is a literal that collides with no declared literal segment (for example `x-probe-0`, with the number increased until it is unique). + +Each variable segment in `p` is instantiated as: +1. `SAMPLE`; +2. every literal segment that another pattern has at the same depth under the same literal prefix. This finds literal-vs-variable precedence flips. + +For partial-segment variables (`{name}.txt`), only the variable part of the segment is substituted. The cartesian product over variables is capped by instantiating one variable at a time with literals while the other variables keep `SAMPLE`. This keeps the probe count linear in patterns × segments × colliding literals. Probes are deduplicated in a `TreeSet`, so findings come out in a deterministic order. + +*Why this is sufficient*: decisions in both models change only at pattern boundaries (segment-aligned prefixes of patterns), and a variable decides differently only between "some literal another pattern names" and "anything else". The probe set has one representative for each such class. + +### D4. Legacy decision semantics +- Pattern → segment matcher: a variable matches `[^/]+` (partial segments use the legacy regex form). A match needs the request to equal the pattern or to continue it with `/`. +- Winner: the longest `pattern.length()` (source text, including `{name}`). +- An allowed entry and a forbidden entry with the same pattern collapse to the allowed one (legacy `RouteTransformer`). The exception is an explicit `@ForbiddenRoute` on an allowed route's exact pattern, which is a generation-time error (spec: Contradictory declarations). +- Ties in length between different patterns that match the same request: legacy picks one in an undefined order. If the tied entries agree on allowed/forbidden and on the upstream path, the decision is that one, with the largest of their timeouts (no finding). Otherwise the decision is *undefined*, and the validator reports one `LEGACY_PRECEDENCE_UNDEFINED` ERROR per (gateway, tied patterns), naming the entries and their origins, with one example request. It reports no other finding for probes whose legacy decision is undefined, so the same problem isn't also reported as an Exposure, Conflict or Lost route. +- Two **allowed** entries on the same gateway with the same pattern string and different service paths are invalid in legacy too: `RouteTransformer.validateAndReturnRoutes` throws "several target paths for forwarding from the same source path". The planner reports one `LEGACY_INVALID` ERROR per (gateway, pattern), naming both routes and their origins. The validator skips Conflict findings caused by that pattern, so the same problem isn't reported twice or blamed on the migration. Same pattern and same service path with different timeouts is valid in legacy (the first registered route wins, in an undefined order), so it goes through the D7 max-timeout merge. +- Upstream path: reproduce the legacy regex rewrite. Captured variable values replace the variables of the service path by position, and the rest of the request path is appended unchanged. + +### D5. Istio decision semantics +DENY check first. Path templates are evaluated as Istio does: `{*}` is one segment, `{**}` is zero or more segments and appears only at the end, and `notPaths` excludes. Then `Exact`, then the longest segment-aligned `PathPrefix`. + +Upstream path: +- `ReplacePrefixMatch`: replace the matched prefix, and normalize a double `/` the way Istio does when the replacement is `/`. +- `ReplaceFullPath`: the static value. +- No filter: unchanged. + +### D6. Cut and rewrite derivation +`cut(path)` = the part of `path` before the segment containing the first `{`, stripped of its trailing slash, or `/` if that is empty. A path without variables is unchanged. `match = cut(gatewayPath)`. If the gateway path and service path differ, `rewrite = cut(servicePath)`. + +Example: `/api/v1/{id}/items` → `/items/{id}/items` gives match `/api/v1` and rewrite `/items`. + +This is correct only when the part after the cut is the same in both paths (variables compared by position), because `ReplacePrefixMatch` keeps everything after the match unchanged. That holds for the routes services declare in practice, so the plugin doesn't check it, to keep the code simple. + +*Alternative*: compare the remainders and fail the build when they differ. Rejected per user decision: the case doesn't occur in practice. + +### D7. Grouping, merging and the Exact split +Planned rules are keyed by `(Gateway, MatchType, value)`. For every `PathPrefix` value `P` that more than one route produces on some gateway: + +1. If all routes have the same rewrite and timeout, emit one rule. +2. Else, if exactly one route `S` has `gatewayPath == P`, and at least one other route has `gatewayPath == P/{var}`, and all routes other than `S` share one rewrite: apply the Exact split to `S`. It becomes two `Exact` rules with `ReplaceFullPath`, keeping S's timeout. The rest are merged into the `PathPrefix` rule, and if their timeouts differ, the max is used with a warning. +3. Else, if all routes have the same rewrite: merge them with the max timeout and log a WARNING. +4. Otherwise, report a CONFLICT finding and emit nothing for `P`. The build fails anyway. + +The Exact split is the same-service form of the document's limitation 2. It keys only on rewrite and timeout, never on backend or headers. It is safe on every gateway where S appears: in legacy, the `P/{var}` entry exists on every border gateway (allowed or forbidden), and because it is longer it takes every request below `P/`. So S only ever received `P` and `P/`. + +A merged rule goes into the widest type's HTTPRoute resource. This works because exposure sets are nested chains (INTERNAL ⊂ PRIVATE ⊂ PUBLIC), so the union of gateways equals the widest type's parentRefs. This removes identical matches spread across several HTTPRoute resources, where Istio would pick one by creation time. + +*Alternative for timeout-only differences*: always fail. Rejected per user decision (merge with the max, warn). + +The Exact split is kept for same-service routes (user decision). It is the only producer of `Exact` matches and `ReplaceFullPath` filters. + +### D8. DENY rule construction +For each `ForbiddenDeclaration` × gateway: +- `paths = [F', F' + "/{**}"]`, where `F'` replaces each `{...}` with `{*}` (`/` → `["/", "/{**}"]`). +- `notPaths` = for every legacy allowed entry Q on that gateway with `len(Q) > len(F)` and `matches(F-pattern, Q-instantiated-with-SAMPLE)`: add `[Q', Q' + "/{**}"]`. + +There is one rule per forbidden path, not one rule with all paths merged. `notPaths` belongs to a specific forbidden path, and Istio ORs rules within a policy. Nested forbidden paths therefore work without extra handling. + +### D9. `@ForbiddenRoute` lives in route-registration-common +Add `ForbiddenRoute.java` next to `Route.java`: `@Retention(RUNTIME)`, `@Target({TYPE, METHOD})`, `@Documented`, `RouteType[] value()` with no default. Retention must be RUNTIME because `RouteScanner` uses `disableRuntimeInvisibleAnnotations()`. Legacy processing classes (`ClassRoutesBuilder` and others) are not changed, so the legacy runtime ignores it. The plugin references it by `ForbiddenRoute.class.getName()`, like `Route`. + +The scanner's `hasRoute` filter becomes `hasRoute || hasForbiddenRoute` (class or method level). Forbidden gateway paths are resolved with the same helpers as `buildClassLevelRoutes` and `buildRoutesForMethod`, extracted into shared path-resolution methods. + +*Alternative*: a separate annotations artifact owned by the plugin. Rejected per user decision: services already depend on route-registration-common. + +### D10. AuthorizationPolicy rendering +The new renderer uses Jackson records, like `HTTPRouteResource`. `security.istio.io/v1`, `kind: AuthorizationPolicy`, `spec.targetRefs`, `spec.action: DENY`, and `spec.rules[].to[].operation.{ports, paths, notPaths}`. Policies are sorted public, private, internal. Labels come from `buildRouteLabels`, moved to a shared helper. + +New Mojo parameters, all set in the plugin `` in `pom.xml`, with no user property, like the existing ones: +- `authorizationPolicyPorts`: `Map`, optional, no default value in the annotation. Keys are border gateway names (`public-gateway-service`, `private-gateway-service`, `internal-gateway-service`, the names used in `@Route(gateways = ...)`). Values are comma-separated port lists; whitespace around ports is trimmed. A gateway that isn't listed, or the whole parameter when it isn't configured, uses `8080`. An unknown key, an empty value or a port outside 1–65535 is a configuration ERROR that fails the build before scanning. The DENY rules of each gateway's policy use that gateway's ports. For example: + + ```xml + + 8080 + 8080,8443 + + ``` + + Here private-gateway-service uses `8080`. Maven fills a `Map` from child element names, so no custom converter is needed. + + *Alternative*: one list for all gateways. Rejected per user decision: the Gateway listener port and the internal-gateway-service port can differ. +- `autoGenerateAuthorizationPolicies`: `boolean`, `@Parameter(defaultValue = "false")`. See D12. + +### D11. Diagnostics format +Findings are grouped by gateway, then kind, then sorted. They are logged at ERROR level (WARN for timeout warnings) as a block, for example: + +``` +[ROUTE-MIGRATION] EXPOSURE on public-gateway + request: /api/v1/svc/resource/x-probe-0/internal-api (any method) + legacy: FORBIDDEN (implicit: INTERNAL route /api/v1/svc/resource/{id}/internal-api from com.acme.ResourceController#internalApi) + istio: ROUTED by PathPrefix /api/v1/svc/resource -> /resource (from com.acme.ResourceController) + fix: add @ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE}) to com.acme.ResourceController#internalApi +``` + +For exposures caused by the cut, where legacy has no entry, the fix suggests `@ForbiddenRoute()` on the class whose mapping equals the cut prefix. If no such class exists, it suggests a method or class mapped to that prefix. While `autoGenerateAuthorizationPolicies` is `false`, every Exposure fix also offers the alternative `or set true`. The exception message summarizes the counts and points to the log. + +### D12. Opt-in automatic DENY rules (`autoGenerateAuthorizationPolicies`) +When the parameter is `true`, `IstioRoutePlanner` adds automatic `DenyRule`s after the HTTPRoute rules are planned and before validation. It evaluates the Istio model *without* any DENY rules (routing only), so the result doesn't depend on the order rules are added. Automatic rules are only for border gateways: + +1. **Implicit forbidden routes.** For every legacy forbidden entry F that comes from a route type (not from `@ForbiddenRoute`) on gateway G: if the routing-only Istio model routes F instantiated with `SAMPLE`, or F + `/` + `SAMPLE`, add a rule for F. It is built exactly as D8 builds it, including `notPaths` from the longer allowed entries below F. Implicit entries that Istio doesn't route anyway (for example an INTERNAL route with no wider prefix above it) get no rule, which keeps policies small. +2. **Cut exposure.** For every planned `PathPrefix` value P on gateway G that has at least one source route whose gateway path contains a variable: if the legacy model routes neither P nor P + `/` + `SAMPLE` on G, add a rule with `paths = [P, P + "/{**}"]`. Its `notPaths` are `[Q', Q' + "/{**}"]` for every legacy allowed entry Q on G that lies below P and is longer than P. This is limitation 4 of the migration document: when a shorter allowed route covers P, legacy already exposed the subtree, and no rule is added. + +Rules are deduplicated by (gateway, paths, notPaths). An explicit `@ForbiddenRoute` rule wins over an identical automatic one, so it keeps its origin. Automatic rules carry a synthetic origin (`auto: implicit forbidden of ` or `auto: cut exposure of

from `) for diagnostics. The contradictory-declaration check (D4) applies only to explicit declarations. Validation runs on the combined plan unchanged, so it verifies automatic rules like any other. + +*Default `false`*: generated DENY rules on shared border gateways are a security-relevant change that service owners should opt into explicitly. By default, the build keeps reporting these cases, with both fixes offered. + +*Alternative*: always generate automatic rules and drop `@ForbiddenRoute`. Rejected per user decision: explicit annotations stay the default, and automation is opt-in. + +### D13. Reading all legacy route annotation forms +`hasRoute` selects classes with `Route`, `Routes` (the `@Repeatable` container, present whenever an element has two or more `@Route`) or `FacadeRoute`, at class or method level, plus `ForbiddenRoute` (D9). Per element, the scanner reads a list of route annotations: every `@Route`, from `Route` or `Routes`, and `@FacadeRoute`, which is a `@Route(RouteType.FACADE)` with its own `gateways`. A method-level list replaces the class-level list, as in legacy. Each entry is resolved with the same rules a single `@Route` has today (type, timeout and the class fallback), reading `getParameterValues(false)`. A missing `gateways`/`hosts` attribute, or an array that holds only empty strings, counts as empty. + +Each entry becomes one declaration per resolved target: +- `gateways` empty, type PUBLIC/PRIVATE/INTERNAL: a border route of that type, with the gateway path from `@Gateway`/`@GatewayRequestMapping` (unchanged). +- `gateways` empty, type FACADE: a **facade** route. +- Every name in `gateways`: a border name (`public-gateway-service`, `private-gateway-service`, `internal-gateway-service`) gives a border route of `RouteType.fromGatewayName`, with `@Gateway` paths. Any other name gives a **composite** route. + +A facade route takes its gateway path from `@FacadeGateway`/`@FacadeGatewayRequestMapping`, resolved with the same helpers as `@Gateway`/`@GatewayRequestMapping`, or uses the service path when there is none. A composite route takes it from `@Gateway`/`@GatewayRequestMapping`. Both become `HttpRoute`s with `Type.FACADE`, so `HttpRoute` doesn't change. This fixes today's deviation, where FACADE used `@Gateway` paths. + +Scan-time finding, reported in the normal report (D11) instead of throwing, so all problems show up in one build: +- `LEGACY_INVALID` (ERROR): non-empty `hosts` together with a border name in `gateways`. Legacy `AbstractRoutesBuilder.validateRouteHosts` throws "Only composite gateway can have hosts" for it. + +Otherwise `hosts` are ignored: Istio has no composite gateway and no virtual hosts on a Service-bound HTTPRoute. + +*Alternative*: keep failing the build on these forms. Rejected per user decision: facade and composite routes must produce service-bound rules (D15), and composite routes normally come as a second `@Route` next to a border one, so `@Routes` and `gateways` have to be read anyway. + +### D14. Route origins without changing `HttpRoute` +`HttpRoute` stays a four-field record, with the same `equals`/`hashCode`. Internally the scanner collects `Map>`, so a route declared by several elements (for example through the superclass recursion in `getRequestMappingPaths`) collapses into one entry, as in today's `Set`, and keeps all its origins. `collectDeclarations` exposes it as `List`, sorted by the path-specificity comparator. `collectRoutes` stays, returns `Set` built from the same map, and is what `GenerateRoutesMojoTest` keeps using. The only assertion that changes is the FACADE route of `SpringTestController8#method1`: it has `@GatewayRequestMapping` but no facade path annotation, so under D13 its gateway path equals its service path, as in legacy. + +*Alternative*: add `origin` to the record. Rejected: it changes equality, breaks `routes.contains(new HttpRoute(...))` and `routes.size()` assertions, and turns the same route found through two elements into two routes. + +### D15. Service-bound HTTPRoute for facade and composite routes +All `Type.FACADE` declarations (facade and composite, D13) form one more planning target, `SERVICE`, next to the three border gateways. The planner applies D6 (cut and rewrite) and D7 (merge, Exact split, conflicts) to it exactly as to a gateway. The Exact split argument holds because all these routes are on the same target. The results are: +- If no planned `SERVICE` rule has a rewrite filter, no service-bound HTTPRoute is generated. Istio then sends every request for the Service to it unchanged, which is what these routes do anyway. If any of them has a timeout, one WARNING says that the timeouts are not applied. +- Otherwise, one HTTPRoute `{{ .Values.SERVICE_NAME }}-java-annotations-facade` with parentRef Service `{{ .Values.SERVICE_NAME }}` (as today) contains **every** planned `SERVICE` rule, including those without a rewrite. Once an HTTPRoute is bound to a Service, Istio returns 404 for requests that no rule matches, so dropping identity rules (today's behavior) breaks them. +- No catch-all `PathPrefix /` rule is added (user decision). + +Legacy facade and composite gateways were separate gateways, so two routes with the same gateway path and different service paths were valid there, for example two composite gateways, or two hosts. On `SERVICE` they collide, and D7 step 4 reports a CONFLICT that names both routes and says they came from different legacy gateways. The same gateway path with the same service path and different timeouts goes through the max-timeout merge. + +`SERVICE` is not part of the legacy model or the differential validation (D2–D5), and D8/D12 never generate DENY rules for it. Its entry point differs from legacy (clients call the Service, not a facade or composite gateway), so there is no legacy decision to compare with. + +*Alternative*: add a catch-all rule so that other paths keep working through the Service. Rejected per user decision. + +## Risks / Trade-offs + +- [Many existing services start failing the build (implicit 404s, cut exposure, legacy-invalid annotations)] → This is intentional (they are insecure or silently wrong today). Every exposure failure names the exact `@ForbiddenRoute` to add or offers `autoGenerateAuthorizationPolicies`. The plugin is released as a new major version, and release notes and the README include a migration section. +- [A DENY rule on a shared border gateway also blocks another service's routes under the same path] → Out of scope per the single-service decision: gateway paths are service-namespaced (`/api///...`), and the plugin doesn't model other services. The README states it. +- [Automatic rules deny more than legacy did] → They are validated like explicit ones (Lost route findings), and the parameter defaults to `false`. +- [The probe set misses a class of paths] → The argument in D3 plus unit tests for every example in the migration document that the plugin can produce (all routes except the two `another-service` routes 3 and 4), plus the spec scenarios. The probe generator is isolated, so it can be extended without touching the models. +- [The legacy model differs from real legacy registration where the scanner already deviates] → Path-resolution deviations are out of scope (Non-Goals), and findings show the pattern and origin, so a mismatch is visible. All legacy annotation forms are read (D13). +- [While the service-bound HTTPRoute exists, requests to the Service on other paths return 404 (no catch-all, user decision)] → The README states it, and it names the fix: declare those endpoints as facade routes, or call them through a border gateway. Before this change, the same HTTPRoute already existed whenever a facade route had a rewrite, and it also dropped identity facade routes. +- [Facade routes that used `@Gateway` paths change their gateway path] → This is the legacy behavior, and the README migration section names it. +- [403 instead of 404 for forbidden paths] → Documented in the README as a known BWC difference. +- [DENY on the wrong listener port does not deny] → `authorizationPolicyPorts` is configurable per gateway, and the README explains that each entry must match that Gateway's listener port, or the internal-gateway-service port. +- [Two-module change (plugin + route-registration-common)] → Implement the annotation first. The plugin pom already tracks the monorepo SNAPSHOT, so both are released in the same train. + +## Migration Plan + +1. Add `@ForbiddenRoute` to route-registration-common and build it (SNAPSHOT). +2. Release the plugin as a new major version. Validation is always on. Consumers that upgrade get it. +3. Consumers fix build failures by adding `@ForbiddenRoute`, setting `autoGenerateAuthorizationPolicies` to `true`, or aligning paths. + +## Open Questions + +None. diff --git a/openspec/changes/regex-routes-prefix-migration/proposal.md b/openspec/changes/regex-routes-prefix-migration/proposal.md new file mode 100644 index 0000000000..f2c76db9a3 --- /dev/null +++ b/openspec/changes/regex-routes-prefix-migration/proposal.md @@ -0,0 +1,59 @@ +# Proposal + +## Why + +The plugin is supposed to reproduce legacy Cloud-Core Service Mesh routing in Istio, but it doesn't when a route has path variables. Such a route becomes a `RegularExpression` match today. Istio always ranks regex below `PathPrefix`, and `ReplacePrefixMatch` does not work with regex, so these routes lose their legacy precedence and their rewrite (see `core-maven-plugins/httproutes-generator-maven-plugin/regex-routes-migration.md`). The plugin also drops the `allowed: false` (404) routes that the legacy library created automatically on wider gateways. As a result, a shorter `PUBLIC` prefix on the public gateway serves an `INTERNAL` endpoint that sits below it. Facade routes are also broken: the plugin reads their gateway paths from the wrong annotation, ignores composite routes, and drops facade routes without a rewrite even when the service already has an HTTPRoute, so Istio returns 404 for them. Services are being migrated now, so we need generated config that is correct or a build that fails, not a "MANUAL REVIEW REQUIRED" comment. + +## What Changes + +- **BREAKING** Every generated HTTPRoute path match is `PathPrefix` or `Exact`. `RegularExpression` matches and the `MANUAL REVIEW REQUIRED` comment are removed. +- A gateway path with path variables is cut at its first variable and becomes a `PathPrefix` match. The prefix rewrite is cut the same way: the service path is cut at its first variable. The plugin assumes that the part after the cut is the same in both paths, as it is in practice, and doesn't check it. +- Routes that collapse to the same prefix on the same gateway are merged when their rewrite is the same. If only their timeouts differ, the merged rule gets the largest timeout and the plugin logs a warning. +- A shorter route that legacy only reached through its bare root path (because a `/{var}` route took everything below it) is emitted as two `Exact` matches on `` and `/`, with a `ReplaceFullPath` rewrite. This keeps legacy precedence when the two routes differ in rewrite or timeout. The migration document uses `Exact` for a header-matched root route to another service. Here it is used only for two routes of the same service, because the plugin can't produce the other case. +- The scanner reads all legacy route annotation forms: the repeatable `@Routes` container (two or more `@Route` on one element), `@Route(gateways = ...)`, `@FacadeRoute`, and facade gateway paths from `@FacadeGateway` / `@FacadeGatewayRequestMapping`. Today it silently skips them. A border gateway name in `gateways` (`public-gateway-service`, `private-gateway-service`, `internal-gateway-service`) gives a route of that type, as in legacy. Every other name in `gateways` is a composite gateway. +- **BREAKING** Facade and composite routes. Istio has no facade or composite gateway, so the plugin does not model them as gateways and does not compare them with legacy routing. Instead: + - they become rules of one HTTPRoute bound to the service's own k8s Service (`{{ .Values.SERVICE_NAME }}`), with the same cut, merge and `Exact` split as on gateways; + - this HTTPRoute is generated only when at least one facade or composite route has a rewrite. Without a rewrite, Istio already routes the request to the service unchanged; + - when it is generated, it contains **all** facade and composite routes of the service, including those without a rewrite. Once any HTTPRoute is bound to a Service, Istio returns 404 for every request its rules don't match. There is no catch-all rule, so other paths called through the Service also return 404 while this HTTPRoute exists; + - facade gateway paths come from `@FacadeGateway` / `@FacadeGatewayRequestMapping`, as in legacy. Today the plugin uses `@Gateway` / `@GatewayRequestMapping` for them. Composite gateway paths come from `@Gateway` / `@GatewayRequestMapping`, as in legacy. Composite `hosts` are ignored. +- New validation step, run before any file is written. The plugin compares legacy mesh routing with the generated Istio routing on every border gateway and **fails the build** with a detailed report when: + - routes with different rewrites collide after the cut (conflict). This also applies to facade and composite routes that end up in the same service-bound rule; + - Istio would route a path that legacy returned 404 for or did not route at all (new exposure). This covers exposure caused by the cut and the legacy implicit forbidden routes (a narrower-type route below a wider-type prefix); + - two legacy routes with gateway paths of the same length match the same request on a gateway and differ in more than the timeout (allowed vs forbidden, or a different upstream path). Legacy picks one of them in an undefined order, so the plugin can't know what to reproduce. When they differ only in timeout, the largest timeout is used and the build doesn't fail; + - two routes have the same gateway path on the same gateway but different service paths. The legacy runtime also rejects this (`RouteTransformer`: "several target paths for forwarding from the same source path"), so it is reported as invalid configuration, not as a migration conflict. The same applies to `hosts` combined with a border gateway name in `gateways` (legacy: "Only composite gateway can have hosts"). +- New annotation `@ForbiddenRoute` in `route-registration-common` marks a class or method gateway path as forbidden on the listed border gateways. +- New output: an Istio `AuthorizationPolicy` (`action: DENY`) per target gateway. It uses `{*}`/`{**}` path templates, `notPaths` for longer allowed routes below the forbidden path, and a scope to the listener ports of that gateway. By default it is built from `@ForbiddenRoute` only, and no policy is generated when no `@ForbiddenRoute` is present. +- New plugin parameter `autoGenerateAuthorizationPolicies` (boolean, default `false`), set in the plugin `` in `pom.xml`. When `true`, the plugin also generates DENY rules without explicit annotations, as Option 1 in the migration document describes: + - for every legacy implicit forbidden route (a narrower-type route on a wider gateway) that the generated HTTPRoutes would otherwise route on that gateway; + - for every controller subtree that the cut exposes and that no shorter allowed route covered in legacy. + + Validation runs on the result either way, so whatever the generated rules don't fix still fails the build. When `false`, these cases fail the build, and the report suggests either `@ForbiddenRoute` or enabling the parameter. +- The plugin never generates `VirtualService` or `EnvoyFilter`. +- Overlaps with routes of **another service** are out of scope. The plugin sends every rule to the single backend (`backendRefVal`:`servicePort`), and the annotations have no header matchers. So a plugin-generated route can't overlap a route with a different cluster or a header-matched route, like the `migrated-api` → `another-service` and `:method: GET` → `another-service` routes in the migration document. Route behavior is only compared on forbidden/allowed, upstream path (rewrite) and timeout. The models, planner and validator have no backend or header-matcher dimension. +- **BREAKING** Existing services can now fail the build: + - with `autoGenerateAuthorizationPolicies` disabled, those with a narrower-type method under a wider-type class prefix, or with variable routes that the cut widens; + - regardless of the parameter, those with rewrite conflicts or legacy-invalid routes. + + The build report says which `@ForbiddenRoute` to add or which parameter to set. Facade routes that relied on `@Gateway` paths get the legacy gateway path instead. The plugin is released as a new major version. + +## Capabilities + +### New Capabilities +- `httproute-generation`: How scanned routes become HTTPRoute rules: reading the legacy annotation forms, prefix-only matching, cutting at the first path variable, deriving the rewrite, merging, the `Exact` split, the service-bound HTTPRoute for facade and composite routes, and which resource kinds the plugin may output. +- `route-migration-validation`: Comparing legacy mesh routing with Istio routing per border gateway, detecting conflicts and new exposure, and failing the build with actionable diagnostics. +- `forbidden-route-policies`: The `@ForbiddenRoute` annotation, the opt-in `autoGenerateAuthorizationPolicies` parameter, and how both become Istio `AuthorizationPolicy` DENY rules. + +### Modified Capabilities + + +## Impact + +- **Plugin code**: + - `HttpRouteRenderer`: match and rewrite generation, regex removal. The service-bound (facade) HTTPRoute keeps rules without a rewrite and is generated only when at least one rule has a rewrite. + - `RouteScanner`: scans `@ForbiddenRoute`, `@Routes`, `@Route(gateways, hosts)`, `@FacadeRoute`, `@FacadeGateway` and `@FacadeGatewayRequestMapping`, and includes classes that carry only `@ForbiddenRoute` or `@FacadeRoute`. Records where each route came from, in a wrapper, so the `HttpRoute` record and its equality stay unchanged. + - `GenerateRoutesMojo`: new optional parameters `authorizationPolicyPorts` (ports per border gateway name, default `8080` for every gateway) and `autoGenerateAuthorizationPolicies` (default `false`). Runs validation, fails the build with `MojoFailureException`, and writes AuthorizationPolicies. + - New model, simulation and policy-rendering classes. +- **Plugin module**: all plugin changes are in `core-maven-plugins/httproutes-generator-maven-plugin` (package `com.netcracker.routes.gateway.plugin`). +- **Annotation module**: `core-rest-libraries/route-registration/route-registration-common` gets the new `@ForbiddenRoute` annotation (RUNTIME retention). The legacy runtime library ignores it. The plugin already depends on `route-registration-common` `7.5.2-SNAPSHOT` from the same monorepo. +- **Generated output**: the YAML file can now also contain `security.istio.io/v1` `AuthorizationPolicy` resources inside the same Helm `SERVICE_MESH_TYPE == Istio` guard. Rules that were regex become prefix or exact rules. The facade HTTPRoute now also lists facade and composite routes without a rewrite. +- **Docs/tests**: the plugin `README.md` (new annotation, validation errors, AuthorizationPolicy output, new parameters, legacy annotation forms, facade and composite routes, migration section). Unit tests for truncation, merging, the `Exact` split, simulation, the service-bound HTTPRoute and policy rendering. Tests reuse the migration document's example only for the routes the plugin can produce (it can't produce the `another-service` routes). The existing renderer tests that expect regex output are updated. In `GenerateRoutesMojoTest`, only the facade assertion for `SpringTestController8` changes, to the legacy gateway path. diff --git a/openspec/changes/regex-routes-prefix-migration/specs/forbidden-route-policies/spec.md b/openspec/changes/regex-routes-prefix-migration/specs/forbidden-route-policies/spec.md new file mode 100644 index 0000000000..290224812b --- /dev/null +++ b/openspec/changes/regex-routes-prefix-migration/specs/forbidden-route-policies/spec.md @@ -0,0 +1,145 @@ +# Spec Delta + +## Purpose + +Lets service developers explicitly mark gateway paths as forbidden on specific border gateways, replacing legacy `allowed: false` routes. The plugin enforces these with Istio `AuthorizationPolicy` DENY rules, which are evaluated before route selection and so do not depend on route ordering. + +## ADDED Requirements + +### Requirement: ForbiddenRoute annotation +The route-registration-common library SHALL provide the annotation `com.netcracker.cloud.routesregistration.common.annotation.ForbiddenRoute`: + +- It has `RUNTIME` retention and targets types and methods. +- Its `value` attribute is a mandatory array of `RouteType`. +- Each value names one border gateway: `PUBLIC` → public gateway, `PRIVATE` → private gateway, `INTERNAL` → internal gateway. A value does not imply the wider gateways. + +The legacy runtime route registration SHALL NOT change its behavior because of this annotation. + +#### Scenario: Annotating an internal-only method +- **WHEN** a method has `@Route(RouteType.INTERNAL)` and `@ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE})` +- **THEN** the method's gateway path is treated as forbidden on the public and private gateways and allowed on the internal gateway + +#### Scenario: Legacy runtime unaffected +- **WHEN** a service using the legacy mesh registers routes at runtime from classes annotated with `@ForbiddenRoute` +- **THEN** the routes it registers are the same as without the annotation + +### Requirement: Forbidden gateway path resolution +The plugin SHALL resolve the forbidden gateway paths of an annotated class or method the same way it resolves gateway paths for `@Route`: from class and method request mappings, `@Gateway` and `@GatewayRequestMapping`. A class-level `@ForbiddenRoute` SHALL forbid only the class-level gateway path(s), not each method path separately. `@ForbiddenRoute` SHALL be scanned on classes and methods even when they carry no `@Route`. + +#### Scenario: Class-level forbidden root without a Route +- **WHEN** a class mapped to `/api/v1/svc/order` has `@ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE, RouteType.INTERNAL})` and no class-level `@Route`, and one of its methods has `@Route(RouteType.PUBLIC)` on `/{id}/items` +- **THEN** `/api/v1/svc/order` is forbidden on all three border gateways, and `/api/v1/svc/order/{id}/items` stays allowed on all three + +#### Scenario: Gateway path remapping +- **WHEN** a method has `@ForbiddenRoute(RouteType.PUBLIC)` and a gateway mapping `@GatewayRequestMapping("/api/x/{id}/secret")` +- **THEN** the forbidden path is `/api/x/{id}/secret`, not the service path + +### Requirement: Invalid ForbiddenRoute usage +The plugin SHALL fail the build with an error naming the annotated element when a `@ForbiddenRoute`: + +- has an empty `value` or contains `FACADE`; +- resolves to a gateway path where a variable does not take up a whole path segment (for example `/files/{name}.txt`); +- resolves to a gateway path that contains `*` wildcards or a regex-constrained variable (for example `{id:\d+}`). + +#### Scenario: Facade is not supported +- **WHEN** a method has `@ForbiddenRoute(RouteType.FACADE)` +- **THEN** the build fails with an error stating that `@ForbiddenRoute` supports only PUBLIC, PRIVATE and INTERNAL + +#### Scenario: Partial-segment variable +- **WHEN** a method has `@ForbiddenRoute(RouteType.PUBLIC)` with gateway path `/files/{name}.txt` +- **THEN** the build fails with an error stating that forbidden paths need whole-segment variables + +### Requirement: AuthorizationPolicy generation +For each border gateway that has at least one forbidden path, the plugin SHALL generate exactly one `security.istio.io/v1` `AuthorizationPolicy` with `action: DENY`: + +- `metadata.name` is `{{ .Values.SERVICE_NAME }}-java-annotations-deny-`, where `` is `public`, `private` or `internal`. +- The metadata labels are the same as for generated HTTPRoutes (defaults or the configured `labels`). +- `targetRefs` has one entry: `group: gateway.networking.k8s.io`, `kind: Gateway`, `name: public-gateway` for public; the same with `name: private-gateway` for private; `group: ""`, `kind: Service`, `name: internal-gateway-service` for internal. + +When `autoGenerateAuthorizationPolicies` is `false` (the default), the plugin SHALL NOT generate any AuthorizationPolicy when no `@ForbiddenRoute` is declared. It SHALL NOT generate DENY rules for legacy implicit forbidden routes or for exposure caused by the cut; the validation step reports those instead. + +#### Scenario: No forbidden declarations +- **WHEN** the project has no `@ForbiddenRoute`, and `autoGenerateAuthorizationPolicies` is not configured +- **THEN** the output contains no `AuthorizationPolicy` + +#### Scenario: Forbidden on public and private +- **WHEN** one method has `@ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE})` +- **THEN** the output contains two AuthorizationPolicies, targeting Gateway `public-gateway` and Gateway `private-gateway`, and none targeting `internal-gateway-service` + +### Requirement: DENY rule content +Each forbidden path F on a gateway SHALL produce one rule in that gateway's policy: + +- `to[].operation.paths` contains F and F + `/{**}`, with every path variable replaced by `{*}`. +- `to[].operation.notPaths` contains Q and Q + `/{**}` (same variable replacement) for every route Q that is allowed on the same gateway in the legacy model, lies below F, and has a longer gateway path than F. These are the routes that won over F in legacy. +- `notPaths` SHALL be omitted when there are none. +- `to[].operation.ports` is set from the ports that the plugin parameter `authorizationPolicyPorts` configures for that gateway. + +Paths within a rule SHALL be deduplicated and sorted deterministically. A forbidden root path `/` SHALL produce `paths: ["/", "/{**}"]`. + +#### Scenario: Forbidden route with a longer allowed route below it +- **WHEN** PUBLIC route `/api/v1/svc/resource`, INTERNAL route `/api/v1/svc/resource/{var1}/internal-api` with `@ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE})`, and PUBLIC route `/api/v1/svc/resource/{var1}/internal-api/status` are declared +- **THEN** the public gateway policy has a rule with `paths` `/api/v1/svc/resource/{*}/internal-api` and `/api/v1/svc/resource/{*}/internal-api/{**}`, and `notPaths` `/api/v1/svc/resource/{*}/internal-api/status` and `/api/v1/svc/resource/{*}/internal-api/status/{**}` + +#### Scenario: Forbidden controller root to close an exposure caused by the cut +- **WHEN** a class mapped to `/api/v1/svc/order` has `@ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE, RouteType.INTERNAL})`, and PUBLIC method route `/api/v1/svc/order/{id}/items` is declared +- **THEN** each of the three policies has a rule with `paths` `/api/v1/svc/order` and `/api/v1/svc/order/{**}`, and `notPaths` `/api/v1/svc/order/{*}/items` and `/api/v1/svc/order/{*}/items/{**}`, and validation reports no Exposure for `/api/v1/svc/order/` + +#### Scenario: Nested forbidden paths +- **WHEN** forbidden path `/a` and forbidden path `/a/b/{id}/c` exist on the same gateway, and allowed route `/a/b` is longer than `/a` and lies below it +- **THEN** the policy has two separate rules: the `/a` rule lists `/a/b` and `/a/b/{**}` in `notPaths`, and the `/a/b/{*}/c` rule has its own `paths` + +### Requirement: Automatic DENY rules +The plugin SHALL accept the boolean parameter `autoGenerateAuthorizationPolicies`, set in the plugin `` in `pom.xml` and defaulting to `false`. When it is `true`, the plugin SHALL add DENY rules to the border gateway policies, besides those from `@ForbiddenRoute`: + +- For every legacy forbidden entry F that comes from a route type (a narrower-type route on a wider gateway), where the generated HTTPRoutes attached to that gateway would route F: a rule for F, built as in "DENY rule content". +- For every generated `PathPrefix` value P that comes from cutting a gateway path with variables, where the legacy model does not route P or the paths below P on that gateway: a rule with `paths` P and P + `/{**}`, and `notPaths` Q and Q + `/{**}` for every allowed route Q on that gateway that lies below P and is longer than P. + +Whether Istio routes a path SHALL be decided from the generated HTTPRoute rules alone, ignoring all DENY rules. Automatic rules SHALL NOT be generated for the service-bound HTTPRoute of facade and composite routes. A rule that is identical to a rule from `@ForbiddenRoute` (same gateway, `paths` and `notPaths`) SHALL be rendered once. Route migration validation SHALL run on all generated rules, automatic ones included. + +#### Scenario: Parameter not set +- **WHEN** `autoGenerateAuthorizationPolicies` is not configured, and PUBLIC route `/api/v1/svc/resource` and INTERNAL route `/api/v1/svc/resource/{id}/internal-api` are declared without `@ForbiddenRoute` +- **THEN** no AuthorizationPolicy is generated, and the build fails with Exposure errors + +#### Scenario: Implicit forbidden route with the parameter enabled +- **WHEN** `autoGenerateAuthorizationPolicies` is `true`, and PUBLIC route `/api/v1/svc/resource`, INTERNAL route `/api/v1/svc/resource/{id}/internal-api` and PUBLIC route `/api/v1/svc/resource/{id}/internal-api/status` are declared without `@ForbiddenRoute` +- **THEN** the public and private gateway policies each have a rule with `paths` `/api/v1/svc/resource/{*}/internal-api` and `/api/v1/svc/resource/{*}/internal-api/{**}`, and `notPaths` `/api/v1/svc/resource/{*}/internal-api/status` and `/api/v1/svc/resource/{*}/internal-api/status/{**}`. No internal gateway policy is generated, and validation reports no error + +#### Scenario: Cut exposure with the parameter enabled +- **WHEN** `autoGenerateAuthorizationPolicies` is `true`, and only PUBLIC route `/api/v1/svc/order/{id}/items` is declared under `/api/v1/svc/order` +- **THEN** each of the three border gateway policies has a rule with `paths` `/api/v1/svc/order` and `/api/v1/svc/order/{**}`, and `notPaths` `/api/v1/svc/order/{*}/items` and `/api/v1/svc/order/{*}/items/{**}`, and validation reports no error + +#### Scenario: No rule when legacy already exposed the subtree +- **WHEN** `autoGenerateAuthorizationPolicies` is `true`, and PUBLIC routes `/api/v1/svc/order` and `/api/v1/svc/order/{id}/items` are declared with the same rewrite +- **THEN** no DENY rule is generated for `/api/v1/svc/order` + +#### Scenario: No rule when Istio doesn't route the forbidden path +- **WHEN** `autoGenerateAuthorizationPolicies` is `true`, and only INTERNAL route `/api/v1/svc/admin` is declared +- **THEN** no AuthorizationPolicy is generated for the public or private gateway + +#### Scenario: Explicit and automatic rule are the same +- **WHEN** `autoGenerateAuthorizationPolicies` is `true`, and the internal-api method also has `@ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE})` +- **THEN** each of the public and private policies contains that rule once + +### Requirement: Port scoping parameter +The plugin SHALL accept the optional parameter `authorizationPolicyPorts`, a map from border gateway name (`public-gateway-service`, `private-gateway-service`, `internal-gateway-service`) to a comma-separated list of ports. A gateway that is not listed SHALL use port `8080`, and when the parameter is not configured, every gateway SHALL use `8080`. The build SHALL NOT fail or warn because the parameter or an entry is missing. Every DENY rule of a gateway's policy SHALL be scoped to that gateway's ports, so that non-HTTP traffic is not denied unintentionally. + +The build SHALL fail with a configuration error, before any file is written, when a key is not a border gateway name, a value is empty, or a port is not a number from 1 to 65535. + +#### Scenario: Default ports +- **WHEN** `authorizationPolicyPorts` is not configured +- **THEN** every DENY rule operation of every policy has `ports: ["8080"]` + +#### Scenario: Ports for one gateway +- **WHEN** `authorizationPolicyPorts` has only `8080,8443`, and policies are generated for all three border gateways +- **THEN** every DENY rule of the internal gateway policy has `ports: ["8080", "8443"]`, and every DENY rule of the public and private gateway policies has `ports: ["8080"]` + +#### Scenario: Unknown gateway name +- **WHEN** `authorizationPolicyPorts` has an entry `8080` +- **THEN** the build fails with an error naming `facade-gateway` and listing the allowed gateway names, and the output file is not written + +### Requirement: Policies share the Helm guard and header +Generated AuthorizationPolicies SHALL be written to the same output file as HTTPRoutes, after all HTTPRoute resources. They SHALL be inside the same `SERVICE_MESH_TYPE == Istio` Helm guard and below the same generated-file header. + +#### Scenario: Combined output +- **WHEN** routes and forbidden routes are declared +- **THEN** the output file has the header, then the Istio guard, then the HTTPRoute documents, then the AuthorizationPolicy documents, then the guard's end diff --git a/openspec/changes/regex-routes-prefix-migration/specs/httproute-generation/spec.md b/openspec/changes/regex-routes-prefix-migration/specs/httproute-generation/spec.md new file mode 100644 index 0000000000..828d600fee --- /dev/null +++ b/openspec/changes/regex-routes-prefix-migration/specs/httproute-generation/spec.md @@ -0,0 +1,168 @@ +# Spec Delta + +## Purpose + +Defines how routes declared with legacy route-registration annotations are translated into Gateway API `HTTPRoute` rules. The generated rules must keep legacy "longest prefix wins" routing and path rewriting in Istio without using regular expression matches. + +## ADDED Requirements + +### Requirement: Allowed output resource kinds +The plugin SHALL emit only `gateway.networking.k8s.io/v1` `HTTPRoute` resources and, when forbidden routes are declared, `security.istio.io/v1` `AuthorizationPolicy` resources. The plugin MUST NOT emit `VirtualService`, `EnvoyFilter` or any other resource kind. + +#### Scenario: Routes without forbidden declarations +- **WHEN** the scanned project declares routes and no forbidden routes +- **THEN** the output file contains only `HTTPRoute` resources + +#### Scenario: Routes that cannot be migrated +- **WHEN** validation finds a conflict that only a `VirtualService` could express +- **THEN** the build fails, and the output contains neither a `VirtualService` nor an `EnvoyFilter` + +### Requirement: Single backend for all rules +Every generated HTTPRoute rule SHALL have the same single `backendRefs` entry: the configured `backendRefVal` and `servicePort`. Route behavior for merging, the Exact split and conflict detection SHALL be only the rewrite and the timeout. The plugin SHALL NOT model or compare backends or header matchers, because annotations can't declare routes to another service or header-matched routes. + +#### Scenario: Colliding routes are never split by backend +- **WHEN** several routes of the scanned project cut to the same `PathPrefix` on a gateway +- **THEN** they are merged, split into `Exact` rules or reported as a conflict based only on their rewrites and timeouts, and every resulting rule has the same `backendRefs` entry + +### Requirement: Path matches are PathPrefix or Exact only +Every generated HTTPRoute rule match SHALL have path type `PathPrefix` or `Exact`. The plugin MUST NOT generate `RegularExpression` path matches. The plugin MUST NOT emit the `MANUAL REVIEW REQUIRED` comment. + +#### Scenario: Route with path variable +- **WHEN** a route has gateway path `/api/v1/my-service/items/{id}` +- **THEN** the generated rule uses a `PathPrefix` match, and the output has no `RegularExpression` match and no `MANUAL REVIEW REQUIRED` comment + +### Requirement: Cut gateway path at the first path variable +When a route's gateway path contains a path variable (`{...}`), the plugin SHALL cut it just before the path segment that contains the first variable. The part before the cut, without a trailing `/`, becomes the `PathPrefix` value. If nothing remains before the cut, the match value SHALL be `/`. Gateway paths without variables SHALL be used unchanged as `PathPrefix` values. + +#### Scenario: Variable in the middle +- **WHEN** a route has gateway path `/api/v1/my-service/resource/{var1}/internal-api/status` +- **THEN** the rule matches `PathPrefix` `/api/v1/my-service/resource` + +#### Scenario: Variable is the last segment +- **WHEN** a route has gateway path `/api/v1/my-service/order/{id}` +- **THEN** the rule matches `PathPrefix` `/api/v1/my-service/order` + +#### Scenario: Variable inside a segment +- **WHEN** a route has gateway path `/api/v1/files/{name}.txt` +- **THEN** the rule matches `PathPrefix` `/api/v1/files` + +#### Scenario: Path without variables +- **WHEN** a route has gateway path `/api/v1/my-service/resource` +- **THEN** the rule matches `PathPrefix` `/api/v1/my-service/resource` + +### Requirement: Cut the prefix rewrite the same way +When the gateway path and service path of a route differ, the plugin SHALL generate a `URLRewrite` filter of type `ReplacePrefixMatch`. Its value SHALL be the service path cut at its first path variable, with the same rule as the gateway path. The plugin SHALL NOT check that the parts after the cut are the same in both paths. When the gateway path and service path are equal, no rewrite filter SHALL be generated. + +#### Scenario: Rewrite with a path variable +- **WHEN** a route has gateway path `/api/v1/my-service/resource/{var1}/sub` and service path `/resource/{var1}/sub` +- **THEN** the rule matches `PathPrefix` `/api/v1/my-service/resource` and rewrites with `ReplacePrefixMatch` `/resource` + +#### Scenario: Rewrite without path variables +- **WHEN** a route has gateway path `/api/v1/my-service/resource` and service path `/resource` +- **THEN** the rule matches `PathPrefix` `/api/v1/my-service/resource` and rewrites with `ReplacePrefixMatch` `/resource` + +#### Scenario: Same paths +- **WHEN** a route has gateway path equal to service path `/items/{id}` +- **THEN** the rule matches `PathPrefix` `/items` and has no `URLRewrite` filter + +### Requirement: One rule per match on a gateway +Every gateway SHALL see at most one generated rule per (path type, path value) match, counting all generated HTTPRoute resources attached to that gateway. When several routes produce the same match on the same gateway with the same rewrite, the plugin SHALL merge them into one rule. The merged rule SHALL go into the HTTPRoute resource of the widest route type among them (PUBLIC > PRIVATE > INTERNAL). If the merged routes have different timeouts and the Exact split does not apply, the merged rule SHALL use the largest timeout, and the plugin SHALL log a warning. The warning names the match, the gateways, the routes merged with their sources, and the timeout that was chosen. + +#### Scenario: Collapsed routes with the same rewrite +- **WHEN** PUBLIC route `/api/v1/my-service/resource` → `/resource` and PUBLIC route `/api/v1/my-service/resource/{var1}/sub` → `/resource/{var1}/sub` are declared +- **THEN** the PUBLIC HTTPRoute contains exactly one rule matching `PathPrefix` `/api/v1/my-service/resource` with `ReplacePrefixMatch` `/resource` + +#### Scenario: Same rewrite, different route types +- **WHEN** PUBLIC route `/api/a` → `/a` and INTERNAL route `/api/a/{id}/x` → `/a/{id}/x` are declared +- **THEN** only the PUBLIC HTTPRoute contains a rule for `PathPrefix` `/api/a`, and the INTERNAL HTTPRoute contains no rule for it + +#### Scenario: Timeouts differ +- **WHEN** two routes collapse to `PathPrefix` `/api/a` with the same rewrite, their timeouts are 5000 ms and 60000 ms, and the Exact split does not apply +- **THEN** the merged rule has `timeouts.request: 1m`, and the build log has a warning naming both routes and the chosen timeout + +### Requirement: Exact split for bare-root routes +The plugin SHALL use `Exact` matches when all of the following hold: + +- a route S has gateway path P with no path variables; +- another route R cuts to the same prefix P because its gateway path is exactly `P/{var}`; +- S and R differ in rewrite or timeout. Both are routes of this service; the split does not depend on backends or header matchers. + +In this case S SHALL be emitted as two rules on all of S's gateways: `Exact` P and `Exact` P + `/`. If S has a rewrite, each rule SHALL use a `ReplaceFullPath` filter, with value S's service path and S's service path + `/` respectively. Both rules keep S's timeout. R and every other route cut to P SHALL be emitted as the `PathPrefix` P rule. If the routes left in the `PathPrefix` P rule still differ in rewrite, the plugin SHALL report a conflict. + +#### Scenario: Controller root with a different rewrite than the single-resource route +- **WHEN** route S `/api/v1/svc/items` → `/v1/items` and route R `/api/v1/svc/items/{id}` → `/v2/items/{id}` share a gateway +- **THEN** that gateway gets these rules: `Exact` `/api/v1/svc/items` with `ReplaceFullPath` `/v1/items`; `Exact` `/api/v1/svc/items/` with `ReplaceFullPath` `/v1/items/`; `PathPrefix` `/api/v1/svc/items` with `ReplacePrefixMatch` `/v2/items` + +#### Scenario: Split does not apply because R continues after the variable +- **WHEN** route S `/api/v1/svc/items` → `/v1/items` and route R `/api/v1/svc/items/{id}/details` → `/v2/items/{id}/details` share a gateway +- **THEN** no `Exact` rules are generated, and the build fails with a conflict report for `PathPrefix` `/api/v1/svc/items` + +### Requirement: Legacy route annotation forms +The plugin SHALL read routes from every legacy route annotation form, at class and method level: `@Route`, the repeatable container `@Routes`, and `@FacadeRoute`. A method-level list of route annotations SHALL replace the class-level list. Each route annotation SHALL be resolved as legacy does: + +- with empty `gateways`, a PUBLIC, PRIVATE or INTERNAL type gives a border route of that type, and FACADE gives a facade route; +- each border gateway name in `gateways` (`public-gateway-service`, `private-gateway-service`, `internal-gateway-service`) gives a border route of the matching type; +- each other name in `gateways` gives a composite route. + +Border and composite routes SHALL take their gateway paths from `@Gateway` / `@GatewayRequestMapping`. Facade routes SHALL take them from `@FacadeGateway` / `@FacadeGatewayRequestMapping`, or use the service path when neither is present. The `hosts` attribute SHALL be ignored. + +#### Scenario: Two Route annotations on one method +- **WHEN** a method mapped to `/items` has `@Route(RouteType.PUBLIC)` and `@Route(gateways = "composite-gw")` +- **THEN** the plugin generates a PUBLIC rule for `/items` and a service-bound rule for `/items` + +#### Scenario: Border gateway name in gateways +- **WHEN** a method mapped to `/items` has `@Route(gateways = "private-gateway-service")` +- **THEN** the plugin generates a PRIVATE rule for `/items` + +#### Scenario: Any other gateway name is composite +- **WHEN** a method mapped to `/items` has `@Route(gateways = "my-service")` and `@GatewayRequestMapping("/api/items")` +- **THEN** the plugin generates a composite route with gateway path `/api/items`, and no border rule + +#### Scenario: Facade route with only a border gateway path annotation +- **WHEN** a method mapped to `/items` has `@Route(RouteType.FACADE)` and `@GatewayRequestMapping("/api/items")`, and no facade gateway path annotation +- **THEN** the facade route has gateway path `/items`, and `/api/items` is not used for it + +#### Scenario: Facade gateway path +- **WHEN** a method mapped to `/items` has `@FacadeRoute` and `@FacadeGatewayRequestMapping("/facade/items")` +- **THEN** the facade route has gateway path `/facade/items` and service path `/items` + +#### Scenario: Method-level list replaces the class-level list +- **WHEN** a class mapped to `/items` has `@Route(RouteType.PUBLIC)`, and its method mapped to `/{id}` has `@Route(RouteType.INTERNAL)` +- **THEN** the method's route `/items/{id}` is only an INTERNAL route, and no PUBLIC route is generated for it + +### Requirement: Service-bound HTTPRoute for facade and composite routes +Facade and composite routes SHALL NOT be attached to any gateway. They SHALL be planned as one more target with the same cut, rewrite, merge and `Exact` split rules as a gateway, and rendered as rules of one HTTPRoute named `{{ .Values.SERVICE_NAME }}-java-annotations-facade`, whose only parentRef is the Service `{{ .Values.SERVICE_NAME }}`. + +The plugin SHALL generate this HTTPRoute only when at least one of its planned rules has a `URLRewrite` filter. When it is generated, it SHALL contain a rule for every facade and composite route, including routes without a rewrite. It SHALL NOT contain a catch-all rule that no route declares. When the HTTPRoute is not generated and some facade or composite route has a timeout, the plugin SHALL log a warning that these timeouts are not applied. + +Two facade or composite routes with the same match and different rewrites SHALL be reported as a conflict, and the build SHALL fail. + +#### Scenario: No rewrite +- **WHEN** the only facade routes are `/items` → `/items` and `/orders` → `/orders` +- **THEN** no service-bound HTTPRoute is generated + +#### Scenario: One route with a rewrite +- **WHEN** facade routes `/facade/items` → `/items` and `/orders` → `/orders` are declared +- **THEN** the service-bound HTTPRoute has a `PathPrefix` `/facade/items` rule with `ReplacePrefixMatch` `/items`, a `PathPrefix` `/orders` rule with no `URLRewrite` filter, and no other rules + +#### Scenario: Composite route +- **WHEN** a method mapped to `/items` has `@Route(gateways = "composite-gw", hosts = "example.com")` and `@GatewayRequestMapping("/composite/items")` +- **THEN** the service-bound HTTPRoute has a `PathPrefix` `/composite/items` rule with `ReplacePrefixMatch` `/items`, and no resource references `composite-gw` or `example.com` + +#### Scenario: Facade and composite routes collide +- **WHEN** a composite route `/x` → `/a` and a facade route `/x` → `/b` are declared +- **THEN** the build fails with a conflict error that names both routes + +### Requirement: Deterministic rendering +Generated resources and rules SHALL be rendered in a deterministic order, so that the same input always produces byte-identical output. Rule order SHALL keep the existing path-specificity sorting: more segments first, then longer path, then lexical order. The sort uses the generated match value, and `Exact` rules come before `PathPrefix` rules with the same value. + +#### Scenario: Repeated builds +- **WHEN** the plugin runs twice on the same compiled classes +- **THEN** both output files are byte-identical + +### Requirement: Output written only after successful validation +The plugin SHALL write the output file only when route migration validation reports no errors. When validation reports errors, the plugin SHALL fail the build and SHALL NOT create or overwrite the output file. + +#### Scenario: Validation error +- **WHEN** validation reports at least one error +- **THEN** the Maven build fails, and the previous output file, if one exists, is left unchanged diff --git a/openspec/changes/regex-routes-prefix-migration/specs/route-migration-validation/spec.md b/openspec/changes/regex-routes-prefix-migration/specs/route-migration-validation/spec.md new file mode 100644 index 0000000000..9ca21acf27 --- /dev/null +++ b/openspec/changes/regex-routes-prefix-migration/specs/route-migration-validation/spec.md @@ -0,0 +1,158 @@ +# Spec Delta + +## Purpose + +Makes sure the generated Istio configuration routes every request the same way the legacy Cloud-Core Service Mesh did on each gateway. The build fails with actionable diagnostics when it doesn't, so migrated services cannot silently expose endpoints or change routing. + +## ADDED Requirements + +### Requirement: Legacy routing model +The plugin SHALL build a legacy routing model for each border gateway from the scanned annotations. The model reproduces legacy route registration: + +- A border route of type T (PUBLIC, PRIVATE, INTERNAL) is **allowed** on the gateways that T exposes: PUBLIC → public, private, internal; PRIVATE → private, internal; INTERNAL → internal. +- The same route is **forbidden** (legacy `allowed: false`, 404) on the other border gateways. +- When an allowed and a forbidden entry have the same gateway path on the same gateway, the allowed entry wins. +- Every `@ForbiddenRoute` declaration adds a forbidden entry on each gateway it lists. +- Facade and composite routes are not part of the model. Istio has no facade or composite gateway, and these routes become service-bound rules that are not compared with legacy routing. + +Legacy matching SHALL treat a gateway path as a pattern: + +- A path variable matches exactly one non-empty path segment. +- The pattern matches a request path equal to it, or any path that continues it with `/`. +- Among matching entries, the entry with the longest gateway path string wins, with variables counted in their `{name}` source form. +- When several matching entries with different gateway paths share the longest length, they SHALL decide the request only if they agree on allowed/forbidden and on the upstream path. The decision then uses the largest of their timeouts. Otherwise the legacy decision is undefined. + +#### Scenario: Narrower type below a wider prefix +- **WHEN** PUBLIC route `/api/v1/svc/resource` and INTERNAL route `/api/v1/svc/resource/{id}/internal-api` are declared +- **THEN** in the legacy model, request `/api/v1/svc/resource/1/internal-api` is forbidden on the public and private gateways and routed on the internal gateway + +#### Scenario: Longer allowed route below a forbidden one +- **WHEN** PUBLIC route `/api/v1/svc/resource`, INTERNAL route `/api/v1/svc/resource/{id}/internal-api` and PUBLIC route `/api/v1/svc/resource/{id}/internal-api/status` are declared +- **THEN** in the legacy model, request `/api/v1/svc/resource/1/internal-api/status` is routed on all three border gateways + +### Requirement: Undefined legacy precedence is an error +When the legacy decision for a request path on a gateway is undefined, the plugin SHALL report a `LEGACY_PRECEDENCE_UNDEFINED` error and fail the build. The error SHALL name the gateway, an example request path, and the tied routes or forbidden declarations with their sources. The plugin SHALL NOT report an Exposure, Conflict or Lost route for request paths whose legacy decision is undefined. + +#### Scenario: Tied routes with different upstream paths +- **WHEN** PUBLIC routes `/s/{a}/b` → `/one/{a}/b` and `/s/b/{a}` → `/two/{a}` are declared +- **THEN** the build fails with a `LEGACY_PRECEDENCE_UNDEFINED` error on the public, private and internal gateways for a request such as `/s/b/b`, naming both routes + +#### Scenario: Tied allowed and forbidden entries +- **WHEN** PUBLIC route `/s/{a}/b` and INTERNAL route `/s/b/{a}` are declared +- **THEN** the build fails with a `LEGACY_PRECEDENCE_UNDEFINED` error on the public and private gateways for a request such as `/s/b/b`, and no such error on the internal gateway, where both routes are allowed with the same upstream path + +#### Scenario: Tied routes that differ only in timeout +- **WHEN** PUBLIC routes `/s/{a}/b` → `/x/{a}/b` with timeout 5000 ms and `/s/b/{a}` → `/x/b/{a}` with timeout 60000 ms are declared +- **THEN** no `LEGACY_PRECEDENCE_UNDEFINED` error is reported, and in the legacy model request `/s/b/b` is routed to `/x/b/b` with timeout 60000 ms + +### Requirement: Istio routing model +The plugin SHALL build an Istio routing model for each border gateway from the generated HTTPRoute rules attached to that gateway and the generated AuthorizationPolicy DENY rules that target it. A request SHALL be evaluated in this order: + +1. If a DENY rule matches (the request matches `paths` and does not match `notPaths`), the request is denied. +2. Otherwise, a matching `Exact` rule wins. +3. Otherwise, the matching `PathPrefix` rule with the longest value wins. `PathPrefix` matches whole path segments only. +4. Otherwise, the request is unrouted. + +#### Scenario: Exact wins over a longer prefix +- **WHEN** a gateway has rules `Exact` `/a` and `PathPrefix` `/a` +- **THEN** request `/a` is served by the `Exact` rule, and request `/a/1` is served by the `PathPrefix` rule + +### Requirement: Differential validation over request paths +The plugin SHALL compare the legacy and Istio models on every border gateway for a set of request paths derived from all declared gateway paths, forbidden paths and generated match values. The set SHALL include at least: + +- every such path, with its variables replaced by a sample segment; +- the path followed by `/`; +- the path followed by one extra segment; +- every intermediate prefix of the path at a segment boundary; +- every path in which a variable is replaced by a literal segment that another declared path has at the same position. + +For each request path and gateway, the plugin SHALL classify the outcome: + +- **Exposure** (error): legacy forbids the request or does not route it, and Istio routes it. +- **Conflict** (error): legacy and Istio both route the request, but the upstream path after rewriting differs. Both models route only to this service, so backends are never compared. +- **Lost route** (error): legacy routes the request, and Istio denies it or does not route it. +- **Timeout change** (warning): legacy and Istio both route the request to the same upstream path, with different timeouts. +- **Equivalent**: every other combination. This includes legacy forbidden vs Istio denied or unrouted. + +Routes of other services on the same gateway, and legacy routes that lead to another service or use header matchers, SHALL NOT be modeled. The plugin can't generate them, so validation does not detect overlaps with them. + +#### Scenario: Cut exposes controller subtree +- **WHEN** only PUBLIC route `/api/v1/svc/order/{id}/items` is declared under `/api/v1/svc/order`, and no `@ForbiddenRoute` covers it +- **THEN** validation reports an Exposure error on the public, private and internal gateways for request paths such as `/api/v1/svc/order` and `/api/v1/svc/order/`, and the build fails + +#### Scenario: Cut exposure already covered by a shorter legacy route +- **WHEN** PUBLIC route `/api/v1/svc/order` and PUBLIC route `/api/v1/svc/order/{id}/items` are declared with the same rewrite +- **THEN** validation reports no Exposure error for `/api/v1/svc/order/` + +#### Scenario: Implicit legacy 404 not declared +- **WHEN** PUBLIC route `/api/v1/svc/resource` and INTERNAL route `/api/v1/svc/resource/{id}/internal-api` are declared, and no `@ForbiddenRoute` covers the internal one +- **THEN** validation reports an Exposure error on the public and private gateways for `/api/v1/svc/resource//internal-api`, and the build fails + +#### Scenario: Implicit legacy 404 for a path without variables +- **WHEN** PUBLIC route `/api/v1/svc/resource` and INTERNAL route `/api/v1/svc/resource/admin` are declared, and no `@ForbiddenRoute` covers the internal one +- **THEN** validation reports an Exposure error on the public and private gateways for `/api/v1/svc/resource/admin` + +#### Scenario: Narrower route with no wider prefix above it +- **WHEN** only INTERNAL route `/api/v1/svc/admin/{id}` is declared, and nothing else covers `/api/v1/svc/admin` +- **THEN** no Exposure error is reported on the public or private gateway, but an Exposure error is reported on the internal gateway for `/api/v1/svc/admin`, which the cut exposes + +#### Scenario: Conflicting behavior after the cut +- **WHEN** PUBLIC route `/api/v1/svc/resource` → `/resource` and PUBLIC route `/api/v1/svc/resource/{id}/v2-api` → `/v2/{id}/v2-api` of the same service are declared, which cannot share a single prefix rewrite +- **THEN** validation reports a Conflict error on all three border gateways for `/api/v1/svc/resource//v2-api`, and the build fails + +#### Scenario: Literal and variable at the same position +- **WHEN** route B `/a/b/{id}/c` → `/x/{id}/c` and route C `/a/b/lit` → `/y` are declared on the same gateway +- **THEN** request `/a/b/lit/c` is evaluated: legacy routes it by B (the longer pattern), Istio routes it by C (the longer prefix), and a Conflict error is reported + +#### Scenario: Only timeouts differ +- **WHEN** two routes collapse into one merged rule with the largest timeout +- **THEN** validation reports Timeout change warnings for the affected request paths, and the build does not fail because of them + +### Requirement: Contradictory declarations are errors +The plugin SHALL report an error when a `@ForbiddenRoute` declaration forbids, on some gateway, exactly the gateway path of a route allowed on that gateway. The plugin SHALL also report an error when two legacy entries of equal gateway path length both match the same request path on a gateway with different results, because legacy precedence between them is undefined. + +#### Scenario: Forbidding an allowed route's own path +- **WHEN** a method has `@Route(RouteType.PUBLIC)` and `@ForbiddenRoute(RouteType.PUBLIC)` with the same gateway path +- **THEN** the build fails with an error that names the method, the path and the public gateway + +### Requirement: Legacy-invalid duplicate routes are errors +The plugin SHALL report a `LEGACY_INVALID` error when two routes are both allowed on the same gateway with the same gateway path and different service paths, because the legacy runtime rejects this configuration too. The plugin SHALL report one error per gateway and gateway path, naming both routes and their sources, and SHALL NOT also report Conflict errors caused by that gateway path. Routes with the same gateway path and service path that differ only in timeout SHALL NOT be reported as legacy-invalid. + +#### Scenario: Same gateway path, different service paths +- **WHEN** INTERNAL routes `/api/x` → `/a` and `/api/x` → `/b` are declared +- **THEN** the build fails with one `LEGACY_INVALID` error for `/api/x` on the internal gateway, and no Conflict error for `/api/x` + +#### Scenario: Only one of them allowed on a gateway +- **WHEN** PUBLIC route `/api/x` → `/a` and INTERNAL route `/api/x` → `/b` are declared +- **THEN** `LEGACY_INVALID` is reported for the internal gateway, where both are allowed, and not for the public or private gateway + +### Requirement: Legacy-invalid annotations are errors +The plugin SHALL report a `LEGACY_INVALID` error naming the class or method when a route annotation has a non-empty `hosts` attribute together with a border gateway name in `gateways`. The legacy runtime rejects this too ("Only composite gateway can have hosts"). This error SHALL be reported together with all other findings, not instead of them. + +#### Scenario: Hosts on a border gateway +- **WHEN** a method has `@Route(gateways = "public-gateway-service", hosts = "example.com")` +- **THEN** the build fails with a `LEGACY_INVALID` error naming the method + +#### Scenario: Hosts on a composite gateway +- **WHEN** a method has `@Route(gateways = "composite-gw", hosts = "example.com")` +- **THEN** no `LEGACY_INVALID` error is reported for it + +### Requirement: Actionable failure report +When validation finds errors, the plugin SHALL log all findings at once, grouped by gateway, and then fail the build. Each finding SHALL include: + +- the finding kind; +- the gateway; +- one example request path; +- the winning legacy entry: gateway path, route type, allowed or forbidden, rewrite, and source (fully qualified class name and method name when applicable); +- the winning Istio rule or DENY rule: match type and value, rewrite, and source routes; +- a suggested remediation. + +For an Exposure, the remediation SHALL name the class or method to annotate and the exact `@ForbiddenRoute(...)` gateways to add. While `autoGenerateAuthorizationPolicies` is `false`, it SHALL also offer enabling that parameter. For a Conflict, the remediation SHALL name the colliding routes. It SHALL suggest aligning their gateway paths or rewrites, or migrating the affected waypoint manually to `VirtualService` outside this plugin. Findings SHALL be deduplicated so that the same (kind, gateway, legacy entry, Istio rule) combination appears only once. + +#### Scenario: Exposure report content +- **WHEN** the implicit 404 scenario above fails validation +- **THEN** the log names the INTERNAL method, the public and private gateways, request path `/api/v1/svc/resource//internal-api`, the Istio rule `PathPrefix /api/v1/svc/resource` that exposes it, the suggestion `@ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE})` on that method, and the alternative `true` + +#### Scenario: Multiple problems +- **WHEN** a project has one Conflict and two Exposure findings +- **THEN** all three findings are logged before the build fails diff --git a/openspec/changes/regex-routes-prefix-migration/tasks.md b/openspec/changes/regex-routes-prefix-migration/tasks.md new file mode 100644 index 0000000000..116dc79507 --- /dev/null +++ b/openspec/changes/regex-routes-prefix-migration/tasks.md @@ -0,0 +1,52 @@ +# Tasks + +## 1. ForbiddenRoute annotation (route-registration-common) + +- [ ] 1.1 Add `ForbiddenRoute.java` to `core-rest-libraries/route-registration/route-registration-common/src/main/java/com/netcracker/cloud/routesregistration/common/annotation/`. Use `@Retention(RUNTIME)`, `@Target({TYPE, METHOD})`, `@Documented`, and `RouteType[] value()` with no default, and add Javadoc saying each value is a single gateway. Verify with `mvn -q install` of route-registration-common: the build succeeds and the existing route-registration tests pass unchanged, which confirms the legacy runtime ignores the annotation. +- [ ] 1.2 Document `@ForbiddenRoute` in `core-rest-libraries/route-registration/README.md`: it is for the Istio generator only, and the legacy runtime ignores it. Verify that the README section renders and names the annotation's fully qualified class name. + +All plugin paths and `mvn` commands in sections 2–7 are relative to `core-maven-plugins/httproutes-generator-maven-plugin`. + +## 2. Route model and scanner + +- [ ] 2.1 Leave the `HttpRoute` record unchanged (D14). Add `DeclaredRoute(HttpRoute route, SortedSet origins)`, where each origin is `class#method` or `class`. Add a `Gateway` enum (PUBLIC, PRIVATE, INTERNAL with the legacy gateway name `public-gateway-service`/`private-gateway-service`/`internal-gateway-service`, a lookup by that name, parentRef/targetRef data and an exposure chain `Type → Set`; FACADE maps to no gateway). Verify with new unit tests for the exposure chain and the lookup by legacy gateway name (unknown names give no gateway), and with `mvn test` staying green without changing `HttpRouteTest` or `GenerateRoutesMojoTest` (its one facade assertion changes only in 2.4). +- [ ] 2.2 Pull the gateway-path resolution in `RouteScanner` out into shared helpers used by both `@Route` and `@ForbiddenRoute`. Add `collectDeclarations(reactorProjects)`, which returns `RouteDeclarations(routes, forbidden, findings)` with `ForbiddenDeclaration(gatewayPath, gateways, origin)`. Build it from an internal `Map>`, so the same route declared by several elements (including through superclass recursion) becomes one `DeclaredRoute` with all its origins. Return the `DeclaredRoute`s sorted by the path-specificity comparator (D14). Keep `collectRoutes` returning `Set` from the same map. Include classes and methods that have only `@ForbiddenRoute`. Verify with new test controllers (class-level forbidden without `@Route`, method-level forbidden with `@GatewayRequestMapping`), and with the existing `GenerateRoutesMojoTest` assertions still passing. +- [ ] 2.3 Validate `@ForbiddenRoute` usage: an empty value, `FACADE`, a variable that is only part of a segment, `*`, or a regex-constrained variable each produce a scan-time error finding (D1) that names the element. Verify with unit tests for each invalid case from the forbidden-route-policies spec. +- [ ] 2.4 Read all legacy route annotation forms (D13). Select classes with `@Routes` or `@FacadeRoute` in `hasRoute`. Read a list of route annotations per element (`@Route`, `@Routes`, `@FacadeRoute`), where a method-level list replaces the class-level one. Read attributes with `getParameterValues(false)`, keeping the existing type, timeout and class-fallback rules, and treat a missing `gateways`/`hosts` attribute, or one holding only empty strings, as empty. Resolve empty `gateways` by type; resolve border names in `gateways` to border routes, and every other name to a composite route. Take facade gateway paths from `@FacadeGateway`/`@FacadeGatewayRequestMapping`, or use the service path, and composite gateway paths from `@Gateway`/`@GatewayRequestMapping`. Ignore `hosts`. Change the `SpringTestController8#method1` assertion in `GenerateRoutesMojoTest` to gateway path = service path. Verify with new test controllers for every "Legacy route annotation forms" scenario (two `@Route` on a method, `@Route(gateways = "private-gateway-service")`, `@Route(gateways = "my-service")` with `@GatewayRequestMapping`, `@Route(FACADE)` with only `@GatewayRequestMapping`, `@FacadeRoute` with `@FacadeGatewayRequestMapping`, method-level list replacing the class-level list), plus a unit test that `gateways = {""}` behaves like an empty `gateways`. +- [ ] 2.5 Report the scan-time finding of D13: `LEGACY_INVALID` for `hosts` with a border name in `gateways`. Verify with unit tests for every scenario in the spec requirement "Legacy-invalid annotations are errors". + +## 3. Path patterns and the legacy routing model + +- [ ] 3.1 Implement `PathPattern`: parse segments and variables (including partial-segment variables), match whole segments with continuation after `/`, give the source length, instantiate with a sample or literal, and render as an Istio template (`{*}`). Verify with unit tests covering `/a/{id}`, `/a/{id}/b`, `/files/{name}.txt`, `/`, and trailing-slash requests. +- [ ] 3.2 Implement `LegacyRouteTable` per gateway. Allowed entries come from the exposure chain, implicit forbidden entries are added on wider gateways, allowed wins over forbidden for the same pattern, explicit `@ForbiddenRoute` entries are added, and FACADE routes are not added to any table. `decide(path)` picks the longest pattern, computes the positional-variable upstream rewrite, and resolves equal-length ties per D4: ties that agree on allowed/forbidden and upstream path use the largest timeout, other ties give an undefined decision. Verify with unit tests reproducing the legacy examples in `core-maven-plugins/httproutes-generator-maven-plugin/regex-routes-migration.md` that the plugin can produce (routes 1, 2, 5, 6 and 7; routes 3 and 4 go to `another-service` and are left out on purpose), and the legacy-model spec scenarios, including the timeout-only tie. `LegacyEntry` and `Decision` have no backend or header-matcher field. +- [ ] 3.3 Report an error when an explicit `@ForbiddenRoute` has exactly the pattern of a route allowed on the same gateway. Verify with a unit test for the "Forbidding an allowed route's own path" scenario. +- [ ] 3.4 Report `LEGACY_INVALID` when two routes are both allowed on the same gateway with the same gateway path and different service paths (D4, mirrors `RouteTransformer.validateAndReturnRoutes`). Report one finding per (gateway, pattern) naming both routes and origins, and pass the pattern to the validator so it suppresses the Conflict findings caused by it (5.3). The same path with only different timeouts is not an error. Verify with unit tests: two INTERNAL routes `/api/x` → `/a` and `/api/x` → `/b` give one `LEGACY_INVALID` on the internal gateway and no Conflict; PUBLIC `/api/x` → `/a` with INTERNAL `/api/x` → `/b` gives `LEGACY_INVALID` only on the internal gateway, where both are allowed; the same paths with timeouts 5s and 60s give only the merge warning. + +## 4. Istio planning: cut, rewrite, merge, Exact split + +- [ ] 4.1 Implement cutting at the first variable and rewrite derivation (design D6), with no remainder check. Verify with unit tests for every scenario in "Cut gateway path at the first path variable" and "Cut the prefix rewrite the same way". +- [ ] 4.2 Implement per-gateway grouping by match, merging into the widest type's resource, and a max-timeout merge with a WARNING finding (design D7, steps 1, 3 and 4). Verify with unit tests for the "One rule per match on a gateway" scenarios, including the cross-type case where the INTERNAL resource has no duplicate rule. +- [ ] 4.3 Implement the Exact split (D7 step 2): two `Exact` rules with `ReplaceFullPath` value and value + `/`, or no filter when S has no rewrite. Verify with unit tests for both "Exact split for bare-root routes" scenarios. +- [ ] 4.4 Implement DENY rule construction (D8): `paths`, `notPaths` from longer allowed routes below the forbidden path, one rule per forbidden path, `/` handling, deterministic ordering. Verify with unit tests for the "DENY rule content" scenarios, including nested forbidden paths. +- [ ] 4.5 Implement automatic DENY rules behind `autoGenerateAuthorizationPolicies` (D12). Evaluate the routing-only Istio model. Add implicit-forbidden rules only where Istio would route the forbidden pattern, and cut-exposure rules only where legacy routed neither P nor P/``. Deduplicate against explicit rules, where explicit wins, and give automatic rules their synthetic origins. Verify with unit tests for every scenario in the spec requirement "Automatic DENY rules": the flag off generates nothing automatic; the flag on with the implicit 404 example gives the internal-api rule with the status `notPaths`; the flag on with the order example gives the `/order` rule with the items `notPaths`; the flag on with a covering shorter route or an INTERNAL-only route gives no rule; and explicit plus automatic identical rules render once. +- [ ] 4.6 Plan facade and composite routes on the `SERVICE` target (D15) with the same cut, rewrite, merge, Exact split and conflict steps (4.1–4.3). Produce no `SERVICE` rules when none has a rewrite, and log the unapplied-timeout warning in that case. Report a conflict for the same match with different rewrites, naming both routes. Never add DENY rules or legacy entries for `SERVICE`. Verify with unit tests for the "No rewrite", "One route with a rewrite", "Composite route" (with `hosts`; no resource references the composite gateway name or the host) and "Facade and composite routes collide" scenarios, and the timeout warning. + +## 5. Istio routing model and differential validation + +- [ ] 5.1 Implement `IstioRouteTable.decide(gateway, path)`: DENY with `{*}`/`{**}`/`notPaths` first, then `Exact`, then the longest segment-aligned `PathPrefix` with `ReplacePrefixMatch`/`ReplaceFullPath` upstream computation, on border gateways only. Verify with unit tests for the "Exact wins over a longer prefix" scenario and for the `ReplacePrefixMatch` `/` edge case. +- [ ] 5.2 Implement probe generation (D3): segment prefixes, `/` and extra-segment probes, a unique sample literal, and literal substitution one variable at a time, with deterministic ordering. Verify with unit tests asserting that the probe set contains `/a/b/lit/c` for patterns `/a/b/{id}/c` and `/a/b/lit`, and contains the cut prefixes with `/` and `/`. +- [ ] 5.3 Implement `MigrationValidator`: classify Exposure, Conflict, Lost route and Timeout change, report `LEGACY_PRECEDENCE_UNDEFINED` for probes with an undefined legacy decision (one per gateway and tied patterns) with no other finding for those probes, skip Conflict findings caused by a `LEGACY_INVALID` pattern (3.4), and deduplicate findings by (kind, gateway, legacy entry, Istio rule). Verify with unit tests for every scenario in "Differential validation over request paths", "Undefined legacy precedence is an error" and "Legacy-invalid duplicate routes are errors" (no Conflict next to `LEGACY_INVALID`), including the migration document's example configuration without the `another-service` routes 3 and 4, with and without the matching `@ForbiddenRoute` declarations. Do not add checks or tests for overlaps with other services or header-matched routes. +- [ ] 5.4 Implement the remediation text and report formatting (D11): gateway grouping, example path, legacy and Istio winners with origins, `@ForbiddenRoute(...)` suggestions naming the element and gateways (for cut exposure: the class whose mapping equals the cut prefix, otherwise a method or class mapped to that prefix), and, while `autoGenerateAuthorizationPolicies` is `false`, the alternative of enabling it. Include the texts for `LEGACY_INVALID`, `LEGACY_PRECEDENCE_UNDEFINED` (naming the tied entries and suggesting to change one of the gateway paths) and `SERVICE` conflicts (saying the routes came from different legacy gateways). The `MojoFailureException` message summarizes the finding counts by kind and points to the log. Verify with a unit test asserting the report text for the "Exposure report content" scenario, a unit test for the cut-exposure suggestion with and without a class mapped to the cut prefix, and a test for the "Multiple problems" scenario (three findings are all logged before failure, and the exception message has the counts). + +## 6. Rendering and Mojo wiring + +- [ ] 6.1 Change `HttpRouteRenderer` to render planned rules: remove `RegularExpression`, `REGEX_MANUAL_REVIEW_WARNING` and `hasRegularExpressionMatch`; support `Exact` matches and the `ReplaceFullPath` filter; keep labels, timeouts, parentRefs and the single `backendRefs` entry (`backendRefVal`:`servicePort`) on every rule, with no per-rule backend; sort by specificity with `Exact` before `PathPrefix`. Render the service-bound HTTPRoute (`-facade`, parentRef Service `{{ .Values.SERVICE_NAME }}`) from the planned `SERVICE` rules, including rules without a rewrite, and remove the `path == gatewayPath` skip for FACADE. Update `HttpRouteRendererTest` (the `RegularExpression`/`MANUAL REVIEW` assertions become `PathPrefix /items` with no warning). Verify that `mvn test` passes and that a test renders twice with byte-identical output. +- [ ] 6.2 Add `AuthorizationPolicyRenderer`: Jackson records, `security.istio.io/v1`, name `{{ .Values.SERVICE_NAME }}-java-annotations-deny-`, shared labels, targetRefs per gateway, `action: DENY`, `ports` from the gateway's entry in `authorizationPolicyPorts`, `paths`/`notPaths`, and the order public, private, internal. Verify with unit tests asserting the YAML for the "Forbidden on public and private" and "No forbidden declarations" scenarios, and for the "Ports for one gateway" scenario. +- [ ] 6.3 Wire the pipeline in `GenerateRoutesMojo` (D1): add the optional parameters `authorizationPolicyPorts` (`Map` from border gateway name to comma-separated ports, `8080` for every gateway that isn't listed; unknown keys, empty values and invalid ports fail the build before scanning) and `autoGenerateAuthorizationPolicies` (`boolean`, `@Parameter(defaultValue = "false")`). Declare `execute() throws MojoExecutionException, MojoFailureException`, and run scan, plan and validation outside the `catch (Exception e)` of `writeRoutesFile`. Log warnings, throw `MojoFailureException` on any error finding without writing the file, and write HTTPRoutes and then AuthorizationPolicies inside the existing header and guard. Verify with a Mojo-level test using a temp base dir: a failing project leaves an existing output file untouched, and a passing project writes the combined file in the specified order, a validation failure surfaces as `MojoFailureException` (not wrapped in `MojoExecutionException`), and, when they aren't configured, `authorizationPolicyPorts` gives `8080` for every gateway and `autoGenerateAuthorizationPolicies` is `false`, plus tests for the "Unknown gateway name" scenario, an empty value, and ports `0`, `65536` and `abc`, each failing the build with no output file written. +- [ ] 6.4 Update `core-maven-plugins/httproutes-generator-maven-plugin/README.md`: prefix-only matching and the cut, the rewrite derivation (the service path is cut at its first variable too, which assumes the part after the cut is the same in both paths; the plugin doesn't check it), merging and the Exact split, the validation step with example error output and fixes, `@ForbiddenRoute` usage, AuthorizationPolicy output, the `authorizationPolicyPorts` (per border gateway name, with the configuration errors) and `autoGenerateAuthorizationPolicies` parameters (as new rows in the existing parameter table, with a `pom.xml` `` example that sets ports for one gateway, and what `autoGenerateAuthorizationPolicies` generates), the legacy annotation forms that are read (`@Routes`, `gateways` where every non-border name is a composite gateway, `@FacadeRoute`, facade gateway paths) and that `hosts` is ignored, facade and composite routes (the service-bound HTTPRoute, when it is generated, that it lists all of them, that other paths through the Service return 404 while it exists and how to fix that (declare those endpoints as facade routes, or call them through a border gateway), the warning that facade and composite timeouts are not applied when no service-bound HTTPRoute is generated, and the change of facade gateway paths to legacy semantics), the legacy-invalid and undefined-precedence errors, a note that DENY rules on shared gateways also apply to other services' paths under the same prefix, the BWC notes (403 vs 404, port scoping), a migration section for existing services (new major version, the fix options in the order `@ForbiddenRoute`, then `autoGenerateAuthorizationPolicies`, then path alignment), and a note that overlaps with routes of other services are not checked. Remove the regex and "MANUAL REVIEW" references, and update "Route Sorting". Verify that the README examples match the actual output of the corresponding unit tests. + +## 7. Integration check + +- [ ] 7.1 Add an end-to-end test controller set that reproduces the migration document example as annotations: public resource controller, internal-api method with `@ForbiddenRoute({PUBLIC, PRIVATE})`, status method, order controller with a class-level `@ForbiddenRoute` for all three gateways. Assert that the generated YAML has only `HTTPRoute` and `AuthorizationPolicy` kinds, no `RegularExpression`/`VirtualService`/`EnvoyFilter`, and the expected rules and policies. Then use a second controller set, identical but without the `@ForbiddenRoute` annotations, and assert that the build fails with Exposure findings. Verify with `mvn -q verify` in the plugin module. +- [ ] 7.2 Run the second controller set (without `@ForbiddenRoute`) again with `autoGenerateAuthorizationPolicies` set to `true`. Assert that the build passes, and that the generated AuthorizationPolicies have the same `paths` and `notPaths` as the explicit variant in 7.1 on each gateway. Verify with `mvn -q verify` in the plugin module. +- [ ] 7.3 Add an end-to-end controller set with facade and composite routes: a `@FacadeRoute` method with `@FacadeGatewayRequestMapping` (a rewrite), a `@Route(RouteType.FACADE)` method without a rewrite, and a method with `@Route(RouteType.PUBLIC)` and `@Route(gateways = "composite-gw")`. Assert that the output has one `-facade` HTTPRoute bound only to Service `{{ .Values.SERVICE_NAME }}` with all three facade and composite rules, that the PUBLIC HTTPRoute has the border rule, and that no resource references `composite-gw`. Then remove the rewrite and assert that no `-facade` HTTPRoute is generated. Verify with `mvn -q verify` in the plugin module. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000000..c4d34acea9 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,32 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours + +# Per-operation guidance (optional) +# Add advisory guidance for how apply and archive work should be conducted. +# This is separate from artifact rules above. +# Example: +# operations: +# apply: +# guidance: +# - Keep test summaries concise +# archive: +# guidance: +# - Summarize the archive outcome before finishing diff --git a/openspec/specs/.gitkeep b/openspec/specs/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 From 80276a7138dea8d95969f74dc1fe918514855fe1 Mon Sep 17 00:00:00 2001 From: iglin Date: Sun, 27 Sep 2026 22:48:46 +0500 Subject: [PATCH 02/11] fix: regex routes migration to istio --- .../README.md | 753 ++++++++++++++++-- .../plugin/AuthorizationPolicyRenderer.java | 86 ++ .../routes/gateway/plugin/DeclaredRoute.java | 27 + .../routes/gateway/plugin/DenyRule.java | 70 ++ .../routes/gateway/plugin/Finding.java | 86 ++ .../routes/gateway/plugin/FindingReport.java | 88 ++ .../gateway/plugin/ForbiddenDeclaration.java | 18 + .../routes/gateway/plugin/Gateway.java | 128 +++ .../gateway/plugin/GenerateRoutesMojo.java | 92 ++- .../gateway/plugin/HttpRouteRenderer.java | 208 ++--- .../routes/gateway/plugin/IstioPlan.java | 52 ++ .../gateway/plugin/IstioRoutePlanner.java | 404 ++++++++++ .../gateway/plugin/IstioRouteTable.java | 85 ++ .../routes/gateway/plugin/LegacyEntry.java | 75 ++ .../gateway/plugin/LegacyRouteTable.java | 213 +++++ .../gateway/plugin/MigrationValidator.java | 259 ++++++ .../routes/gateway/plugin/PathPattern.java | 248 ++++++ .../routes/gateway/plugin/PlannedRule.java | 133 ++++ .../routes/gateway/plugin/Probes.java | 137 ++++ .../routes/gateway/plugin/ResourceLabels.java | 51 ++ .../gateway/plugin/RouteDeclarations.java | 33 + .../routes/gateway/plugin/RouteMigration.java | 46 ++ .../routes/gateway/plugin/RouteScanner.java | 525 ++++++++---- .../AuthorizationPolicyRendererTest.java | 137 ++++ .../gateway/plugin/FindingReportTest.java | 140 ++++ .../routes/gateway/plugin/GatewayTest.java | 56 ++ .../plugin/GenerateRoutesMojoExecuteTest.java | 152 ++++ .../plugin/GenerateRoutesMojoTest.java | 2 +- .../gateway/plugin/HttpRouteRendererTest.java | 200 ++++- .../gateway/plugin/IstioRoutePlannerTest.java | 493 ++++++++++++ .../gateway/plugin/IstioRouteTableTest.java | 106 +++ .../gateway/plugin/LegacyRouteTableTest.java | 309 +++++++ .../plugin/MigrationValidatorTest.java | 356 +++++++++ .../gateway/plugin/PathPatternTest.java | 155 ++++ .../routes/gateway/plugin/ProbesTest.java | 73 ++ .../gateway/plugin/RouteMigrationE2ETest.java | 199 +++++ .../plugin/RouteScannerDeclarationsTest.java | 205 +++++ .../plugin/e2e/facade/FacadeController.java | 34 + .../e2e/facadeidentity/FacadeController.java | 30 + .../plugin/e2e/forbidden/OrderController.java | 25 + .../e2e/forbidden/ResourceController.java | 38 + .../e2e/unforbidden/OrderController.java | 23 + .../e2e/unforbidden/ResourceController.java | 36 + .../gateway/plugin/scan/ScanControllers.java | 179 +++++ .../route-registration/README.md | 28 + .../common/annotation/ForbiddenRoute.java | 32 + .../regex-routes-prefix-migration/design.md | 25 +- .../specs/forbidden-route-policies/spec.md | 25 +- .../specs/httproute-generation/spec.md | 14 +- .../specs/route-migration-validation/spec.md | 12 +- .../regex-routes-prefix-migration/tasks.md | 56 +- 51 files changed, 6505 insertions(+), 452 deletions(-) create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRenderer.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/DeclaredRoute.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/DenyRule.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Finding.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/FindingReport.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenDeclaration.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Gateway.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/IstioPlan.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/IstioRoutePlanner.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/IstioRouteTable.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/LegacyEntry.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/LegacyRouteTable.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/MigrationValidator.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/PathPattern.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/PlannedRule.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Probes.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ResourceLabels.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteDeclarations.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteMigration.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRendererTest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/FindingReportTest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GatewayTest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojoExecuteTest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/IstioRoutePlannerTest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/IstioRouteTableTest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/LegacyRouteTableTest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/MigrationValidatorTest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/PathPatternTest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/ProbesTest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/RouteMigrationE2ETest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/RouteScannerDeclarationsTest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/facade/FacadeController.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/facadeidentity/FacadeController.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/forbidden/OrderController.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/forbidden/ResourceController.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/unforbidden/OrderController.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/unforbidden/ResourceController.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/scan/ScanControllers.java create mode 100644 core-rest-libraries/route-registration/route-registration-common/src/main/java/com/netcracker/cloud/routesregistration/common/annotation/ForbiddenRoute.java diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/README.md b/core-maven-plugins/httproutes-generator-maven-plugin/README.md index 6710a6dd46..bb93e74192 100644 --- a/core-maven-plugins/httproutes-generator-maven-plugin/README.md +++ b/core-maven-plugins/httproutes-generator-maven-plugin/README.md @@ -2,14 +2,20 @@ This module contains `com.netcracker.cloud.plugins:httproutes-generator-maven-plugin`. It scans compiled Java classes and generates Gateway API `HTTPRoute` manifests -for Istio deployments. +and Istio `AuthorizationPolicy` manifests for Istio deployments. ## What It Does - Scans Spring MVC and Quarkus/JAX-RS endpoints from compiled classes. -- Includes only classes/methods annotated with `@Route`. -- Supports gateway path remapping via `@Gateway` and `@GatewayRequestMapping`. -- Groups generated routes by route type (`INTERNAL`, `PRIVATE`, `PUBLIC`, `FACADE`). +- Includes only classes/methods annotated with `@Route`, `@Routes`, `@FacadeRoute` or `@ForbiddenRoute`. +- Supports gateway path remapping via `@Gateway` and `@GatewayRequestMapping`, and facade gateway paths via + `@FacadeGateway` and `@FacadeGatewayRequestMapping`. +- Generates only `PathPrefix` and `Exact` path matches (see [How Paths Are Matched](#how-paths-are-matched)). +- Groups generated routes by route type (`PUBLIC`, `PRIVATE`, `INTERNAL`, `FACADE`). +- Generates `AuthorizationPolicy` resources with `DENY` rules for paths that must not be reachable + through a gateway (see [Forbidden Routes and AuthorizationPolicies](#forbidden-routes-and-authorizationpolicies)). +- Compares the legacy mesh routing with the generated Istio routing before writing anything, and fails the build + when they differ (see [Route Validation](#route-validation)). - Emits Helm-ready YAML wrapped in: `{{- if eq .Values.SERVICE_MESH_TYPE "Istio" }}`. - Runs as an **aggregator** mojo, so it collects routes across reactor modules. @@ -31,7 +37,7 @@ Add the plugin to your `pom.xml`: com.netcracker.cloud.plugins httproutes-generator-maven-plugin - 1.0.0 + 1.0.0 @@ -60,13 +66,31 @@ Add the plugin to your `pom.xml`: ### Parameters -| Parameter | Type | Default | Description | -|-----------------|----------------------|------------------------------------------|-----------------------------------------------------------------------------------------| -| `packages` | `String[]` | `com.netcracker` | Package prefixes scanned in compiled classes. | -| `servicePort` | `int` | `8080` | Backend service port for generated `backendRefs`. | -| `outputFile` | `String` | `gateway-httproutes.yaml` | Output path relative to project base dir. | -| `backendRefVal` | `String` | `{{ .Values.DEPLOYMENT_RESOURCE_NAME }}` | Backend service name in generated routes. | -| `labels` | `List

+ * Reproduces legacy registration ({@code RouteTransformer}): every border route is registered on all three + * border gateways, allowed on the gateways its type exposes and forbidden on the wider ones, and an allowed entry + * wins over a forbidden one with the same gateway path. {@code @ForbiddenRoute} declarations add forbidden entries. + * Facade and composite routes are not part of the model. + */ +public final class LegacyRouteTable { + + private static final Comparator ENTRY_ORDER = Comparator + .comparingInt((LegacyEntry e) -> -e.pattern().length()) + .thenComparing(e -> e.pattern().source()) + .thenComparing(LegacyEntry::kind) + .thenComparing(LegacyEntry::origin); + + private final Map> entries; + private final Map> legacyInvalidPatterns; + private final List findings; + + private LegacyRouteTable(Map> entries, + Map> legacyInvalidPatterns, + List findings) { + this.entries = entries; + this.legacyInvalidPatterns = legacyInvalidPatterns; + this.findings = List.copyOf(findings); + } + + public static LegacyRouteTable build(RouteDeclarations declarations) { + Map> entries = new EnumMap<>(Gateway.class); + Map> invalid = new EnumMap<>(Gateway.class); + List findings = new ArrayList<>(); + for (Gateway gateway : Gateway.values()) { + List table = new ArrayList<>(); + Map> allowed = new LinkedHashMap<>(); + List narrower = new ArrayList<>(); + for (DeclaredRoute route : declarations.routes()) { + HttpRoute.Type type = route.route().type(); + if (type == HttpRoute.Type.FACADE) { + continue; + } + if (Gateway.exposedBy(type).contains(gateway)) { + allowed.computeIfAbsent(PathPattern.of(route.route().gatewayPath()), p -> new ArrayList<>()).add(route); + table.add(LegacyEntry.allowed(route)); + } else { + narrower.add(route); + } + } + for (DeclaredRoute route : narrower) { + if (!allowed.containsKey(PathPattern.of(route.route().gatewayPath()))) { + table.add(LegacyEntry.implicitForbidden(route)); + } + } + for (ForbiddenDeclaration declaration : declarations.forbidden()) { + if (!declaration.gateways().contains(gateway)) { + continue; + } + List contradicted = allowed.get(PathPattern.of(declaration.gatewayPath())); + if (contradicted == null) { + table.add(LegacyEntry.explicitForbidden(declaration)); + } else { + findings.add(contradictoryDeclaration(gateway, declaration, contradicted)); + } + } + Set invalidPatterns = new TreeSet<>(Comparator.comparing(PathPattern::source)); + allowed.forEach((pattern, routes) -> { + Set servicePaths = routes.stream().map(r -> r.route().path()).collect(Collectors.toSet()); + if (servicePaths.size() > 1) { + invalidPatterns.add(pattern); + findings.add(duplicateGatewayPath(gateway, pattern, routes)); + } + }); + table.sort(ENTRY_ORDER); + entries.put(gateway, List.copyOf(table)); + invalid.put(gateway, Collections.unmodifiableSet(invalidPatterns)); + } + return new LegacyRouteTable(entries, invalid, findings); + } + + /** + * @return the entries of the gateway, longest pattern first + */ + public List entries(Gateway gateway) { + return entries.get(gateway); + } + + /** + * @return gateway paths with two allowed routes to different service paths, reported as {@code LEGACY_INVALID} + */ + public Set legacyInvalidPatterns(Gateway gateway) { + return legacyInvalidPatterns.get(gateway); + } + + /** + * @return contradictory declarations and legacy-invalid duplicate routes + */ + public List findings() { + return findings; + } + + /** + * Legacy decision: the matching entry with the longest gateway path wins. Entries of equal length decide together + * only if they are all forbidden, or all allowed with the same upstream path (then with the largest timeout). + */ + public Decision decide(Gateway gateway, String requestPath) { + List tied = new ArrayList<>(); + List matches = new ArrayList<>(); + int longest = -1; + for (LegacyEntry entry : entries.get(gateway)) { + if (entry.pattern().length() < longest) { + break; + } + Optional match = entry.pattern().match(requestPath); + if (match.isPresent()) { + longest = entry.pattern().length(); + tied.add(entry); + matches.add(match.get()); + } + } + if (tied.isEmpty()) { + return new Decision.Unrouted(); + } + if (tied.stream().noneMatch(LegacyEntry::isAllowed)) { + return new Decision.Forbidden(tied); + } + if (tied.stream().allMatch(LegacyEntry::isAllowed)) { + Set upstreams = new TreeSet<>(); + for (int i = 0; i < tied.size(); i++) { + upstreams.add(tied.get(i).upstream(matches.get(i))); + } + if (upstreams.size() == 1) { + long timeout = tied.stream().mapToLong(LegacyEntry::timeout).max().orElse(0); + return new Decision.Routed(upstreams.iterator().next(), timeout, tied); + } + } + return new Decision.Undefined(tied); + } + + private static Finding contradictoryDeclaration(Gateway gateway, ForbiddenDeclaration declaration, + List routes) { + return Finding.of(Finding.Kind.CONTRADICTORY_DECLARATION, gateway.refName(), + "element", declaration.origin(), + "path", declaration.gatewayPath(), + "route", routes.stream().map(DeclaredRoute::describe).collect(Collectors.joining("; ")), + "problem", "@ForbiddenRoute forbids the gateway path of a route that is allowed on " + gateway.refName(), + "fix", "remove RouteType." + gateway.routeType() + " from @ForbiddenRoute on " + declaration.origin() + + ", or change the route type so that the route is not allowed on " + gateway.refName()); + } + + private static Finding duplicateGatewayPath(Gateway gateway, PathPattern pattern, List routes) { + List details = new ArrayList<>(List.of("path", pattern.source())); + routes.stream() + .sorted(Comparator.comparing((DeclaredRoute r) -> r.route().path()).thenComparing(DeclaredRoute::originsText)) + .forEach(route -> { + details.add("route"); + details.add(route.describe()); + }); + details.addAll(List.of( + "problem", "the routes are allowed on " + gateway.refName() + " with the same gateway path and different " + + "service paths; the legacy runtime rejects this too (\"several target paths for forwarding " + + "from the same source path\")", + "fix", "use one service path for gateway path " + pattern.source() + ", or change the gateway path of " + + "one of the routes")); + return Finding.of(Finding.Kind.LEGACY_INVALID, gateway.refName(), details.toArray(String[]::new)); + } + + /** + * Legacy decision for one request path on one gateway. There is no backend: routed requests go to this service. + */ + public sealed interface Decision { + + /** + * @param entries the winning entries, several when entries of equal length agree + */ + record Routed(String upstreamPath, long timeout, List entries) implements Decision { + public Routed { + entries = List.copyOf(entries); + } + } + + record Forbidden(List entries) implements Decision { + public Forbidden { + entries = List.copyOf(entries); + } + } + + record Unrouted() implements Decision { + } + + /** + * Entries of equal length that disagree: legacy picked one of them in an undefined order. + */ + record Undefined(List tied) implements Decision { + public Undefined { + tied = List.copyOf(tied); + } + } + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/MigrationValidator.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/MigrationValidator.java new file mode 100644 index 0000000000..665e0e604b --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/MigrationValidator.java @@ -0,0 +1,259 @@ +package com.netcracker.routes.gateway.plugin; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.stream.Collectors; + +/** + * Compares the legacy and Istio routing models on every border gateway for the probe request paths (design D3–D5, D11). + */ +public final class MigrationValidator { + + private static final String NOT_ROUTED = "NOT ROUTED (404)"; + + private final boolean autoGenerateAuthorizationPolicies; + + public MigrationValidator(boolean autoGenerateAuthorizationPolicies) { + this.autoGenerateAuthorizationPolicies = autoGenerateAuthorizationPolicies; + } + + public List validate(RouteDeclarations declarations, LegacyRouteTable legacy, IstioPlan plan) { + IstioRouteTable istio = IstioRouteTable.of(plan); + SortedSet probes = Probes.probes(declarations, plan); + Map, Finding> findings = new LinkedHashMap<>(); + for (Gateway gateway : Gateway.values()) { + for (String probe : probes) { + Check check = new Check(gateway, probe, legacy.decide(gateway, probe), istio.decide(gateway, probe), + declarations, legacy); + check.classify(findings); + } + } + return List.copyOf(findings.values()); + } + + private final class Check { + private final Gateway gateway; + private final String probe; + private final LegacyRouteTable.Decision legacyDecision; + private final IstioRouteTable.Decision istioDecision; + private final RouteDeclarations declarations; + private final LegacyRouteTable legacy; + + Check(Gateway gateway, String probe, LegacyRouteTable.Decision legacyDecision, IstioRouteTable.Decision istioDecision, + RouteDeclarations declarations, LegacyRouteTable legacy) { + this.gateway = gateway; + this.probe = probe; + this.legacyDecision = legacyDecision; + this.istioDecision = istioDecision; + this.declarations = declarations; + this.legacy = legacy; + } + + void classify(Map, Finding> findings) { + if (legacyDecision instanceof LegacyRouteTable.Decision.Undefined undefined) { + Set patterns = undefined.tied().stream().map(e -> e.pattern().source()) + .collect(Collectors.toCollection(TreeSet::new)); + findings.putIfAbsent(List.of(Finding.Kind.LEGACY_PRECEDENCE_UNDEFINED, gateway, patterns), + undefined(undefined).about(patterns)); + return; + } + if (istioDecision instanceof IstioRouteTable.Decision.Conflicted) { + // reported by the planner + return; + } + boolean legacyRouted = legacyDecision instanceof LegacyRouteTable.Decision.Routed; + if (!legacyRouted && istioDecision instanceof IstioRouteTable.Decision.Routed routed) { + add(findings, Finding.Kind.EXPOSURE, routed.rule(), exposure(routed.rule())); + } else if (legacyDecision instanceof LegacyRouteTable.Decision.Routed legacyRoute) { + if (istioDecision instanceof IstioRouteTable.Decision.Routed routed) { + if (!legacyRoute.upstreamPath().equals(routed.upstreamPath())) { + add(findings, Finding.Kind.CONFLICT, routed.rule(), conflict(legacyRoute, routed)); + } else if (legacyRoute.timeout() != routed.timeout()) { + add(findings, Finding.Kind.TIMEOUT_CHANGE, routed.rule(), timeoutChange(legacyRoute, routed)); + } + } else { + Object rule = istioDecision instanceof IstioRouteTable.Decision.Denied denied ? denied.rule() : NOT_ROUTED; + add(findings, Finding.Kind.LOST_ROUTE, rule, lostRoute()); + } + } + } + + private void add(Map, Finding> findings, Finding.Kind kind, Object istioRule, List details) { + List key = List.of(kind, gateway, legacyEntries(), istioRule); + findings.computeIfAbsent(key, k -> { + Finding finding = Finding.of(kind, gateway.refName(), details.toArray(String[]::new)); + return kind == Finding.Kind.CONFLICT ? finding.about(collidingGatewayPaths(istioRule)) : finding; + }); + } + + /** + * @return the gateway paths of the legacy entries and of the sources of the Istio rule + */ + private Set collidingGatewayPaths(Object istioRule) { + Set paths = new TreeSet<>(); + legacyEntries().forEach(e -> paths.add(e.pattern().source())); + if (istioRule instanceof PlannedRule rule) { + rule.sources().forEach(r -> paths.add(r.route().gatewayPath())); + } + return paths; + } + + private List legacyEntries() { + return switch (legacyDecision) { + case LegacyRouteTable.Decision.Routed routed -> routed.entries(); + case LegacyRouteTable.Decision.Forbidden forbidden -> forbidden.entries(); + case LegacyRouteTable.Decision.Undefined undefined -> undefined.tied(); + case LegacyRouteTable.Decision.Unrouted unrouted -> List.of(); + }; + } + + private List header() { + List details = new ArrayList<>(List.of("request", probe)); + List entries = legacyEntries(); + if (entries.isEmpty()) { + details.addAll(List.of("legacy", NOT_ROUTED)); + } + for (LegacyEntry entry : entries) { + details.addAll(List.of("legacy", entry.describe())); + } + details.addAll(List.of("istio", switch (istioDecision) { + case IstioRouteTable.Decision.Routed routed -> "ROUTED by " + routed.rule().describe(); + case IstioRouteTable.Decision.Denied denied -> "DENIED by " + denied.rule().describe(); + case IstioRouteTable.Decision.Conflicted conflicted -> "no rule for conflicted PathPrefix " + conflicted.prefix().value(); + case IstioRouteTable.Decision.Unrouted unrouted -> NOT_ROUTED; + })); + return details; + } + + private List exposure(PlannedRule rule) { + List details = header(); + List fixes = new ArrayList<>(); + List unforbiddable = new ArrayList<>(); + if (legacyDecision instanceof LegacyRouteTable.Decision.Forbidden forbidden) { + details.addAll(List.of("problem", "legacy forbids this request on " + gateway.refName() + + ", and the generated rule routes it")); + forbidden.entries().stream().filter(e -> e.kind() == LegacyEntry.Kind.IMPLICIT_FORBIDDEN).forEach(entry -> { + Optional inexpressible = PathPattern.forbiddenPathProblem(entry.pattern().source()); + if (inexpressible.isPresent()) { + unforbiddable.add("change the gateway path " + entry.pattern().source() + " of " + + String.join(", ", entry.route().origins()) + ", which neither @ForbiddenRoute nor an " + + "automatic DENY rule can forbid: " + inexpressible.get()); + return; + } + Set gateways = EnumSet.allOf(Gateway.class); + gateways.removeAll(Gateway.exposedBy(entry.route().route().type())); + fixes.add("add " + forbiddenRoute(gateways) + " to " + String.join(", ", entry.route().origins())); + }); + } else { + details.addAll(List.of("problem", "legacy doesn't route this request on " + gateway.refName() + + ", and the generated rule " + rule.matchType().apiName() + " " + rule.value() + + " routes it, because the gateway path was cut at its first variable")); + fixes.add(cutExposureFix(rule)); + } + if (fixes.isEmpty() && unforbiddable.isEmpty()) { + fixes.add("check the @ForbiddenRoute declarations that cover " + probe); + } + if (!autoGenerateAuthorizationPolicies && !fixes.isEmpty()) { + fixes.add("or set true in the " + + "plugin configuration to generate the DENY rule automatically"); + } + fixes.addAll(unforbiddable); + fixes.forEach(fix -> details.addAll(List.of("fix", fix))); + return details; + } + + /** + * Suggests {@code @ForbiddenRoute} on the class mapped to the cut prefix, or else on a method or class mapped to it, + * for the gateways on which legacy doesn't route the prefix. Longer routes below the prefix stay allowed, + * because they become {@code notPaths} of the DENY rule. + */ + private String cutExposureFix(PlannedRule rule) { + String prefix = rule.value(); + Set gateways = EnumSet.of(gateway); + for (Gateway g : rule.gateways()) { + if (!(legacy.decide(g, prefix) instanceof LegacyRouteTable.Decision.Routed)) { + gateways.add(g); + } + } + SortedSet elements = declarations.elementGatewayPaths().getOrDefault(prefix, new TreeSet<>()); + List classes = elements.stream().filter(e -> !e.contains("#")).toList(); + String annotation = forbiddenRoute(gateways); + if (!classes.isEmpty()) { + return "add " + annotation + " to class " + String.join(", ", classes); + } + if (!elements.isEmpty()) { + return "add " + annotation + " to one of " + String.join(", ", elements); + } + return "add " + annotation + " to a class or method mapped to " + prefix; + } + + private List conflict(LegacyRouteTable.Decision.Routed legacyRoute, IstioRouteTable.Decision.Routed routed) { + List details = header(); + SortedSet origins = new TreeSet<>(); + legacyRoute.entries().forEach(e -> origins.addAll(e.route().origins())); + routed.rule().sources().forEach(r -> origins.addAll(r.origins())); + details.addAll(List.of( + "problem", "legacy forwards this request to " + legacyRoute.upstreamPath() + + ", and the generated rule forwards it to " + routed.upstreamPath(), + "fix", "align the gateway paths or rewrites of the colliding routes from " + String.join(", ", origins) + + ", or migrate the affected waypoint manually to VirtualService outside this plugin")); + return details; + } + + private List timeoutChange(LegacyRouteTable.Decision.Routed legacyRoute, IstioRouteTable.Decision.Routed routed) { + List details = header(); + details.addAll(List.of("problem", "legacy uses timeout " + duration(legacyRoute.timeout()) + + ", and the generated rule uses " + duration(routed.timeout()))); + return details; + } + + private List lostRoute() { + List details = header(); + if (istioDecision instanceof IstioRouteTable.Decision.Denied denied && denied.rule().isAutomatic()) { + details.addAll(List.of( + "problem", "legacy routes this request, and the automatic DENY rule from " + denied.rule().origin() + + " denies it", + "fix", "change the gateway path of this route or of the routes the DENY rule comes from; " + + "the rule was generated automatically, so there is no @ForbiddenRoute declaration to narrow")); + } else if (istioDecision instanceof IstioRouteTable.Decision.Denied denied) { + details.addAll(List.of( + "problem", "legacy routes this request, and the DENY rule from " + denied.rule().origin() + " denies it", + "fix", "narrow the @ForbiddenRoute declaration, or change the gateway path of one of the routes")); + } else { + details.addAll(List.of( + "problem", "legacy routes this request, and no generated rule routes it", + "fix", "change the gateway path of one of the routes involved")); + } + return details; + } + + private Finding undefined(LegacyRouteTable.Decision.Undefined undefined) { + List details = new ArrayList<>(List.of("request", probe)); + undefined.tied().forEach(entry -> details.addAll(List.of("entry", entry.describe()))); + details.addAll(List.of( + "problem", "these legacy entries have gateway paths of the same length that match the request with " + + "different results, so legacy picks one of them in an undefined order", + "fix", "change the gateway path of one of these routes so that only one of them matches such requests")); + return Finding.of(Finding.Kind.LEGACY_PRECEDENCE_UNDEFINED, gateway.refName(), details.toArray(String[]::new)); + } + } + + static String forbiddenRoute(Set gateways) { + List types = Arrays.stream(Gateway.values()).filter(gateways::contains) + .map(g -> "RouteType." + g.routeType().name()).toList(); + return types.size() == 1 ? "@ForbiddenRoute(" + types.get(0) + ")" + : "@ForbiddenRoute({" + String.join(", ", types) + "})"; + } + + private static String duration(long timeout) { + return timeout > 0 ? HttpRouteRenderer.formatDuration(timeout) : "none"; + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/PathPattern.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/PathPattern.java new file mode 100644 index 0000000000..78ba5b2dfb --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/PathPattern.java @@ -0,0 +1,248 @@ +package com.netcracker.routes.gateway.plugin; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A gateway or service path with {@code {name}} variables, matched the way the legacy gateways match it: + * a variable matches one non-empty segment, and the pattern matches a request equal to it or continuing it with {@code /}. + */ +public final class PathPattern { + + private static final Pattern VARIABLE = Pattern.compile("\\{[^/{}]*}"); + private static final Pattern REGEX_CONSTRAINED_VARIABLE = Pattern.compile("\\{[^/}]*:"); + private static final String VARIABLE_REGEX = "([^/]+)"; + + private final String source; + private final List segments; + private final Pattern matcher; + private final int variableCount; + + private PathPattern(String source) { + this.source = normalize(source); + this.segments = splitSegments(this.source); + String body = this.source.endsWith("/") ? this.source.substring(0, this.source.length() - 1) : this.source; + StringBuilder regex = new StringBuilder(); + Matcher variables = VARIABLE.matcher(body); + int last = 0; + int count = 0; + while (variables.find()) { + regex.append(Pattern.quote(body.substring(last, variables.start()))).append(VARIABLE_REGEX); + last = variables.end(); + count++; + } + regex.append(Pattern.quote(body.substring(last))).append("(/.*)?"); + this.matcher = Pattern.compile(regex.toString()); + this.variableCount = count; + } + + public static PathPattern of(String source) { + return new PathPattern(source); + } + + public String source() { + return source; + } + + /** + * @return length of the source text, variables counted in their {@code {name}} form (legacy precedence) + */ + public int length() { + return source.length(); + } + + public List segments() { + return segments; + } + + public boolean hasVariables() { + return variableCount > 0; + } + + public Optional match(String requestPath) { + Matcher m = matcher.matcher(requestPath); + if (!m.matches()) { + return Optional.empty(); + } + List captures = new ArrayList<>(variableCount); + for (int i = 1; i <= variableCount; i++) { + captures.add(m.group(i)); + } + String remainder = m.group(variableCount + 1); + return Optional.of(new Match(captures, remainder == null ? "" : remainder)); + } + + public boolean matches(String requestPath) { + return matcher.matcher(requestPath).matches(); + } + + /** + * @return the source with every variable replaced by {@code sample} + */ + public String instantiate(String sample) { + return VARIABLE.matcher(source).replaceAll(Matcher.quoteReplacement(sample)); + } + + /** + * @return the source with its variables replaced by position with {@code values}; variables without a value are kept + */ + public String substitute(List values) { + Matcher variables = VARIABLE.matcher(source); + StringBuilder result = new StringBuilder(); + int index = 0; + while (variables.find()) { + String replacement = index < values.size() ? values.get(index) : variables.group(); + variables.appendReplacement(result, Matcher.quoteReplacement(replacement)); + index++; + } + variables.appendTail(result); + return result.toString(); + } + + /** + * @return the source as an Istio AuthorizationPolicy path template: every segment with a variable becomes {@code {*}}, + * and the trailing slash is dropped, because the pattern matches the path without it too + */ + public String istioTemplate() { + List matched = matchSegments(); + if (matched.isEmpty()) { + return "/"; + } + StringBuilder template = new StringBuilder(); + for (String segment : matched) { + template.append('/').append(hasVariable(segment) ? "{*}" : segment); + } + return template.toString(); + } + + /** + * Compares the patterns segment by segment, up to the shorter one: a literal overlaps an equal literal + * and a variable segment that matches it, and two variable segments always overlap. + * + * @return whether some request path matches both patterns + */ + public boolean overlaps(PathPattern other) { + List mine = matchSegments(); + List theirs = other.matchSegments(); + for (int i = 0; i < Math.min(mine.size(), theirs.size()); i++) { + if (!segmentsOverlap(mine.get(i), theirs.get(i))) { + return false; + } + } + return true; + } + + private static boolean segmentsOverlap(String a, String b) { + if (hasVariable(a) && hasVariable(b)) { + return true; + } + if (hasVariable(a) || hasVariable(b)) { + String variable = hasVariable(a) ? a : b; + String literal = hasVariable(a) ? b : a; + return of("/" + variable).matches("/" + literal); + } + return a.equals(b); + } + + /** + * @return the segments without the empty segment of a trailing {@code /}, which matching ignores + */ + List matchSegments() { + return !segments.isEmpty() && segments.get(segments.size() - 1).isEmpty() + ? segments.subList(0, segments.size() - 1) : segments; + } + + public static boolean hasVariable(String segment) { + return segment.contains("{"); + } + + /** + * @return whether the segment is exactly one variable, such as {@code {id}} + */ + public static boolean isVariable(String segment) { + return VARIABLE.matcher(segment).matches(); + } + + /** + * Cuts the path before the segment that contains the first variable, without the trailing slash. + * Returns {@code /} if nothing remains, and the path without its trailing slash if it has no variables: + * legacy matches {@code /a/} and {@code /a} the same way, and Istio ignores the trailing slash of a {@code PathPrefix}. + */ + public static String cut(String path) { + String normalized = normalize(path); + int variable = normalized.indexOf('{'); + String prefix = variable < 0 ? normalized : normalized.substring(0, normalized.lastIndexOf('/', variable)); + while (prefix.endsWith("/")) { + prefix = prefix.substring(0, prefix.length() - 1); + } + return prefix.isEmpty() ? "/" : prefix; + } + + /** + * @return why the path can't be a forbidden path, or empty if it can + */ + public static Optional forbiddenPathProblem(String path) { + if (path.contains("*")) { + return Optional.of("forbidden paths must not contain * wildcards"); + } + if (REGEX_CONSTRAINED_VARIABLE.matcher(path).find()) { + return Optional.of("forbidden paths must not contain regex-constrained variables such as {id:\\d+}"); + } + boolean partial = splitSegments(normalize(path)).stream() + .anyMatch(segment -> hasVariable(segment) && !isVariable(segment)); + if (partial) { + return Optional.of("forbidden paths need whole-segment variables, a variable must take up a whole path segment"); + } + return Optional.empty(); + } + + /** + * Appends the unmatched rest of a request path to a rewritten path, the way both meshes join them: + * the trailing slash of {@code rewritten} is dropped, and an empty result becomes {@code /}. + */ + static String appendRemainder(String rewritten, String remainder) { + String base = rewritten.endsWith("/") ? rewritten.substring(0, rewritten.length() - 1) : rewritten; + String result = base + remainder; + return result.isEmpty() ? "/" : result; + } + + static String normalize(String path) { + if (path == null || path.isEmpty()) { + return "/"; + } + return path.startsWith("/") ? path : "/" + path; + } + + private static List splitSegments(String source) { + if (source.equals("/")) { + return List.of(); + } + return List.copyOf(Arrays.asList(source.substring(1).split("/", -1))); + } + + @Override + public boolean equals(Object o) { + return o instanceof PathPattern other && source.equals(other.source); + } + + @Override + public int hashCode() { + return source.hashCode(); + } + + @Override + public String toString() { + return source; + } + + /** + * @param captures values of the variables, in order + * @param remainder the part of the request after the pattern, empty or starting with {@code /} + */ + public record Match(List captures, String remainder) { + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/PlannedRule.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/PlannedRule.java new file mode 100644 index 0000000000..065ba8d60e --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/PlannedRule.java @@ -0,0 +1,133 @@ +package com.netcracker.routes.gateway.plugin; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * One planned HTTPRoute rule. + * + * @param resourceType HTTPRoute resource the rule goes into; {@code FACADE} is the service-bound HTTPRoute + * @param matchType path match type + * @param value path match value + * @param rewriteType URL rewrite filter type, or {@code null} for no filter + * @param rewriteValue URL rewrite value, or {@code null} for no filter + * @param timeout request timeout in milliseconds, {@code 0} for none + * @param sources the declared routes the rule comes from + */ +public record PlannedRule(HttpRoute.Type resourceType, MatchType matchType, String value, + RewriteType rewriteType, String rewriteValue, long timeout, List sources) { + + public PlannedRule { + Objects.requireNonNull(resourceType, "resourceType"); + Objects.requireNonNull(matchType, "matchType"); + Objects.requireNonNull(value, "value"); + if ((rewriteType == null) != (rewriteValue == null)) { + throw new IllegalArgumentException("rewrite type and value must be set together"); + } + sources = sources.stream() + .sorted(Comparator.comparing((DeclaredRoute r) -> r.route().gatewayPath()) + .thenComparing(r -> r.route().path()) + .thenComparing(r -> r.route().type()) + .thenComparingLong(r -> r.route().timeout()) + .thenComparing(DeclaredRoute::originsText)) + .toList(); + } + + /** + * @return the border gateways the rule is attached to; none for the service-bound HTTPRoute + */ + public Set gateways() { + return Gateway.exposedBy(resourceType); + } + + public boolean hasRewrite() { + return rewriteType != null; + } + + /** + * @return the prefix a {@code PathPrefix} rule matches, without the trailing slash Istio ignores + */ + String prefixBase() { + return prefixBase(value); + } + + static String prefixBase(String prefix) { + return prefix.endsWith("/") ? prefix.substring(0, prefix.length() - 1) : prefix; + } + + /** + * @return whether the rule matches the request path, whole segments only for {@code PathPrefix} + */ + public boolean matches(String requestPath) { + return matchType == MatchType.EXACT ? value.equals(requestPath) : prefixMatches(value, requestPath); + } + + /** + * @return whether a {@code PathPrefix} match on {@code prefix} matches the request path, whole segments only + */ + static boolean prefixMatches(String prefix, String requestPath) { + String base = prefixBase(prefix); + return base.isEmpty() || requestPath.equals(base) || requestPath.startsWith(base + "/"); + } + + /** + * @return the path Istio sends upstream for a request path this rule matches + */ + public String upstream(String requestPath) { + if (rewriteType == null) { + return requestPath; + } + if (rewriteType == RewriteType.REPLACE_FULL_PATH) { + return rewriteValue; + } + return PathPattern.appendRemainder(rewriteValue, requestPath.substring(prefixBase().length())); + } + + /** + * @return for example {@code PathPrefix /api/v1/svc/resource -> /resource (from com.acme.C, com.acme.C#m)} + */ + public String describe() { + StringBuilder text = new StringBuilder(matchType.apiName()).append(' ').append(value); + if (rewriteType != null) { + text.append(" -> ").append(rewriteValue); + if (rewriteType == RewriteType.REPLACE_FULL_PATH) { + text.append(" (").append(rewriteType.apiName()).append(')'); + } + } + String origins = sources.stream().flatMap(r -> r.origins().stream()).distinct().sorted().collect(Collectors.joining(", ")); + return text.append(" (from ").append(origins).append(')').toString(); + } + + public enum MatchType { + EXACT("Exact"), + PATH_PREFIX("PathPrefix"); + + private final String apiName; + + MatchType(String apiName) { + this.apiName = apiName; + } + + public String apiName() { + return apiName; + } + } + + public enum RewriteType { + REPLACE_PREFIX_MATCH("ReplacePrefixMatch"), + REPLACE_FULL_PATH("ReplaceFullPath"); + + private final String apiName; + + RewriteType(String apiName) { + this.apiName = apiName; + } + + public String apiName() { + return apiName; + } + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Probes.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Probes.java new file mode 100644 index 0000000000..45d8d6a550 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Probes.java @@ -0,0 +1,137 @@ +package com.netcracker.routes.gateway.plugin; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.stream.Stream; + +/** + * Request paths on which the legacy and Istio models are compared (design D3). + */ +public final class Probes { + + static final String SAMPLE_PREFIX = "x-probe-"; + + private Probes() { + } + + /** + * @return {@code x-probe-N} with the smallest N that is not a segment of any of the paths + */ + public static String sample(Collection paths) { + Set segments = new HashSet<>(); + for (String path : paths) { + segments.addAll(PathPattern.of(path).segments()); + } + int n = 0; + while (segments.contains(SAMPLE_PREFIX + n)) { + n++; + } + return SAMPLE_PREFIX + n; + } + + /** + * @return the sample for all gateway paths, service paths and forbidden paths of the declarations + */ + public static String sample(RouteDeclarations declarations) { + return sample(Stream.concat( + declarations.routes().stream().flatMap(r -> Stream.of(r.route().gatewayPath(), r.route().path())), + declarations.forbidden().stream().map(ForbiddenDeclaration::gatewayPath)).toList()); + } + + /** + * @return {@code path/segment}, or {@code /segment} for the root + */ + static String child(String path, String segment) { + return (path.endsWith("/") ? path : path + "/") + segment; + } + + /** + * @return probes for the border gateway paths, forbidden paths and border match values of the plan + */ + public static SortedSet probes(RouteDeclarations declarations, IstioPlan plan) { + List patterns = new ArrayList<>(); + declarations.routes().stream().filter(r -> r.route().type() != HttpRoute.Type.FACADE) + .forEach(r -> patterns.add(r.route().gatewayPath())); + declarations.forbidden().forEach(f -> patterns.add(f.gatewayPath())); + plan.rules().stream().filter(r -> r.resourceType() != HttpRoute.Type.FACADE).forEach(r -> patterns.add(r.value())); + plan.conflicted().stream().filter(c -> c.resourceType() != HttpRoute.Type.FACADE).forEach(c -> patterns.add(c.value())); + return probes(patterns, plan.sample()); + } + + /** + * Request paths on which legacy and Istio decisions can differ (design D3). For every segment prefix of every pattern: + * the prefix, the prefix with {@code /}, and the prefix with one more {@code sample} segment. Variables are + * instantiated with {@code sample}, and, one variable at a time, with every literal segment that another pattern + * has at the same position under a compatible prefix. + */ + public static SortedSet probes(Collection patterns, String sample) { + List> segmentLists = patterns.stream().map(Probes::segments).distinct().toList(); + SortedSet probes = new TreeSet<>(); + for (List segments : segmentLists) { + for (int k = 0; k <= segments.size(); k++) { + List prefix = segments.subList(0, k); + List base = prefix.stream().map(s -> instantiateSegment(s, sample)).toList(); + addProbes(probes, base, sample); + for (int i = 0; i < k; i++) { + if (!PathPattern.hasVariable(prefix.get(i))) { + continue; + } + for (String literal : literalsAt(segmentLists, prefix, i)) { + List instance = new ArrayList<>(base); + instance.set(i, literal); + addProbes(probes, instance, sample); + } + } + } + } + return probes; + } + + private static void addProbes(Set probes, List segments, String sample) { + String path = "/" + String.join("/", segments); + probes.add(path); + if (!path.equals("/")) { + probes.add(path + "/"); + } + probes.add(child(path, sample)); + } + + /** + * @return literal segments at position {@code i} of other patterns whose earlier segments are compatible with + * {@code prefix}, and which the variable segment {@code prefix[i]} matches + */ + private static Set literalsAt(List> segmentLists, List prefix, int i) { + PathPattern variable = PathPattern.of("/" + prefix.get(i)); + Set literals = new TreeSet<>(); + for (List other : segmentLists) { + if (other.size() <= i || PathPattern.hasVariable(other.get(i)) || !variable.matches("/" + other.get(i))) { + continue; + } + boolean compatible = true; + for (int j = 0; j < i && compatible; j++) { + compatible = prefix.get(j).equals(other.get(j)) + || PathPattern.hasVariable(prefix.get(j)) || PathPattern.hasVariable(other.get(j)); + } + if (compatible) { + literals.add(other.get(i)); + } + } + return literals; + } + + /** + * @return the segments of a pattern without the empty segment of a trailing {@code /} + */ + private static List segments(String pattern) { + return PathPattern.of(pattern).matchSegments(); + } + + private static String instantiateSegment(String segment, String sample) { + return PathPattern.of("/" + segment).instantiate(sample).substring(1); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ResourceLabels.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ResourceLabels.java new file mode 100644 index 0000000000..784ed8113b --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ResourceLabels.java @@ -0,0 +1,51 @@ +package com.netcracker.routes.gateway.plugin; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +import java.util.Map; +import java.util.TreeMap; + +/** + * Labels and YAML serialization shared by the generated HTTPRoutes and AuthorizationPolicies. + */ +final class ResourceLabels { + + private static final ObjectMapper YAML_MAPPER = yamlMapper(); + private static final Map DEFAULT_LABELS = Map.of( + "app.kubernetes.io/name", "{{ .Values.SERVICE_NAME }}", + "app.kubernetes.io/part-of", "{{ .Values.APPLICATION_NAME }}", + "app.kubernetes.io/managed-by", "{{ .Values.MANAGED_BY }}", + "deployment.netcracker.com/sessionId", "{{ .Values.DEPLOYMENT_SESSION_ID }}", + "deployer.cleanup/allow", "true", + "app.kubernetes.io/processed-by-operator", "istiod" + ); + + private ResourceLabels() { + } + + /** + * @return the custom labels if any are configured, else the default labels, sorted by key + */ + static Map of(Map customLabels) { + if (customLabels == null || customLabels.isEmpty()) { + return new TreeMap<>(DEFAULT_LABELS); + } + return new TreeMap<>(customLabels); + } + + static String writeYaml(Object resource) { + try { + return YAML_MAPPER.writeValueAsString(resource); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize " + resource.getClass().getSimpleName() + " to YAML", e); + } + } + + private static ObjectMapper yamlMapper() { + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + return mapper; + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteDeclarations.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteDeclarations.java new file mode 100644 index 0000000000..64f43c0a33 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteDeclarations.java @@ -0,0 +1,33 @@ +package com.netcracker.routes.gateway.plugin; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.SortedSet; +import java.util.TreeMap; + +/** + * Everything the scanner found. + * + * @param routes declared routes, sorted by path specificity + * @param forbidden {@code @ForbiddenRoute} declarations + * @param findings scan-time findings + * @param elementGatewayPaths border gateway path → classes ({@code class}) and methods ({@code class#method}) mapped to it, + * used to suggest where to put {@code @ForbiddenRoute} + */ +public record RouteDeclarations(List routes, + List forbidden, + List findings, + Map> elementGatewayPaths) { + + public RouteDeclarations { + routes = List.copyOf(routes); + forbidden = List.copyOf(forbidden); + findings = List.copyOf(findings); + elementGatewayPaths = Collections.unmodifiableMap(new TreeMap<>(elementGatewayPaths)); + } + + public RouteDeclarations(List routes, List forbidden) { + this(routes, forbidden, List.of(), Map.of()); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteMigration.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteMigration.java new file mode 100644 index 0000000000..1f23e3d2a7 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteMigration.java @@ -0,0 +1,46 @@ +package com.netcracker.routes.gateway.plugin; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * The pipeline after scanning (design D1): legacy model, Istio plan and validation. + * + * @param findings the findings of all stages: scan, legacy model, planner and validator + */ +public record RouteMigration(RouteDeclarations declarations, LegacyRouteTable legacy, IstioPlan plan, List findings) { + + public RouteMigration { + findings = List.copyOf(findings); + } + + public static RouteMigration run(RouteDeclarations declarations, boolean autoGenerateAuthorizationPolicies) { + LegacyRouteTable legacy = LegacyRouteTable.build(declarations); + IstioPlan plan = new IstioRoutePlanner(autoGenerateAuthorizationPolicies).plan(declarations, legacy); + List findings = new ArrayList<>(declarations.findings()); + findings.addAll(legacy.findings()); + findings.addAll(plan.findings()); + findings.addAll(new MigrationValidator(autoGenerateAuthorizationPolicies).validate(declarations, legacy, plan)); + Set legacyInvalid = legacyInvalidGatewayPaths(legacy); + findings.removeIf(f -> f.gatewayPaths().stream().anyMatch(legacyInvalid::contains)); + return new RouteMigration(declarations, legacy, plan, findings); + } + + /** + * Collisions of a route whose gateway path is {@code LEGACY_INVALID} aren't reported: that error fails the build + * already, and legacy has no defined behavior to compare the route with. + */ + private static Set legacyInvalidGatewayPaths(LegacyRouteTable legacy) { + Set paths = new HashSet<>(); + for (Gateway gateway : Gateway.values()) { + legacy.legacyInvalidPatterns(gateway).forEach(p -> paths.add(p.source())); + } + return paths; + } + + public boolean hasErrors() { + return findings.stream().anyMatch(Finding::isError); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteScanner.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteScanner.java index 0c1678bb87..70c1229bfc 100644 --- a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteScanner.java +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteScanner.java @@ -1,7 +1,11 @@ package com.netcracker.routes.gateway.plugin; -import com.netcracker.cloud.routesregistration.common.annotation.Gateway; +import com.netcracker.cloud.routesregistration.common.annotation.FacadeGateway; +import com.netcracker.cloud.routesregistration.common.annotation.FacadeRoute; +import com.netcracker.cloud.routesregistration.common.annotation.ForbiddenRoute; import com.netcracker.cloud.routesregistration.common.annotation.Route; +import com.netcracker.cloud.routesregistration.common.annotation.Routes; +import com.netcracker.cloud.routesregistration.common.spring.gateway.route.annotation.FacadeGatewayRequestMapping; import com.netcracker.cloud.routesregistration.common.spring.gateway.route.annotation.GatewayRequestMapping; import io.github.classgraph.*; import jakarta.ws.rs.*; @@ -12,6 +16,8 @@ import java.io.File; import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; import java.util.stream.Stream; public class RouteScanner { @@ -26,8 +32,16 @@ public class RouteScanner { ); private static final String ROUTE_ANNOTATION = Route.class.getName(); - private static final String GATEWAY_ANNOTATION = Gateway.class.getName(); + private static final String ROUTES_ANNOTATION = Routes.class.getName(); + private static final String FACADE_ROUTE_ANNOTATION = FacadeRoute.class.getName(); + private static final String FORBIDDEN_ROUTE_ANNOTATION = ForbiddenRoute.class.getName(); + private static final String GATEWAY_ANNOTATION = com.netcracker.cloud.routesregistration.common.annotation.Gateway.class.getName(); private static final String GATEWAY_REQUEST_MAPPING = GatewayRequestMapping.class.getName(); + private static final String FACADE_GATEWAY_ANNOTATION = FacadeGateway.class.getName(); + private static final String FACADE_GATEWAY_REQUEST_MAPPING = FacadeGatewayRequestMapping.class.getName(); + private static final List ROUTE_SELECTING_ANNOTATIONS = List.of( + ROUTE_ANNOTATION, ROUTES_ANNOTATION, FACADE_ROUTE_ANNOTATION, FORBIDDEN_ROUTE_ANNOTATION + ); private final String[] packages; private final Log log; @@ -38,29 +52,65 @@ public RouteScanner(String[] packages, Log log) { } public Set collectRoutes(List reactorProjects) throws MojoExecutionException { - Set allRoutes = new HashSet<>(); + return collectDeclarations(reactorProjects).routes().stream() + .map(DeclaredRoute::route) + .collect(Collectors.toSet()); + } + + public RouteDeclarations collectDeclarations(List reactorProjects) throws MojoExecutionException { + Declarations declarations = new Declarations(); for (MavenProject module : reactorProjects) { log.info("Scanning module: " + module.getArtifactId()); - allRoutes.addAll(getRoutes(module)); + scanModule(module, declarations); } - return allRoutes; + return declarations.toRouteDeclarations(); + } + + /** + * Collects declarations of one classes directory, as {@link #collectDeclarations(List)} does for a module. + */ + RouteDeclarations collectDeclarations(File classesDir) throws MojoExecutionException { + Declarations declarations = new Declarations(); + scanClassesDir(classesDir, declarations); + return declarations.toRouteDeclarations(); } public Set getRoutes(MavenProject module) throws MojoExecutionException { - File classesDir = new File(module.getBuild().getOutputDirectory()); + Declarations declarations = new Declarations(); + scanModule(module, declarations); + return declarations.routeSet(); + } + + /** + * Collects declarations of the given classes, as {@link #collectDeclarations} does for scanned modules. + */ + RouteDeclarations declarationsOf(Collection classes) { + Declarations declarations = new Declarations(); + classes.stream() + .filter(this::hasRoute) + .sorted(Comparator.comparing(ClassInfo::getName)) + .forEach(classInfo -> collectClass(classInfo, declarations)); + return declarations.toRouteDeclarations(); + } + + private void scanModule(MavenProject module, Declarations declarations) throws MojoExecutionException { + scanClassesDir(new File(module.getBuild().getOutputDirectory()), declarations); + } + + private void scanClassesDir(File classesDir, Declarations declarations) throws MojoExecutionException { if (!classesDir.exists()) { log.warn("No classes to scan: outputDirectory does not exist."); - return Collections.emptySet(); + return; } try (ScanResult scan = createScanResult(classesDir)) { FrameworkType framework = detectFramework(scan); if (framework == FrameworkType.NONE) { log.info("No supported framework detected (Spring or Quarkus)"); - return Collections.emptySet(); + return; } - return scanClassesForRoutes(scan, framework); + scanClassesForRoutes(scan, framework, declarations); } catch (Exception e) { throw new MojoExecutionException("Failed scanning annotations", e); } @@ -85,12 +135,12 @@ private FrameworkType detectFramework(ScanResult scan) { return FrameworkType.NONE; } - private Set scanClassesForRoutes(ScanResult scan, FrameworkType framework) { - return getAnnotatedClasses(scan, framework) + private void scanClassesForRoutes(ScanResult scan, FrameworkType framework, Declarations declarations) { + getAnnotatedClasses(scan, framework) .distinct() .filter(this::hasRoute) - .flatMap(classInfo -> getRequestMappingPaths(classInfo).stream()) - .collect(java.util.stream.Collectors.toSet()); + .sorted(Comparator.comparing(ClassInfo::getName)) + .forEach(classInfo -> collectClass(classInfo, declarations)); } private Stream getAnnotatedClasses(ScanResult scan, FrameworkType framework) { @@ -117,71 +167,237 @@ private Stream getClassesWithAnnotations(ScanResult scan, Set getRequestMappingPaths(ClassInfo classInfo) { + Declarations declarations = new Declarations(); + collectClass(classInfo, declarations); + return declarations.routeSet(); + } + + private void collectClass(ClassInfo classInfo, Declarations declarations) { log.info("Get Request Mappings for Class: " + classInfo.getName()); - RouteMetadata classMetadata = extractClassMetadata(classInfo); - Set routes = processMethodRoutes(classInfo, classMetadata); - routes.addAll(buildClassLevelRoutes(classInfo, classMetadata)); + int before = declarations.routes.size(); + ClassContext classContext = extractClassContext(classInfo); + for (MethodInfo methodInfo : classInfo.getMethodInfo()) { + getHttpMappingAnnotations(methodInfo) + .forEach(mappingAnn -> collectMethod(classContext, methodInfo, mappingAnn, declarations)); + } + collectClassLevel(classContext, declarations); if (classInfo.getSuperclass() != null) { - routes.addAll(getRequestMappingPaths(classInfo.getSuperclass())); + collectClass(classInfo.getSuperclass(), declarations); } - log.info("Found " + routes.size() + " routes"); - return routes; + log.info("Found " + (declarations.routes.size() - before) + " routes"); } - private RouteMetadata extractClassMetadata(ClassInfo classInfo) { - return new RouteMetadata( - getRouteType(classInfo.getAnnotationInfo(ROUTE_ANNOTATION)), - getRouteTimeout(classInfo.getAnnotationInfo(ROUTE_ANNOTATION)), - resolveGatewayMappings(classInfo), - resolveRequestMappings(classInfo) + private ClassContext extractClassContext(ClassInfo classInfo) { + AnnotationInfo classRoute = classInfo.getAnnotationInfo(ROUTE_ANNOTATION); + return new ClassContext( + classInfo.getName(), + resolveRequestMappings(classInfo), + resolveGatewayMappings(classInfo::getAnnotationInfo, PathKind.BORDER), + resolveGatewayMappings(classInfo::getAnnotationInfo, PathKind.FACADE), + readRouteEntries(classInfo.getAnnotationInfo()), + classInfo.getAnnotationInfo(FORBIDDEN_ROUTE_ANNOTATION), + getRouteType(classRoute), + getRouteTimeout(classRoute) ); } - private Set processMethodRoutes(ClassInfo classInfo, RouteMetadata classMetadata) { - return classInfo.getMethodInfo().stream() - .flatMap(methodInfo -> getHttpMappingAnnotations(methodInfo) - .map(mappingAnn -> Map.entry(methodInfo, mappingAnn))) - .flatMap(entry -> buildRoutesForMethod( - entry.getKey(), - entry.getValue(), - classMetadata - ).stream()) - .collect(java.util.stream.Collectors.toSet()); - } + private void collectClassLevel(ClassContext classContext, Declarations declarations) { + String origin = classContext.name(); + List borderPairs = classPairs(classContext, PathKind.BORDER); + declarations.addElementPaths(borderPairs, origin); - private Set buildClassLevelRoutes(ClassInfo classInfo, RouteMetadata classMetadata) { - if (!classInfo.hasAnnotation(ROUTE_ANNOTATION)) { - return Collections.emptySet(); + for (RouteEntry entry : classContext.routes()) { + HttpRoute.Type type = entry.type().orElse(HttpRoute.Type.INTERNAL); + long timeout = entry.timeout().orElse(0L); + checkHosts(entry, origin, declarations); + for (Target target : resolveTargets(entry, type)) { + classPairs(classContext, target.pathKind()).forEach(pair -> declarations.addRoute( + new HttpRoute(pair.servicePath(), pair.gatewayPath(), target.type(), timeout), origin)); + } } - if (classMetadata.requestMappings().isEmpty() && classMetadata.gatewayMappings().isEmpty()) { - return Collections.emptySet(); + readForbiddenGateways(classContext.forbiddenRoute(), origin, declarations) + .ifPresent(gateways -> borderPairs.forEach(pair -> + declarations.addForbidden(pair.gatewayPath(), gateways, origin))); + } + + private void collectMethod(ClassContext classContext, MethodInfo methodInfo, AnnotationInfo mappingAnn, Declarations declarations) { + String origin = classContext.name() + "#" + methodInfo.getName(); + List mappingPaths = resolveMappingPaths(methodInfo, mappingAnn); + List borderPairs = methodPairs(classContext, methodInfo, PathKind.BORDER, mappingPaths); + declarations.addElementPaths(borderPairs, origin); + + for (RouteEntry entry : readRouteEntries(methodInfo.getAnnotationInfo())) { + HttpRoute.Type type = entry.type().orElse(classContext.fallbackType().orElse(HttpRoute.Type.INTERNAL)); + long timeout = entry.timeout().orElse(classContext.fallbackTimeout().orElse(0L)); + checkHosts(entry, origin, declarations); + for (Target target : resolveTargets(entry, type)) { + methodPairs(classContext, methodInfo, target.pathKind(), mappingPaths).forEach(pair -> declarations.addRoute( + new HttpRoute(pair.servicePath(), pair.gatewayPath(), target.type(), timeout), origin)); + } } - HttpRoute.Type routeType = classMetadata.routeType().orElse(HttpRoute.Type.INTERNAL); - long routeTimeout = classMetadata.routeTimeout().orElse(0L); + readForbiddenGateways(methodInfo.getAnnotationInfo(FORBIDDEN_ROUTE_ANNOTATION), origin, declarations) + .ifPresent(gateways -> borderPairs.forEach(pair -> + declarations.addForbidden(pair.gatewayPath(), gateways, origin))); + } + + /** + * Gateway paths of the class itself: {@code @Gateway}-style mappings of the kind combined with the class request mappings. + */ + private List classPairs(ClassContext classContext, PathKind kind) { + List gatewayMappings = classContext.gatewayMappings(kind); + List requestMappings = classContext.requestMappings(); + if (requestMappings.isEmpty() && gatewayMappings.isEmpty()) { + return List.of(); + } + if (!gatewayMappings.isEmpty()) { + return buildClassGatewayPairs(gatewayMappings, List.of(), requestMappings, List.of("")); + } + return requestMappings.stream().map(path -> new PathPair(path, path)).toList(); + } - if (!classMetadata.gatewayMappings().isEmpty()) { - return buildClassGatewayRoutes( - classMetadata.gatewayMappings(), - List.of(), - classMetadata.requestMappings(), - List.of(""), - routeType, - routeTimeout + private List methodPairs(ClassContext classContext, MethodInfo methodInfo, PathKind kind, List mappingPaths) { + List methodGatewayMappings = resolveGatewayMappings(methodInfo::getAnnotationInfo, kind); + if (!classContext.gatewayMappings(kind).isEmpty()) { + return buildClassGatewayPairs( + classContext.gatewayMappings(kind), + methodGatewayMappings, + classContext.requestMappings(), + mappingPaths ); } + if (!methodGatewayMappings.isEmpty()) { + return buildMethodGatewayPairs(methodGatewayMappings, classContext.requestMappings(), mappingPaths); + } + return buildStandardPairs(classContext.requestMappings(), mappingPaths); + } + + /** + * Legacy resolution of one route annotation into targets: empty {@code gateways} goes by type, a border gateway name + * gives a border route of that gateway's type, and every other name is a composite gateway. + */ + private List resolveTargets(RouteEntry entry, HttpRoute.Type type) { + if (entry.gateways().isEmpty()) { + return List.of(type == HttpRoute.Type.FACADE + ? new Target(HttpRoute.Type.FACADE, PathKind.FACADE) + : new Target(type, PathKind.BORDER)); + } + return entry.gateways().stream() + .map(name -> Gateway.fromLegacyName(name) + .map(gateway -> new Target(gateway.routeType(), PathKind.BORDER)) + .orElse(new Target(HttpRoute.Type.FACADE, PathKind.BORDER))) + .distinct() + .toList(); + } - return classMetadata.requestMappings().stream() - .map(path -> new HttpRoute(path, routeType, routeTimeout)) - .collect(java.util.stream.Collectors.toSet()); + private void checkHosts(RouteEntry entry, String origin, Declarations declarations) { + if (entry.hosts().isEmpty()) { + return; + } + entry.gateways().stream() + .filter(name -> Gateway.fromLegacyName(name).isPresent()) + .forEach(name -> declarations.findings.add(Finding.of(Finding.Kind.LEGACY_INVALID, null, + "element", origin, + "problem", "hosts " + entry.hosts() + " are set together with border gateway " + name + + " in gateways; the legacy runtime rejects this too (\"Only composite gateway can have hosts\")", + "fix", "remove hosts, or list only composite gateway names in gateways"))); + } + + private Optional> readForbiddenGateways(AnnotationInfo forbiddenRoute, String origin, Declarations declarations) { + if (forbiddenRoute == null) { + return Optional.empty(); + } + List names = enumValueNames(forbiddenRoute.getParameterValues(false).getValue("value")); + if (names.isEmpty()) { + declarations.findings.add(Finding.of(Finding.Kind.INVALID_FORBIDDEN_ROUTE, null, + "element", origin, + "problem", "@ForbiddenRoute has an empty value", + "fix", "list the gateways to forbid: PUBLIC, PRIVATE and/or INTERNAL")); + return Optional.empty(); + } + Set gateways = EnumSet.noneOf(Gateway.class); + for (String name : names) { + Optional gateway = Gateway.fromRouteTypeName(name); + if (gateway.isEmpty()) { + declarations.findings.add(Finding.of(Finding.Kind.INVALID_FORBIDDEN_ROUTE, null, + "element", origin, + "problem", "@ForbiddenRoute supports only PUBLIC, PRIVATE and INTERNAL, found " + name, + "fix", "remove " + name + " from @ForbiddenRoute")); + return Optional.empty(); + } + gateways.add(gateway.get()); + } + return Optional.of(gateways); + } + + /** + * Reads every route annotation of an element: each {@code @Route} (classgraph may unwrap the {@code @Routes} + * container into several of them), the {@code @Routes} container when it is not unwrapped, and {@code @FacadeRoute}. + */ + private List readRouteEntries(AnnotationInfoList annotations) { + List entries = new ArrayList<>(); + for (AnnotationInfo annotation : annotations) { + String name = annotation.getName(); + if (ROUTE_ANNOTATION.equals(name)) { + entries.add(routeEntry(annotation)); + } else if (ROUTES_ANNOTATION.equals(name) + && annotation.getParameterValues(false).getValue("value") instanceof Object[] values) { + Arrays.stream(values) + .filter(AnnotationInfo.class::isInstance) + .map(AnnotationInfo.class::cast) + .map(this::routeEntry) + .forEach(entries::add); + } else if (FACADE_ROUTE_ANNOTATION.equals(name)) { + entries.add(new RouteEntry( + Optional.of(HttpRoute.Type.FACADE), + getRouteTimeout(annotation), + stringValues(annotation.getParameterValues(false).getValue("gateways")), + List.of() + )); + } + } + return entries; + } + + private RouteEntry routeEntry(AnnotationInfo route) { + AnnotationParameterValueList parameters = route.getParameterValues(false); + return new RouteEntry( + getRouteType(route), + getRouteTimeout(route), + stringValues(parameters.getValue("gateways")), + stringValues(parameters.getValue("hosts")) + ); } - private boolean hasMethodRouteAnnotation(MethodInfo methodInfo) { - return methodInfo.hasAnnotation(ROUTE_ANNOTATION); + private static List stringValues(Object value) { + Stream values = switch (value) { + case null -> Stream.empty(); + case Object[] objects -> Arrays.stream(objects); + default -> Stream.of(value); + }; + return values + .filter(String.class::isInstance) + .map(String.class::cast) + .filter(s -> !s.isEmpty()) + .distinct() + .toList(); + } + + private static List enumValueNames(Object value) { + Stream values = switch (value) { + case null -> Stream.empty(); + case Object[] objects -> Arrays.stream(objects); + default -> Stream.of(value); + }; + return values + .filter(AnnotationEnumValue.class::isInstance) + .map(AnnotationEnumValue.class::cast) + .map(AnnotationEnumValue::getValueName) + .toList(); } private Stream getHttpMappingAnnotations(MethodInfo methodInfo) { @@ -200,25 +416,22 @@ private Stream getHttpMappingAnnotations(MethodInfo methodInfo) } private boolean hasRoute(ClassInfo classInfo) { - return classInfo.hasAnnotation(Route.class) || classInfo.hasMethodAnnotation(Route.class); + return ROUTE_SELECTING_ANNOTATIONS.stream() + .anyMatch(annotation -> classInfo.hasAnnotation(annotation) || classInfo.hasMethodAnnotation(annotation)); } private boolean isRequestMappingAnnotation(AnnotationInfo annotationInfo) { return RequestMapping.class.getName().equals(annotationInfo.getName()); } - private List resolveGatewayMappings(ClassInfo classInfo) { - if (classInfo.hasAnnotation(GATEWAY_REQUEST_MAPPING)) { - return getAnnotationPathFor(classInfo.getAnnotationInfo(GATEWAY_REQUEST_MAPPING)); + private List resolveGatewayMappings(Function annotations, PathKind kind) { + String requestMapping = kind == PathKind.BORDER ? GATEWAY_REQUEST_MAPPING : FACADE_GATEWAY_REQUEST_MAPPING; + String gateway = kind == PathKind.BORDER ? GATEWAY_ANNOTATION : FACADE_GATEWAY_ANNOTATION; + AnnotationInfo requestMappingInfo = annotations.apply(requestMapping); + if (requestMappingInfo != null) { + return getAnnotationPathFor(requestMappingInfo); } - return getAnnotationPathFor(classInfo.getAnnotationInfo(GATEWAY_ANNOTATION)); - } - - private List resolveGatewayMappings(MethodInfo methodInfo) { - if (methodInfo.hasAnnotation(GATEWAY_REQUEST_MAPPING)) { - return getAnnotationPathFor(methodInfo.getAnnotationInfo(GATEWAY_REQUEST_MAPPING)); - } - return getAnnotationPathFor(methodInfo.getAnnotationInfo(GATEWAY_ANNOTATION)); + return getAnnotationPathFor(annotations.apply(gateway)); } private List resolveRequestMappings(ClassInfo classInfo) { @@ -248,48 +461,6 @@ private boolean isHttpMappingAnnotation(AnnotationInfo annotationInfo) { PATCH.class.getName().equals(name); } - private Set buildRoutesForMethod( - MethodInfo methodInfo, - AnnotationInfo mappingAnn, - RouteMetadata classMetadata - ) { - if (!hasMethodRouteAnnotation(methodInfo)) { - return Collections.emptySet(); - } - - HttpRoute.Type routeType = getRouteType(methodInfo.getAnnotationInfo(ROUTE_ANNOTATION)) - .orElse(classMetadata.routeType().orElse(HttpRoute.Type.INTERNAL)); - - long routeTimeout = getRouteTimeout(methodInfo.getAnnotationInfo(ROUTE_ANNOTATION)) - .orElse(classMetadata.routeTimeout().orElse(0L)); - - List methodGatewayMappings = resolveGatewayMappings(methodInfo); - List mappingPaths = resolveMappingPaths(methodInfo, mappingAnn); - - if (!classMetadata.gatewayMappings().isEmpty()) { - return buildClassGatewayRoutes( - classMetadata.gatewayMappings(), - methodGatewayMappings, - classMetadata.requestMappings(), - mappingPaths, - routeType, - routeTimeout - ); - } - - if (!methodGatewayMappings.isEmpty()) { - return buildMethodGatewayRoutes( - methodGatewayMappings, - classMetadata.requestMappings(), - mappingPaths, - routeType, - routeTimeout - ); - } - - return buildStandardRoutes(classMetadata.requestMappings(), mappingPaths, routeType, routeTimeout); - } - private List resolveMappingPaths(MethodInfo methodInfo, AnnotationInfo mappingAnn) { if (JAX_RS_HTTP_ANNOTATIONS.stream().map(Class::getName).anyMatch(s -> s.equals(mappingAnn.getClassInfo().getName()))) { List paths = getAnnotationPathFor(methodInfo.getAnnotationInfo(Path.class.getName())); @@ -298,31 +469,24 @@ private List resolveMappingPaths(MethodInfo methodInfo, AnnotationInfo m return getAnnotationPathFor(mappingAnn); } - private Set buildStandardRoutes( - List classMappings, - List methodMappings, - HttpRoute.Type routeType, - long routeTimeout - ) { + private List buildStandardPairs(List classMappings, List methodMappings) { if (classMappings.isEmpty()) { return methodMappings.stream() - .map(path -> new HttpRoute(path, routeType, routeTimeout)) - .collect(java.util.stream.Collectors.toSet()); + .map(path -> new PathPair(path, path)) + .toList(); } return classMappings.stream() .flatMap(classPrefix -> methodMappings.stream() - .map(methodPath -> new HttpRoute(classPrefix + methodPath, routeType, routeTimeout))) - .collect(java.util.stream.Collectors.toSet()); + .map(methodPath -> new PathPair(classPrefix + methodPath, classPrefix + methodPath))) + .toList(); } - private Set buildClassGatewayRoutes( + private List buildClassGatewayPairs( List classGatewayMappings, List methodGatewayMappings, List classMappings, - List methodMappings, - HttpRoute.Type routeType, - long routeTimeout + List methodMappings ) { List effectiveMethodMappings = methodMappings.isEmpty() ? List.of("/") : methodMappings; List effectiveMethodGatewayMappings = methodGatewayMappings.isEmpty() @@ -334,37 +498,25 @@ private Set buildClassGatewayRoutes( return classGatewayMappings.stream() .flatMap(classPrefix -> effectiveMethodGatewayMappings.stream() - .map(methodPath -> new HttpRoute( - servicePrefix + mappingPath, - classPrefix + methodPath, - routeType, - routeTimeout - ))) - .collect(java.util.stream.Collectors.toSet()); + .map(methodPath -> new PathPair(servicePrefix + mappingPath, classPrefix + methodPath))) + .toList(); } - private Set buildMethodGatewayRoutes( + private List buildMethodGatewayPairs( List methodGatewayMappings, List classMappings, - List methodMappings, - HttpRoute.Type routeType, - long routeTimeout + List methodMappings ) { if (methodGatewayMappings.isEmpty() || methodMappings.isEmpty()) { - return Collections.emptySet(); + return List.of(); } String servicePrefix = classMappings.isEmpty() ? "" : classMappings.get(0); String mappingPath = methodMappings.get(0); return methodGatewayMappings.stream() - .map(methodPath -> new HttpRoute( - servicePrefix + mappingPath, - methodPath, - routeType, - routeTimeout - )) - .collect(java.util.stream.Collectors.toSet()); + .map(methodPath -> new PathPair(servicePrefix + mappingPath, methodPath)) + .toList(); } private List getAnnotationPathFor(AnnotationInfo annotationInfo) { @@ -459,10 +611,87 @@ private enum FrameworkType { SPRING, QUARKUS, NONE } - private record RouteMetadata( - Optional routeType, - Optional routeTimeout, - List gatewayMappings, - List requestMappings + /** + * Which gateway path annotations apply: {@code @Gateway}/{@code @GatewayRequestMapping} for border and composite + * routes, {@code @FacadeGateway}/{@code @FacadeGatewayRequestMapping} for facade routes. + */ + private enum PathKind { + BORDER, FACADE + } + + private record PathPair(String servicePath, String gatewayPath) {} + + private record Target(HttpRoute.Type type, PathKind pathKind) {} + + private record RouteEntry( + Optional type, + Optional timeout, + List gateways, + List hosts ) {} + + private record ClassContext( + String name, + List requestMappings, + List borderGatewayMappings, + List facadeGatewayMappings, + List routes, + AnnotationInfo forbiddenRoute, + Optional fallbackType, + Optional fallbackTimeout + ) { + List gatewayMappings(PathKind kind) { + return kind == PathKind.BORDER ? borderGatewayMappings : facadeGatewayMappings; + } + } + + private static final class Declarations { + private final Map> routes = new HashMap<>(); + private final Set forbidden = new LinkedHashSet<>(); + private final Set findings = new LinkedHashSet<>(); + private final Map> elementGatewayPaths = new TreeMap<>(); + + void addRoute(HttpRoute route, String origin) { + routes.computeIfAbsent(route, r -> new TreeSet<>()).add(origin); + } + + void addElementPaths(List pairs, String origin) { + pairs.forEach(pair -> elementGatewayPaths + .computeIfAbsent(PathPattern.normalize(pair.gatewayPath()), p -> new TreeSet<>()) + .add(origin)); + } + + void addForbidden(String gatewayPath, Set gateways, String origin) { + Optional problem = PathPattern.forbiddenPathProblem(gatewayPath); + if (problem.isPresent()) { + findings.add(Finding.of(Finding.Kind.INVALID_FORBIDDEN_ROUTE, null, + "element", origin, + "path", PathPattern.normalize(gatewayPath), + "problem", problem.get(), + "fix", "change the gateway path, or remove @ForbiddenRoute from " + origin)); + return; + } + forbidden.add(new ForbiddenDeclaration(gatewayPath, gateways, origin)); + } + + Set routeSet() { + return new HashSet<>(routes.keySet()); + } + + RouteDeclarations toRouteDeclarations() { + Comparator order = HttpRouteRenderer.pathSpecificityComparator() + .thenComparing(HttpRoute::path) + .thenComparing(HttpRoute::type) + .thenComparingLong(HttpRoute::timeout); + List declared = routes.entrySet().stream() + .map(e -> new DeclaredRoute(e.getKey(), e.getValue())) + .sorted(Comparator.comparing(DeclaredRoute::route, order)) + .toList(); + List forbiddenList = forbidden.stream() + .sorted(Comparator.comparing(ForbiddenDeclaration::gatewayPath) + .thenComparing(ForbiddenDeclaration::origin)) + .toList(); + return new RouteDeclarations(declared, forbiddenList, List.copyOf(findings), elementGatewayPaths); + } + } } diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRendererTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRendererTest.java new file mode 100644 index 0000000000..e905ddff32 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRendererTest.java @@ -0,0 +1,137 @@ +package com.netcracker.routes.gateway.plugin; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static com.netcracker.routes.gateway.plugin.Gateway.INTERNAL; +import static com.netcracker.routes.gateway.plugin.Gateway.PRIVATE; +import static com.netcracker.routes.gateway.plugin.Gateway.PUBLIC; +import static com.netcracker.routes.gateway.plugin.LegacyRouteTableTest.forbidden; +import static com.netcracker.routes.gateway.plugin.LegacyRouteTableTest.route; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AuthorizationPolicyRendererTest { + + private static final String RESOURCE = "/api/v1/svc/resource"; + private static final String ORDER = "/api/v1/svc/order"; + + private static String render(Map> ports, List routes, List forbidden) { + RouteMigration migration = RouteMigration.run(new RouteDeclarations(routes, forbidden), false); + assertFalse(migration.hasErrors(), migration.findings().toString()); + return new AuthorizationPolicyRenderer(Map.of(), ports).generateAuthorizationPoliciesYaml(migration.plan().denyRules()); + } + + @Test + void forbiddenOnPublicAndPrivate() { + String yaml = render(Map.of(), List.of( + route(RESOURCE, RESOURCE, HttpRoute.Type.PUBLIC, "com.acme.ResourceController"), + route(RESOURCE + "/{id}/internal-api", RESOURCE + "/{id}/internal-api", HttpRoute.Type.INTERNAL, + "com.acme.ResourceController#internalApi"), + route(RESOURCE + "/{id}/internal-api/status", RESOURCE + "/{id}/internal-api/status", HttpRoute.Type.PUBLIC, + "com.acme.ResourceController#status")), + List.of(forbidden(RESOURCE + "/{id}/internal-api", "com.acme.ResourceController#internalApi", PUBLIC, PRIVATE))); + + assertEquals(""" + --- + apiVersion: "security.istio.io/v1" + kind: "AuthorizationPolicy" + metadata: + name: "{{ .Values.SERVICE_NAME }}-java-annotations-deny-public" + labels: + app.kubernetes.io/managed-by: "{{ .Values.MANAGED_BY }}" + app.kubernetes.io/name: "{{ .Values.SERVICE_NAME }}" + app.kubernetes.io/part-of: "{{ .Values.APPLICATION_NAME }}" + app.kubernetes.io/processed-by-operator: "istiod" + deployer.cleanup/allow: "true" + deployment.netcracker.com/sessionId: "{{ .Values.DEPLOYMENT_SESSION_ID }}" + spec: + targetRefs: + - group: "gateway.networking.k8s.io" + kind: "Gateway" + name: "public-gateway" + action: "DENY" + rules: + - to: + - operation: + ports: + - "8080" + paths: + - "/api/v1/svc/resource/{*}/internal-api" + - "/api/v1/svc/resource/{*}/internal-api/{**}" + notPaths: + - "/api/v1/svc/resource/{*}/internal-api/status" + - "/api/v1/svc/resource/{*}/internal-api/status/{**}" + --- + apiVersion: "security.istio.io/v1" + kind: "AuthorizationPolicy" + metadata: + name: "{{ .Values.SERVICE_NAME }}-java-annotations-deny-private" + labels: + app.kubernetes.io/managed-by: "{{ .Values.MANAGED_BY }}" + app.kubernetes.io/name: "{{ .Values.SERVICE_NAME }}" + app.kubernetes.io/part-of: "{{ .Values.APPLICATION_NAME }}" + app.kubernetes.io/processed-by-operator: "istiod" + deployer.cleanup/allow: "true" + deployment.netcracker.com/sessionId: "{{ .Values.DEPLOYMENT_SESSION_ID }}" + spec: + targetRefs: + - group: "gateway.networking.k8s.io" + kind: "Gateway" + name: "private-gateway" + action: "DENY" + rules: + - to: + - operation: + ports: + - "8080" + paths: + - "/api/v1/svc/resource/{*}/internal-api" + - "/api/v1/svc/resource/{*}/internal-api/{**}" + notPaths: + - "/api/v1/svc/resource/{*}/internal-api/status" + - "/api/v1/svc/resource/{*}/internal-api/status/{**}" + """, yaml); + assertFalse(yaml.contains("internal-gateway-service"), yaml); + } + + @Test + void noForbiddenDeclarations() { + String yaml = render(Map.of(), List.of(route(RESOURCE, RESOURCE, HttpRoute.Type.PUBLIC, "c")), List.of()); + + assertEquals("", yaml); + } + + @Test + void portsForOneGateway() { + String yaml = render(Map.of(INTERNAL, List.of("8080", "8443")), + List.of(route(ORDER + "/{id}/items", ORDER + "/{id}/items", HttpRoute.Type.PUBLIC, "items")), + List.of(forbidden(ORDER, "com.acme.OrderController", PUBLIC, PRIVATE, INTERNAL))); + + String[] policies = yaml.split("---\n"); + assertEquals(4, policies.length, yaml); + assertTrue(policies[1].contains("deny-public\"") && policies[1].contains("ports:\n - \"8080\"\n paths:"), policies[1]); + assertTrue(policies[2].contains("deny-private\"") && policies[2].contains("ports:\n - \"8080\"\n paths:"), policies[2]); + assertTrue(policies[3].contains("deny-internal\"") + && policies[3].contains("ports:\n - \"8080\"\n - \"8443\"\n paths:"), policies[3]); + assertTrue(policies[3].contains(""" + targetRefs: + - group: "" + kind: "Service" + name: "internal-gateway-service" + """), policies[3]); + } + + @Test + void customLabels() { + List rules = List.of(new DenyRule(PUBLIC, List.of("/a", "/a/{**}"), List.of(), "x")); + + String yaml = new AuthorizationPolicyRenderer(Map.of("team", "platform"), Map.of()).generateAuthorizationPoliciesYaml(rules); + + assertTrue(yaml.contains(" labels:\n team: \"platform\"\nspec:"), yaml); + assertFalse(yaml.contains("notPaths"), yaml); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/FindingReportTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/FindingReportTest.java new file mode 100644 index 0000000000..c10aa800c9 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/FindingReportTest.java @@ -0,0 +1,140 @@ +package com.netcracker.routes.gateway.plugin; + +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static com.netcracker.routes.gateway.plugin.LegacyRouteTableTest.route; +import static com.netcracker.routes.gateway.plugin.MigrationValidatorTest.ORDER; +import static com.netcracker.routes.gateway.plugin.MigrationValidatorTest.RESOURCE; +import static com.netcracker.routes.gateway.plugin.MigrationValidatorTest.SAMPLE; +import static com.netcracker.routes.gateway.plugin.MigrationValidatorTest.findings; +import static com.netcracker.routes.gateway.plugin.MigrationValidatorTest.implicit404Routes; +import static com.netcracker.routes.gateway.plugin.MigrationValidatorTest.withElements; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FindingReportTest { + + static final class CapturingLog extends SystemStreamLog { + final List errors = new ArrayList<>(); + final List warnings = new ArrayList<>(); + + @Override + public void error(CharSequence content) { + errors.add(content.toString()); + } + + @Override + public void warn(CharSequence content) { + warnings.add(content.toString()); + } + } + + private static String report(List findings) { + return String.join("\n", FindingReport.sorted(findings).stream().map(FindingReport::format).toList()); + } + + private static void assertContains(String text, String part) { + assertTrue(text.contains(part), "expected <" + part + "> in:\n" + text); + } + + @Test + void exposureReportContent() { + List findings = findings(false, new RouteDeclarations(implicit404Routes(), List.of())); + String report = report(findings); + + assertContains(report, "[ROUTE-MIGRATION] EXPOSURE on public-gateway"); + assertContains(report, "[ROUTE-MIGRATION] EXPOSURE on private-gateway"); + assertContains(report, "request: " + RESOURCE + "/" + SAMPLE + "/internal-api"); + assertContains(report, "FORBIDDEN (implicit: INTERNAL route " + RESOURCE + "/{id}/internal-api (from com.acme.ResourceController#internalApi))"); + assertContains(report, "ROUTED by PathPrefix " + RESOURCE + " (from com.acme.ResourceController, com.acme.ResourceController#internalApi)"); + assertContains(report, "add @ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE}) to com.acme.ResourceController#internalApi"); + assertContains(report, "true"); + } + + @Test + void cutExposureSuggestionNamesClassMappedToPrefix() { + List routes = List.of(route(ORDER + "/{id}/items", ORDER + "/{id}/items", HttpRoute.Type.PUBLIC, "com.acme.OrderController#items")); + String report = report(findings(false, withElements(routes, Map.of( + ORDER, List.of("com.acme.OrderController"), + ORDER + "/{id}/items", List.of("com.acme.OrderController#items"))))); + + assertContains(report, "add @ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE, RouteType.INTERNAL}) to class com.acme.OrderController"); + } + + @Test + void cutExposureSuggestionWithoutClassMappedToPrefix() { + List routes = List.of(route(ORDER + "/{id}/items", ORDER + "/{id}/items", HttpRoute.Type.PUBLIC, "com.acme.OrderController#items")); + String report = report(findings(false, withElements(routes, Map.of( + ORDER + "/{id}/items", List.of("com.acme.OrderController#items"))))); + + assertContains(report, "add @ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE, RouteType.INTERNAL}) to a class or method mapped to " + ORDER); + } + + @Test + void cutExposureOfPrefixWithLegacyRouteBelowIt() { + List routes = List.of( + route(ORDER + "/{id}", ORDER + "/{id}", HttpRoute.Type.INTERNAL, "com.acme.OrderController#get"), + route(ORDER + "/{id}/items", ORDER + "/{id}/items", HttpRoute.Type.PUBLIC, "com.acme.OrderController#items")); + String report = report(findings(false, withElements(routes, Map.of(ORDER, List.of("com.acme.OrderController"))))); + + // legacy routes /order/ on the internal gateway but not /order itself + assertContains(report, "[ROUTE-MIGRATION] EXPOSURE on internal-gateway-service\n request: " + ORDER + "\n"); + assertContains(report, "add @ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE, RouteType.INTERNAL}) to class com.acme.OrderController"); + assertContains(report, "add @ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE}) to com.acme.OrderController#get"); + } + + @Test + void multipleProblemsAreAllLoggedAndCounted() { + List routes = new ArrayList<>(implicit404Routes()); + routes.add(route("/api/v1/svc/other/{id}/a", "/o/{id}/a", HttpRoute.Type.INTERNAL, "a")); + routes.add(route("/api/v1/svc/other/{id}/b", "/p/{id}/b", HttpRoute.Type.INTERNAL, "b")); + List findings = findings(false, new RouteDeclarations(routes, List.of())); + CapturingLog log = new CapturingLog(); + + FindingReport.log(log, findings); + + assertEquals(3, log.errors.size(), String.join("\n", log.errors)); + assertTrue(log.errors.get(0).startsWith("[ROUTE-MIGRATION] EXPOSURE on public-gateway"), log.errors.get(0)); + assertTrue(log.errors.get(1).startsWith("[ROUTE-MIGRATION] EXPOSURE on private-gateway"), log.errors.get(1)); + assertTrue(log.errors.get(2).startsWith("[ROUTE-MIGRATION] CONFLICT on internal-gateway-service"), log.errors.get(2)); + assertEquals("Route migration validation failed with 3 errors (1 CONFLICT, 2 EXPOSURE), " + + "see the [ROUTE-MIGRATION] errors in the build log", FindingReport.summary(findings)); + } + + @Test + void warningsAreLoggedAsWarnings() { + List findings = findings(false, new RouteDeclarations(List.of( + route("/api/a", "/a", HttpRoute.Type.PUBLIC, 5000, "a"), + route("/api/a/{id}/x", "/a/{id}/x", HttpRoute.Type.PUBLIC, 60000, "b")), List.of())); + CapturingLog log = new CapturingLog(); + + FindingReport.log(log, findings); + + assertEquals(List.of(), log.errors); + assertFalse(log.warnings.isEmpty()); + } + + @Test + void reportTextForLegacyAndServiceFindings() { + String legacyInvalid = report(findings(false, new RouteDeclarations(List.of( + route("/api/x", "/a", HttpRoute.Type.INTERNAL, "a"), route("/api/x", "/b", HttpRoute.Type.INTERNAL, "b")), List.of()))); + assertContains(legacyInvalid, "[ROUTE-MIGRATION] LEGACY_INVALID on internal-gateway-service"); + assertContains(legacyInvalid, "several target paths"); + + String undefined = report(findings(false, new RouteDeclarations(List.of( + route("/s/{a}/b", "/s/{a}/b", HttpRoute.Type.PUBLIC, "a"), route("/s/b/{a}", "/s/b/{a}", HttpRoute.Type.INTERNAL, "b")), List.of()))); + assertContains(undefined, "[ROUTE-MIGRATION] LEGACY_PRECEDENCE_UNDEFINED on public-gateway"); + assertContains(undefined, "change the gateway path of one of these routes"); + + String service = report(findings(false, new RouteDeclarations(List.of( + route("/x", "/a", HttpRoute.Type.FACADE, "composite"), route("/x", "/b", HttpRoute.Type.FACADE, "facade")), List.of()))); + assertContains(service, "[ROUTE-MIGRATION] CONFLICT on service-bound HTTPRoute"); + assertContains(service, "different legacy gateways"); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GatewayTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GatewayTest.java new file mode 100644 index 0000000000..2f830d00bf --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GatewayTest.java @@ -0,0 +1,56 @@ +package com.netcracker.routes.gateway.plugin; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GatewayTest { + + @Test + void exposureChainFollowsRouteType() { + assertEquals(Set.of(Gateway.PUBLIC, Gateway.PRIVATE, Gateway.INTERNAL), Gateway.exposedBy(HttpRoute.Type.PUBLIC)); + assertEquals(Set.of(Gateway.PRIVATE, Gateway.INTERNAL), Gateway.exposedBy(HttpRoute.Type.PRIVATE)); + assertEquals(Set.of(Gateway.INTERNAL), Gateway.exposedBy(HttpRoute.Type.INTERNAL)); + assertEquals(Set.of(), Gateway.exposedBy(HttpRoute.Type.FACADE)); + } + + @Test + void lookupByLegacyGatewayName() { + assertEquals(Optional.of(Gateway.PUBLIC), Gateway.fromLegacyName("public-gateway-service")); + assertEquals(Optional.of(Gateway.PRIVATE), Gateway.fromLegacyName("private-gateway-service")); + assertEquals(Optional.of(Gateway.INTERNAL), Gateway.fromLegacyName("internal-gateway-service")); + assertEquals(Optional.empty(), Gateway.fromLegacyName("composite-gw")); + assertEquals(Optional.empty(), Gateway.fromLegacyName("public-gateway")); + assertEquals(Optional.empty(), Gateway.fromLegacyName("")); + assertEquals(Optional.empty(), Gateway.fromLegacyName(null)); + } + + @Test + void lookupByRouteTypeName() { + assertEquals(Optional.of(Gateway.PUBLIC), Gateway.fromRouteTypeName("PUBLIC")); + assertEquals(Optional.of(Gateway.INTERNAL), Gateway.fromRouteTypeName("INTERNAL")); + assertEquals(Optional.empty(), Gateway.fromRouteTypeName("FACADE")); + } + + @Test + void istioReferenceData() { + assertEquals("gateway.networking.k8s.io", Gateway.PUBLIC.refGroup()); + assertEquals("Gateway", Gateway.PUBLIC.refKind()); + assertEquals("public-gateway", Gateway.PUBLIC.refName()); + assertEquals("Gateway", Gateway.PRIVATE.refKind()); + assertEquals("private-gateway", Gateway.PRIVATE.refName()); + assertEquals("", Gateway.INTERNAL.refGroup()); + assertEquals("Service", Gateway.INTERNAL.refKind()); + assertEquals("internal-gateway-service", Gateway.INTERNAL.refName()); + } + + @Test + void widestType() { + assertEquals(HttpRoute.Type.PUBLIC, Gateway.widest(List.of(HttpRoute.Type.INTERNAL, HttpRoute.Type.PUBLIC, HttpRoute.Type.PRIVATE))); + assertEquals(HttpRoute.Type.PRIVATE, Gateway.widest(List.of(HttpRoute.Type.INTERNAL, HttpRoute.Type.PRIVATE))); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojoExecuteTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojoExecuteTest.java new file mode 100644 index 0000000000..08c659f669 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojoExecuteTest.java @@ -0,0 +1,152 @@ +package com.netcracker.routes.gateway.plugin; + +import org.apache.maven.plugin.MojoFailureException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.File; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Runs {@link GenerateRoutesMojo#execute()} on the controller sets in the {@code e2e} test packages. + */ +class GenerateRoutesMojoExecuteTest { + + static final String E2E_PACKAGE = "com.netcracker.routes.gateway.plugin.e2e."; + static final String OUTPUT_FILE = "target/gateway-httproutes.yaml"; + + @TempDir + Path baseDir; + + /** + * @return a Mojo with default parameters that scans the {@code e2e.} package + */ + static GenerateRoutesMojo mojo(String controllerSet) throws Exception { + GenerateRoutesMojo mojo = new GenerateRoutesMojo(); + set(mojo, "packages", new String[]{E2E_PACKAGE + controllerSet}); + set(mojo, "servicePort", 8080); + set(mojo, "outputFile", OUTPUT_FILE); + set(mojo, "backendRefVal", "{{ .Values.DEPLOYMENT_RESOURCE_NAME }}"); + return mojo; + } + + /** + * Runs the Mojo on the test classes directory. {@code MavenProject} can't be loaded in tests: maven-project 2.2.1 + * needs classes that the maven-artifact version on the classpath doesn't have. + */ + static void execute(GenerateRoutesMojo mojo, Path baseDir) throws Exception { + File classesDir = testClassesDir(); + mojo.execute(baseDir, scanner -> scanner.collectDeclarations(classesDir)); + } + + static void set(GenerateRoutesMojo mojo, String field, Object value) throws Exception { + Field f = GenerateRoutesMojo.class.getDeclaredField(field); + f.setAccessible(true); + f.set(mojo, value); + } + + private static File testClassesDir() throws Exception { + return new File(GenerateRoutesMojoExecuteTest.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + + static List kinds(String yaml) { + Matcher matcher = Pattern.compile("(?m)^kind: \"([^\"]+)\"$").matcher(yaml); + return matcher.results().map(m -> m.group(1)).toList(); + } + + @Test + void passingProjectWritesCombinedFileInOrder() throws Exception { + execute(mojo("forbidden"), baseDir); + + String yaml = Files.readString(baseDir.resolve(OUTPUT_FILE)); + assertTrue(yaml.startsWith("# -----"), yaml); + int guard = yaml.indexOf("{{- if eq .Values.SERVICE_MESH_TYPE \"Istio\" }}\n---\n"); + assertTrue(guard > yaml.indexOf("DO NOT EDIT"), yaml); + assertTrue(yaml.endsWith("{{- end }}\n"), yaml); + assertEquals(List.of("HTTPRoute", "AuthorizationPolicy", "AuthorizationPolicy", "AuthorizationPolicy"), kinds(yaml)); + assertTrue(yaml.indexOf("kind: \"HTTPRoute\"") < yaml.indexOf("kind: \"AuthorizationPolicy\""), yaml); + assertEquals(5, yaml.split("ports:\n - \"8080\"\n paths:", -1).length - 1, yaml); + assertFalse(yaml.contains("RegularExpression") || yaml.contains("MANUAL REVIEW"), yaml); + } + + @Test + void validationFailureLeavesExistingFileUntouched() throws Exception { + Path file = baseDir.resolve(OUTPUT_FILE); + Files.createDirectories(file.getParent()); + Files.writeString(file, "previous output\n"); + + // autoGenerateAuthorizationPolicies is false when it isn't configured, so the exposures are errors + MojoFailureException e = assertThrows(MojoFailureException.class, () -> execute(mojo("unforbidden"), baseDir)); + + assertTrue(e.getMessage().startsWith("Route migration validation failed with "), e.getMessage()); + assertTrue(e.getMessage().contains("EXPOSURE"), e.getMessage()); + assertEquals("previous output\n", Files.readString(file)); + } + + /** + * Configuration errors fail the build before scanning. + */ + private void executeWithoutScan(GenerateRoutesMojo mojo) throws Exception { + mojo.execute(baseDir, scanner -> { + throw new AssertionError("scanned despite a configuration error"); + }); + } + + @Test + void unknownGatewayNameFailsTheBuild() throws Exception { + GenerateRoutesMojo mojo = mojo("forbidden"); + set(mojo, "authorizationPolicyPorts", Map.of("facade-gateway", "8080")); + + MojoFailureException e = assertThrows(MojoFailureException.class, () -> executeWithoutScan(mojo)); + + assertEquals("Unknown gateway name 'facade-gateway' in , allowed names are " + + "public-gateway-service, private-gateway-service, internal-gateway-service", e.getMessage()); + assertFalse(Files.exists(baseDir.resolve(OUTPUT_FILE))); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "0", "65536", "abc", "8080,", "8080,abc"}) + void invalidPortsFailTheBuild(String ports) throws Exception { + GenerateRoutesMojo mojo = mojo("forbidden"); + set(mojo, "authorizationPolicyPorts", Map.of("internal-gateway-service", ports)); + + MojoFailureException e = assertThrows(MojoFailureException.class, () -> executeWithoutScan(mojo)); + + assertTrue(e.getMessage().contains("'internal-gateway-service' in "), e.getMessage()); + assertFalse(Files.exists(baseDir.resolve(OUTPUT_FILE))); + } + + @Test + void portsForOneGateway() throws Exception { + GenerateRoutesMojo mojo = mojo("forbidden"); + set(mojo, "authorizationPolicyPorts", Map.of("internal-gateway-service", " 8080, 8443 ")); + + execute(mojo, baseDir); + + String yaml = Files.readString(baseDir.resolve(OUTPUT_FILE)); + String internal = yaml.substring(yaml.indexOf("-java-annotations-deny-internal\"")); + assertTrue(internal.contains("ports:\n - \"8080\"\n - \"8443\"\n paths:"), internal); + String publicAndPrivate = yaml.substring(0, yaml.indexOf("-java-annotations-deny-internal\"")); + assertFalse(publicAndPrivate.contains("8443"), publicAndPrivate); + } + + @Test + void parsesPortsPerGateway() throws Exception { + assertEquals(Map.of(), GenerateRoutesMojo.parsePorts(null)); + assertEquals(Map.of(Gateway.PUBLIC, List.of("8080", "9090")), + GenerateRoutesMojo.parsePorts(Map.of("public-gateway-service", "8080, 9090,8080"))); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojoTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojoTest.java index 81beca33a1..0880fcccb6 100644 --- a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojoTest.java +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojoTest.java @@ -225,7 +225,7 @@ void testGetRequestMappingPaths_SpringTestController8() { assertEquals(3, routes.size()); assertTrue(routes.contains(new HttpRoute(RoutesTestConfiguration.CLASS_ROUTE_PATH_TO_1, RoutesTestConfiguration.CLASS_ROUTE_PATH_FROM_1, HttpRoute.Type.PUBLIC))); - assertTrue(routes.contains(new HttpRoute(RoutesTestConfiguration.CLASS_ROUTE_PATH_TO_1 + RoutesTestConfiguration.METHOD_ROUTE_PATH_TO_1, RoutesTestConfiguration.CLASS_ROUTE_PATH_FROM_1 + RoutesTestConfiguration.METHOD_ROUTE_PATH_FROM_1, HttpRoute.Type.FACADE, 10_000))); + assertTrue(routes.contains(new HttpRoute(RoutesTestConfiguration.CLASS_ROUTE_PATH_TO_1 + RoutesTestConfiguration.METHOD_ROUTE_PATH_TO_1, RoutesTestConfiguration.CLASS_ROUTE_PATH_TO_1 + RoutesTestConfiguration.METHOD_ROUTE_PATH_TO_1, HttpRoute.Type.FACADE, 10_000))); assertTrue(routes.contains(new HttpRoute(RoutesTestConfiguration.CLASS_ROUTE_PATH_TO_1 + RoutesTestConfiguration.METHOD_ROUTE_PATH_TO_2, RoutesTestConfiguration.CLASS_ROUTE_PATH_FROM_1 + RoutesTestConfiguration.METHOD_ROUTE_PATH_TO_2, HttpRoute.Type.PRIVATE, 20_000))); } } diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/HttpRouteRendererTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/HttpRouteRendererTest.java index 4c32ec5f1e..37a32a70ad 100644 --- a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/HttpRouteRendererTest.java +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/HttpRouteRendererTest.java @@ -5,44 +5,47 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Set; +import static com.netcracker.routes.gateway.plugin.LegacyRouteTableTest.route; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class HttpRouteRendererTest { + static List plan(DeclaredRoute... routes) { + return RouteMigration.run(new RouteDeclarations(List.of(routes), List.of()), false).plan().rules(); + } + + private static String render(DeclaredRoute... routes) { + return new HttpRouteRenderer("{{ CustomBackendRef }}").generateHttpRoutesYaml(8081, plan(routes)); + } + @Test void generatesYamlWithMatchesRewritesAndTimeouts() { - Set routes = Set.of( - new HttpRoute("/api", HttpRoute.Type.INTERNAL), - new HttpRoute("/svc", "/gateway", HttpRoute.Type.PUBLIC, 5_000), - new HttpRoute("/items/{id}", HttpRoute.Type.PRIVATE) - ); - - String yaml = new HttpRouteRenderer("{{ CustomBackendRef }}").generateHttpRoutesYaml(8081, routes); + String yaml = render( + route("/api", "/api", HttpRoute.Type.INTERNAL, "a"), + route("/gateway", "/svc", HttpRoute.Type.PUBLIC, 5_000, "b"), + route("/items/{id}", "/items/{id}", HttpRoute.Type.PRIVATE, "c")); assertTrue(yaml.contains("HTTPRoute")); assertTrue(yaml.contains("ReplacePrefixMatch")); assertTrue(yaml.contains("request: \"5s\"")); - assertTrue(yaml.contains("RegularExpression")); - assertTrue(yaml.contains("items/([^/]+)")); + assertTrue(yaml.contains("type: \"PathPrefix\"\n value: \"/items\"\n"), yaml); + assertFalse(yaml.contains("RegularExpression")); assertTrue(yaml.contains("{{ CustomBackendRef }}")); - assertTrue(yaml.contains("# MANUAL REVIEW REQUIRED")); + assertFalse(yaml.contains("MANUAL REVIEW")); } @Test void replacesDefaultLabelsWhenCustomLabelsProvided() { - Set routes = Set.of(new HttpRoute("/api", HttpRoute.Type.INTERNAL)); - String yaml = new HttpRouteRenderer( "{{ CustomBackendRef }}", Map.of( "team", "platform", "app.kubernetes.io/managed-by", "custom-manager" ) - ).generateHttpRoutesYaml(8081, routes); + ).generateHttpRoutesYaml(8081, plan(route("/api", "/api", HttpRoute.Type.INTERNAL, "a"))); assertTrue(yaml.contains("team:")); assertTrue(yaml.contains("platform")); @@ -52,33 +55,166 @@ void replacesDefaultLabelsWhenCustomLabelsProvided() { assertFalse(yaml.contains("deployment.netcracker.com/sessionId:")); } + @Test + void rendersExactSplitWithReplaceFullPath() { + String yaml = render( + route("/api/v1/svc/items", "/v1/items", HttpRoute.Type.INTERNAL, "list"), + route("/api/v1/svc/items/{id}", "/v2/items/{id}", HttpRoute.Type.INTERNAL, "get")); + + assertEquals(""" + apiVersion: "gateway.networking.k8s.io/v1" + kind: "HTTPRoute" + metadata: + name: "{{ .Values.SERVICE_NAME }}-java-annotations-internal" + labels: + app.kubernetes.io/managed-by: "{{ .Values.MANAGED_BY }}" + app.kubernetes.io/name: "{{ .Values.SERVICE_NAME }}" + app.kubernetes.io/part-of: "{{ .Values.APPLICATION_NAME }}" + app.kubernetes.io/processed-by-operator: "istiod" + deployer.cleanup/allow: "true" + deployment.netcracker.com/sessionId: "{{ .Values.DEPLOYMENT_SESSION_ID }}" + spec: + parentRefs: + - group: "" + kind: "Service" + name: "internal-gateway-service" + rules: + - matches: + - path: + type: "Exact" + value: "/api/v1/svc/items/" + filters: + - type: "URLRewrite" + urlRewrite: + path: + type: "ReplaceFullPath" + replaceFullPath: "/v1/items/" + backendRefs: + - group: "" + kind: "Service" + name: "{{ CustomBackendRef }}" + port: 8081 + weight: 1 + - matches: + - path: + type: "Exact" + value: "/api/v1/svc/items" + filters: + - type: "URLRewrite" + urlRewrite: + path: + type: "ReplaceFullPath" + replaceFullPath: "/v1/items" + backendRefs: + - group: "" + kind: "Service" + name: "{{ CustomBackendRef }}" + port: 8081 + weight: 1 + - matches: + - path: + type: "PathPrefix" + value: "/api/v1/svc/items" + filters: + - type: "URLRewrite" + urlRewrite: + path: + type: "ReplacePrefixMatch" + replacePrefixMatch: "/v2/items" + backendRefs: + - group: "" + kind: "Service" + name: "{{ CustomBackendRef }}" + port: 8081 + weight: 1 + """, yaml.replace("---\n", "")); + } + + @Test + void rendersResourcesInOrderPublicPrivateInternalFacade() { + String yaml = render( + route("/facade/items", "/items", HttpRoute.Type.FACADE, "f"), + route("/api/internal", "/api/internal", HttpRoute.Type.INTERNAL, "i"), + route("/api/private", "/api/private", HttpRoute.Type.PRIVATE, "p"), + route("/api/public", "/api/public", HttpRoute.Type.PUBLIC, "u")); + + int publicAt = yaml.indexOf("-java-annotations-public\""); + int privateAt = yaml.indexOf("-java-annotations-private\""); + int internalAt = yaml.indexOf("-java-annotations-internal\""); + int facadeAt = yaml.indexOf("-java-annotations-facade\""); + assertTrue(0 <= publicAt && publicAt < privateAt && privateAt < internalAt && internalAt < facadeAt, yaml); + } + + @Test + void serviceBoundRouteHasAllFacadeRulesAndOnlyTheServiceParentRef() { + String yaml = render( + route("/facade/items", "/items", HttpRoute.Type.FACADE, "f"), + route("/orders", "/orders", HttpRoute.Type.FACADE, "o")); + + assertTrue(yaml.startsWith("---\napiVersion: \"gateway.networking.k8s.io/v1\"\nkind: \"HTTPRoute\"\nmetadata:\n" + + " name: \"{{ .Values.SERVICE_NAME }}-java-annotations-facade\"\n"), yaml); + assertTrue(yaml.contains(""" + parentRefs: + - group: "" + kind: "Service" + name: "{{ .Values.SERVICE_NAME }}" + rules: + """), yaml); + assertEquals(1, yaml.split("kind: \"HTTPRoute\"", -1).length - 1, yaml); + assertTrue(yaml.contains("value: \"/facade/items\"") && yaml.contains("replacePrefixMatch: \"/items\""), yaml); + assertTrue(yaml.contains("value: \"/orders\"\n filters: []\n"), yaml); + } + + @Test + void noServiceBoundRouteWithoutRewrite() { + String yaml = render( + route("/items", "/items", HttpRoute.Type.FACADE, "f"), + route("/api/public", "/api/public", HttpRoute.Type.PUBLIC, "u")); + + assertFalse(yaml.contains("-facade"), yaml); + assertTrue(yaml.contains("-java-annotations-public"), yaml); + } + + @Test + void renderingTwiceGivesIdenticalOutput() { + DeclaredRoute[] routes = { + route("/facade/items", "/items", HttpRoute.Type.FACADE, "f"), + route("/api/v1/svc/items", "/v1/items", HttpRoute.Type.PUBLIC, "list"), + route("/api/v1/svc/items/{id}", "/v2/items/{id}", HttpRoute.Type.PUBLIC, "get"), + route("/api/v1/svc/orders/{id}", "/orders/{id}", HttpRoute.Type.INTERNAL, 5_000, "o"), + route("/api/v1/svc/admin", "/admin", HttpRoute.Type.PRIVATE, "a")}; + + assertEquals(render(routes), render(routes)); + } + @Test void sortsRulesByPathSpecificity() { - List routes = new ArrayList<>(List.of( - new HttpRoute("/alpha", HttpRoute.Type.INTERNAL), - new HttpRoute("/alpha/bravo", HttpRoute.Type.INTERNAL), - new HttpRoute("/alpha/bravo/charlie", HttpRoute.Type.INTERNAL) - )); + List paths = new ArrayList<>(List.of("/alpha", "/alpha/bravo", "/alpha/bravo/charlie")); - routes.sort(HttpRouteRenderer.pathSpecificityComparator()); + paths.sort(HttpRouteRenderer.pathSpecificity()); - assertEquals("/alpha/bravo/charlie", routes.get(0).gatewayPath()); - assertEquals("/alpha/bravo", routes.get(1).gatewayPath()); - assertEquals("/alpha", routes.get(2).gatewayPath()); + assertEquals(List.of("/alpha/bravo/charlie", "/alpha/bravo", "/alpha"), paths); } @Test void sortsByLengthThenLexicalWhenSegmentsEqual() { - List routes = new ArrayList<>(List.of( - new HttpRoute("/aa/za", HttpRoute.Type.INTERNAL), - new HttpRoute("/aa/ab", HttpRoute.Type.INTERNAL), - new HttpRoute("/aa/abcd", HttpRoute.Type.INTERNAL) - )); + List paths = new ArrayList<>(List.of("/aa/za", "/aa/ab", "/aa/abcd")); + + paths.sort(HttpRouteRenderer.pathSpecificity()); + + assertEquals(List.of("/aa/abcd", "/aa/ab", "/aa/za"), paths); + } + + @Test + void sortsExactBeforePathPrefixWithTheSameValue() { + List rules = new ArrayList<>(List.of( + new PlannedRule(HttpRoute.Type.PUBLIC, PlannedRule.MatchType.PATH_PREFIX, "/a", null, null, 0, List.of()), + new PlannedRule(HttpRoute.Type.PUBLIC, PlannedRule.MatchType.EXACT, "/a", null, null, 0, List.of()), + new PlannedRule(HttpRoute.Type.PUBLIC, PlannedRule.MatchType.EXACT, "/a/", null, null, 0, List.of()))); - routes.sort(HttpRouteRenderer.pathSpecificityComparator()); + rules.sort(HttpRouteRenderer.ruleOrder()); - assertEquals("/aa/abcd", routes.get(0).gatewayPath()); - assertEquals("/aa/ab", routes.get(1).gatewayPath()); - assertEquals("/aa/za", routes.get(2).gatewayPath()); + assertEquals(List.of("Exact /a/", "Exact /a", "PathPrefix /a"), + rules.stream().map(r -> r.matchType().apiName() + " " + r.value()).toList()); } } diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/IstioRoutePlannerTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/IstioRoutePlannerTest.java new file mode 100644 index 0000000000..2e9649b4e6 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/IstioRoutePlannerTest.java @@ -0,0 +1,493 @@ +package com.netcracker.routes.gateway.plugin; + +import com.netcracker.routes.gateway.plugin.scan.ScanControllers; +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static com.netcracker.routes.gateway.plugin.Gateway.INTERNAL; +import static com.netcracker.routes.gateway.plugin.Gateway.PRIVATE; +import static com.netcracker.routes.gateway.plugin.Gateway.PUBLIC; +import static com.netcracker.routes.gateway.plugin.HttpRoute.Type; +import static com.netcracker.routes.gateway.plugin.LegacyRouteTableTest.forbidden; +import static com.netcracker.routes.gateway.plugin.LegacyRouteTableTest.route; +import static com.netcracker.routes.gateway.plugin.PlannedRule.MatchType.EXACT; +import static com.netcracker.routes.gateway.plugin.PlannedRule.MatchType.PATH_PREFIX; +import static com.netcracker.routes.gateway.plugin.PlannedRule.RewriteType.REPLACE_FULL_PATH; +import static com.netcracker.routes.gateway.plugin.PlannedRule.RewriteType.REPLACE_PREFIX_MATCH; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class IstioRoutePlannerTest { + + private static final String RESOURCE = "/api/v1/svc/resource"; + private static final String ORDER = "/api/v1/svc/order"; + + static IstioPlan plan(boolean auto, List routes, List forbidden) { + RouteDeclarations declarations = new RouteDeclarations(routes, forbidden); + return new IstioRoutePlanner(auto).plan(declarations, LegacyRouteTable.build(declarations)); + } + + private static IstioPlan plan(DeclaredRoute... routes) { + return plan(false, List.of(routes), List.of()); + } + + /** + * Rule shape without its sources: resource type, match, rewrite and timeout. + */ + private record Shape(Type type, PlannedRule.MatchType match, String value, PlannedRule.RewriteType rewrite, + String rewriteValue, long timeout) { + } + + private static Shape shape(Type type, PlannedRule.MatchType match, String value, PlannedRule.RewriteType rewrite, + String rewriteValue, long timeout) { + return new Shape(type, match, value, rewrite, rewriteValue, timeout); + } + + private static List shapes(IstioPlan plan) { + return plan.rules().stream() + .map(r -> shape(r.resourceType(), r.matchType(), r.value(), r.rewriteType(), r.rewriteValue(), r.timeout())) + .toList(); + } + + private static List kinds(IstioPlan plan) { + return plan.findings().stream().map(Finding::kind).toList(); + } + + private static DenyRule deny(Gateway gateway, List paths, List notPaths) { + return new DenyRule(gateway, paths, notPaths, ""); + } + + private static List withoutOrigins(List rules) { + return rules.stream().map(r -> deny(r.gateway(), r.paths(), r.notPaths())).toList(); + } + + // 4.1 cut and rewrite + + @Test + void cutsGatewayPathAtFirstVariable() { + assertEquals("/api/v1/my-service/resource", PathPattern.cut("/api/v1/my-service/resource/{var1}/internal-api/status")); + assertEquals("/api/v1/my-service/order", PathPattern.cut("/api/v1/my-service/order/{id}")); + assertEquals("/api/v1/files", PathPattern.cut("/api/v1/files/{name}.txt")); + assertEquals("/api/v1/my-service/resource", PathPattern.cut("/api/v1/my-service/resource")); + assertEquals("/", PathPattern.cut("/{id}")); + } + + @Test + void cutsRewriteTheSameWay() { + assertEquals(List.of(shape(Type.PUBLIC, PATH_PREFIX, RESOURCE, REPLACE_PREFIX_MATCH, "/resource", 0)), + shapes(plan(route(RESOURCE + "/{var1}/sub", "/resource/{var1}/sub", Type.PUBLIC, "a")))); + assertEquals(List.of(shape(Type.PUBLIC, PATH_PREFIX, RESOURCE, REPLACE_PREFIX_MATCH, "/resource", 0)), + shapes(plan(route(RESOURCE, "/resource", Type.PUBLIC, "a")))); + assertEquals(List.of(shape(Type.PUBLIC, PATH_PREFIX, "/items", null, null, 0)), + shapes(plan(route("/items/{id}", "/items/{id}", Type.PUBLIC, "a")))); + assertEquals(Optional.empty(), IstioRoutePlanner.rewriteOf(new HttpRoute("/items/{id}", "/items/{id}", Type.PUBLIC, 0), "/items")); + } + + @Test + void identityRewriteAfterCutHasNoFilter() { + // the paths differ only after the cut, so the cut rewrite equals the match + assertEquals(List.of(shape(Type.PUBLIC, PATH_PREFIX, "/items", null, null, 0)), + shapes(plan(route("/items/{id}/a", "/items/{id}/b", Type.PUBLIC, "a")))); + } + + // 4.2 grouping and merging + + @Test + void collapsedRoutesWithSameRewrite() { + IstioPlan plan = plan( + route(RESOURCE, "/resource", Type.PUBLIC, "a"), + route(RESOURCE + "/{var1}/sub", "/resource/{var1}/sub", Type.PUBLIC, "b")); + + assertEquals(List.of(shape(Type.PUBLIC, PATH_PREFIX, RESOURCE, REPLACE_PREFIX_MATCH, "/resource", 0)), shapes(plan)); + assertEquals(2, plan.rules().get(0).sources().size()); + assertEquals(List.of(), plan.findings()); + } + + @Test + void sameRewriteDifferentTypesGoesIntoWidestResource() { + IstioPlan plan = plan( + route("/api/a", "/a", Type.PUBLIC, "a"), + route("/api/a/{id}/x", "/a/{id}/x", Type.INTERNAL, "b")); + + assertEquals(List.of(shape(Type.PUBLIC, PATH_PREFIX, "/api/a", REPLACE_PREFIX_MATCH, "/a", 0)), shapes(plan)); + assertEquals(List.of(), plan.rules(Type.INTERNAL)); + } + + @Test + void differentTimeoutsMergeWithLargestAndWarn() { + IstioPlan plan = plan( + route("/api/a", "/a", Type.PUBLIC, 5000, "a"), + route("/api/a/{id}/x", "/a/{id}/x", Type.PUBLIC, 60000, "b")); + + assertEquals(List.of(shape(Type.PUBLIC, PATH_PREFIX, "/api/a", REPLACE_PREFIX_MATCH, "/a", 60000)), shapes(plan)); + assertEquals(List.of(Finding.Kind.TIMEOUT_MERGE), kinds(plan)); + Finding warning = plan.findings().get(0); + assertEquals("public-gateway, private-gateway, internal-gateway-service", warning.target()); + String text = warning.details().toString(); + assertTrue(text.contains("/api/a -> /a (from a) [ReplacePrefixMatch /a, timeout 5s]"), text); + assertTrue(text.contains("/api/a/{id}/x -> /a/{id}/x (from b)"), text); + assertTrue(warning.detail("timeout").orElseThrow().contains("1m"), text); + } + + @Test + void sameRouteWithDifferentTimeoutsGivesOnlyMergeWarning() { + List routes = List.of( + route("/api/x", "/a", Type.INTERNAL, 5000, "a"), + route("/api/x", "/a", Type.INTERNAL, 60000, "b")); + RouteDeclarations declarations = new RouteDeclarations(routes, List.of()); + LegacyRouteTable legacy = LegacyRouteTable.build(declarations); + IstioPlan plan = new IstioRoutePlanner(false).plan(declarations, legacy); + + assertEquals(List.of(), legacy.findings()); + assertEquals(List.of(Finding.Kind.TIMEOUT_MERGE), kinds(plan)); + assertEquals(List.of(shape(Type.INTERNAL, PATH_PREFIX, "/api/x", REPLACE_PREFIX_MATCH, "/a", 60000)), shapes(plan)); + } + + @Test + void conflictOfLegacyInvalidDuplicatesNamesTheirGatewayPaths() { + // RouteMigration drops this conflict, because LEGACY_INVALID is reported for /api/x + IstioPlan plan = plan(route("/api/x", "/a", Type.INTERNAL, "a"), route("/api/x", "/b", Type.INTERNAL, "b")); + + assertEquals(List.of(new IstioPlan.ConflictedPrefix("/api/x", Type.INTERNAL)), plan.conflicted()); + assertEquals(List.of(Finding.Kind.CONFLICT), kinds(plan)); + assertEquals(java.util.Set.of("/api/x"), plan.findings().get(0).gatewayPaths()); + } + + @Test + void differentRewritesOnOnePrefixConflict() { + IstioPlan plan = plan( + route(RESOURCE + "/{var1}/v1-api", "/resource/{var1}/v1-api", Type.PUBLIC, "a"), + route(RESOURCE + "/{var1}/v2-api", "/v2/resource/{var1}/v2-api", Type.PUBLIC, "b")); + + assertEquals(List.of(), plan.rules()); + assertEquals(List.of(new IstioPlan.ConflictedPrefix(RESOURCE, Type.PUBLIC)), plan.conflicted()); + assertEquals(List.of(Finding.Kind.CONFLICT), kinds(plan)); + Finding conflict = plan.findings().get(0); + assertEquals("public-gateway, private-gateway, internal-gateway-service", conflict.target()); + assertEquals(RESOURCE + "/" + plan.sample() + "/v1-api", conflict.detail("request").orElseThrow()); + String text = conflict.details().toString(); + assertTrue(text.contains("(from a)") && text.contains("(from b)"), text); + } + + @Test + void conflictTargetsOnlyGatewaysWhereBothRoutesAreAllowed() { + IstioPlan plan = plan( + route("/api/a/{id}/x", "/a/{id}/x", Type.PUBLIC, "a"), + route("/api/a/{id}/y", "/b/{id}/y", Type.INTERNAL, "b")); + + assertEquals(List.of(new IstioPlan.ConflictedPrefix("/api/a", Type.PUBLIC)), plan.conflicted()); + assertEquals("internal-gateway-service", plan.findings().get(0).target()); + } + + // 4.3 Exact split + + @Test + void exactSplitForControllerRootWithDifferentRewrite() { + IstioPlan plan = plan( + route("/api/v1/svc/items", "/v1/items", Type.PUBLIC, "s"), + route("/api/v1/svc/items/{id}", "/v2/items/{id}", Type.PUBLIC, "r")); + + assertEquals(List.of( + shape(Type.PUBLIC, EXACT, "/api/v1/svc/items", REPLACE_FULL_PATH, "/v1/items", 0), + shape(Type.PUBLIC, EXACT, "/api/v1/svc/items/", REPLACE_FULL_PATH, "/v1/items/", 0), + shape(Type.PUBLIC, PATH_PREFIX, "/api/v1/svc/items", REPLACE_PREFIX_MATCH, "/v2/items", 0)), shapes(plan)); + assertEquals(List.of(), plan.findings()); + } + + @Test + void exactSplitForControllerRootWithTrailingSlash() { + // Spring: @RequestMapping("/api/x") on the class, @GetMapping("/") and @GetMapping("/{id}") on methods + IstioPlan plan = plan( + route("/api/x/", "/a/", Type.PUBLIC, "s"), + route("/api/x/{id}", "/b/{id}", Type.PUBLIC, "r")); + + assertEquals(List.of( + shape(Type.PUBLIC, EXACT, "/api/x", REPLACE_FULL_PATH, "/a", 0), + shape(Type.PUBLIC, EXACT, "/api/x/", REPLACE_FULL_PATH, "/a/", 0), + shape(Type.PUBLIC, PATH_PREFIX, "/api/x", REPLACE_PREFIX_MATCH, "/b", 0)), shapes(plan)); + assertEquals(List.of(), plan.findings()); + } + + @Test + void controllerRootWithTrailingSlashSharesRuleOfItsSubtree() { + IstioPlan plan = plan( + route("/api/x/", "/x/", Type.PUBLIC, "s"), + route("/api/x/{id}", "/x/{id}", Type.PUBLIC, "r")); + + assertEquals(List.of(shape(Type.PUBLIC, PATH_PREFIX, "/api/x", REPLACE_PREFIX_MATCH, "/x", 0)), shapes(plan)); + assertEquals(List.of(), plan.findings()); + } + + @Test + void controllerRootWithTrailingSlashKeepsItsTimeout() { + IstioPlan plan = plan( + route("/api/x/", "/api/x/", Type.PUBLIC, 1000, "s"), + route("/api/x/{id}", "/api/x/{id}", Type.PUBLIC, 5000, "r")); + + assertEquals(List.of( + shape(Type.PUBLIC, EXACT, "/api/x", null, null, 1000), + shape(Type.PUBLIC, EXACT, "/api/x/", null, null, 1000), + shape(Type.PUBLIC, PATH_PREFIX, "/api/x", null, null, 5000)), shapes(plan)); + assertEquals(List.of(), plan.findings()); + } + + @Test + void exactSplitWithoutRewriteKeepsTimeoutOfRoot() { + IstioPlan plan = plan( + route("/items", "/items", Type.INTERNAL, 5000, "s"), + route("/items/{id}", "/items/{id}", Type.PUBLIC, 60000, "r")); + + assertEquals(List.of( + shape(Type.INTERNAL, EXACT, "/items", null, null, 5000), + shape(Type.INTERNAL, EXACT, "/items/", null, null, 5000), + shape(Type.PUBLIC, PATH_PREFIX, "/items", null, null, 60000)), shapes(plan)); + assertEquals(List.of(), plan.findings()); + } + + @Test + void exactSplitOnRootHasOneRule() { + IstioPlan plan = plan(route("/", "/v1", Type.PUBLIC, "s"), route("/{id}", "/v2/{id}", Type.PUBLIC, "r")); + + assertEquals(List.of( + shape(Type.PUBLIC, EXACT, "/", REPLACE_FULL_PATH, "/v1", 0), + shape(Type.PUBLIC, PATH_PREFIX, "/", REPLACE_PREFIX_MATCH, "/v2", 0)), shapes(plan)); + } + + @Test + void noExactSplitWhenRouteContinuesAfterVariable() { + IstioPlan plan = plan( + route("/api/v1/svc/items", "/v1/items", Type.PUBLIC, "s"), + route("/api/v1/svc/items/{id}/details", "/v2/items/{id}/details", Type.PUBLIC, "r")); + + assertEquals(List.of(), plan.rules()); + assertEquals(List.of(Finding.Kind.CONFLICT), kinds(plan)); + assertEquals("PathPrefix /api/v1/svc/items", plan.findings().get(0).detail("match").orElseThrow()); + } + + // 4.4 DENY rule content + + @Test + void forbiddenRouteWithLongerAllowedRouteBelow() { + IstioPlan plan = plan(false, List.of( + route(RESOURCE, RESOURCE, Type.PUBLIC, "root"), + route(RESOURCE + "/{var1}/internal-api", RESOURCE + "/{var1}/internal-api", Type.INTERNAL, "internalApi"), + route(RESOURCE + "/{var1}/internal-api/status", RESOURCE + "/{var1}/internal-api/status", Type.PUBLIC, "status")), + List.of(forbidden(RESOURCE + "/{var1}/internal-api", "internalApi", PUBLIC, PRIVATE))); + + List paths = List.of(RESOURCE + "/{*}/internal-api", RESOURCE + "/{*}/internal-api/{**}"); + List notPaths = List.of(RESOURCE + "/{*}/internal-api/status", RESOURCE + "/{*}/internal-api/status/{**}"); + assertEquals(List.of(deny(PUBLIC, paths, notPaths), deny(PRIVATE, paths, notPaths)), withoutOrigins(plan.denyRules())); + assertEquals("internalApi", plan.denyRules().get(0).origin()); + } + + @Test + void forbiddenControllerRootClosesCutExposure() { + IstioPlan plan = plan(false, List.of(route(ORDER + "/{id}/items", ORDER + "/{id}/items", Type.PUBLIC, "items")), + List.of(forbidden(ORDER, "OrderController", PUBLIC, PRIVATE, INTERNAL))); + + List paths = List.of(ORDER, ORDER + "/{**}"); + List notPaths = List.of(ORDER + "/{*}/items", ORDER + "/{*}/items/{**}"); + assertEquals(List.of(deny(PUBLIC, paths, notPaths), deny(PRIVATE, paths, notPaths), deny(INTERNAL, paths, notPaths)), + withoutOrigins(plan.denyRules())); + } + + @Test + void longerAllowedRouteWithVariableOverLiteralOfForbiddenPathIsInNotPaths() { + IstioPlan plan = plan(false, List.of(route("/a/{id}/x/y", "/a/{id}/x/y", Type.PUBLIC, "y")), + List.of(forbidden("/a/lit/x", "x", PUBLIC))); + + assertEquals(List.of(deny(PUBLIC, List.of("/a/lit/x", "/a/lit/x/{**}"), List.of("/a/{*}/x/y", "/a/{*}/x/y/{**}"))), + withoutOrigins(plan.denyRules())); + } + + @Test + void longerAllowedRouteThatOverlapsForbiddenPathFromAboveIsInNotPaths() { + IstioPlan plan = plan(false, List.of(route("/{p}/bb", "/{p}/bb", Type.PUBLIC, "bb")), + List.of(forbidden("/a/{x}", "x", PUBLIC))); + + assertEquals(List.of(deny(PUBLIC, List.of("/a/{*}", "/a/{*}/{**}"), List.of("/{*}/bb", "/{*}/bb/{**}"))), + withoutOrigins(plan.denyRules())); + } + + @Test + void forbiddenPathWithTrailingSlash() { + IstioPlan plan = plan(false, List.of(route("/a", "/a", Type.PUBLIC, "a")), List.of(forbidden("/a/b/", "b", PUBLIC))); + + assertEquals(List.of(deny(PUBLIC, List.of("/a/b", "/a/b/{**}"), List.of())), withoutOrigins(plan.denyRules())); + } + + @Test + void nestedForbiddenPathsGiveSeparateRules() { + IstioPlan plan = plan(false, List.of(route("/a/b", "/a/b", Type.PUBLIC, "b")), + List.of(forbidden("/a/b/{id}/c", "c", PUBLIC), forbidden("/a", "a", PUBLIC))); + + assertEquals(List.of( + deny(PUBLIC, List.of("/a", "/a/{**}"), List.of("/a/b", "/a/b/{**}")), + deny(PUBLIC, List.of("/a/b/{*}/c", "/a/b/{*}/c/{**}"), List.of())), + withoutOrigins(plan.denyRules())); + } + + @Test + void forbiddenRootPath() { + IstioPlan plan = plan(false, List.of(route("/a", "/a", Type.PUBLIC, "a")), List.of(forbidden("/", "root", PUBLIC))); + + assertEquals(List.of(deny(PUBLIC, List.of("/", "/{**}"), List.of("/a", "/a/{**}"))), withoutOrigins(plan.denyRules())); + } + + // 4.5 automatic DENY rules + + private static List implicitForbiddenRoutes() { + return List.of( + route(RESOURCE, RESOURCE, Type.PUBLIC, "root"), + route(RESOURCE + "/{id}/internal-api", RESOURCE + "/{id}/internal-api", Type.INTERNAL, "internalApi"), + route(RESOURCE + "/{id}/internal-api/status", RESOURCE + "/{id}/internal-api/status", Type.PUBLIC, "status")); + } + + @Test + void noAutomaticRulesWhenParameterIsOff() { + IstioPlan plan = plan(false, List.of( + route(RESOURCE, RESOURCE, Type.PUBLIC, "root"), + route(RESOURCE + "/{id}/internal-api", RESOURCE + "/{id}/internal-api", Type.INTERNAL, "internalApi")), List.of()); + + assertEquals(List.of(), plan.denyRules()); + } + + @Test + void automaticRuleForImplicitForbiddenRoute() { + IstioPlan plan = plan(true, implicitForbiddenRoutes(), List.of()); + + List paths = List.of(RESOURCE + "/{*}/internal-api", RESOURCE + "/{*}/internal-api/{**}"); + List notPaths = List.of(RESOURCE + "/{*}/internal-api/status", RESOURCE + "/{*}/internal-api/status/{**}"); + assertEquals(List.of(deny(PUBLIC, paths, notPaths), deny(PRIVATE, paths, notPaths)), withoutOrigins(plan.denyRules())); + assertTrue(plan.denyRules().stream().allMatch(DenyRule::isAutomatic)); + assertTrue(plan.denyRules().get(0).origin().contains("implicit forbidden of INTERNAL route"), plan.denyRules().get(0).origin()); + } + + @Test + void automaticRuleForCutExposure() { + IstioPlan plan = plan(true, List.of(route(ORDER + "/{id}/items", ORDER + "/{id}/items", Type.PUBLIC, "items")), List.of()); + + List paths = List.of(ORDER, ORDER + "/{**}"); + List notPaths = List.of(ORDER + "/{*}/items", ORDER + "/{*}/items/{**}"); + assertEquals(List.of(deny(PUBLIC, paths, notPaths), deny(PRIVATE, paths, notPaths), deny(INTERNAL, paths, notPaths)), + withoutOrigins(plan.denyRules())); + assertEquals("auto: cut exposure of " + ORDER + " from items", plan.denyRules().get(0).origin()); + } + + @Test + void automaticRuleForCutPrefixWithLegacyRouteBelowIt() { + IstioPlan plan = plan(true, List.of( + route(ORDER + "/{id}", ORDER + "/{id}", Type.INTERNAL, "get"), + route(ORDER + "/{id}/items", ORDER + "/{id}/items", Type.PUBLIC, "items")), List.of()); + + List internal = withoutOrigins(plan.denyRules(INTERNAL)); + assertEquals(List.of(deny(INTERNAL, List.of(ORDER, ORDER + "/{**}"), + List.of(ORDER + "/{*}", ORDER + "/{*}/items", ORDER + "/{*}/items/{**}", ORDER + "/{*}/{**}"))), internal); + } + + @Test + void noAutomaticRuleWhenLegacyExposedTheSubtree() { + IstioPlan plan = plan(true, List.of( + route(ORDER, "/order", Type.PUBLIC, "root"), + route(ORDER + "/{id}/items", "/order/{id}/items", Type.PUBLIC, "items")), List.of()); + + assertEquals(List.of(), plan.denyRules()); + } + + @Test + void noAutomaticRuleWhenIstioDoesNotRouteForbiddenPath() { + IstioPlan plan = plan(true, List.of(route("/api/v1/svc/admin", "/admin", Type.INTERNAL, "admin")), List.of()); + + assertEquals(List.of(), plan.denyRules()); + } + + @Test + void noAutomaticRuleForPartialSegmentVariable() { + // neither @ForbiddenRoute nor an AuthorizationPolicy path can express {name}.txt; the validator reports the exposure + IstioPlan plan = plan(true, List.of( + route("/api/files", "/files", Type.PUBLIC, "list"), + route("/api/files/{name}.txt", "/files/{name}.txt", Type.PRIVATE, "text")), List.of()); + + assertEquals(List.of(), plan.denyRules()); + } + + @Test + void explicitAndAutomaticIdenticalRulesAreRenderedOnce() { + IstioPlan plan = plan(true, implicitForbiddenRoutes(), + List.of(forbidden(RESOURCE + "/{id}/internal-api", "internalApi", PUBLIC, PRIVATE))); + + assertEquals(2, plan.denyRules().size()); + assertTrue(plan.denyRules().stream().noneMatch(DenyRule::isAutomatic)); + } + + // 4.6 SERVICE target + + @Test + void noServiceRulesWithoutRewrite() { + IstioPlan plan = plan(route("/items", "/items", Type.FACADE, "a"), route("/orders", "/orders", Type.FACADE, "b")); + + assertEquals(List.of(), plan.rules()); + assertEquals(List.of(), plan.findings()); + } + + @Test + void unappliedFacadeTimeoutIsWarned() { + IstioPlan plan = plan(route("/items", "/items", Type.FACADE, 5000, "a")); + + assertEquals(List.of(), plan.rules()); + assertEquals(List.of(Finding.Kind.TIMEOUT_NOT_APPLIED), kinds(plan)); + assertEquals(Finding.SERVICE_TARGET, plan.findings().get(0).target()); + assertFalse(plan.findings().get(0).isError()); + } + + @Test + void allServiceRulesWhenOneHasRewrite() { + IstioPlan plan = plan(route("/facade/items", "/items", Type.FACADE, "a"), route("/orders", "/orders", Type.FACADE, "b")); + + assertEquals(List.of( + shape(Type.FACADE, PATH_PREFIX, "/facade/items", REPLACE_PREFIX_MATCH, "/items", 0), + shape(Type.FACADE, PATH_PREFIX, "/orders", null, null, 0)), shapes(plan)); + assertEquals(List.of(), plan.denyRules()); + } + + @Test + void compositeRouteGoesToServiceTarget() { + RouteScanner scanner = new RouteScanner(new String[]{"com.netcracker"}, new SystemStreamLog()); + RouteDeclarations declarations = RouteScannerDeclarationsTest.scan(scanner, ScanControllers.CompositeWithGatewayPath.class); + IstioPlan plan = new IstioRoutePlanner(true).plan(declarations, LegacyRouteTable.build(declarations)); + + assertEquals(List.of(shape(Type.FACADE, PATH_PREFIX, "/composite/items", REPLACE_PREFIX_MATCH, "/items", 0)), shapes(plan)); + assertEquals(List.of(), plan.denyRules()); + assertFalse(plan.toString().contains("composite-gw"), plan.toString()); + assertFalse(plan.toString().contains("example.com"), plan.toString()); + } + + @Test + void facadeAndCompositeRoutesCollide() { + IstioPlan plan = plan(route("/x", "/a", Type.FACADE, "composite"), route("/x", "/b", Type.FACADE, "facade")); + + assertEquals(List.of(Finding.Kind.CONFLICT), kinds(plan)); + Finding conflict = plan.findings().get(0); + assertEquals(Finding.SERVICE_TARGET, conflict.target()); + String text = conflict.details().toString(); + assertTrue(text.contains("(from composite)") && text.contains("(from facade)"), text); + assertTrue(conflict.detail("problem").orElseThrow().contains("different legacy gateways"), text); + assertEquals(List.of(), plan.rules()); + } + + @Test + void facadeRoutesNeverGetLegacyEntriesOrDenyRules() { + List routes = List.of(route("/facade/items", "/items", Type.FACADE, "a")); + RouteDeclarations declarations = new RouteDeclarations(routes, List.of()); + LegacyRouteTable legacy = LegacyRouteTable.build(declarations); + + for (Gateway gateway : Gateway.values()) { + assertEquals(List.of(), legacy.entries(gateway)); + } + assertEquals(List.of(), new IstioRoutePlanner(true).plan(declarations, legacy).denyRules()); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/IstioRouteTableTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/IstioRouteTableTest.java new file mode 100644 index 0000000000..3f188f24b3 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/IstioRouteTableTest.java @@ -0,0 +1,106 @@ +package com.netcracker.routes.gateway.plugin; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static com.netcracker.routes.gateway.plugin.Gateway.INTERNAL; +import static com.netcracker.routes.gateway.plugin.Gateway.PUBLIC; +import static com.netcracker.routes.gateway.plugin.LegacyRouteTableTest.route; +import static com.netcracker.routes.gateway.plugin.PlannedRule.MatchType.EXACT; +import static com.netcracker.routes.gateway.plugin.PlannedRule.MatchType.PATH_PREFIX; +import static com.netcracker.routes.gateway.plugin.PlannedRule.RewriteType.REPLACE_FULL_PATH; +import static com.netcracker.routes.gateway.plugin.PlannedRule.RewriteType.REPLACE_PREFIX_MATCH; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; + +class IstioRouteTableTest { + + private static final DeclaredRoute SOURCE = route("/a", "/a", HttpRoute.Type.PUBLIC, "a"); + + private static PlannedRule rule(HttpRoute.Type type, PlannedRule.MatchType match, String value, + PlannedRule.RewriteType rewrite, String rewriteValue) { + return new PlannedRule(type, match, value, rewrite, rewriteValue, 0, List.of(SOURCE)); + } + + private static IstioPlan plan(List rules, List denyRules) { + return new IstioPlan(rules, List.of(), denyRules, List.of(), "x-probe-0"); + } + + private static IstioRouteTable.Decision.Routed assertRouted(IstioRouteTable table, Gateway gateway, String request, + String upstream) { + IstioRouteTable.Decision decision = table.decide(gateway, request); + IstioRouteTable.Decision.Routed routed = assertInstanceOf(IstioRouteTable.Decision.Routed.class, decision, + gateway + " " + request + ": " + decision); + assertEquals(upstream, routed.upstreamPath(), gateway + " " + request); + return routed; + } + + @Test + void exactWinsOverLongerPrefix() { + PlannedRule exact = rule(HttpRoute.Type.PUBLIC, EXACT, "/a", REPLACE_FULL_PATH, "/v1/a"); + PlannedRule prefix = rule(HttpRoute.Type.PUBLIC, PATH_PREFIX, "/a", REPLACE_PREFIX_MATCH, "/v2/a"); + IstioRouteTable table = IstioRouteTable.of(plan(List.of(prefix, exact), List.of())); + + assertSame(exact, assertRouted(table, PUBLIC, "/a", "/v1/a").rule()); + assertSame(prefix, assertRouted(table, PUBLIC, "/a/1", "/v2/a/1").rule()); + } + + @Test + void longestSegmentAlignedPrefixWins() { + PlannedRule shorter = rule(HttpRoute.Type.PUBLIC, PATH_PREFIX, "/a", null, null); + PlannedRule longer = rule(HttpRoute.Type.PUBLIC, PATH_PREFIX, "/a/b", REPLACE_PREFIX_MATCH, "/x"); + IstioRouteTable table = IstioRouteTable.of(plan(List.of(shorter, longer), List.of())); + + assertRouted(table, PUBLIC, "/a/b/c", "/x/c"); + assertRouted(table, PUBLIC, "/a/bc", "/a/bc"); + assertInstanceOf(IstioRouteTable.Decision.Unrouted.class, table.decide(PUBLIC, "/ab")); + } + + @Test + void replacePrefixMatchWithRootDoesNotDoubleSlash() { + IstioRouteTable table = IstioRouteTable.of(plan( + List.of(rule(HttpRoute.Type.PUBLIC, PATH_PREFIX, "/api/a", REPLACE_PREFIX_MATCH, "/")), List.of())); + + assertRouted(table, PUBLIC, "/api/a/x", "/x"); + assertRouted(table, PUBLIC, "/api/a", "/"); + assertRouted(table, PUBLIC, "/api/a/", "/"); + } + + @Test + void rulesAttachOnlyToGatewaysOfTheirResource() { + IstioRouteTable table = IstioRouteTable.of(plan( + List.of(rule(HttpRoute.Type.INTERNAL, PATH_PREFIX, "/a", null, null), + rule(HttpRoute.Type.FACADE, PATH_PREFIX, "/b", null, null)), List.of())); + + assertRouted(table, INTERNAL, "/a", "/a"); + assertInstanceOf(IstioRouteTable.Decision.Unrouted.class, table.decide(PUBLIC, "/a")); + assertInstanceOf(IstioRouteTable.Decision.Unrouted.class, table.decide(INTERNAL, "/b")); + } + + @Test + void denyRulesComeFirstAndHonorNotPaths() { + DenyRule deny = new DenyRule(PUBLIC, DenyRule.pathAndSubtree("/a/{*}/internal"), + DenyRule.pathAndSubtree("/a/{*}/internal/status"), "internal"); + IstioPlan plan = plan(List.of(rule(HttpRoute.Type.PUBLIC, PATH_PREFIX, "/a", null, null)), List.of(deny)); + IstioRouteTable table = IstioRouteTable.of(plan); + + assertInstanceOf(IstioRouteTable.Decision.Denied.class, table.decide(PUBLIC, "/a/1/internal")); + assertInstanceOf(IstioRouteTable.Decision.Denied.class, table.decide(PUBLIC, "/a/1/internal/x/y")); + assertRouted(table, PUBLIC, "/a/1/internal/status", "/a/1/internal/status"); + assertRouted(table, PUBLIC, "/a/1/2/internal", "/a/1/2/internal"); + assertRouted(table, Gateway.PRIVATE, "/a/1/internal", "/a/1/internal"); + assertRouted(IstioRouteTable.routingOnly(plan.rules(), plan.conflicted()), PUBLIC, "/a/1/internal", "/a/1/internal"); + } + + @Test + void longerConflictedPrefixHidesShorterRule() { + IstioPlan plan = new IstioPlan(List.of(rule(HttpRoute.Type.PUBLIC, PATH_PREFIX, "/a", null, null)), + List.of(new IstioPlan.ConflictedPrefix("/a/b", HttpRoute.Type.PUBLIC)), List.of(), List.of(), "x-probe-0"); + IstioRouteTable table = IstioRouteTable.of(plan); + + assertInstanceOf(IstioRouteTable.Decision.Conflicted.class, table.decide(PUBLIC, "/a/b/c")); + assertRouted(table, PUBLIC, "/a/c", "/a/c"); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/LegacyRouteTableTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/LegacyRouteTableTest.java new file mode 100644 index 0000000000..27399232f4 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/LegacyRouteTableTest.java @@ -0,0 +1,309 @@ +package com.netcracker.routes.gateway.plugin; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import static com.netcracker.routes.gateway.plugin.Gateway.INTERNAL; +import static com.netcracker.routes.gateway.plugin.Gateway.PRIVATE; +import static com.netcracker.routes.gateway.plugin.Gateway.PUBLIC; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LegacyRouteTableTest { + + private static final String RESOURCE = "/api/v1/my-service/resource"; + private static final String ORDER = "/api/v1/my-service/order"; + + static DeclaredRoute route(String gatewayPath, String path, HttpRoute.Type type, long timeout, String origin) { + return new DeclaredRoute(new HttpRoute(path, gatewayPath, type, timeout), new TreeSet<>(List.of(origin))); + } + + static DeclaredRoute route(String gatewayPath, String path, HttpRoute.Type type, String origin) { + return route(gatewayPath, path, type, 0, origin); + } + + static ForbiddenDeclaration forbidden(String gatewayPath, String origin, Gateway... gateways) { + return new ForbiddenDeclaration(gatewayPath, EnumSet.copyOf(List.of(gateways)), origin); + } + + private static LegacyRouteTable table(List routes, List forbidden) { + return LegacyRouteTable.build(new RouteDeclarations(routes, forbidden)); + } + + private static LegacyRouteTable table(DeclaredRoute... routes) { + return table(List.of(routes), List.of()); + } + + private static void assertRouted(LegacyRouteTable table, Gateway gateway, String request, String upstream, long timeout) { + LegacyRouteTable.Decision decision = table.decide(gateway, request); + LegacyRouteTable.Decision.Routed routed = assertInstanceOf(LegacyRouteTable.Decision.Routed.class, decision, + gateway + " " + request + ": " + decision); + assertEquals(upstream, routed.upstreamPath(), gateway + " " + request); + assertEquals(timeout, routed.timeout(), gateway + " " + request); + } + + private static void assertRouted(LegacyRouteTable table, Gateway gateway, String request, String upstream) { + assertRouted(table, gateway, request, upstream, 0); + } + + private static void assertForbidden(LegacyRouteTable table, Gateway gateway, String request) { + LegacyRouteTable.Decision decision = table.decide(gateway, request); + assertInstanceOf(LegacyRouteTable.Decision.Forbidden.class, decision, gateway + " " + request + ": " + decision); + } + + private static void assertUnrouted(LegacyRouteTable table, Gateway gateway, String request) { + LegacyRouteTable.Decision decision = table.decide(gateway, request); + assertInstanceOf(LegacyRouteTable.Decision.Unrouted.class, decision, gateway + " " + request + ": " + decision); + } + + private static LegacyRouteTable.Decision.Undefined assertUndefined(LegacyRouteTable table, Gateway gateway, String request) { + LegacyRouteTable.Decision decision = table.decide(gateway, request); + return assertInstanceOf(LegacyRouteTable.Decision.Undefined.class, decision, gateway + " " + request + ": " + decision); + } + + /** + * Routes 1, 2, 5, 6 and 7 of the example in regex-routes-migration.md; routes 3 and 4 go to another service. + */ + static List migrationDocumentRoutes() { + return List.of( + route(RESOURCE, "/resource", HttpRoute.Type.PUBLIC, "com.acme.ResourceController"), + route(RESOURCE + "/{var1}/internal-api", "/resource/{var1}/internal-api", HttpRoute.Type.INTERNAL, + "com.acme.ResourceController#internalApi"), + route(RESOURCE + "/{var1}", "/resource/{var1}", HttpRoute.Type.PUBLIC, "com.acme.ResourceController#get"), + route(RESOURCE + "/{var1}/internal-api/status", "/resource/{var1}/internal-api/status", HttpRoute.Type.PUBLIC, + "com.acme.ResourceController#status"), + route(ORDER + "/{var1}/items", "/order/{var1}/items", HttpRoute.Type.PUBLIC, "com.acme.OrderController#items")); + } + + private static void assertMigrationDocumentRouting(LegacyRouteTable table) { + for (Gateway gateway : Gateway.values()) { + assertRouted(table, gateway, RESOURCE, "/resource"); + assertRouted(table, gateway, RESOURCE + "/", "/resource/"); + assertRouted(table, gateway, RESOURCE + "/123", "/resource/123"); + assertRouted(table, gateway, RESOURCE + "/123/other", "/resource/123/other"); + assertRouted(table, gateway, RESOURCE + "/123/internal-api/status", "/resource/123/internal-api/status"); + assertRouted(table, gateway, RESOURCE + "/123/internal-api/status/x", "/resource/123/internal-api/status/x"); + assertRouted(table, gateway, ORDER + "/123/items", "/order/123/items"); + assertRouted(table, gateway, ORDER + "/123/items/5", "/order/123/items/5"); + assertUnrouted(table, gateway, ORDER); + assertUnrouted(table, gateway, ORDER + "/123"); + assertUnrouted(table, gateway, ORDER + "/123/other"); + } + for (Gateway gateway : List.of(PUBLIC, PRIVATE)) { + assertForbidden(table, gateway, RESOURCE + "/123/internal-api"); + assertForbidden(table, gateway, RESOURCE + "/123/internal-api/"); + assertForbidden(table, gateway, RESOURCE + "/123/internal-api/other"); + } + assertRouted(table, INTERNAL, RESOURCE + "/123/internal-api", "/resource/123/internal-api"); + assertRouted(table, INTERNAL, RESOURCE + "/123/internal-api/other", "/resource/123/internal-api/other"); + } + + @Test + void migrationDocumentExample() { + LegacyRouteTable table = table(migrationDocumentRoutes(), List.of()); + + assertMigrationDocumentRouting(table); + assertEquals(List.of(), table.findings()); + } + + @Test + void migrationDocumentExampleWithForbiddenRoute() { + LegacyRouteTable table = table(migrationDocumentRoutes(), + List.of(forbidden(RESOURCE + "/{var1}/internal-api", "com.acme.ResourceController#internalApi", PUBLIC, PRIVATE))); + + assertMigrationDocumentRouting(table); + assertEquals(List.of(), table.findings()); + LegacyRouteTable.Decision.Forbidden decision = assertInstanceOf(LegacyRouteTable.Decision.Forbidden.class, + table.decide(PUBLIC, RESOURCE + "/1/internal-api")); + assertEquals(Set.of(LegacyEntry.Kind.IMPLICIT_FORBIDDEN, LegacyEntry.Kind.EXPLICIT_FORBIDDEN), + Set.copyOf(decision.entries().stream().map(LegacyEntry::kind).toList())); + } + + @Test + void narrowerTypeBelowWiderPrefix() { + LegacyRouteTable table = table( + route("/api/v1/svc/resource", "/api/v1/svc/resource", HttpRoute.Type.PUBLIC, "C"), + route("/api/v1/svc/resource/{id}/internal-api", "/api/v1/svc/resource/{id}/internal-api", HttpRoute.Type.INTERNAL, "C#m")); + + assertForbidden(table, PUBLIC, "/api/v1/svc/resource/1/internal-api"); + assertForbidden(table, PRIVATE, "/api/v1/svc/resource/1/internal-api"); + assertRouted(table, INTERNAL, "/api/v1/svc/resource/1/internal-api", "/api/v1/svc/resource/1/internal-api"); + } + + @Test + void longerAllowedRouteBelowForbiddenOne() { + LegacyRouteTable table = table( + route("/api/v1/svc/resource", "/api/v1/svc/resource", HttpRoute.Type.PUBLIC, "C"), + route("/api/v1/svc/resource/{id}/internal-api", "/api/v1/svc/resource/{id}/internal-api", HttpRoute.Type.INTERNAL, "C#m"), + route("/api/v1/svc/resource/{id}/internal-api/status", "/api/v1/svc/resource/{id}/internal-api/status", + HttpRoute.Type.PUBLIC, "C#status")); + + for (Gateway gateway : Gateway.values()) { + assertRouted(table, gateway, "/api/v1/svc/resource/1/internal-api/status", "/api/v1/svc/resource/1/internal-api/status"); + } + } + + @Test + void allowedWinsOverImplicitForbiddenWithSamePattern() { + LegacyRouteTable table = table( + route("/api/x", "/a", HttpRoute.Type.PUBLIC, "C#public"), + route("/api/x", "/a", HttpRoute.Type.INTERNAL, "C#internal")); + + assertTrue(table.entries(PUBLIC).stream().allMatch(LegacyEntry::isAllowed)); + assertRouted(table, PUBLIC, "/api/x/1", "/a/1"); + } + + @Test + void facadeRoutesAreNotInTheModel() { + LegacyRouteTable table = table(route("/facade/items", "/items", HttpRoute.Type.FACADE, "C#m")); + + for (Gateway gateway : Gateway.values()) { + assertEquals(List.of(), table.entries(gateway)); + assertUnrouted(table, gateway, "/facade/items"); + } + } + + @Test + void variableUpstreamKeepsCapturesByPosition() { + LegacyRouteTable table = table(route("/api/v1/{tenant}/users/{id}", "/users/{id}/tenants/{tenant}", HttpRoute.Type.PUBLIC, "C#m")); + + // positional: the first gateway variable replaces the first service variable + assertRouted(table, PUBLIC, "/api/v1/t1/users/u1/x", "/users/t1/tenants/u1/x"); + } + + @Test + void rootServicePath() { + LegacyRouteTable table = table(route("/api/svc", "/", HttpRoute.Type.PUBLIC, "C")); + + assertRouted(table, PUBLIC, "/api/svc", "/"); + assertRouted(table, PUBLIC, "/api/svc/", "/"); + assertRouted(table, PUBLIC, "/api/svc/a", "/a"); + } + + // ties (D4) + + @Test + void tiedRoutesWithDifferentUpstreamPaths() { + LegacyRouteTable table = table( + route("/s/{a}/b", "/one/{a}/b", HttpRoute.Type.PUBLIC, "C#one"), + route("/s/b/{a}", "/two/{a}", HttpRoute.Type.PUBLIC, "C#two")); + + for (Gateway gateway : Gateway.values()) { + LegacyRouteTable.Decision.Undefined undefined = assertUndefined(table, gateway, "/s/b/b"); + assertEquals(List.of("/s/b/{a}", "/s/{a}/b"), undefined.tied().stream().map(e -> e.pattern().source()).toList()); + } + assertRouted(table, PUBLIC, "/s/x/b", "/one/x/b"); + assertRouted(table, PUBLIC, "/s/b/x", "/two/x"); + } + + @Test + void tiedAllowedAndForbiddenEntries() { + LegacyRouteTable table = table( + route("/s/{a}/b", "/s/{a}/b", HttpRoute.Type.PUBLIC, "C#one"), + route("/s/b/{a}", "/s/b/{a}", HttpRoute.Type.INTERNAL, "C#two")); + + assertUndefined(table, PUBLIC, "/s/b/b"); + assertUndefined(table, PRIVATE, "/s/b/b"); + assertRouted(table, INTERNAL, "/s/b/b", "/s/b/b"); + } + + @Test + void tiedRoutesThatDifferOnlyInTimeout() { + LegacyRouteTable table = table( + route("/s/{a}/b", "/x/{a}/b", HttpRoute.Type.PUBLIC, 5000, "C#one"), + route("/s/b/{a}", "/x/b/{a}", HttpRoute.Type.PUBLIC, 60000, "C#two")); + + assertRouted(table, PUBLIC, "/s/b/b", "/x/b/b", 60000); + assertRouted(table, PUBLIC, "/s/c/b", "/x/c/b", 5000); + assertEquals(List.of(), table.findings()); + } + + @Test + void tiedForbiddenEntries() { + LegacyRouteTable table = table( + route("/s/{a}/b", "/s/{a}/b", HttpRoute.Type.INTERNAL, "C#one"), + route("/s/b/{a}", "/t/{a}", HttpRoute.Type.INTERNAL, "C#two")); + + assertForbidden(table, PUBLIC, "/s/b/b"); + assertUndefined(table, INTERNAL, "/s/b/b"); + } + + // 3.3 + + @Test + void forbiddingAnAllowedRoutesOwnPath() { + String origin = "com.acme.ItemController#items"; + LegacyRouteTable table = table(List.of(route("/api/items", "/items", HttpRoute.Type.PUBLIC, origin)), + List.of(forbidden("/api/items", origin, PUBLIC))); + + assertEquals(1, table.findings().size()); + Finding finding = table.findings().get(0); + assertEquals(Finding.Kind.CONTRADICTORY_DECLARATION, finding.kind()); + assertTrue(finding.isError()); + assertEquals("public-gateway", finding.target()); + assertEquals(origin, finding.detail("element").orElseThrow()); + assertEquals("/api/items", finding.detail("path").orElseThrow()); + assertTrue(finding.detail("problem").orElseThrow().contains("public-gateway")); + assertTrue(table.entries(PUBLIC).stream().allMatch(LegacyEntry::isAllowed)); + } + + @Test + void forbiddingPathOfNarrowerRouteIsNotContradictory() { + String origin = "com.acme.ItemController#items"; + LegacyRouteTable table = table(List.of(route("/api/items", "/items", HttpRoute.Type.INTERNAL, origin)), + List.of(forbidden("/api/items", origin, PUBLIC, PRIVATE))); + + assertEquals(List.of(), table.findings()); + assertForbidden(table, PUBLIC, "/api/items"); + } + + // 3.4 + + @Test + void sameGatewayPathDifferentServicePaths() { + LegacyRouteTable table = table( + route("/api/x", "/a", HttpRoute.Type.INTERNAL, "C#a"), + route("/api/x", "/b", HttpRoute.Type.INTERNAL, "C#b")); + + assertEquals(1, table.findings().size()); + Finding finding = table.findings().get(0); + assertEquals(Finding.Kind.LEGACY_INVALID, finding.kind()); + assertEquals("internal-gateway-service", finding.target()); + assertEquals("/api/x", finding.detail("path").orElseThrow()); + List routes = new ArrayList<>(); + finding.details().stream().filter(d -> d.label().equals("route")).forEach(d -> routes.add(d.text())); + assertEquals(List.of("INTERNAL route /api/x -> /a (from C#a)", "INTERNAL route /api/x -> /b (from C#b)"), routes); + assertEquals(Set.of(PathPattern.of("/api/x")), table.legacyInvalidPatterns(INTERNAL)); + assertEquals(Set.of(), table.legacyInvalidPatterns(PUBLIC)); + } + + @Test + void onlyOneOfTheDuplicatesAllowedOnAGateway() { + LegacyRouteTable table = table( + route("/api/x", "/a", HttpRoute.Type.PUBLIC, "C#a"), + route("/api/x", "/b", HttpRoute.Type.INTERNAL, "C#b")); + + assertEquals(List.of("internal-gateway-service"), table.findings().stream().map(Finding::target).toList()); + assertEquals(Finding.Kind.LEGACY_INVALID, table.findings().get(0).kind()); + assertEquals(Set.of(), table.legacyInvalidPatterns(PUBLIC)); + assertEquals(Set.of(), table.legacyInvalidPatterns(PRIVATE)); + assertRouted(table, PUBLIC, "/api/x", "/a"); + } + + @Test + void sameGatewayPathSameServicePathDifferentTimeouts() { + LegacyRouteTable table = table( + route("/api/x", "/a", HttpRoute.Type.INTERNAL, 5000, "C#a"), + route("/api/x", "/a", HttpRoute.Type.INTERNAL, 60000, "C#b")); + + assertEquals(List.of(), table.findings()); + assertEquals(Set.of(), table.legacyInvalidPatterns(INTERNAL)); + assertRouted(table, INTERNAL, "/api/x/1", "/a/1", 60000); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/MigrationValidatorTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/MigrationValidatorTest.java new file mode 100644 index 0000000000..73baca7165 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/MigrationValidatorTest.java @@ -0,0 +1,356 @@ +package com.netcracker.routes.gateway.plugin; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.SortedSet; +import java.util.TreeSet; + +import static com.netcracker.routes.gateway.plugin.Gateway.INTERNAL; +import static com.netcracker.routes.gateway.plugin.Gateway.PRIVATE; +import static com.netcracker.routes.gateway.plugin.Gateway.PUBLIC; +import static com.netcracker.routes.gateway.plugin.LegacyRouteTableTest.forbidden; +import static com.netcracker.routes.gateway.plugin.LegacyRouteTableTest.route; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MigrationValidatorTest { + + static final String RESOURCE = "/api/v1/svc/resource"; + static final String ORDER = "/api/v1/svc/order"; + static final String SAMPLE = "x-probe-0"; + + /** + * @return findings of all stages: scan, legacy table, planner and validator + */ + static List findings(boolean auto, RouteDeclarations declarations) { + return RouteMigration.run(declarations, auto).findings(); + } + + private static List findings(DeclaredRoute... routes) { + return findings(false, new RouteDeclarations(List.of(routes), List.of())); + } + + private static List errors(List findings) { + return findings.stream().filter(Finding::isError).toList(); + } + + private static List ofKind(List findings, Finding.Kind kind) { + return findings.stream().filter(f -> f.kind() == kind).toList(); + } + + private static List targets(List findings) { + return findings.stream().map(Finding::target).toList(); + } + + private static List requests(List findings) { + return findings.stream().map(f -> f.detail("request").orElseThrow()).toList(); + } + + private static final List ALL_GATEWAYS = List.of(PUBLIC.refName(), PRIVATE.refName(), INTERNAL.refName()); + private static final List PUBLIC_AND_PRIVATE = List.of(PUBLIC.refName(), PRIVATE.refName()); + + static List implicit404Routes() { + return List.of( + route(RESOURCE, RESOURCE, HttpRoute.Type.PUBLIC, "com.acme.ResourceController"), + route(RESOURCE + "/{id}/internal-api", RESOURCE + "/{id}/internal-api", HttpRoute.Type.INTERNAL, + "com.acme.ResourceController#internalApi")); + } + + // Differential validation over request paths + + @Test + void cutExposesControllerSubtree() { + List findings = findings(route(ORDER + "/{id}/items", ORDER + "/{id}/items", HttpRoute.Type.PUBLIC, "items")); + + List exposures = ofKind(findings, Finding.Kind.EXPOSURE); + assertEquals(ALL_GATEWAYS, targets(exposures)); + assertEquals(List.of(ORDER, ORDER, ORDER), requests(exposures)); + assertEquals(exposures, errors(findings)); + } + + @Test + void cutExposureCoveredByShorterLegacyRoute() { + List findings = findings( + route(ORDER, "/order", HttpRoute.Type.PUBLIC, "root"), + route(ORDER + "/{id}/items", "/order/{id}/items", HttpRoute.Type.PUBLIC, "items")); + + assertEquals(List.of(), findings); + } + + @Test + void implicitLegacy404NotDeclared() { + List findings = findings(false, new RouteDeclarations(implicit404Routes(), List.of())); + + List exposures = ofKind(findings, Finding.Kind.EXPOSURE); + assertEquals(PUBLIC_AND_PRIVATE, targets(exposures)); + assertEquals(List.of(RESOURCE + "/" + SAMPLE + "/internal-api", RESOURCE + "/" + SAMPLE + "/internal-api"), + requests(exposures)); + assertEquals(exposures, errors(findings)); + } + + @Test + void implicitLegacy404ForPathWithoutVariables() { + List findings = findings( + route(RESOURCE, RESOURCE, HttpRoute.Type.PUBLIC, "root"), + route(RESOURCE + "/admin", RESOURCE + "/admin", HttpRoute.Type.INTERNAL, "admin")); + + List exposures = ofKind(findings, Finding.Kind.EXPOSURE); + assertEquals(PUBLIC_AND_PRIVATE, targets(exposures)); + assertEquals(List.of(RESOURCE + "/admin", RESOURCE + "/admin"), requests(exposures)); + } + + @Test + void narrowerRouteWithNoWiderPrefixAbove() { + List findings = findings(route("/api/v1/svc/admin/{id}", "/api/v1/svc/admin/{id}", HttpRoute.Type.INTERNAL, "admin")); + + List exposures = ofKind(findings, Finding.Kind.EXPOSURE); + assertEquals(List.of(INTERNAL.refName()), targets(exposures)); + assertEquals(List.of("/api/v1/svc/admin"), requests(exposures)); + assertEquals(exposures, errors(findings)); + } + + @Test + void conflictingBehaviorAfterCut() { + List findings = findings( + route(RESOURCE, "/resource", HttpRoute.Type.PUBLIC, "root"), + route(RESOURCE + "/{id}/v2-api", "/v2/{id}/v2-api", HttpRoute.Type.PUBLIC, "v2")); + + List conflicts = ofKind(findings, Finding.Kind.CONFLICT); + assertEquals(List.of(String.join(", ", ALL_GATEWAYS)), targets(conflicts)); + assertEquals(List.of(RESOURCE + "/" + SAMPLE + "/v2-api"), requests(conflicts)); + assertEquals(conflicts, errors(findings)); + } + + @Test + void literalAndVariableAtSamePosition() { + List findings = findings( + route("/a/b/{id}/c", "/x/{id}/c", HttpRoute.Type.PUBLIC, "B"), + route("/a/b/lit", "/y", HttpRoute.Type.PUBLIC, "C")); + + List conflicts = ofKind(findings, Finding.Kind.CONFLICT); + assertEquals(ALL_GATEWAYS, targets(conflicts)); + assertEquals(List.of("/a/b/lit/c", "/a/b/lit/c", "/a/b/lit/c"), requests(conflicts)); + String text = conflicts.get(0).details().toString(); + assertTrue(text.contains("/x/lit/c") && text.contains("/y/c"), text); + } + + @Test + void onlyTimeoutsDiffer() { + List findings = findings( + route("/api/a", "/a", HttpRoute.Type.PUBLIC, 5000, "a"), + route("/api/a/{id}/x", "/a/{id}/x", HttpRoute.Type.PUBLIC, 60000, "b")); + + assertEquals(List.of(), errors(findings)); + List changes = ofKind(findings, Finding.Kind.TIMEOUT_CHANGE); + assertEquals(ALL_GATEWAYS, targets(changes)); + assertEquals(List.of("/api/a", "/api/a", "/api/a"), requests(changes)); + } + + // Undefined legacy precedence + + @Test + void tiedAllowedAndForbiddenEntries() { + List findings = findings( + route("/s/{a}/b", "/s/{a}/b", HttpRoute.Type.PUBLIC, "a"), + route("/s/b/{a}", "/s/b/{a}", HttpRoute.Type.INTERNAL, "b")); + + List undefined = ofKind(findings, Finding.Kind.LEGACY_PRECEDENCE_UNDEFINED); + assertEquals(PUBLIC_AND_PRIVATE, targets(undefined)); + assertEquals(List.of("/s/b/b", "/s/b/b"), requests(undefined)); + String text = undefined.get(0).details().toString(); + assertTrue(text.contains("(from a)") && text.contains("(from b)"), text); + assertTrue(undefined.get(0).detail("fix").orElseThrow().contains("change the gateway path"), text); + assertTrue(findings.stream().noneMatch(f -> f.detail("request").orElse("").equals("/s/b/b") + && f.kind() != Finding.Kind.LEGACY_PRECEDENCE_UNDEFINED), findings.toString()); + } + + @Test + void tiedRoutesThatDifferOnlyInTimeout() { + List findings = findings( + route("/s/{a}/b", "/x/{a}/b", HttpRoute.Type.PUBLIC, 5000, "a"), + route("/s/b/{a}", "/x/b/{a}", HttpRoute.Type.PUBLIC, 60000, "b")); + + assertEquals(List.of(), ofKind(findings, Finding.Kind.LEGACY_PRECEDENCE_UNDEFINED)); + } + + // Legacy-invalid duplicate routes + + @Test + void sameGatewayPathDifferentServicePaths() { + List findings = findings( + route("/api/x", "/a", HttpRoute.Type.INTERNAL, "a"), + route("/api/x", "/b", HttpRoute.Type.INTERNAL, "b")); + + assertEquals(List.of(Finding.Kind.LEGACY_INVALID), findings.stream().map(Finding::kind).toList()); + assertEquals(INTERNAL.refName(), findings.get(0).target()); + } + + @Test + void legacyInvalidOnlyWhereBothAreAllowed() { + List findings = findings( + route("/api/x", "/a", HttpRoute.Type.PUBLIC, "a"), + route("/api/x", "/b", HttpRoute.Type.INTERNAL, "b")); + + assertEquals(List.of(Finding.Kind.LEGACY_INVALID), findings.stream().map(Finding::kind).toList()); + assertEquals(INTERNAL.refName(), findings.get(0).target()); + } + + @Test + void noUndefinedPrecedenceOrConflictNextToLegacyInvalid() { + // /s/{a}/b ties with /s/b/{a} for /s/b/b, and the cut gives /s different rewrites; /s/b only covers its cut + List findings = findings( + route("/s/{a}/b", "/one/{a}/b", HttpRoute.Type.INTERNAL, "a"), + route("/s/{a}/b", "/other/{a}/b", HttpRoute.Type.INTERNAL, "b"), + route("/s/b/{a}", "/two/{a}", HttpRoute.Type.INTERNAL, "c"), + route("/s/b", "/two", HttpRoute.Type.INTERNAL, "d")); + + assertEquals(List.of(Finding.Kind.LEGACY_INVALID), errors(findings).stream().map(Finding::kind).toList(), findings.toString()); + } + + @Test + void sameRouteWithDifferentTimeoutsIsOnlyWarned() { + List findings = findings( + route("/api/x", "/a", HttpRoute.Type.INTERNAL, 5000, "a"), + route("/api/x", "/a", HttpRoute.Type.INTERNAL, 60000, "b")); + + assertEquals(List.of(), errors(findings)); + assertEquals(List.of(), ofKind(findings, Finding.Kind.LEGACY_INVALID)); + assertEquals(1, ofKind(findings, Finding.Kind.TIMEOUT_MERGE).size()); + } + + // The example of regex-routes-migration.md without the another-service routes 3 and 4 + + @Test + void migrationDocumentExample() { + List findings = findings(false, new RouteDeclarations(LegacyRouteTableTest.migrationDocumentRoutes(), List.of())); + + List exposures = ofKind(findings, Finding.Kind.EXPOSURE); + assertEquals(exposures, errors(findings)); + String resource = "/api/v1/my-service/resource"; + String order = "/api/v1/my-service/order"; + assertEquals(List.of( + PUBLIC.refName() + " " + order, + PUBLIC.refName() + " " + resource + "/" + SAMPLE + "/internal-api", + PRIVATE.refName() + " " + order, + PRIVATE.refName() + " " + resource + "/" + SAMPLE + "/internal-api", + INTERNAL.refName() + " " + order), + exposures.stream().map(f -> f.target() + " " + f.detail("request").orElseThrow()).toList()); + } + + @Test + void migrationDocumentExampleWithForbiddenRoutes() { + String resource = "/api/v1/my-service/resource"; + String order = "/api/v1/my-service/order"; + List forbidden = List.of( + forbidden(resource + "/{var1}/internal-api", "com.acme.ResourceController#internalApi", PUBLIC, PRIVATE), + forbidden(order, "com.acme.OrderController", PUBLIC, PRIVATE, INTERNAL)); + + List findings = findings(false, new RouteDeclarations(LegacyRouteTableTest.migrationDocumentRoutes(), forbidden)); + + assertEquals(List.of(), findings); + } + + // DENY rules and automatic DENY rules in validation + + @Test + void forbiddenControllerRootClosesCutExposure() { + List findings = findings(false, new RouteDeclarations( + List.of(route(ORDER + "/{id}/items", ORDER + "/{id}/items", HttpRoute.Type.PUBLIC, "items")), + List.of(forbidden(ORDER, "OrderController", PUBLIC, PRIVATE, INTERNAL)))); + + assertEquals(List.of(), findings); + } + + @Test + void automaticRulesCloseImplicitAndCutExposures() { + List implicit = new ArrayList<>(implicit404Routes()); + implicit.add(route(RESOURCE + "/{id}/internal-api/status", RESOURCE + "/{id}/internal-api/status", + HttpRoute.Type.PUBLIC, "status")); + + assertEquals(List.of(), findings(true, new RouteDeclarations(implicit, List.of()))); + assertEquals(List.of(), findings(true, new RouteDeclarations( + List.of(route(ORDER + "/{id}/items", ORDER + "/{id}/items", HttpRoute.Type.PUBLIC, "items")), List.of()))); + assertEquals(List.of(), findings(true, new RouteDeclarations(List.of( + route(ORDER + "/{id}", ORDER + "/{id}", HttpRoute.Type.INTERNAL, "get"), + route(ORDER + "/{id}/items", ORDER + "/{id}/items", HttpRoute.Type.PUBLIC, "items")), List.of()))); + } + + @Test + void longerRoutesOverlappingForbiddenPathStayRouted() { + // legacy routes /a/lit/x/y and /a/bb by the longer routes; the DENY rules exempt them in notPaths + assertEquals(List.of(), ofKind(findings(false, new RouteDeclarations( + List.of(route("/a/{id}/x/y", "/a/{id}/x/y", HttpRoute.Type.PUBLIC, "y")), + List.of(forbidden("/a/lit/x", "x", PUBLIC)))), Finding.Kind.LOST_ROUTE)); + assertEquals(List.of(), ofKind(findings(false, new RouteDeclarations( + List.of(route("/{p}/bb", "/{p}/bb", HttpRoute.Type.PUBLIC, "bb")), + List.of(forbidden("/a/{x}", "x", PUBLIC)))), Finding.Kind.LOST_ROUTE)); + } + + @Test + void automaticDenyRuleOnLegacyRoutedPathIsLostRoute() { + // the cut exposure rule for order is decided on order only, and the catch-all route, shorter than order, + // routes order/x in legacy, so it is not in notPaths + String order = "/api/v1/my-service/order"; + List findings = findings(true, new RouteDeclarations(List.of( + route("/{a}/{b}/{c}/{d}/{e}", "/{a}/{b}/{c}/{d}/{e}", HttpRoute.Type.PUBLIC, "any"), + route(order + "/{id}/items", order + "/{id}/items", HttpRoute.Type.PUBLIC, "items")), List.of())); + + List lost = ofKind(findings, Finding.Kind.LOST_ROUTE).stream() + .filter(f -> f.detail("request").orElseThrow().equals(order + "/" + SAMPLE)).toList(); + assertEquals(ALL_GATEWAYS, targets(lost)); + Finding finding = lost.get(0); + assertTrue(finding.detail("problem").orElseThrow().contains("automatic DENY rule from auto: cut exposure of " + order), + finding.toString()); + String fix = finding.detail("fix").orElseThrow(); + assertTrue(fix.contains("no @ForbiddenRoute declaration to narrow"), fix); + assertFalse(fix.contains("narrow the @ForbiddenRoute"), fix); + } + + // Partial-segment variables + + private static List partialSegmentRoutes() { + return List.of( + route("/api/files", "/files", HttpRoute.Type.PUBLIC, "com.acme.FileController"), + route("/api/files/{name}.txt", "/files/{name}.txt", HttpRoute.Type.PRIVATE, "com.acme.FileController#text")); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void partialSegmentVariableExposureAsksToChangeTheGatewayPath(boolean auto) { + List findings = findings(auto, new RouteDeclarations(partialSegmentRoutes(), List.of())); + + assertEquals(List.of(Finding.Kind.EXPOSURE), errors(findings).stream().map(Finding::kind).toList()); + Finding exposure = errors(findings).get(0); + assertEquals(PUBLIC.refName(), exposure.target()); + assertEquals("/api/files/" + SAMPLE + ".txt", exposure.detail("request").orElseThrow()); + List fixes = exposure.details().stream().filter(d -> d.label().equals("fix")).map(Finding.Detail::text).toList(); + assertEquals(1, fixes.size(), fixes.toString()); + assertTrue(fixes.get(0).startsWith("change the gateway path /api/files/{name}.txt of com.acme.FileController#text"), + fixes.get(0)); + assertTrue(fixes.get(0).contains("whole-segment"), fixes.get(0)); + } + + // Trailing slash of a controller root + + @Test + void controllerRootWithTrailingSlashPlansCleanly() { + assertEquals(List.of(), findings( + route("/api/x/", "/a/", HttpRoute.Type.PUBLIC, "s"), + route("/api/x/{id}", "/b/{id}", HttpRoute.Type.PUBLIC, "r"))); + assertEquals(List.of(), findings( + route("/api/x/", "/api/x/", HttpRoute.Type.PUBLIC, 1000, "s"), + route("/api/x/{id}", "/api/x/{id}", HttpRoute.Type.PUBLIC, 5000, "r"))); + } + + static RouteDeclarations withElements(List routes, Map> elements) { + Map> elementGatewayPaths = new java.util.TreeMap<>(); + elements.forEach((path, names) -> elementGatewayPaths.put(path, new TreeSet<>(names))); + return new RouteDeclarations(routes, List.of(), List.of(), elementGatewayPaths); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/PathPatternTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/PathPatternTest.java new file mode 100644 index 0000000000..dd5106da87 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/PathPatternTest.java @@ -0,0 +1,155 @@ +package com.netcracker.routes.gateway.plugin; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PathPatternTest { + + @Test + void variableAsLastSegment() { + PathPattern pattern = PathPattern.of("/a/{id}"); + + assertEquals(List.of("a", "{id}"), pattern.segments()); + assertTrue(pattern.hasVariables()); + assertEquals(7, pattern.length()); + assertEquals(Optional.of(new PathPattern.Match(List.of("1"), "")), pattern.match("/a/1")); + assertEquals(Optional.of(new PathPattern.Match(List.of("1"), "/")), pattern.match("/a/1/")); + assertEquals(Optional.of(new PathPattern.Match(List.of("1"), "/x/y")), pattern.match("/a/1/x/y")); + assertFalse(pattern.matches("/a")); + assertFalse(pattern.matches("/a/")); + assertFalse(pattern.matches("/b/1")); + assertEquals("/a/{*}", pattern.istioTemplate()); + } + + @Test + void variableInTheMiddle() { + PathPattern pattern = PathPattern.of("/a/{id}/b"); + + assertEquals(Optional.of(new PathPattern.Match(List.of("1"), "")), pattern.match("/a/1/b")); + assertEquals(Optional.of(new PathPattern.Match(List.of("1"), "/c")), pattern.match("/a/1/b/c")); + assertFalse(pattern.matches("/a/1")); + assertFalse(pattern.matches("/a/1/bc")); + assertFalse(pattern.matches("/a/1/2/b")); + assertEquals("/a/x-probe-0/b", pattern.instantiate("x-probe-0")); + assertEquals("/a/lit/b", pattern.substitute(List.of("lit"))); + assertEquals("/a/{*}/b", pattern.istioTemplate()); + } + + @Test + void partialSegmentVariable() { + PathPattern pattern = PathPattern.of("/files/{name}.txt"); + + assertEquals(List.of("files", "{name}.txt"), pattern.segments()); + assertEquals(Optional.of(new PathPattern.Match(List.of("report"), "")), pattern.match("/files/report.txt")); + assertFalse(pattern.matches("/files/report.csv")); + assertFalse(pattern.matches("/files/.txt")); + assertEquals("/files/x.txt", pattern.instantiate("x")); + assertEquals("/files/{*}", pattern.istioTemplate()); + assertFalse(PathPattern.isVariable("{name}.txt")); + assertTrue(PathPattern.hasVariable("{name}.txt")); + assertTrue(PathPattern.isVariable("{name}")); + } + + @Test + void rootMatchesEverything() { + PathPattern pattern = PathPattern.of("/"); + + assertEquals(List.of(), pattern.segments()); + assertFalse(pattern.hasVariables()); + assertEquals(1, pattern.length()); + assertEquals(Optional.of(new PathPattern.Match(List.of(), "/")), pattern.match("/")); + assertEquals(Optional.of(new PathPattern.Match(List.of(), "/a/b")), pattern.match("/a/b")); + assertEquals("/", pattern.istioTemplate()); + } + + @Test + void literalPatternMatchesWholeSegmentsAndTrailingSlash() { + PathPattern pattern = PathPattern.of("/api/users"); + + assertEquals(Optional.of(new PathPattern.Match(List.of(), "")), pattern.match("/api/users")); + assertEquals(Optional.of(new PathPattern.Match(List.of(), "/")), pattern.match("/api/users/")); + assertFalse(pattern.matches("/api/usersX")); + assertFalse(pattern.matches("/api")); + } + + @Test + void patternWithTrailingSlashMatchesLikeWithout() { + PathPattern pattern = PathPattern.of("/api/users/"); + + assertEquals("/api/users/", pattern.source()); + assertEquals(11, pattern.length()); + assertTrue(pattern.matches("/api/users")); + assertTrue(pattern.matches("/api/users/")); + assertTrue(pattern.matches("/api/users/1")); + } + + @Test + void sourceIsNormalized() { + assertEquals("/", PathPattern.of("").source()); + assertEquals("/", PathPattern.of(null).source()); + assertEquals("/a", PathPattern.of("a").source()); + assertEquals(PathPattern.of("/a"), PathPattern.of("a")); + } + + @Test + void substituteKeepsVariablesWithoutValue() { + assertEquals("/one/1/{b}", PathPattern.of("/one/{a}/{b}").substitute(List.of("1"))); + assertEquals("/one/1/2", PathPattern.of("/one/{a}/{b}").substitute(List.of("1", "2"))); + } + + @Test + void lengthCountsVariablesInSourceForm() { + assertEquals("/s/{a}/b".length(), PathPattern.of("/s/{a}/b").length()); + assertEquals(PathPattern.of("/s/{a}/b").length(), PathPattern.of("/s/b/{a}").length()); + } + + @Test + void cutAtFirstVariable() { + assertEquals("/api/v1/my-service/resource", PathPattern.cut("/api/v1/my-service/resource/{var1}/internal-api/status")); + assertEquals("/api/v1/my-service/order", PathPattern.cut("/api/v1/my-service/order/{id}")); + assertEquals("/api/v1/files", PathPattern.cut("/api/v1/files/{name}.txt")); + assertEquals("/api/v1/my-service/resource", PathPattern.cut("/api/v1/my-service/resource")); + assertEquals("/", PathPattern.cut("/{id}")); + assertEquals("/", PathPattern.cut("/{id}/items")); + assertEquals("/a", PathPattern.cut("/a//{id}")); + } + + @Test + void cutDropsTrailingSlashOfPathWithoutVariables() { + // Spring @GetMapping("/") under @RequestMapping("/api/items") must share the group of /api/items/{id} + assertEquals("/api/items", PathPattern.cut("/api/items/")); + assertEquals(PathPattern.cut("/api/items/{id}"), PathPattern.cut("/api/items/")); + assertEquals("/", PathPattern.cut("/")); + } + + @Test + void istioTemplateDropsTrailingSlash() { + assertEquals("/a/b", PathPattern.of("/a/b/").istioTemplate()); + assertEquals("/a/{*}", PathPattern.of("/a/{id}/").istioTemplate()); + } + + @Test + void overlapsComparesSegmentsUpToTheShorterPattern() { + assertTrue(PathPattern.of("/a/lit/x").overlaps(PathPattern.of("/a/{id}/x/y"))); + assertTrue(PathPattern.of("/a/{x}").overlaps(PathPattern.of("/{p}/bb"))); + assertTrue(PathPattern.of("/a/b/").overlaps(PathPattern.of("/a/b/c"))); + assertTrue(PathPattern.of("/files/{name}.txt").overlaps(PathPattern.of("/files/a.txt/meta"))); + assertFalse(PathPattern.of("/files/{name}.txt").overlaps(PathPattern.of("/files/a.csv"))); + assertFalse(PathPattern.of("/a/b").overlaps(PathPattern.of("/a/c/d"))); + assertFalse(PathPattern.of("/a/lit/x").overlaps(PathPattern.of("/a/{id}/z/y"))); + } + + @Test + void forbiddenPathProblems() { + assertEquals(Optional.empty(), PathPattern.forbiddenPathProblem("/a/{id}/b")); + assertTrue(PathPattern.forbiddenPathProblem("/files/*").orElseThrow().contains("* wildcards")); + assertTrue(PathPattern.forbiddenPathProblem("/items/{id:\\d+}").orElseThrow().contains("regex-constrained")); + assertTrue(PathPattern.forbiddenPathProblem("/files/{name}.txt").orElseThrow().contains("whole-segment")); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/ProbesTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/ProbesTest.java new file mode 100644 index 0000000000..ca7bc229a3 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/ProbesTest.java @@ -0,0 +1,73 @@ +package com.netcracker.routes.gateway.plugin; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.SortedSet; + +import static com.netcracker.routes.gateway.plugin.LegacyRouteTableTest.route; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProbesTest { + + @Test + void sampleCollidesWithNoSegment() { + assertEquals("x-probe-0", Probes.sample(List.of("/a/{id}"))); + assertEquals("x-probe-2", Probes.sample(List.of("/a/x-probe-0", "/x-probe-1/{id}"))); + } + + @Test + void literalOfAnotherPatternReplacesVariable() { + SortedSet probes = Probes.probes(List.of("/a/b/{id}/c", "/a/b/lit"), "s"); + + assertTrue(probes.contains("/a/b/lit/c"), probes.toString()); + assertTrue(probes.contains("/a/b/s/c"), probes.toString()); + } + + @Test + void literalsReplaceOneVariableAtATime() { + SortedSet probes = Probes.probes(List.of("/a/{x}/{y}", "/a/l/m", "/a/n/p"), "s"); + + assertTrue(probes.containsAll(List.of("/a/l/s", "/a/n/s", "/a/s/m", "/a/s/p", "/a/s/s")), probes.toString()); + assertFalse(probes.contains("/a/l/m/x"), probes.toString()); + assertFalse(probes.contains("/a/n/m"), probes.toString()); + } + + @Test + void literalsComeOnlyFromCompatiblePrefixes() { + SortedSet probes = Probes.probes(List.of("/a/{id}", "/b/lit"), "s"); + + assertFalse(probes.contains("/a/lit"), probes.toString()); + } + + @Test + void partialSegmentVariable() { + SortedSet probes = Probes.probes(List.of("/files/{name}.txt", "/files/readme.txt", "/files/image.png"), "s"); + + assertTrue(probes.contains("/files/s.txt"), probes.toString()); + assertTrue(probes.contains("/files/readme.txt"), probes.toString()); + assertFalse(probes.contains("/files/image.png.txt"), probes.toString()); + } + + @Test + void prefixesWithSlashAndExtraSegment() { + SortedSet probes = Probes.probes(List.of("/a/{id}/b", "/items/"), "s"); + + assertEquals(List.of("/", "/a", "/a/", "/a/s", "/a/s/", "/a/s/b", "/a/s/b/", "/a/s/b/s", "/a/s/s", + "/items", "/items/", "/items/s", "/s"), List.copyOf(probes)); + } + + @Test + void probesContainCutPrefixes() { + String order = "/api/v1/svc/order"; + RouteDeclarations declarations = new RouteDeclarations( + List.of(route(order + "/{id}/items", "/order/{id}/items", HttpRoute.Type.PUBLIC, "items")), List.of()); + IstioPlan plan = new IstioRoutePlanner(false).plan(declarations, LegacyRouteTable.build(declarations)); + + SortedSet probes = Probes.probes(declarations, plan); + + assertTrue(probes.containsAll(List.of(order, order + "/", order + "/" + plan.sample())), probes.toString()); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/RouteMigrationE2ETest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/RouteMigrationE2ETest.java new file mode 100644 index 0000000000..48d1cf66c0 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/RouteMigrationE2ETest.java @@ -0,0 +1,199 @@ +package com.netcracker.routes.gateway.plugin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.apache.maven.plugin.MojoFailureException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.netcracker.routes.gateway.plugin.GenerateRoutesMojoExecuteTest.OUTPUT_FILE; +import static com.netcracker.routes.gateway.plugin.GenerateRoutesMojoExecuteTest.execute; +import static com.netcracker.routes.gateway.plugin.GenerateRoutesMojoExecuteTest.mojo; +import static com.netcracker.routes.gateway.plugin.GenerateRoutesMojoExecuteTest.set; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end tests of the controller sets in the {@code e2e} test packages: the example of regex-routes-migration.md + * with and without {@code @ForbiddenRoute}, and facade and composite routes. + */ +class RouteMigrationE2ETest { + + private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory()); + private static final String RESOURCE = "/api/v1/my-service/resource"; + private static final String ORDER = "/api/v1/my-service/order"; + + @TempDir + Path baseDir; + + private String generate(String controllerSet, boolean auto) throws Exception { + GenerateRoutesMojo mojo = mojo(controllerSet); + set(mojo, "autoGenerateAuthorizationPolicies", auto); + execute(mojo, baseDir); + return Files.readString(baseDir.resolve(OUTPUT_FILE)); + } + + /** + * @return the YAML documents of the output file, without the header and the Helm guard + */ + @SuppressWarnings("unchecked") + private static List> documents(String yaml) throws Exception { + String body = yaml.lines().filter(l -> !l.startsWith("#") && !l.startsWith("{{-")).collect(Collectors.joining("\n")); + List> documents = new ArrayList<>(); + try (var values = YAML.readerFor(Map.class).readValues(body)) { + while (values.hasNext()) { + documents.add((Map) values.next()); + } + } + return documents; + } + + @SuppressWarnings("unchecked") + private static T at(Object node, Object... path) { + Object current = node; + for (Object key : path) { + current = key instanceof Integer i ? ((List) current).get(i) : ((Map) current).get(key); + } + return (T) current; + } + + private static Map> byName(List> documents) { + Map> result = new LinkedHashMap<>(); + documents.forEach(d -> result.put(at(d, "metadata", "name"), d)); + return result; + } + + /** + * @return the rules of an HTTPRoute, for example {@code PathPrefix /a -> ReplacePrefixMatch /b} + */ + private static List httpRouteRules(Map route) { + List> rules = at(route, "spec", "rules"); + return rules.stream().map(rule -> { + String match = at(rule, "matches", 0, "path", "type") + " " + at(rule, "matches", 0, "path", "value"); + List> filters = at(rule, "filters"); + if (filters.isEmpty()) { + return match; + } + Map rewrite = at(filters.get(0), "urlRewrite", "path"); + String type = (String) rewrite.get("type"); + String value = (String) (type.equals("ReplaceFullPath") ? rewrite.get("replaceFullPath") : rewrite.get("replacePrefixMatch")); + return match + " -> " + type + " " + value; + }).toList(); + } + + /** + * @return the paths and notPaths of each rule of an AuthorizationPolicy + */ + private static List>> policyRules(Map policy) { + List> rules = at(policy, "spec", "rules"); + return rules.stream().map(rule -> { + Map operation = at(rule, "to", 0, "operation"); + List notPaths = operation.containsKey("notPaths") ? at(operation, "notPaths") : List.of(); + return List.of(at(operation, "paths"), notPaths); + }).toList(); + } + + private static final String NAME = "{{ .Values.SERVICE_NAME }}-java-annotations-"; + + private static List>> expectedOrderRule() { + return List.of(List.of(List.of(ORDER, ORDER + "/{**}"), List.of(ORDER + "/{*}/items", ORDER + "/{*}/items/{**}"))); + } + + private static List> expectedInternalApiRule() { + return List.of(List.of(RESOURCE + "/{*}/internal-api", RESOURCE + "/{*}/internal-api/{**}"), + List.of(RESOURCE + "/{*}/internal-api/status", RESOURCE + "/{*}/internal-api/status/{**}")); + } + + // 7.1 + + @Test + void migrationDocumentExampleWithForbiddenRoutes() throws Exception { + String yaml = generate("forbidden", false); + List> documents = documents(yaml); + + assertEquals(List.of("HTTPRoute", "AuthorizationPolicy", "AuthorizationPolicy", "AuthorizationPolicy"), + documents.stream().map(d -> d.get("kind")).toList()); + assertFalse(yaml.contains("RegularExpression") || yaml.contains("VirtualService") || yaml.contains("EnvoyFilter"), yaml); + + Map> byName = byName(documents); + assertEquals(List.of( + "PathPrefix " + RESOURCE + " -> ReplacePrefixMatch /resource", + "PathPrefix " + ORDER + " -> ReplacePrefixMatch /order"), + httpRouteRules(byName.get(NAME + "public"))); + List>> publicRules = new ArrayList<>(expectedOrderRule()); + publicRules.add(expectedInternalApiRule()); + assertEquals(publicRules, policyRules(byName.get(NAME + "deny-public"))); + assertEquals(publicRules, policyRules(byName.get(NAME + "deny-private"))); + assertEquals(expectedOrderRule(), policyRules(byName.get(NAME + "deny-internal"))); + } + + @Test + void migrationDocumentExampleWithoutForbiddenRoutesFails() throws Exception { + MojoFailureException e = assertThrows(MojoFailureException.class, () -> generate("unforbidden", false)); + + assertEquals("Route migration validation failed with 5 errors (5 EXPOSURE), " + + "see the [ROUTE-MIGRATION] errors in the build log", e.getMessage()); + assertFalse(Files.exists(baseDir.resolve(OUTPUT_FILE))); + } + + // 7.2 + + @Test + void automaticPoliciesMatchExplicitOnes() throws Exception { + Map> explicit = byName(documents(generate("forbidden", false))); + Map> automatic = byName(documents(generate("unforbidden", true))); + + assertEquals(explicit.keySet(), automatic.keySet()); + for (String gateway : List.of("public", "private", "internal")) { + assertEquals(policyRules(explicit.get(NAME + "deny-" + gateway)), policyRules(automatic.get(NAME + "deny-" + gateway)), gateway); + } + assertEquals(httpRouteRules(explicit.get(NAME + "public")), httpRouteRules(automatic.get(NAME + "public"))); + } + + // 7.3 + + @Test + void facadeAndCompositeRoutesGoToServiceBoundRoute() throws Exception { + String yaml = generate("facade", false); + Map> byName = byName(documents(yaml)); + + assertEquals(List.of(NAME + "public", NAME + "facade"), List.copyOf(byName.keySet())); + Map facade = byName.get(NAME + "facade"); + assertEquals(List.of(Map.of("group", "", "kind", "Service", "name", "{{ .Values.SERVICE_NAME }}")), + at(facade, "spec", "parentRefs")); + assertEquals(List.of( + "PathPrefix /api/v1/svc/shared -> ReplacePrefixMatch /shared", + "PathPrefix /facade/items -> ReplacePrefixMatch /items", + "PathPrefix /orders"), + httpRouteRules(facade)); + assertEquals(List.of("PathPrefix /api/v1/svc/shared -> ReplacePrefixMatch /shared"), httpRouteRules(byName.get(NAME + "public"))); + assertFalse(yaml.contains("composite-gw") || yaml.contains("example.com"), yaml); + assertFalse(yaml.contains("AuthorizationPolicy"), yaml); + } + + @Test + void noServiceBoundRouteWithoutRewrite() throws Exception { + String yaml = generate("facadeidentity", false); + Map> byName = byName(documents(yaml)); + + assertEquals(List.of(NAME + "public"), List.copyOf(byName.keySet())); + assertEquals(List.of("PathPrefix /shared"), httpRouteRules(byName.get(NAME + "public"))); + assertFalse(yaml.contains("composite-gw") || yaml.contains("example.com"), yaml); + } + + @Test + void generatedFileIsByteIdenticalOnRerun() throws Exception { + assertEquals(generate("forbidden", false), generate("forbidden", false)); + assertTrue(generate("facade", false).contains(NAME + "facade")); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/RouteScannerDeclarationsTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/RouteScannerDeclarationsTest.java new file mode 100644 index 0000000000..fa162fc797 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/RouteScannerDeclarationsTest.java @@ -0,0 +1,205 @@ +package com.netcracker.routes.gateway.plugin; + +import com.netcracker.routes.gateway.plugin.scan.ScanControllers; +import io.github.classgraph.ClassGraph; +import io.github.classgraph.ClassInfo; +import io.github.classgraph.ScanResult; +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; + +import static com.netcracker.routes.gateway.plugin.HttpRoute.Type.FACADE; +import static com.netcracker.routes.gateway.plugin.HttpRoute.Type.INTERNAL; +import static com.netcracker.routes.gateway.plugin.HttpRoute.Type.PRIVATE; +import static com.netcracker.routes.gateway.plugin.HttpRoute.Type.PUBLIC; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RouteScannerDeclarationsTest { + + private final RouteScanner scanner = new RouteScanner(new String[]{"com.netcracker"}, new SystemStreamLog()); + + static RouteDeclarations scan(RouteScanner scanner, Class... classes) { + String[] names = Arrays.stream(classes).map(Class::getName).toArray(String[]::new); + try (ScanResult scan = new ClassGraph().enableAllInfo().acceptClasses(names).scan()) { + List infos = Arrays.stream(names).map(scan::getClassInfo).toList(); + return scanner.declarationsOf(infos); + } + } + + private RouteDeclarations scan(Class... classes) { + return scan(scanner, classes); + } + + private static Set routes(RouteDeclarations declarations) { + return declarations.routes().stream().map(DeclaredRoute::route).collect(Collectors.toSet()); + } + + private static String origin(Class type, String method) { + return type.getName() + "#" + method; + } + + // 2.2 + + @Test + void classLevelForbiddenWithoutRoute() { + RouteDeclarations declarations = scan(ScanControllers.ForbiddenClassWithoutRoute.class); + + assertEquals(List.of(new ForbiddenDeclaration("/api/v1/svc/order", + EnumSet.allOf(Gateway.class), ScanControllers.ForbiddenClassWithoutRoute.class.getName())), + declarations.forbidden()); + assertEquals(Set.of(new HttpRoute("/api/v1/svc/order/{id}/items", PUBLIC, 0)), routes(declarations)); + assertEquals(List.of(), declarations.findings()); + } + + @Test + void methodLevelForbiddenUsesGatewayPath() { + RouteDeclarations declarations = scan(ScanControllers.ForbiddenWithGatewayMapping.class); + + assertEquals(List.of(new ForbiddenDeclaration("/api/x/{id}/secret", EnumSet.of(Gateway.PUBLIC), + origin(ScanControllers.ForbiddenWithGatewayMapping.class, "secret"))), + declarations.forbidden()); + assertEquals(Set.of(new HttpRoute("/secret/{id}", "/api/x/{id}/secret", INTERNAL, 0)), routes(declarations)); + } + + @Test + void sameRouteFromSeveralElementsKeepsAllOrigins() { + RouteDeclarations declarations = scan(ScanControllers.SharedChild.class, ScanControllers.SharedBase.class); + + assertEquals(1, declarations.routes().size()); + DeclaredRoute route = declarations.routes().get(0); + assertEquals(new HttpRoute("/shared", PUBLIC, 0), route.route()); + assertEquals(new TreeSet<>(List.of(ScanControllers.SharedBase.class.getName(), ScanControllers.SharedChild.class.getName())), + route.origins()); + } + + @Test + void declaredRoutesAreSortedBySpecificity() { + RouteDeclarations declarations = scan(ScanControllers.MethodListReplacesClassList.class, + ScanControllers.ForbiddenClassWithoutRoute.class); + + assertEquals(List.of("/api/v1/svc/order/{id}/items", "/items/{id}", "/items"), + declarations.routes().stream().map(r -> r.route().gatewayPath()).toList()); + } + + @Test + void elementGatewayPathsRecordClassesAndMethods() { + RouteDeclarations declarations = scan(ScanControllers.ForbiddenClassWithoutRoute.class); + + assertEquals(Set.of(ScanControllers.ForbiddenClassWithoutRoute.class.getName()), + declarations.elementGatewayPaths().get("/api/v1/svc/order")); + assertEquals(Set.of(origin(ScanControllers.ForbiddenClassWithoutRoute.class, "items")), + declarations.elementGatewayPaths().get("/api/v1/svc/order/{id}/items")); + } + + // 2.3 + + private void assertInvalidForbidden(Class controller, String method, String problemPart) { + RouteDeclarations declarations = scan(controller); + + assertEquals(List.of(), declarations.forbidden()); + assertEquals(1, declarations.findings().size(), declarations.findings().toString()); + Finding finding = declarations.findings().get(0); + assertEquals(Finding.Kind.INVALID_FORBIDDEN_ROUTE, finding.kind()); + assertTrue(finding.isError()); + assertEquals(origin(controller, method), finding.detail("element").orElseThrow()); + assertTrue(finding.detail("problem").orElseThrow().contains(problemPart), finding.toString()); + } + + @Test + void forbiddenWithEmptyValue() { + assertInvalidForbidden(ScanControllers.ForbiddenEmpty.class, "empty", "empty value"); + } + + @Test + void forbiddenWithFacade() { + assertInvalidForbidden(ScanControllers.ForbiddenFacade.class, "facade", + "@ForbiddenRoute supports only PUBLIC, PRIVATE and INTERNAL"); + } + + @Test + void forbiddenWithPartialSegmentVariable() { + assertInvalidForbidden(ScanControllers.ForbiddenPartialSegment.class, "file", "forbidden paths need whole-segment variables"); + } + + @Test + void forbiddenWithWildcard() { + assertInvalidForbidden(ScanControllers.ForbiddenWildcard.class, "files", "* wildcards"); + } + + @Test + void forbiddenWithRegexConstrainedVariable() { + assertInvalidForbidden(ScanControllers.ForbiddenRegexVariable.class, "item", "regex-constrained variables"); + } + + // 2.4 + + @Test + void twoRouteAnnotationsOnOneMethod() { + assertEquals(Set.of(new HttpRoute("/items", PUBLIC, 0), new HttpRoute("/items", FACADE, 0)), + routes(scan(ScanControllers.TwoRoutes.class))); + } + + @Test + void borderGatewayNameInGateways() { + assertEquals(Set.of(new HttpRoute("/items", PRIVATE, 0)), routes(scan(ScanControllers.BorderGatewayName.class))); + } + + @Test + void anyOtherGatewayNameIsComposite() { + assertEquals(Set.of(new HttpRoute("/items", "/api/items", FACADE, 0)), + routes(scan(ScanControllers.CompositeGatewayName.class))); + } + + @Test + void facadeRouteIgnoresBorderGatewayPath() { + assertEquals(Set.of(new HttpRoute("/items", "/items", FACADE, 0)), + routes(scan(ScanControllers.FacadeWithBorderMapping.class))); + } + + @Test + void facadeRouteUsesFacadeGatewayPath() { + assertEquals(Set.of(new HttpRoute("/items", "/facade/items", FACADE, 0)), + routes(scan(ScanControllers.FacadeRouteWithFacadeMapping.class))); + } + + @Test + void methodLevelListReplacesClassLevelList() { + assertEquals(Set.of(new HttpRoute("/items", PUBLIC, 0), new HttpRoute("/items/{id}", INTERNAL, 0)), + routes(scan(ScanControllers.MethodListReplacesClassList.class))); + } + + @Test + void gatewaysWithOnlyEmptyStringIsEmpty() { + assertEquals(Set.of(new HttpRoute("/items", PUBLIC, 0)), routes(scan(ScanControllers.EmptyGatewaysString.class))); + } + + // 2.5 + + @Test + void hostsOnBorderGatewayIsLegacyInvalid() { + RouteDeclarations declarations = scan(ScanControllers.HostsOnBorderGateway.class); + + assertEquals(1, declarations.findings().size()); + Finding finding = declarations.findings().get(0); + assertEquals(Finding.Kind.LEGACY_INVALID, finding.kind()); + assertTrue(finding.isError()); + assertEquals(origin(ScanControllers.HostsOnBorderGateway.class, "items"), finding.detail("element").orElseThrow()); + assertTrue(finding.detail("problem").orElseThrow().contains("Only composite gateway can have hosts")); + assertEquals(Set.of(new HttpRoute("/items", PUBLIC, 0)), routes(declarations)); + } + + @Test + void hostsOnCompositeGatewayIsValid() { + RouteDeclarations declarations = scan(ScanControllers.HostsOnCompositeGateway.class); + + assertEquals(List.of(), declarations.findings()); + assertEquals(Set.of(new HttpRoute("/items", FACADE, 0)), routes(declarations)); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/facade/FacadeController.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/facade/FacadeController.java new file mode 100644 index 0000000000..480e79e584 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/facade/FacadeController.java @@ -0,0 +1,34 @@ +package com.netcracker.routes.gateway.plugin.e2e.facade; + +import com.netcracker.cloud.routesregistration.common.annotation.FacadeRoute; +import com.netcracker.cloud.routesregistration.common.annotation.Route; +import com.netcracker.cloud.routesregistration.common.gateway.route.RouteType; +import com.netcracker.cloud.routesregistration.common.spring.gateway.route.annotation.FacadeGatewayRequestMapping; +import com.netcracker.cloud.routesregistration.common.spring.gateway.route.annotation.GatewayRequestMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Facade and composite routes, one of them with a rewrite. + */ +@RestController +public class FacadeController { + + @GetMapping("/items") + @FacadeGatewayRequestMapping("/facade/items") + @FacadeRoute + public void items() { + } + + @GetMapping("/orders") + @Route(RouteType.FACADE) + public void orders() { + } + + @GetMapping("/shared") + @GatewayRequestMapping("/api/v1/svc/shared") + @Route(RouteType.PUBLIC) + @Route(gateways = "composite-gw", hosts = "example.com") + public void shared() { + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/facadeidentity/FacadeController.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/facadeidentity/FacadeController.java new file mode 100644 index 0000000000..960e204055 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/facadeidentity/FacadeController.java @@ -0,0 +1,30 @@ +package com.netcracker.routes.gateway.plugin.e2e.facadeidentity; + +import com.netcracker.cloud.routesregistration.common.annotation.FacadeRoute; +import com.netcracker.cloud.routesregistration.common.annotation.Route; +import com.netcracker.cloud.routesregistration.common.gateway.route.RouteType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * The facade and composite routes of {@code e2e.facade} without any rewrite. + */ +@RestController +public class FacadeController { + + @GetMapping("/items") + @FacadeRoute + public void items() { + } + + @GetMapping("/orders") + @Route(RouteType.FACADE) + public void orders() { + } + + @GetMapping("/shared") + @Route(RouteType.PUBLIC) + @Route(gateways = "composite-gw", hosts = "example.com") + public void shared() { + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/forbidden/OrderController.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/forbidden/OrderController.java new file mode 100644 index 0000000000..464288ad92 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/forbidden/OrderController.java @@ -0,0 +1,25 @@ +package com.netcracker.routes.gateway.plugin.e2e.forbidden; + +import com.netcracker.cloud.routesregistration.common.annotation.ForbiddenRoute; +import com.netcracker.cloud.routesregistration.common.annotation.Route; +import com.netcracker.cloud.routesregistration.common.gateway.route.RouteType; +import com.netcracker.cloud.routesregistration.common.spring.gateway.route.annotation.GatewayRequestMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * The order controller of the example in regex-routes-migration.md. + */ +@RestController +@RequestMapping("/order") +@GatewayRequestMapping("/api/v1/my-service/order") +@ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE, RouteType.INTERNAL}) +public class OrderController { + + @GetMapping("/{var1}/items") + @GatewayRequestMapping("/{var1}/items") + @Route(RouteType.PUBLIC) + public void items() { + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/forbidden/ResourceController.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/forbidden/ResourceController.java new file mode 100644 index 0000000000..fc295affd5 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/forbidden/ResourceController.java @@ -0,0 +1,38 @@ +package com.netcracker.routes.gateway.plugin.e2e.forbidden; + +import com.netcracker.cloud.routesregistration.common.annotation.ForbiddenRoute; +import com.netcracker.cloud.routesregistration.common.annotation.Route; +import com.netcracker.cloud.routesregistration.common.gateway.route.RouteType; +import com.netcracker.cloud.routesregistration.common.spring.gateway.route.annotation.GatewayRequestMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * The resource controller of the example in regex-routes-migration.md. + */ +@RestController +@RequestMapping("/resource") +@GatewayRequestMapping("/api/v1/my-service/resource") +@Route(RouteType.PUBLIC) +public class ResourceController { + + @GetMapping("/{var1}") + @GatewayRequestMapping("/{var1}") + @Route(RouteType.PUBLIC) + public void get() { + } + + @GetMapping("/{var1}/internal-api") + @GatewayRequestMapping("/{var1}/internal-api") + @Route(RouteType.INTERNAL) + @ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE}) + public void internalApi() { + } + + @GetMapping("/{var1}/internal-api/status") + @GatewayRequestMapping("/{var1}/internal-api/status") + @Route(RouteType.PUBLIC) + public void status() { + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/unforbidden/OrderController.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/unforbidden/OrderController.java new file mode 100644 index 0000000000..f1d0b7314f --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/unforbidden/OrderController.java @@ -0,0 +1,23 @@ +package com.netcracker.routes.gateway.plugin.e2e.unforbidden; + +import com.netcracker.cloud.routesregistration.common.annotation.Route; +import com.netcracker.cloud.routesregistration.common.gateway.route.RouteType; +import com.netcracker.cloud.routesregistration.common.spring.gateway.route.annotation.GatewayRequestMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * The order controller of the example in regex-routes-migration.md. + */ +@RestController +@RequestMapping("/order") +@GatewayRequestMapping("/api/v1/my-service/order") +public class OrderController { + + @GetMapping("/{var1}/items") + @GatewayRequestMapping("/{var1}/items") + @Route(RouteType.PUBLIC) + public void items() { + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/unforbidden/ResourceController.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/unforbidden/ResourceController.java new file mode 100644 index 0000000000..73a237863a --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/e2e/unforbidden/ResourceController.java @@ -0,0 +1,36 @@ +package com.netcracker.routes.gateway.plugin.e2e.unforbidden; + +import com.netcracker.cloud.routesregistration.common.annotation.Route; +import com.netcracker.cloud.routesregistration.common.gateway.route.RouteType; +import com.netcracker.cloud.routesregistration.common.spring.gateway.route.annotation.GatewayRequestMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * The resource controller of the example in regex-routes-migration.md. + */ +@RestController +@RequestMapping("/resource") +@GatewayRequestMapping("/api/v1/my-service/resource") +@Route(RouteType.PUBLIC) +public class ResourceController { + + @GetMapping("/{var1}") + @GatewayRequestMapping("/{var1}") + @Route(RouteType.PUBLIC) + public void get() { + } + + @GetMapping("/{var1}/internal-api") + @GatewayRequestMapping("/{var1}/internal-api") + @Route(RouteType.INTERNAL) + public void internalApi() { + } + + @GetMapping("/{var1}/internal-api/status") + @GatewayRequestMapping("/{var1}/internal-api/status") + @Route(RouteType.PUBLIC) + public void status() { + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/scan/ScanControllers.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/scan/ScanControllers.java new file mode 100644 index 0000000000..6830dade94 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/scan/ScanControllers.java @@ -0,0 +1,179 @@ +package com.netcracker.routes.gateway.plugin.scan; + +import com.netcracker.cloud.routesregistration.common.annotation.FacadeRoute; +import com.netcracker.cloud.routesregistration.common.annotation.ForbiddenRoute; +import com.netcracker.cloud.routesregistration.common.annotation.Route; +import com.netcracker.cloud.routesregistration.common.gateway.route.RouteType; +import com.netcracker.cloud.routesregistration.common.spring.gateway.route.annotation.FacadeGatewayRequestMapping; +import com.netcracker.cloud.routesregistration.common.spring.gateway.route.annotation.GatewayRequestMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Controllers for scanner tests of route declarations, {@code @ForbiddenRoute} and legacy annotation forms. + */ +public final class ScanControllers { + + private ScanControllers() { + } + + @RestController + @RequestMapping("/api/v1/svc/order") + @ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE, RouteType.INTERNAL}) + public static class ForbiddenClassWithoutRoute { + @GetMapping("/{id}/items") + @Route(RouteType.PUBLIC) + public void items() { + } + } + + @RestController + public static class ForbiddenWithGatewayMapping { + @GetMapping("/secret/{id}") + @GatewayRequestMapping("/api/x/{id}/secret") + @Route(RouteType.INTERNAL) + @ForbiddenRoute(RouteType.PUBLIC) + public void secret() { + } + } + + @RestController + @RequestMapping("/shared") + @Route(RouteType.PUBLIC) + public static class SharedBase { + } + + @RestController + @RequestMapping("/shared") + @Route(RouteType.PUBLIC) + public static class SharedChild extends SharedBase { + } + + @RestController + public static class ForbiddenEmpty { + @GetMapping("/empty") + @ForbiddenRoute({}) + public void empty() { + } + } + + @RestController + public static class ForbiddenFacade { + @GetMapping("/facade") + @ForbiddenRoute(RouteType.FACADE) + public void facade() { + } + } + + @RestController + public static class ForbiddenPartialSegment { + @GetMapping("/files/{name}.txt") + @ForbiddenRoute(RouteType.PUBLIC) + public void file() { + } + } + + @RestController + public static class ForbiddenWildcard { + @GetMapping("/files/*") + @ForbiddenRoute(RouteType.PUBLIC) + public void files() { + } + } + + @RestController + public static class ForbiddenRegexVariable { + @GetMapping("/items/{id:\\d+}") + @ForbiddenRoute(RouteType.PUBLIC) + public void item() { + } + } + + @RestController + public static class TwoRoutes { + @GetMapping("/items") + @Route(RouteType.PUBLIC) + @Route(gateways = "composite-gw") + public void items() { + } + } + + @RestController + public static class BorderGatewayName { + @GetMapping("/items") + @Route(gateways = "private-gateway-service") + public void items() { + } + } + + @RestController + public static class CompositeGatewayName { + @GetMapping("/items") + @GatewayRequestMapping("/api/items") + @Route(gateways = "my-service") + public void items() { + } + } + + @RestController + public static class FacadeWithBorderMapping { + @GetMapping("/items") + @GatewayRequestMapping("/api/items") + @Route(RouteType.FACADE) + public void items() { + } + } + + @RestController + public static class FacadeRouteWithFacadeMapping { + @GetMapping("/items") + @FacadeGatewayRequestMapping("/facade/items") + @FacadeRoute + public void items() { + } + } + + @RestController + @RequestMapping("/items") + @Route(RouteType.PUBLIC) + public static class MethodListReplacesClassList { + @GetMapping("/{id}") + @Route(RouteType.INTERNAL) + public void item() { + } + } + + @RestController + public static class EmptyGatewaysString { + @GetMapping("/items") + @Route(value = RouteType.PUBLIC, gateways = {""}) + public void items() { + } + } + + @RestController + public static class HostsOnBorderGateway { + @GetMapping("/items") + @Route(gateways = "public-gateway-service", hosts = "example.com") + public void items() { + } + } + + @RestController + public static class HostsOnCompositeGateway { + @GetMapping("/items") + @Route(gateways = "composite-gw", hosts = "example.com") + public void items() { + } + } + + @RestController + public static class CompositeWithGatewayPath { + @GetMapping("/items") + @Route(gateways = "composite-gw", hosts = "example.com") + @GatewayRequestMapping("/composite/items") + public void items() { + } + } +} diff --git a/core-rest-libraries/route-registration/README.md b/core-rest-libraries/route-registration/README.md index 064838214e..aefad9f963 100644 --- a/core-rest-libraries/route-registration/README.md +++ b/core-rest-libraries/route-registration/README.md @@ -310,3 +310,31 @@ public class IngressRoutesController { There are several restrictions on using custom hosts: * Don't use mixed facade and border gateways in one annotation if you used custom hosts. Split it into two annotations. * If you have several facade routes and use hosts field in the @Route annotation, make sure all your routes use this field or have you set the default mapping in application.yml/application.properties + +###### 9. Forbidding gateway paths for the Istio HTTPRoute generator (optional) + +`@ForbiddenRoute` (`com.netcracker.cloud.routesregistration.common.annotation.ForbiddenRoute`) is used **only** by the +[httproutes-generator-maven-plugin](../../core-maven-plugins/httproutes-generator-maven-plugin/README.md), which turns it into +Istio `AuthorizationPolicy` DENY rules. The legacy runtime route registration ignores this annotation: the routes it registers +are the same with or without it. + +The annotation marks the gateway path of a class or method as forbidden on the listed border gateways. Each value names one +gateway and does not imply the wider ones: `PUBLIC` is the public gateway, `PRIVATE` is the private gateway and `INTERNAL` is +the internal gateway. `FACADE` is not supported. The gateway path is resolved the same way as for `@Route` (request mappings, +`@Gateway` and `@GatewayRequestMapping`). A class-level `@ForbiddenRoute` forbids only the class-level gateway path, and it +does not need a `@Route` on the same element. + +```java +@RestController +@RequestMapping("/api/v1/svc/resource") +@Route(RouteType.PUBLIC) +public class ResourceController { + + @GetMapping("/{id}/internal-api") + @Route(RouteType.INTERNAL) + @ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE}) + public ResponseEntity internalApi(@PathVariable String id) { + //... + } +} +``` diff --git a/core-rest-libraries/route-registration/route-registration-common/src/main/java/com/netcracker/cloud/routesregistration/common/annotation/ForbiddenRoute.java b/core-rest-libraries/route-registration/route-registration-common/src/main/java/com/netcracker/cloud/routesregistration/common/annotation/ForbiddenRoute.java new file mode 100644 index 0000000000..858375168b --- /dev/null +++ b/core-rest-libraries/route-registration/route-registration-common/src/main/java/com/netcracker/cloud/routesregistration/common/annotation/ForbiddenRoute.java @@ -0,0 +1,32 @@ +package com.netcracker.cloud.routesregistration.common.annotation; + +import com.netcracker.cloud.routesregistration.common.gateway.route.RouteType; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for Class or Method to mark out its gateway path as forbidden on the listed border gateways. + *

+ * Used only by the Istio HTTPRoute generator maven plugin, which turns it into an Istio {@code AuthorizationPolicy} + * DENY rule. Legacy runtime route registration ignores this annotation. + *

+ * A class-level annotation forbids only the class-level gateway path(s), not each method path separately. + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ForbiddenRoute { + + /** + * Border gateways on which the gateway path is forbidden. Each value names a single gateway and does not imply + * the wider gateways: {@link RouteType#PUBLIC} is the public gateway, {@link RouteType#PRIVATE} is the private + * gateway and {@link RouteType#INTERNAL} is the internal gateway. {@link RouteType#FACADE} is not supported. + * + * @return border gateways on which the gateway path is forbidden + */ + RouteType[] value(); +} diff --git a/openspec/changes/regex-routes-prefix-migration/design.md b/openspec/changes/regex-routes-prefix-migration/design.md index 9b99d7b393..42bd9b85f8 100644 --- a/openspec/changes/regex-routes-prefix-migration/design.md +++ b/openspec/changes/regex-routes-prefix-migration/design.md @@ -80,7 +80,7 @@ For partial-segment variables (`{name}.txt`), only the variable part of the segm - Winner: the longest `pattern.length()` (source text, including `{name}`). - An allowed entry and a forbidden entry with the same pattern collapse to the allowed one (legacy `RouteTransformer`). The exception is an explicit `@ForbiddenRoute` on an allowed route's exact pattern, which is a generation-time error (spec: Contradictory declarations). - Ties in length between different patterns that match the same request: legacy picks one in an undefined order. If the tied entries agree on allowed/forbidden and on the upstream path, the decision is that one, with the largest of their timeouts (no finding). Otherwise the decision is *undefined*, and the validator reports one `LEGACY_PRECEDENCE_UNDEFINED` ERROR per (gateway, tied patterns), naming the entries and their origins, with one example request. It reports no other finding for probes whose legacy decision is undefined, so the same problem isn't also reported as an Exposure, Conflict or Lost route. -- Two **allowed** entries on the same gateway with the same pattern string and different service paths are invalid in legacy too: `RouteTransformer.validateAndReturnRoutes` throws "several target paths for forwarding from the same source path". The planner reports one `LEGACY_INVALID` ERROR per (gateway, pattern), naming both routes and their origins. The validator skips Conflict findings caused by that pattern, so the same problem isn't reported twice or blamed on the migration. Same pattern and same service path with different timeouts is valid in legacy (the first registered route wins, in an undefined order), so it goes through the D7 max-timeout merge. +- Two **allowed** entries on the same gateway with the same pattern string and different service paths are invalid in legacy too: `RouteTransformer.validateAndReturnRoutes` throws "several target paths for forwarding from the same source path". The planner reports one `LEGACY_INVALID` ERROR per (gateway, pattern), naming both routes and their origins. Conflict and `LEGACY_PRECEDENCE_UNDEFINED` findings carry the gateway paths they involve. `RouteMigration` drops, in one filter step after planning and validation, every such finding that involves a legacy-invalid gateway path (on any gateway), so the same problem isn't reported twice or blamed on the migration. Once the legacy-invalid path is fixed, the next build reports whatever problems remain for it. Same pattern and same service path with different timeouts is valid in legacy (the first registered route wins, in an undefined order), so it goes through the D7 max-timeout merge. - Upstream path: reproduce the legacy regex rewrite. Captured variable values replace the variables of the service path by position, and the rest of the request path is appended unchanged. ### D5. Istio decision semantics @@ -92,7 +92,7 @@ Upstream path: - No filter: unchanged. ### D6. Cut and rewrite derivation -`cut(path)` = the part of `path` before the segment containing the first `{`, stripped of its trailing slash, or `/` if that is empty. A path without variables is unchanged. `match = cut(gatewayPath)`. If the gateway path and service path differ, `rewrite = cut(servicePath)`. +`cut(path)` = the part of `path` before the segment containing the first `{`, stripped of its trailing slash, or `/` if that is empty. A path without variables only loses its trailing slash (`/api/items/` → `/api/items`), because legacy matches `/x/` and `/x` the same way and Gateway API ignores a trailing slash in `PathPrefix`. Without this, `@GetMapping("/")` and `@GetMapping("/{id}")` in a controller mapped to `/api/items` would give two `PathPrefix` rules that match the same requests, and Istio would pick one in an undefined order. Istio path templates in DENY rules (D8) drop the trailing slash too. `match = cut(gatewayPath)`. If the gateway path and service path differ, `rewrite = cut(servicePath)`. Example: `/api/v1/{id}/items` → `/items/{id}/items` gives match `/api/v1` and rewrite `/items`. @@ -101,16 +101,16 @@ This is correct only when the part after the cut is the same in both paths (vari *Alternative*: compare the remainders and fail the build when they differ. Rejected per user decision: the case doesn't occur in practice. ### D7. Grouping, merging and the Exact split -Planned rules are keyed by `(Gateway, MatchType, value)`. For every `PathPrefix` value `P` that more than one route produces on some gateway: +Routes are grouped by their cut value `P` across all border route types (the service-bound HTTPRoute of D14 groups its routes separately). Grouping across types instead of per gateway is simpler and gives the same result: exposure sets are nested chains, so every gateway that has two routes of a group has them in the same group, and the merged rule goes into the widest type's resource (see below). For every `P` that more than one route produces: 1. If all routes have the same rewrite and timeout, emit one rule. -2. Else, if exactly one route `S` has `gatewayPath == P`, and at least one other route has `gatewayPath == P/{var}`, and all routes other than `S` share one rewrite: apply the Exact split to `S`. It becomes two `Exact` rules with `ReplaceFullPath`, keeping S's timeout. The rest are merged into the `PathPrefix` rule, and if their timeouts differ, the max is used with a warning. +2. Else, if exactly one route `S` has `gatewayPath == P` or `gatewayPath == P/` (Spring `@GetMapping("/")`), and at least one other route has `gatewayPath == P/{var}`, and all routes other than `S` share one rewrite: apply the Exact split to `S`. It becomes two `Exact` rules with `ReplaceFullPath`, keeping S's timeout. The rest are merged into the `PathPrefix` rule, and if their timeouts differ, the max is used with a warning. 3. Else, if all routes have the same rewrite: merge them with the max timeout and log a WARNING. 4. Otherwise, report a CONFLICT finding and emit nothing for `P`. The build fails anyway. The Exact split is the same-service form of the document's limitation 2. It keys only on rewrite and timeout, never on backend or headers. It is safe on every gateway where S appears: in legacy, the `P/{var}` entry exists on every border gateway (allowed or forbidden), and because it is longer it takes every request below `P/`. So S only ever received `P` and `P/`. -A merged rule goes into the widest type's HTTPRoute resource. This works because exposure sets are nested chains (INTERNAL ⊂ PRIVATE ⊂ PUBLIC), so the union of gateways equals the widest type's parentRefs. This removes identical matches spread across several HTTPRoute resources, where Istio would pick one by creation time. +A merged rule goes into the widest type's HTTPRoute resource. This works because exposure sets are nested chains (INTERNAL ⊂ PRIVATE ⊂ PUBLIC), so the union of gateways equals the widest type's parentRefs. This removes identical matches spread across several HTTPRoute resources, where Istio would pick one by creation time. On the wider gateways, the narrower routes of the group are then routed where legacy forbade them; validation reports that as Exposure unless DENY rules (D8, D12) cover it. *Alternative for timeout-only differences*: always fail. Rejected per user decision (merge with the max, warn). @@ -119,7 +119,8 @@ The Exact split is kept for same-service routes (user decision). It is the only ### D8. DENY rule construction For each `ForbiddenDeclaration` × gateway: - `paths = [F', F' + "/{**}"]`, where `F'` replaces each `{...}` with `{*}` (`/` → `["/", "/{**}"]`). -- `notPaths` = for every legacy allowed entry Q on that gateway with `len(Q) > len(F)` and `matches(F-pattern, Q-instantiated-with-SAMPLE)`: add `[Q', Q' + "/{**}"]`. +- `notPaths` = for every legacy allowed entry Q on that gateway with `len(Q) > len(F)` and `overlaps(F, Q)`: add `[Q', Q' + "/{**}"]`. +- `overlaps(F, Q)` compares the segments up to the shorter of the two patterns: two variables always overlap, a variable and a literal overlap if the variable's segment pattern matches the literal, and two literals must be equal. It doesn't instantiate Q with a sample, because a sample misses a literal of F in the same segment as a variable of Q: forbidden `/a/lit/x` with allowed `/a/{id}/x/y` needs `/a/{*}/x/y` in `notPaths`, or the DENY rule takes `/a/lit/x/y`, which legacy routes. Where Q' is wider than what legacy routed through Q under F (Q' also excludes `/a/other/x/y`), the excluded requests are outside F's `paths` anyway, so nothing extra is let through. There is one rule per forbidden path, not one rule with all paths merged. `notPaths` belongs to a specific forbidden path, and Istio ORs rules within a policy. Nested forbidden paths therefore work without extra handling. @@ -159,13 +160,19 @@ Findings are grouped by gateway, then kind, then sorted. They are logged at ERRO fix: add @ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE}) to com.acme.ResourceController#internalApi ``` -For exposures caused by the cut, where legacy has no entry, the fix suggests `@ForbiddenRoute()` on the class whose mapping equals the cut prefix. If no such class exists, it suggests a method or class mapped to that prefix. While `autoGenerateAuthorizationPolicies` is `false`, every Exposure fix also offers the alternative `or set true`. The exception message summarizes the counts and points to the log. +For exposures caused by the cut, where legacy has no entry, the fix suggests `@ForbiddenRoute()` on the class whose mapping equals the cut prefix. If no such class exists, it suggests a method or class mapped to that prefix. While `autoGenerateAuthorizationPolicies` is `false`, every Exposure fix that suggests a `@ForbiddenRoute` also offers the alternative `or set true`. + +Some exposures can't be fixed with a DENY rule: an implicit forbidden route with a partial-segment variable (`/api/files/{name}.txt`) can't be written as an Istio path template, `@ForbiddenRoute` rejects it (spec: forbidden paths need whole-segment variables) and D12 generates no rule for it. For those, the fix asks to change the gateway path of the named route and says why neither option works. It doesn't offer `autoGenerateAuthorizationPolicies`, which wouldn't help. + +A Lost route caused by an automatic DENY rule (D12) says the rule was generated automatically and asks to change the gateway path of the route or of the routes the rule comes from. It doesn't ask to narrow a `@ForbiddenRoute` declaration, because the user never wrote one. + +The exception message summarizes the counts and points to the log. ### D12. Opt-in automatic DENY rules (`autoGenerateAuthorizationPolicies`) When the parameter is `true`, `IstioRoutePlanner` adds automatic `DenyRule`s after the HTTPRoute rules are planned and before validation. It evaluates the Istio model *without* any DENY rules (routing only), so the result doesn't depend on the order rules are added. Automatic rules are only for border gateways: -1. **Implicit forbidden routes.** For every legacy forbidden entry F that comes from a route type (not from `@ForbiddenRoute`) on gateway G: if the routing-only Istio model routes F instantiated with `SAMPLE`, or F + `/` + `SAMPLE`, add a rule for F. It is built exactly as D8 builds it, including `notPaths` from the longer allowed entries below F. Implicit entries that Istio doesn't route anyway (for example an INTERNAL route with no wider prefix above it) get no rule, which keeps policies small. -2. **Cut exposure.** For every planned `PathPrefix` value P on gateway G that has at least one source route whose gateway path contains a variable: if the legacy model routes neither P nor P + `/` + `SAMPLE` on G, add a rule with `paths = [P, P + "/{**}"]`. Its `notPaths` are `[Q', Q' + "/{**}"]` for every legacy allowed entry Q on G that lies below P and is longer than P. This is limitation 4 of the migration document: when a shorter allowed route covers P, legacy already exposed the subtree, and no rule is added. +1. **Implicit forbidden routes.** For every legacy forbidden entry F that comes from a route type (not from `@ForbiddenRoute`) on gateway G: if the routing-only Istio model routes F instantiated with `SAMPLE`, or F + `/` + `SAMPLE`, add a rule for F. It is built exactly as D8 builds it, including `notPaths` from the longer allowed entries below F. Implicit entries that Istio doesn't route anyway (for example an INTERNAL route with no wider prefix above it) get no rule, which keeps policies small. Entries with a partial-segment variable get no rule either: `{*}` stands for a whole segment, so a rule for `/api/files/{name}.txt` would deny every file below `/api/files`, including the ones legacy routes. The validator reports them as Exposure with the fix of D11. +2. **Cut exposure.** For every planned `PathPrefix` value P on gateway G that has at least one source route whose gateway path contains a variable: if the legacy model does not route P on G, add a rule with `paths = [P, P + "/{**}"]`. Its `notPaths` are `[Q', Q' + "/{**}"]` for every legacy allowed entry Q on G that lies below P and is longer than P. This is limitation 4 of the migration document: when a shorter allowed route covers P, legacy already exposed the subtree, and no rule is added. Checking P alone is enough: a shorter route that routes paths below P also routes P, and longer allowed routes below P stay reachable through `notPaths`. An allowed route below P that isn't longer than P (for example a catch-all `/{a}/{b}/{c}/{d}` from another class) isn't in `notPaths`. If legacy routes a request below P through it, the rule denies that request and the validator reports a Lost route (D11). Rules are deduplicated by (gateway, paths, notPaths). An explicit `@ForbiddenRoute` rule wins over an identical automatic one, so it keeps its origin. Automatic rules carry a synthetic origin (`auto: implicit forbidden of ` or `auto: cut exposure of

from `) for diagnostics. The contradictory-declaration check (D4) applies only to explicit declarations. Validation runs on the combined plan unchanged, so it verifies automatic rules like any other. diff --git a/openspec/changes/regex-routes-prefix-migration/specs/forbidden-route-policies/spec.md b/openspec/changes/regex-routes-prefix-migration/specs/forbidden-route-policies/spec.md index 290224812b..c5fc6be944 100644 --- a/openspec/changes/regex-routes-prefix-migration/specs/forbidden-route-policies/spec.md +++ b/openspec/changes/regex-routes-prefix-migration/specs/forbidden-route-policies/spec.md @@ -70,7 +70,8 @@ When `autoGenerateAuthorizationPolicies` is `false` (the default), the plugin SH Each forbidden path F on a gateway SHALL produce one rule in that gateway's policy: - `to[].operation.paths` contains F and F + `/{**}`, with every path variable replaced by `{*}`. -- `to[].operation.notPaths` contains Q and Q + `/{**}` (same variable replacement) for every route Q that is allowed on the same gateway in the legacy model, lies below F, and has a longer gateway path than F. These are the routes that won over F in legacy. +- `to[].operation.notPaths` contains Q and Q + `/{**}` (same variable replacement) for every route Q that is allowed on the same gateway in the legacy model, overlaps F, and has a longer gateway path than F. These are the routes that won over F in legacy. Q overlaps F when their segments agree up to the shorter of the two: a variable overlaps any segment it can match, including a literal of the other path, and two literals must be equal. +- A trailing `/` of F or Q SHALL be dropped. - `notPaths` SHALL be omitted when there are none. - `to[].operation.ports` is set from the ports that the plugin parameter `authorizationPolicyPorts` configures for that gateway. @@ -84,6 +85,14 @@ Paths within a rule SHALL be deduplicated and sorted deterministically. A forbid - **WHEN** a class mapped to `/api/v1/svc/order` has `@ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE, RouteType.INTERNAL})`, and PUBLIC method route `/api/v1/svc/order/{id}/items` is declared - **THEN** each of the three policies has a rule with `paths` `/api/v1/svc/order` and `/api/v1/svc/order/{**}`, and `notPaths` `/api/v1/svc/order/{*}/items` and `/api/v1/svc/order/{*}/items/{**}`, and validation reports no Exposure for `/api/v1/svc/order/` +#### Scenario: Longer allowed route with a variable where the forbidden path has a literal +- **WHEN** `@ForbiddenRoute(RouteType.PUBLIC)` forbids `/a/lit/x`, and PUBLIC route `/a/{id}/x/y` is declared +- **THEN** the public gateway policy has a rule with `paths` `/a/lit/x` and `/a/lit/x/{**}`, and `notPaths` `/a/{*}/x/y` and `/a/{*}/x/y/{**}`, and validation reports no Lost route for `/a/lit/x/y` + +#### Scenario: Forbidden path with a trailing slash +- **WHEN** `@ForbiddenRoute(RouteType.PUBLIC)` forbids `/a/b/` +- **THEN** the rule has `paths` `/a/b` and `/a/b/{**}` + #### Scenario: Nested forbidden paths - **WHEN** forbidden path `/a` and forbidden path `/a/b/{id}/c` exist on the same gateway, and allowed route `/a/b` is longer than `/a` and lies below it - **THEN** the policy has two separate rules: the `/a` rule lists `/a/b` and `/a/b/{**}` in `notPaths`, and the `/a/b/{*}/c` rule has its own `paths` @@ -91,10 +100,10 @@ Paths within a rule SHALL be deduplicated and sorted deterministically. A forbid ### Requirement: Automatic DENY rules The plugin SHALL accept the boolean parameter `autoGenerateAuthorizationPolicies`, set in the plugin `` in `pom.xml` and defaulting to `false`. When it is `true`, the plugin SHALL add DENY rules to the border gateway policies, besides those from `@ForbiddenRoute`: -- For every legacy forbidden entry F that comes from a route type (a narrower-type route on a wider gateway), where the generated HTTPRoutes attached to that gateway would route F: a rule for F, built as in "DENY rule content". -- For every generated `PathPrefix` value P that comes from cutting a gateway path with variables, where the legacy model does not route P or the paths below P on that gateway: a rule with `paths` P and P + `/{**}`, and `notPaths` Q and Q + `/{**}` for every allowed route Q on that gateway that lies below P and is longer than P. +- For every legacy forbidden entry F that comes from a route type (a narrower-type route on a wider gateway), where the generated HTTPRoutes attached to that gateway would route F: a rule for F, built as in "DENY rule content". F SHALL be skipped when `@ForbiddenRoute` would reject its gateway path (for example a partial-segment variable), because `{*}` would deny the whole segment; the validation step reports the exposure instead. +- For every generated `PathPrefix` value P that comes from cutting a gateway path with variables, where the legacy model does not route P itself on that gateway: a rule with `paths` P and P + `/{**}`, and `notPaths` Q and Q + `/{**}` for every allowed route Q on that gateway that lies below P and is longer than P. -Whether Istio routes a path SHALL be decided from the generated HTTPRoute rules alone, ignoring all DENY rules. Automatic rules SHALL NOT be generated for the service-bound HTTPRoute of facade and composite routes. A rule that is identical to a rule from `@ForbiddenRoute` (same gateway, `paths` and `notPaths`) SHALL be rendered once. Route migration validation SHALL run on all generated rules, automatic ones included. +Whether Istio routes a path SHALL be decided from the generated HTTPRoute rules alone, ignoring all DENY rules. Automatic rules SHALL NOT be generated for the service-bound HTTPRoute of facade and composite routes. A rule that is identical to a rule from `@ForbiddenRoute` (same gateway, `paths` and `notPaths`) SHALL be rendered once. Route migration validation SHALL run on all generated rules, automatic ones included. Whether a cut exposure rule is needed SHALL be decided on P alone, so an allowed route below P that is not longer than P (for example a catch-all from another class) is not in `notPaths`; validation reports the requests it loses as Lost route. #### Scenario: Parameter not set - **WHEN** `autoGenerateAuthorizationPolicies` is not configured, and PUBLIC route `/api/v1/svc/resource` and INTERNAL route `/api/v1/svc/resource/{id}/internal-api` are declared without `@ForbiddenRoute` @@ -112,6 +121,14 @@ Whether Istio routes a path SHALL be decided from the generated HTTPRoute rules - **WHEN** `autoGenerateAuthorizationPolicies` is `true`, and PUBLIC routes `/api/v1/svc/order` and `/api/v1/svc/order/{id}/items` are declared with the same rewrite - **THEN** no DENY rule is generated for `/api/v1/svc/order` +#### Scenario: No rule for a partial-segment variable +- **WHEN** `autoGenerateAuthorizationPolicies` is `true`, and PUBLIC route `/api/files` and PRIVATE route `/api/files/{name}.txt` are declared +- **THEN** no DENY rule is generated, and the build fails with an Exposure error on the public gateway + +#### Scenario: Automatic rule over a shorter catch-all route +- **WHEN** `autoGenerateAuthorizationPolicies` is `true`, and PUBLIC routes `/api/v1/my-service/order/{id}/items` and `/{a}/{b}/{c}/{d}/{e}` are declared +- **THEN** the cut exposure rule for `/api/v1/my-service/order` is generated, and the build fails with a Lost route error for `/api/v1/my-service/order/`, which legacy routes by the catch-all route + #### Scenario: No rule when Istio doesn't route the forbidden path - **WHEN** `autoGenerateAuthorizationPolicies` is `true`, and only INTERNAL route `/api/v1/svc/admin` is declared - **THEN** no AuthorizationPolicy is generated for the public or private gateway diff --git a/openspec/changes/regex-routes-prefix-migration/specs/httproute-generation/spec.md b/openspec/changes/regex-routes-prefix-migration/specs/httproute-generation/spec.md index 828d600fee..6e1aa40ebf 100644 --- a/openspec/changes/regex-routes-prefix-migration/specs/httproute-generation/spec.md +++ b/openspec/changes/regex-routes-prefix-migration/specs/httproute-generation/spec.md @@ -32,7 +32,7 @@ Every generated HTTPRoute rule match SHALL have path type `PathPrefix` or `Exact - **THEN** the generated rule uses a `PathPrefix` match, and the output has no `RegularExpression` match and no `MANUAL REVIEW REQUIRED` comment ### Requirement: Cut gateway path at the first path variable -When a route's gateway path contains a path variable (`{...}`), the plugin SHALL cut it just before the path segment that contains the first variable. The part before the cut, without a trailing `/`, becomes the `PathPrefix` value. If nothing remains before the cut, the match value SHALL be `/`. Gateway paths without variables SHALL be used unchanged as `PathPrefix` values. +When a route's gateway path contains a path variable (`{...}`), the plugin SHALL cut it just before the path segment that contains the first variable. The part before the cut, without a trailing `/`, becomes the `PathPrefix` value. If nothing remains before the cut, the match value SHALL be `/`. Gateway paths without variables SHALL be used as `PathPrefix` values without their trailing `/`, because legacy and Gateway API both treat `/x/` and `/x` as the same match. #### Scenario: Variable in the middle - **WHEN** a route has gateway path `/api/v1/my-service/resource/{var1}/internal-api/status` @@ -50,6 +50,10 @@ When a route's gateway path contains a path variable (`{...}`), the plugin SHALL - **WHEN** a route has gateway path `/api/v1/my-service/resource` - **THEN** the rule matches `PathPrefix` `/api/v1/my-service/resource` +#### Scenario: Path without variables with a trailing slash +- **WHEN** a class mapped to `/api/v1/svc/items` has methods mapped to `/` and `/{id}`, both PUBLIC with the same rewrite and timeout +- **THEN** the PUBLIC HTTPRoute contains exactly one rule matching `PathPrefix` `/api/v1/svc/items` + ### Requirement: Cut the prefix rewrite the same way When the gateway path and service path of a route differ, the plugin SHALL generate a `URLRewrite` filter of type `ReplacePrefixMatch`. Its value SHALL be the service path cut at its first path variable, with the same rule as the gateway path. The plugin SHALL NOT check that the parts after the cut are the same in both paths. When the gateway path and service path are equal, no rewrite filter SHALL be generated. @@ -66,7 +70,7 @@ When the gateway path and service path of a route differ, the plugin SHALL gener - **THEN** the rule matches `PathPrefix` `/items` and has no `URLRewrite` filter ### Requirement: One rule per match on a gateway -Every gateway SHALL see at most one generated rule per (path type, path value) match, counting all generated HTTPRoute resources attached to that gateway. When several routes produce the same match on the same gateway with the same rewrite, the plugin SHALL merge them into one rule. The merged rule SHALL go into the HTTPRoute resource of the widest route type among them (PUBLIC > PRIVATE > INTERNAL). If the merged routes have different timeouts and the Exact split does not apply, the merged rule SHALL use the largest timeout, and the plugin SHALL log a warning. The warning names the match, the gateways, the routes merged with their sources, and the timeout that was chosen. +Every gateway SHALL see at most one generated rule per (path type, path value) match, counting all generated HTTPRoute resources attached to that gateway. The plugin SHALL group border routes by match across all route types. When several routes produce the same match with the same rewrite, the plugin SHALL merge them into one rule. The merged rule SHALL go into the HTTPRoute resource of the widest route type among them (PUBLIC > PRIVATE > INTERNAL). If the merged routes have different timeouts and the Exact split does not apply, the merged rule SHALL use the largest timeout, and the plugin SHALL log a warning. The warning names the match, the gateways, the routes merged with their sources, and the timeout that was chosen. #### Scenario: Collapsed routes with the same rewrite - **WHEN** PUBLIC route `/api/v1/my-service/resource` → `/resource` and PUBLIC route `/api/v1/my-service/resource/{var1}/sub` → `/resource/{var1}/sub` are declared @@ -83,7 +87,7 @@ Every gateway SHALL see at most one generated rule per (path type, path value) m ### Requirement: Exact split for bare-root routes The plugin SHALL use `Exact` matches when all of the following hold: -- a route S has gateway path P with no path variables; +- a route S has gateway path P or P + `/` with no path variables; - another route R cuts to the same prefix P because its gateway path is exactly `P/{var}`; - S and R differ in rewrite or timeout. Both are routes of this service; the split does not depend on backends or header matchers. @@ -93,6 +97,10 @@ In this case S SHALL be emitted as two rules on all of S's gateways: `Exact` P a - **WHEN** route S `/api/v1/svc/items` → `/v1/items` and route R `/api/v1/svc/items/{id}` → `/v2/items/{id}` share a gateway - **THEN** that gateway gets these rules: `Exact` `/api/v1/svc/items` with `ReplaceFullPath` `/v1/items`; `Exact` `/api/v1/svc/items/` with `ReplaceFullPath` `/v1/items/`; `PathPrefix` `/api/v1/svc/items` with `ReplacePrefixMatch` `/v2/items` +#### Scenario: Controller root with a trailing slash +- **WHEN** route S `/api/v1/svc/items/` → `/v1/items/` and route R `/api/v1/svc/items/{id}` → `/v2/items/{id}` share a gateway +- **THEN** that gateway gets the same three rules as for S `/api/v1/svc/items` → `/v1/items`, and validation reports no error + #### Scenario: Split does not apply because R continues after the variable - **WHEN** route S `/api/v1/svc/items` → `/v1/items` and route R `/api/v1/svc/items/{id}/details` → `/v2/items/{id}/details` share a gateway - **THEN** no `Exact` rules are generated, and the build fails with a conflict report for `PathPrefix` `/api/v1/svc/items` diff --git a/openspec/changes/regex-routes-prefix-migration/specs/route-migration-validation/spec.md b/openspec/changes/regex-routes-prefix-migration/specs/route-migration-validation/spec.md index 9ca21acf27..3c873f36bc 100644 --- a/openspec/changes/regex-routes-prefix-migration/specs/route-migration-validation/spec.md +++ b/openspec/changes/regex-routes-prefix-migration/specs/route-migration-validation/spec.md @@ -116,12 +116,16 @@ The plugin SHALL report an error when a `@ForbiddenRoute` declaration forbids, o - **THEN** the build fails with an error that names the method, the path and the public gateway ### Requirement: Legacy-invalid duplicate routes are errors -The plugin SHALL report a `LEGACY_INVALID` error when two routes are both allowed on the same gateway with the same gateway path and different service paths, because the legacy runtime rejects this configuration too. The plugin SHALL report one error per gateway and gateway path, naming both routes and their sources, and SHALL NOT also report Conflict errors caused by that gateway path. Routes with the same gateway path and service path that differ only in timeout SHALL NOT be reported as legacy-invalid. +The plugin SHALL report a `LEGACY_INVALID` error when two routes are both allowed on the same gateway with the same gateway path and different service paths, because the legacy runtime rejects this configuration too. The plugin SHALL report one error per gateway and gateway path, naming both routes and their sources. The plugin SHALL NOT report Conflict or `LEGACY_PRECEDENCE_UNDEFINED` errors that involve that gateway path, on any gateway, so the problem isn't reported twice; after it is fixed, the next build reports what remains. Routes with the same gateway path and service path that differ only in timeout SHALL NOT be reported as legacy-invalid. #### Scenario: Same gateway path, different service paths - **WHEN** INTERNAL routes `/api/x` → `/a` and `/api/x` → `/b` are declared - **THEN** the build fails with one `LEGACY_INVALID` error for `/api/x` on the internal gateway, and no Conflict error for `/api/x` +#### Scenario: Undefined precedence involving a legacy-invalid gateway path +- **WHEN** INTERNAL routes `/s/{a}/b` → `/one/{a}/b`, `/s/{a}/b` → `/other/{a}/b`, `/s/b/{a}` → `/two/{a}` and `/s/b` → `/two` are declared +- **THEN** the build fails with a `LEGACY_INVALID` error for `/s/{a}/b` and no other error + #### Scenario: Only one of them allowed on a gateway - **WHEN** PUBLIC route `/api/x` → `/a` and INTERNAL route `/api/x` → `/b` are declared - **THEN** `LEGACY_INVALID` is reported for the internal gateway, where both are allowed, and not for the public or private gateway @@ -147,12 +151,16 @@ When validation finds errors, the plugin SHALL log all findings at once, grouped - the winning Istio rule or DENY rule: match type and value, rewrite, and source routes; - a suggested remediation. -For an Exposure, the remediation SHALL name the class or method to annotate and the exact `@ForbiddenRoute(...)` gateways to add. While `autoGenerateAuthorizationPolicies` is `false`, it SHALL also offer enabling that parameter. For a Conflict, the remediation SHALL name the colliding routes. It SHALL suggest aligning their gateway paths or rewrites, or migrating the affected waypoint manually to `VirtualService` outside this plugin. Findings SHALL be deduplicated so that the same (kind, gateway, legacy entry, Istio rule) combination appears only once. +For an Exposure, the remediation SHALL name the class or method to annotate and the exact `@ForbiddenRoute(...)` gateways to add. While `autoGenerateAuthorizationPolicies` is `false`, it SHALL also offer enabling that parameter. When the exposed legacy forbidden route has a gateway path that neither `@ForbiddenRoute` nor an automatic DENY rule can forbid (a partial-segment variable), the remediation SHALL instead ask to change that gateway path, name the route and its source, and say why no DENY rule can be used. For a Lost route caused by an automatic DENY rule, the remediation SHALL say the rule was generated automatically and ask to change the gateway paths involved; it SHALL NOT ask to narrow a `@ForbiddenRoute` declaration. For a Conflict, the remediation SHALL name the colliding routes. It SHALL suggest aligning their gateway paths or rewrites, or migrating the affected waypoint manually to `VirtualService` outside this plugin. Findings SHALL be deduplicated so that the same (kind, gateway, legacy entry, Istio rule) combination appears only once. #### Scenario: Exposure report content - **WHEN** the implicit 404 scenario above fails validation - **THEN** the log names the INTERNAL method, the public and private gateways, request path `/api/v1/svc/resource//internal-api`, the Istio rule `PathPrefix /api/v1/svc/resource` that exposes it, the suggestion `@ForbiddenRoute({RouteType.PUBLIC, RouteType.PRIVATE})` on that method, and the alternative `true` +#### Scenario: Exposure that no DENY rule can fix +- **WHEN** PUBLIC route `/api/files` and PRIVATE route `/api/files/{name}.txt` are declared, with or without `autoGenerateAuthorizationPolicies` +- **THEN** the Exposure error on the public gateway has one remediation: change the gateway path `/api/files/{name}.txt` of the PRIVATE route, because forbidden paths need whole-segment variables + #### Scenario: Multiple problems - **WHEN** a project has one Conflict and two Exposure findings - **THEN** all three findings are logged before the build fails diff --git a/openspec/changes/regex-routes-prefix-migration/tasks.md b/openspec/changes/regex-routes-prefix-migration/tasks.md index 116dc79507..710300ef05 100644 --- a/openspec/changes/regex-routes-prefix-migration/tasks.md +++ b/openspec/changes/regex-routes-prefix-migration/tasks.md @@ -2,51 +2,51 @@ ## 1. ForbiddenRoute annotation (route-registration-common) -- [ ] 1.1 Add `ForbiddenRoute.java` to `core-rest-libraries/route-registration/route-registration-common/src/main/java/com/netcracker/cloud/routesregistration/common/annotation/`. Use `@Retention(RUNTIME)`, `@Target({TYPE, METHOD})`, `@Documented`, and `RouteType[] value()` with no default, and add Javadoc saying each value is a single gateway. Verify with `mvn -q install` of route-registration-common: the build succeeds and the existing route-registration tests pass unchanged, which confirms the legacy runtime ignores the annotation. -- [ ] 1.2 Document `@ForbiddenRoute` in `core-rest-libraries/route-registration/README.md`: it is for the Istio generator only, and the legacy runtime ignores it. Verify that the README section renders and names the annotation's fully qualified class name. +- [x] 1.1 Add `ForbiddenRoute.java` to `core-rest-libraries/route-registration/route-registration-common/src/main/java/com/netcracker/cloud/routesregistration/common/annotation/`. Use `@Retention(RUNTIME)`, `@Target({TYPE, METHOD})`, `@Documented`, and `RouteType[] value()` with no default, and add Javadoc saying each value is a single gateway. Verify with `mvn -q install` of route-registration-common: the build succeeds and the existing route-registration tests pass unchanged, which confirms the legacy runtime ignores the annotation. +- [x] 1.2 Document `@ForbiddenRoute` in `core-rest-libraries/route-registration/README.md`: it is for the Istio generator only, and the legacy runtime ignores it. Verify that the README section renders and names the annotation's fully qualified class name. All plugin paths and `mvn` commands in sections 2–7 are relative to `core-maven-plugins/httproutes-generator-maven-plugin`. ## 2. Route model and scanner -- [ ] 2.1 Leave the `HttpRoute` record unchanged (D14). Add `DeclaredRoute(HttpRoute route, SortedSet origins)`, where each origin is `class#method` or `class`. Add a `Gateway` enum (PUBLIC, PRIVATE, INTERNAL with the legacy gateway name `public-gateway-service`/`private-gateway-service`/`internal-gateway-service`, a lookup by that name, parentRef/targetRef data and an exposure chain `Type → Set`; FACADE maps to no gateway). Verify with new unit tests for the exposure chain and the lookup by legacy gateway name (unknown names give no gateway), and with `mvn test` staying green without changing `HttpRouteTest` or `GenerateRoutesMojoTest` (its one facade assertion changes only in 2.4). -- [ ] 2.2 Pull the gateway-path resolution in `RouteScanner` out into shared helpers used by both `@Route` and `@ForbiddenRoute`. Add `collectDeclarations(reactorProjects)`, which returns `RouteDeclarations(routes, forbidden, findings)` with `ForbiddenDeclaration(gatewayPath, gateways, origin)`. Build it from an internal `Map>`, so the same route declared by several elements (including through superclass recursion) becomes one `DeclaredRoute` with all its origins. Return the `DeclaredRoute`s sorted by the path-specificity comparator (D14). Keep `collectRoutes` returning `Set` from the same map. Include classes and methods that have only `@ForbiddenRoute`. Verify with new test controllers (class-level forbidden without `@Route`, method-level forbidden with `@GatewayRequestMapping`), and with the existing `GenerateRoutesMojoTest` assertions still passing. -- [ ] 2.3 Validate `@ForbiddenRoute` usage: an empty value, `FACADE`, a variable that is only part of a segment, `*`, or a regex-constrained variable each produce a scan-time error finding (D1) that names the element. Verify with unit tests for each invalid case from the forbidden-route-policies spec. -- [ ] 2.4 Read all legacy route annotation forms (D13). Select classes with `@Routes` or `@FacadeRoute` in `hasRoute`. Read a list of route annotations per element (`@Route`, `@Routes`, `@FacadeRoute`), where a method-level list replaces the class-level one. Read attributes with `getParameterValues(false)`, keeping the existing type, timeout and class-fallback rules, and treat a missing `gateways`/`hosts` attribute, or one holding only empty strings, as empty. Resolve empty `gateways` by type; resolve border names in `gateways` to border routes, and every other name to a composite route. Take facade gateway paths from `@FacadeGateway`/`@FacadeGatewayRequestMapping`, or use the service path, and composite gateway paths from `@Gateway`/`@GatewayRequestMapping`. Ignore `hosts`. Change the `SpringTestController8#method1` assertion in `GenerateRoutesMojoTest` to gateway path = service path. Verify with new test controllers for every "Legacy route annotation forms" scenario (two `@Route` on a method, `@Route(gateways = "private-gateway-service")`, `@Route(gateways = "my-service")` with `@GatewayRequestMapping`, `@Route(FACADE)` with only `@GatewayRequestMapping`, `@FacadeRoute` with `@FacadeGatewayRequestMapping`, method-level list replacing the class-level list), plus a unit test that `gateways = {""}` behaves like an empty `gateways`. -- [ ] 2.5 Report the scan-time finding of D13: `LEGACY_INVALID` for `hosts` with a border name in `gateways`. Verify with unit tests for every scenario in the spec requirement "Legacy-invalid annotations are errors". +- [x] 2.1 Leave the `HttpRoute` record unchanged (D14). Add `DeclaredRoute(HttpRoute route, SortedSet origins)`, where each origin is `class#method` or `class`. Add a `Gateway` enum (PUBLIC, PRIVATE, INTERNAL with the legacy gateway name `public-gateway-service`/`private-gateway-service`/`internal-gateway-service`, a lookup by that name, parentRef/targetRef data and an exposure chain `Type → Set`; FACADE maps to no gateway). Verify with new unit tests for the exposure chain and the lookup by legacy gateway name (unknown names give no gateway), and with `mvn test` staying green without changing `HttpRouteTest` or `GenerateRoutesMojoTest` (its one facade assertion changes only in 2.4). +- [x] 2.2 Pull the gateway-path resolution in `RouteScanner` out into shared helpers used by both `@Route` and `@ForbiddenRoute`. Add `collectDeclarations(reactorProjects)`, which returns `RouteDeclarations(routes, forbidden, findings)` with `ForbiddenDeclaration(gatewayPath, gateways, origin)`. Build it from an internal `Map>`, so the same route declared by several elements (including through superclass recursion) becomes one `DeclaredRoute` with all its origins. Return the `DeclaredRoute`s sorted by the path-specificity comparator (D14). Keep `collectRoutes` returning `Set` from the same map. Include classes and methods that have only `@ForbiddenRoute`. Verify with new test controllers (class-level forbidden without `@Route`, method-level forbidden with `@GatewayRequestMapping`), and with the existing `GenerateRoutesMojoTest` assertions still passing. +- [x] 2.3 Validate `@ForbiddenRoute` usage: an empty value, `FACADE`, a variable that is only part of a segment, `*`, or a regex-constrained variable each produce a scan-time error finding (D1) that names the element. Verify with unit tests for each invalid case from the forbidden-route-policies spec. +- [x] 2.4 Read all legacy route annotation forms (D13). Select classes with `@Routes` or `@FacadeRoute` in `hasRoute`. Read a list of route annotations per element (`@Route`, `@Routes`, `@FacadeRoute`), where a method-level list replaces the class-level one. Read attributes with `getParameterValues(false)`, keeping the existing type, timeout and class-fallback rules, and treat a missing `gateways`/`hosts` attribute, or one holding only empty strings, as empty. Resolve empty `gateways` by type; resolve border names in `gateways` to border routes, and every other name to a composite route. Take facade gateway paths from `@FacadeGateway`/`@FacadeGatewayRequestMapping`, or use the service path, and composite gateway paths from `@Gateway`/`@GatewayRequestMapping`. Ignore `hosts`. Change the `SpringTestController8#method1` assertion in `GenerateRoutesMojoTest` to gateway path = service path. Verify with new test controllers for every "Legacy route annotation forms" scenario (two `@Route` on a method, `@Route(gateways = "private-gateway-service")`, `@Route(gateways = "my-service")` with `@GatewayRequestMapping`, `@Route(FACADE)` with only `@GatewayRequestMapping`, `@FacadeRoute` with `@FacadeGatewayRequestMapping`, method-level list replacing the class-level list), plus a unit test that `gateways = {""}` behaves like an empty `gateways`. +- [x] 2.5 Report the scan-time finding of D13: `LEGACY_INVALID` for `hosts` with a border name in `gateways`. Verify with unit tests for every scenario in the spec requirement "Legacy-invalid annotations are errors". ## 3. Path patterns and the legacy routing model -- [ ] 3.1 Implement `PathPattern`: parse segments and variables (including partial-segment variables), match whole segments with continuation after `/`, give the source length, instantiate with a sample or literal, and render as an Istio template (`{*}`). Verify with unit tests covering `/a/{id}`, `/a/{id}/b`, `/files/{name}.txt`, `/`, and trailing-slash requests. -- [ ] 3.2 Implement `LegacyRouteTable` per gateway. Allowed entries come from the exposure chain, implicit forbidden entries are added on wider gateways, allowed wins over forbidden for the same pattern, explicit `@ForbiddenRoute` entries are added, and FACADE routes are not added to any table. `decide(path)` picks the longest pattern, computes the positional-variable upstream rewrite, and resolves equal-length ties per D4: ties that agree on allowed/forbidden and upstream path use the largest timeout, other ties give an undefined decision. Verify with unit tests reproducing the legacy examples in `core-maven-plugins/httproutes-generator-maven-plugin/regex-routes-migration.md` that the plugin can produce (routes 1, 2, 5, 6 and 7; routes 3 and 4 go to `another-service` and are left out on purpose), and the legacy-model spec scenarios, including the timeout-only tie. `LegacyEntry` and `Decision` have no backend or header-matcher field. -- [ ] 3.3 Report an error when an explicit `@ForbiddenRoute` has exactly the pattern of a route allowed on the same gateway. Verify with a unit test for the "Forbidding an allowed route's own path" scenario. -- [ ] 3.4 Report `LEGACY_INVALID` when two routes are both allowed on the same gateway with the same gateway path and different service paths (D4, mirrors `RouteTransformer.validateAndReturnRoutes`). Report one finding per (gateway, pattern) naming both routes and origins, and pass the pattern to the validator so it suppresses the Conflict findings caused by it (5.3). The same path with only different timeouts is not an error. Verify with unit tests: two INTERNAL routes `/api/x` → `/a` and `/api/x` → `/b` give one `LEGACY_INVALID` on the internal gateway and no Conflict; PUBLIC `/api/x` → `/a` with INTERNAL `/api/x` → `/b` gives `LEGACY_INVALID` only on the internal gateway, where both are allowed; the same paths with timeouts 5s and 60s give only the merge warning. +- [x] 3.1 Implement `PathPattern`: parse segments and variables (including partial-segment variables), match whole segments with continuation after `/`, give the source length, instantiate with a sample or literal, and render as an Istio template (`{*}`). Verify with unit tests covering `/a/{id}`, `/a/{id}/b`, `/files/{name}.txt`, `/`, and trailing-slash requests. +- [x] 3.2 Implement `LegacyRouteTable` per gateway. Allowed entries come from the exposure chain, implicit forbidden entries are added on wider gateways, allowed wins over forbidden for the same pattern, explicit `@ForbiddenRoute` entries are added, and FACADE routes are not added to any table. `decide(path)` picks the longest pattern, computes the positional-variable upstream rewrite, and resolves equal-length ties per D4: ties that agree on allowed/forbidden and upstream path use the largest timeout, other ties give an undefined decision. Verify with unit tests reproducing the legacy examples in `core-maven-plugins/httproutes-generator-maven-plugin/regex-routes-migration.md` that the plugin can produce (routes 1, 2, 5, 6 and 7; routes 3 and 4 go to `another-service` and are left out on purpose), and the legacy-model spec scenarios, including the timeout-only tie. `LegacyEntry` and `Decision` have no backend or header-matcher field. +- [x] 3.3 Report an error when an explicit `@ForbiddenRoute` has exactly the pattern of a route allowed on the same gateway. Verify with a unit test for the "Forbidding an allowed route's own path" scenario. +- [x] 3.4 Report `LEGACY_INVALID` when two routes are both allowed on the same gateway with the same gateway path and different service paths (D4, mirrors `RouteTransformer.validateAndReturnRoutes`). Report one finding per (gateway, pattern) naming both routes and origins, and make `RouteMigration` drop, in one filter step, the Conflict and `LEGACY_PRECEDENCE_UNDEFINED` findings that involve that gateway path (5.3). The same path with only different timeouts is not an error. Verify with unit tests: two INTERNAL routes `/api/x` → `/a` and `/api/x` → `/b` give one `LEGACY_INVALID` on the internal gateway and no Conflict; PUBLIC `/api/x` → `/a` with INTERNAL `/api/x` → `/b` gives `LEGACY_INVALID` only on the internal gateway, where both are allowed; the same paths with timeouts 5s and 60s give only the merge warning. ## 4. Istio planning: cut, rewrite, merge, Exact split -- [ ] 4.1 Implement cutting at the first variable and rewrite derivation (design D6), with no remainder check. Verify with unit tests for every scenario in "Cut gateway path at the first path variable" and "Cut the prefix rewrite the same way". -- [ ] 4.2 Implement per-gateway grouping by match, merging into the widest type's resource, and a max-timeout merge with a WARNING finding (design D7, steps 1, 3 and 4). Verify with unit tests for the "One rule per match on a gateway" scenarios, including the cross-type case where the INTERNAL resource has no duplicate rule. -- [ ] 4.3 Implement the Exact split (D7 step 2): two `Exact` rules with `ReplaceFullPath` value and value + `/`, or no filter when S has no rewrite. Verify with unit tests for both "Exact split for bare-root routes" scenarios. -- [ ] 4.4 Implement DENY rule construction (D8): `paths`, `notPaths` from longer allowed routes below the forbidden path, one rule per forbidden path, `/` handling, deterministic ordering. Verify with unit tests for the "DENY rule content" scenarios, including nested forbidden paths. -- [ ] 4.5 Implement automatic DENY rules behind `autoGenerateAuthorizationPolicies` (D12). Evaluate the routing-only Istio model. Add implicit-forbidden rules only where Istio would route the forbidden pattern, and cut-exposure rules only where legacy routed neither P nor P/``. Deduplicate against explicit rules, where explicit wins, and give automatic rules their synthetic origins. Verify with unit tests for every scenario in the spec requirement "Automatic DENY rules": the flag off generates nothing automatic; the flag on with the implicit 404 example gives the internal-api rule with the status `notPaths`; the flag on with the order example gives the `/order` rule with the items `notPaths`; the flag on with a covering shorter route or an INTERNAL-only route gives no rule; and explicit plus automatic identical rules render once. -- [ ] 4.6 Plan facade and composite routes on the `SERVICE` target (D15) with the same cut, rewrite, merge, Exact split and conflict steps (4.1–4.3). Produce no `SERVICE` rules when none has a rewrite, and log the unapplied-timeout warning in that case. Report a conflict for the same match with different rewrites, naming both routes. Never add DENY rules or legacy entries for `SERVICE`. Verify with unit tests for the "No rewrite", "One route with a rewrite", "Composite route" (with `hosts`; no resource references the composite gateway name or the host) and "Facade and composite routes collide" scenarios, and the timeout warning. +- [x] 4.1 Implement cutting at the first variable and rewrite derivation (design D6), with no remainder check. Verify with unit tests for every scenario in "Cut gateway path at the first path variable" and "Cut the prefix rewrite the same way". +- [x] 4.2 Implement grouping by match across route types (cut values without a trailing slash, so `P/` joins the `P` group and can be the Exact split root), merging into the widest type's resource, and a max-timeout merge with a WARNING finding (design D7, steps 1, 3 and 4). Verify with unit tests for the "One rule per match on a gateway" scenarios, including the cross-type case where the INTERNAL resource has no duplicate rule. +- [x] 4.3 Implement the Exact split (D7 step 2): two `Exact` rules with `ReplaceFullPath` value and value + `/`, or no filter when S has no rewrite. Verify with unit tests for both "Exact split for bare-root routes" scenarios. +- [x] 4.4 Implement DENY rule construction (D8): `paths`, `notPaths` from longer allowed routes that overlap the forbidden path segment by segment (a variable overlaps a literal it matches), one rule per forbidden path, `/` handling, deterministic ordering. Verify with unit tests for the "DENY rule content" scenarios, including nested forbidden paths. +- [x] 4.5 Implement automatic DENY rules behind `autoGenerateAuthorizationPolicies` (D12). Evaluate the routing-only Istio model. Add implicit-forbidden rules only where Istio would route the forbidden pattern and `@ForbiddenRoute` would accept it (no partial-segment variables), and cut-exposure rules only where legacy does not route P. Deduplicate against explicit rules, where explicit wins, and give automatic rules their synthetic origins. Verify with unit tests for every scenario in the spec requirement "Automatic DENY rules": the flag off generates nothing automatic; the flag on with the implicit 404 example gives the internal-api rule with the status `notPaths`; the flag on with the order example gives the `/order` rule with the items `notPaths`; the flag on with INTERNAL `/order/{id}` and PUBLIC `/order/{id}/items` gives the internal `/order` rule with the `/order/{*}` `notPaths`; the flag on with a covering shorter route or an INTERNAL-only route gives no rule; and explicit plus automatic identical rules render once. +- [x] 4.6 Plan facade and composite routes on the `SERVICE` target (D15) with the same cut, rewrite, merge, Exact split and conflict steps (4.1–4.3). Produce no `SERVICE` rules when none has a rewrite, and log the unapplied-timeout warning in that case. Report a conflict for the same match with different rewrites, naming both routes. Never add DENY rules or legacy entries for `SERVICE`. Verify with unit tests for the "No rewrite", "One route with a rewrite", "Composite route" (with `hosts`; no resource references the composite gateway name or the host) and "Facade and composite routes collide" scenarios, and the timeout warning. ## 5. Istio routing model and differential validation -- [ ] 5.1 Implement `IstioRouteTable.decide(gateway, path)`: DENY with `{*}`/`{**}`/`notPaths` first, then `Exact`, then the longest segment-aligned `PathPrefix` with `ReplacePrefixMatch`/`ReplaceFullPath` upstream computation, on border gateways only. Verify with unit tests for the "Exact wins over a longer prefix" scenario and for the `ReplacePrefixMatch` `/` edge case. -- [ ] 5.2 Implement probe generation (D3): segment prefixes, `/` and extra-segment probes, a unique sample literal, and literal substitution one variable at a time, with deterministic ordering. Verify with unit tests asserting that the probe set contains `/a/b/lit/c` for patterns `/a/b/{id}/c` and `/a/b/lit`, and contains the cut prefixes with `/` and `/`. -- [ ] 5.3 Implement `MigrationValidator`: classify Exposure, Conflict, Lost route and Timeout change, report `LEGACY_PRECEDENCE_UNDEFINED` for probes with an undefined legacy decision (one per gateway and tied patterns) with no other finding for those probes, skip Conflict findings caused by a `LEGACY_INVALID` pattern (3.4), and deduplicate findings by (kind, gateway, legacy entry, Istio rule). Verify with unit tests for every scenario in "Differential validation over request paths", "Undefined legacy precedence is an error" and "Legacy-invalid duplicate routes are errors" (no Conflict next to `LEGACY_INVALID`), including the migration document's example configuration without the `another-service` routes 3 and 4, with and without the matching `@ForbiddenRoute` declarations. Do not add checks or tests for overlaps with other services or header-matched routes. -- [ ] 5.4 Implement the remediation text and report formatting (D11): gateway grouping, example path, legacy and Istio winners with origins, `@ForbiddenRoute(...)` suggestions naming the element and gateways (for cut exposure: the class whose mapping equals the cut prefix, otherwise a method or class mapped to that prefix), and, while `autoGenerateAuthorizationPolicies` is `false`, the alternative of enabling it. Include the texts for `LEGACY_INVALID`, `LEGACY_PRECEDENCE_UNDEFINED` (naming the tied entries and suggesting to change one of the gateway paths) and `SERVICE` conflicts (saying the routes came from different legacy gateways). The `MojoFailureException` message summarizes the finding counts by kind and points to the log. Verify with a unit test asserting the report text for the "Exposure report content" scenario, a unit test for the cut-exposure suggestion with and without a class mapped to the cut prefix, and a test for the "Multiple problems" scenario (three findings are all logged before failure, and the exception message has the counts). +- [x] 5.1 Implement `IstioRouteTable.decide(gateway, path)`: DENY with `{*}`/`{**}`/`notPaths` first, then `Exact`, then the longest segment-aligned `PathPrefix` with `ReplacePrefixMatch`/`ReplaceFullPath` upstream computation, on border gateways only. Verify with unit tests for the "Exact wins over a longer prefix" scenario and for the `ReplacePrefixMatch` `/` edge case. +- [x] 5.2 Implement probe generation (D3): segment prefixes, `/` and extra-segment probes, a unique sample literal, and literal substitution one variable at a time, with deterministic ordering. Verify with unit tests asserting that the probe set contains `/a/b/lit/c` for patterns `/a/b/{id}/c` and `/a/b/lit`, and contains the cut prefixes with `/` and `/`. +- [x] 5.3 Implement `MigrationValidator`: classify Exposure, Conflict, Lost route and Timeout change, report `LEGACY_PRECEDENCE_UNDEFINED` for probes with an undefined legacy decision (one per gateway and tied patterns) with no other finding for those probes, leave the suppression of findings next to a `LEGACY_INVALID` pattern to the `RouteMigration` filter (3.4), and deduplicate findings by (kind, gateway, legacy entry, Istio rule). Verify with unit tests for every scenario in "Differential validation over request paths", "Undefined legacy precedence is an error" and "Legacy-invalid duplicate routes are errors" (no Conflict next to `LEGACY_INVALID`), including the migration document's example configuration without the `another-service` routes 3 and 4, with and without the matching `@ForbiddenRoute` declarations. Do not add checks or tests for overlaps with other services or header-matched routes. +- [x] 5.4 Implement the remediation text and report formatting (D11): gateway grouping, example path, legacy and Istio winners with origins, `@ForbiddenRoute(...)` suggestions naming the element and gateways (for cut exposure: the class whose mapping equals the cut prefix, otherwise a method or class mapped to that prefix), and, while `autoGenerateAuthorizationPolicies` is `false`, the alternative of enabling it. Include the texts for `LEGACY_INVALID`, `LEGACY_PRECEDENCE_UNDEFINED` (naming the tied entries and suggesting to change one of the gateway paths) and `SERVICE` conflicts (saying the routes came from different legacy gateways). The `MojoFailureException` message summarizes the finding counts by kind and points to the log. Verify with a unit test asserting the report text for the "Exposure report content" scenario, a unit test for the cut-exposure suggestion with and without a class mapped to the cut prefix, and a test for the "Multiple problems" scenario (three findings are all logged before failure, and the exception message has the counts). ## 6. Rendering and Mojo wiring -- [ ] 6.1 Change `HttpRouteRenderer` to render planned rules: remove `RegularExpression`, `REGEX_MANUAL_REVIEW_WARNING` and `hasRegularExpressionMatch`; support `Exact` matches and the `ReplaceFullPath` filter; keep labels, timeouts, parentRefs and the single `backendRefs` entry (`backendRefVal`:`servicePort`) on every rule, with no per-rule backend; sort by specificity with `Exact` before `PathPrefix`. Render the service-bound HTTPRoute (`-facade`, parentRef Service `{{ .Values.SERVICE_NAME }}`) from the planned `SERVICE` rules, including rules without a rewrite, and remove the `path == gatewayPath` skip for FACADE. Update `HttpRouteRendererTest` (the `RegularExpression`/`MANUAL REVIEW` assertions become `PathPrefix /items` with no warning). Verify that `mvn test` passes and that a test renders twice with byte-identical output. -- [ ] 6.2 Add `AuthorizationPolicyRenderer`: Jackson records, `security.istio.io/v1`, name `{{ .Values.SERVICE_NAME }}-java-annotations-deny-`, shared labels, targetRefs per gateway, `action: DENY`, `ports` from the gateway's entry in `authorizationPolicyPorts`, `paths`/`notPaths`, and the order public, private, internal. Verify with unit tests asserting the YAML for the "Forbidden on public and private" and "No forbidden declarations" scenarios, and for the "Ports for one gateway" scenario. -- [ ] 6.3 Wire the pipeline in `GenerateRoutesMojo` (D1): add the optional parameters `authorizationPolicyPorts` (`Map` from border gateway name to comma-separated ports, `8080` for every gateway that isn't listed; unknown keys, empty values and invalid ports fail the build before scanning) and `autoGenerateAuthorizationPolicies` (`boolean`, `@Parameter(defaultValue = "false")`). Declare `execute() throws MojoExecutionException, MojoFailureException`, and run scan, plan and validation outside the `catch (Exception e)` of `writeRoutesFile`. Log warnings, throw `MojoFailureException` on any error finding without writing the file, and write HTTPRoutes and then AuthorizationPolicies inside the existing header and guard. Verify with a Mojo-level test using a temp base dir: a failing project leaves an existing output file untouched, and a passing project writes the combined file in the specified order, a validation failure surfaces as `MojoFailureException` (not wrapped in `MojoExecutionException`), and, when they aren't configured, `authorizationPolicyPorts` gives `8080` for every gateway and `autoGenerateAuthorizationPolicies` is `false`, plus tests for the "Unknown gateway name" scenario, an empty value, and ports `0`, `65536` and `abc`, each failing the build with no output file written. -- [ ] 6.4 Update `core-maven-plugins/httproutes-generator-maven-plugin/README.md`: prefix-only matching and the cut, the rewrite derivation (the service path is cut at its first variable too, which assumes the part after the cut is the same in both paths; the plugin doesn't check it), merging and the Exact split, the validation step with example error output and fixes, `@ForbiddenRoute` usage, AuthorizationPolicy output, the `authorizationPolicyPorts` (per border gateway name, with the configuration errors) and `autoGenerateAuthorizationPolicies` parameters (as new rows in the existing parameter table, with a `pom.xml` `` example that sets ports for one gateway, and what `autoGenerateAuthorizationPolicies` generates), the legacy annotation forms that are read (`@Routes`, `gateways` where every non-border name is a composite gateway, `@FacadeRoute`, facade gateway paths) and that `hosts` is ignored, facade and composite routes (the service-bound HTTPRoute, when it is generated, that it lists all of them, that other paths through the Service return 404 while it exists and how to fix that (declare those endpoints as facade routes, or call them through a border gateway), the warning that facade and composite timeouts are not applied when no service-bound HTTPRoute is generated, and the change of facade gateway paths to legacy semantics), the legacy-invalid and undefined-precedence errors, a note that DENY rules on shared gateways also apply to other services' paths under the same prefix, the BWC notes (403 vs 404, port scoping), a migration section for existing services (new major version, the fix options in the order `@ForbiddenRoute`, then `autoGenerateAuthorizationPolicies`, then path alignment), and a note that overlaps with routes of other services are not checked. Remove the regex and "MANUAL REVIEW" references, and update "Route Sorting". Verify that the README examples match the actual output of the corresponding unit tests. +- [x] 6.1 Change `HttpRouteRenderer` to render planned rules: remove `RegularExpression`, `REGEX_MANUAL_REVIEW_WARNING` and `hasRegularExpressionMatch`; support `Exact` matches and the `ReplaceFullPath` filter; keep labels, timeouts, parentRefs and the single `backendRefs` entry (`backendRefVal`:`servicePort`) on every rule, with no per-rule backend; sort by specificity with `Exact` before `PathPrefix`. Render the service-bound HTTPRoute (`-facade`, parentRef Service `{{ .Values.SERVICE_NAME }}`) from the planned `SERVICE` rules, including rules without a rewrite, and remove the `path == gatewayPath` skip for FACADE. Update `HttpRouteRendererTest` (the `RegularExpression`/`MANUAL REVIEW` assertions become `PathPrefix /items` with no warning). Verify that `mvn test` passes and that a test renders twice with byte-identical output. +- [x] 6.2 Add `AuthorizationPolicyRenderer`: Jackson records, `security.istio.io/v1`, name `{{ .Values.SERVICE_NAME }}-java-annotations-deny-`, shared labels, targetRefs per gateway, `action: DENY`, `ports` from the gateway's entry in `authorizationPolicyPorts`, `paths`/`notPaths`, and the order public, private, internal. Verify with unit tests asserting the YAML for the "Forbidden on public and private" and "No forbidden declarations" scenarios, and for the "Ports for one gateway" scenario. +- [x] 6.3 Wire the pipeline in `GenerateRoutesMojo` (D1): add the optional parameters `authorizationPolicyPorts` (`Map` from border gateway name to comma-separated ports, `8080` for every gateway that isn't listed; unknown keys, empty values and invalid ports fail the build before scanning) and `autoGenerateAuthorizationPolicies` (`boolean`, `@Parameter(defaultValue = "false")`). Declare `execute() throws MojoExecutionException, MojoFailureException`, and run scan, plan and validation outside the `catch (Exception e)` of `writeRoutesFile`. Log warnings, throw `MojoFailureException` on any error finding without writing the file, and write HTTPRoutes and then AuthorizationPolicies inside the existing header and guard. Verify with a Mojo-level test using a temp base dir: a failing project leaves an existing output file untouched, and a passing project writes the combined file in the specified order, a validation failure surfaces as `MojoFailureException` (not wrapped in `MojoExecutionException`), and, when they aren't configured, `authorizationPolicyPorts` gives `8080` for every gateway and `autoGenerateAuthorizationPolicies` is `false`, plus tests for the "Unknown gateway name" scenario, an empty value, and ports `0`, `65536` and `abc`, each failing the build with no output file written. +- [x] 6.4 Update `core-maven-plugins/httproutes-generator-maven-plugin/README.md`: prefix-only matching and the cut, the rewrite derivation (the service path is cut at its first variable too, which assumes the part after the cut is the same in both paths; the plugin doesn't check it), merging and the Exact split, the validation step with example error output and fixes, `@ForbiddenRoute` usage, AuthorizationPolicy output, the `authorizationPolicyPorts` (per border gateway name, with the configuration errors) and `autoGenerateAuthorizationPolicies` parameters (as new rows in the existing parameter table, with a `pom.xml` `` example that sets ports for one gateway, and what `autoGenerateAuthorizationPolicies` generates), the legacy annotation forms that are read (`@Routes`, `gateways` where every non-border name is a composite gateway, `@FacadeRoute`, facade gateway paths) and that `hosts` is ignored, facade and composite routes (the service-bound HTTPRoute, when it is generated, that it lists all of them, that other paths through the Service return 404 while it exists and how to fix that (declare those endpoints as facade routes, or call them through a border gateway), the warning that facade and composite timeouts are not applied when no service-bound HTTPRoute is generated, and the change of facade gateway paths to legacy semantics), the legacy-invalid and undefined-precedence errors, a note that DENY rules on shared gateways also apply to other services' paths under the same prefix, the BWC notes (403 vs 404, port scoping), a migration section for existing services (new major version, the fix options in the order `@ForbiddenRoute`, then `autoGenerateAuthorizationPolicies`, then path alignment), and a note that overlaps with routes of other services are not checked. Remove the regex and "MANUAL REVIEW" references, and update "Route Sorting". Verify that the README examples match the actual output of the corresponding unit tests. ## 7. Integration check -- [ ] 7.1 Add an end-to-end test controller set that reproduces the migration document example as annotations: public resource controller, internal-api method with `@ForbiddenRoute({PUBLIC, PRIVATE})`, status method, order controller with a class-level `@ForbiddenRoute` for all three gateways. Assert that the generated YAML has only `HTTPRoute` and `AuthorizationPolicy` kinds, no `RegularExpression`/`VirtualService`/`EnvoyFilter`, and the expected rules and policies. Then use a second controller set, identical but without the `@ForbiddenRoute` annotations, and assert that the build fails with Exposure findings. Verify with `mvn -q verify` in the plugin module. -- [ ] 7.2 Run the second controller set (without `@ForbiddenRoute`) again with `autoGenerateAuthorizationPolicies` set to `true`. Assert that the build passes, and that the generated AuthorizationPolicies have the same `paths` and `notPaths` as the explicit variant in 7.1 on each gateway. Verify with `mvn -q verify` in the plugin module. -- [ ] 7.3 Add an end-to-end controller set with facade and composite routes: a `@FacadeRoute` method with `@FacadeGatewayRequestMapping` (a rewrite), a `@Route(RouteType.FACADE)` method without a rewrite, and a method with `@Route(RouteType.PUBLIC)` and `@Route(gateways = "composite-gw")`. Assert that the output has one `-facade` HTTPRoute bound only to Service `{{ .Values.SERVICE_NAME }}` with all three facade and composite rules, that the PUBLIC HTTPRoute has the border rule, and that no resource references `composite-gw`. Then remove the rewrite and assert that no `-facade` HTTPRoute is generated. Verify with `mvn -q verify` in the plugin module. +- [x] 7.1 Add an end-to-end test controller set that reproduces the migration document example as annotations: public resource controller, internal-api method with `@ForbiddenRoute({PUBLIC, PRIVATE})`, status method, order controller with a class-level `@ForbiddenRoute` for all three gateways. Assert that the generated YAML has only `HTTPRoute` and `AuthorizationPolicy` kinds, no `RegularExpression`/`VirtualService`/`EnvoyFilter`, and the expected rules and policies. Then use a second controller set, identical but without the `@ForbiddenRoute` annotations, and assert that the build fails with Exposure findings. Verify with `mvn -q verify` in the plugin module. +- [x] 7.2 Run the second controller set (without `@ForbiddenRoute`) again with `autoGenerateAuthorizationPolicies` set to `true`. Assert that the build passes, and that the generated AuthorizationPolicies have the same `paths` and `notPaths` as the explicit variant in 7.1 on each gateway. Verify with `mvn -q verify` in the plugin module. +- [x] 7.3 Add an end-to-end controller set with facade and composite routes: a `@FacadeRoute` method with `@FacadeGatewayRequestMapping` (a rewrite), a `@Route(RouteType.FACADE)` method without a rewrite, and a method with `@Route(RouteType.PUBLIC)` and `@Route(gateways = "composite-gw")`. Assert that the output has one `-facade` HTTPRoute bound only to Service `{{ .Values.SERVICE_NAME }}` with all three facade and composite rules, that the PUBLIC HTTPRoute has the border rule, and that no resource references `composite-gw`. Then remove the rewrite and assert that no `-facade` HTTPRoute is generated. Verify with `mvn -q verify` in the plugin module. From 22d5171916c9426a79b8b62c0a6b632791d75e52 Mon Sep 17 00:00:00 2001 From: iglin Date: Wed, 30 Sep 2026 17:28:11 +0500 Subject: [PATCH 03/11] fix: regex routes for istio --- .../README.md | 418 ++++----------- .../REVIEW.md | 54 ++ .../regex-routes-migration.md | 423 +++++++++++++++ .../plugin/AuthorizationPolicyRenderer.java | 167 ++++-- .../routes/gateway/plugin/DeclaredRoute.java | 27 - .../routes/gateway/plugin/DenyRule.java | 70 --- .../routes/gateway/plugin/Finding.java | 86 --- .../routes/gateway/plugin/FindingReport.java | 88 ---- .../gateway/plugin/ForbiddenDeclaration.java | 18 - .../routes/gateway/plugin/ForbiddenPath.java | 14 + .../routes/gateway/plugin/Gateway.java | 128 ----- .../gateway/plugin/GenerateRoutesMojo.java | 81 +-- .../gateway/plugin/HttpRouteRenderer.java | 230 ++++++-- .../routes/gateway/plugin/IstioPlan.java | 52 -- .../gateway/plugin/IstioRoutePlanner.java | 404 -------------- .../gateway/plugin/IstioRouteTable.java | 85 --- .../routes/gateway/plugin/LegacyEntry.java | 75 --- .../gateway/plugin/LegacyRouteTable.java | 213 -------- .../gateway/plugin/MigrationValidator.java | 259 --------- .../routes/gateway/plugin/PathPattern.java | 248 --------- .../routes/gateway/plugin/PlannedRule.java | 133 ----- .../routes/gateway/plugin/Probes.java | 137 ----- .../routes/gateway/plugin/Problems.java | 22 + .../routes/gateway/plugin/ResourceLabels.java | 51 -- .../gateway/plugin/RouteDeclarations.java | 33 -- .../routes/gateway/plugin/RouteMigration.java | 46 -- .../routes/gateway/plugin/RoutePaths.java | 129 +++++ .../routes/gateway/plugin/RouteScanner.java | 185 ++----- .../AuthorizationPolicyRendererTest.java | 217 ++++++-- .../gateway/plugin/FindingReportTest.java | 140 ----- .../routes/gateway/plugin/GatewayTest.java | 56 -- .../plugin/GenerateRoutesMojoExecuteTest.java | 62 +-- .../gateway/plugin/HttpRouteRendererTest.java | 182 +++++-- .../gateway/plugin/IstioRoutePlannerTest.java | 493 ------------------ .../gateway/plugin/IstioRouteTableTest.java | 106 ---- .../gateway/plugin/LegacyRouteTableTest.java | 309 ----------- .../plugin/MigrationValidatorTest.java | 356 ------------- .../gateway/plugin/PathPatternTest.java | 155 ------ .../routes/gateway/plugin/ProbesTest.java | 73 --- .../gateway/plugin/RouteMigrationE2ETest.java | 3 +- .../routes/gateway/plugin/RoutePathsTest.java | 50 ++ .../plugin/RouteScannerDeclarationsTest.java | 151 ++---- .../gateway/plugin/scan/ScanControllers.java | 44 -- .../regex-routes-prefix-migration/design.md | 248 +++------ .../regex-routes-prefix-migration/proposal.md | 28 +- .../specs/forbidden-route-policies/spec.md | 68 +-- .../specs/httproute-generation/spec.md | 16 +- .../specs/route-migration-validation/spec.md | 163 ++---- .../regex-routes-prefix-migration/tasks.md | 53 +- 49 files changed, 1711 insertions(+), 5138 deletions(-) create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/REVIEW.md create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/regex-routes-migration.md delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/DeclaredRoute.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/DenyRule.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Finding.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/FindingReport.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenDeclaration.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenPath.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Gateway.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/IstioPlan.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/IstioRoutePlanner.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/IstioRouteTable.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/LegacyEntry.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/LegacyRouteTable.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/MigrationValidator.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/PathPattern.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/PlannedRule.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Probes.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Problems.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ResourceLabels.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteDeclarations.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteMigration.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RoutePaths.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/FindingReportTest.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/GatewayTest.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/IstioRoutePlannerTest.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/IstioRouteTableTest.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/LegacyRouteTableTest.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/MigrationValidatorTest.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/PathPatternTest.java delete mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/ProbesTest.java create mode 100644 core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/RoutePathsTest.java diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/README.md b/core-maven-plugins/httproutes-generator-maven-plugin/README.md index bb93e74192..fed82d6024 100644 --- a/core-maven-plugins/httproutes-generator-maven-plugin/README.md +++ b/core-maven-plugins/httproutes-generator-maven-plugin/README.md @@ -14,8 +14,8 @@ and Istio `AuthorizationPolicy` manifests for Istio deployments. - Groups generated routes by route type (`PUBLIC`, `PRIVATE`, `INTERNAL`, `FACADE`). - Generates `AuthorizationPolicy` resources with `DENY` rules for paths that must not be reachable through a gateway (see [Forbidden Routes and AuthorizationPolicies](#forbidden-routes-and-authorizationpolicies)). -- Compares the legacy mesh routing with the generated Istio routing before writing anything, and fails the build - when they differ (see [Route Validation](#route-validation)). +- Fails the build before writing anything when the generated routing can't keep the legacy behavior + (see [Route Errors](#route-errors)). - Emits Helm-ready YAML wrapped in: `{{- if eq .Values.SERVICE_MESH_TYPE "Istio" }}`. - Runs as an **aggregator** mojo, so it collects routes across reactor modules. @@ -73,25 +73,19 @@ Add the plugin to your `pom.xml`: | `outputFile` | `String` | `gateway-httproutes.yaml` | Output path relative to project base dir. | | `backendRefVal` | `String` | `{{ .Values.DEPLOYMENT_RESOURCE_NAME }}` | Backend service name in generated routes. | | `labels` | `List

+ * Legacy gateways forbid a route on the gateways wider than its type, and don't route a path no route matches. + * Istio routes by the {@code PathPrefix} cut from each gateway path, so it routes some of these paths: + *

    + *
  • a narrower route's path below the prefix of a route exposed on the gateway;
  • + *
  • the prefix itself, cut from a route with variables, if no route exposed on the gateway matches it.
  • + *
+ * These paths need DENY rules. They are generated if {@code autoGenerateAuthorizationPolicies} is set, else they must + * be declared with {@code @ForbiddenRoute}, as all other forbidden paths. */ public class AuthorizationPolicyRenderer { - public static final List DEFAULT_PORTS = List.of("8080"); + private static final List PORTS = List.of("8080"); private static final String ACTION_DENY = "DENY"; + private static final List GATEWAYS = + List.of(HttpRoute.Type.PUBLIC, HttpRoute.Type.PRIVATE, HttpRoute.Type.INTERNAL); private final Map labels; - private final Map> ports; + private final boolean autoGenerateAuthorizationPolicies; - /** - * @param labels custom labels, or empty for the default labels - * @param ports ports per gateway; a gateway that isn't listed uses {@link #DEFAULT_PORTS} - */ - public AuthorizationPolicyRenderer(Map labels, Map> ports) { + public AuthorizationPolicyRenderer(Map labels, boolean autoGenerateAuthorizationPolicies) { this.labels = labels == null ? Collections.emptyMap() : labels; - this.ports = ports == null ? Collections.emptyMap() : ports; + this.autoGenerateAuthorizationPolicies = autoGenerateAuthorizationPolicies; } @JsonInclude(JsonInclude.Include.NON_NULL) @@ -56,31 +60,130 @@ public record Operation(List ports, List paths, List not } } - public String generateAuthorizationPoliciesYaml(List denyRules) { - List policies = new ArrayList<>(); - for (Gateway gateway : Gateway.values()) { - List rules = denyRules.stream().filter(r -> r.gateway() == gateway).toList(); + /** + * A path forbidden on a gateway that Istio would route without a DENY rule. + */ + private record Missing(String reason, Set gateways) { + } + + public String generateAuthorizationPoliciesYaml(Set routes, Set forbidden, Problems problems) { + List border = routes.stream().filter(r -> r.type() != HttpRoute.Type.FACADE).toList(); + Map missing = new TreeMap<>(); + Set inexpressible = new TreeSet<>(); + StringBuilder yaml = new StringBuilder(); + for (HttpRoute.Type gateway : GATEWAYS) { + Collection rules = + denyRules(gateway, border, forbidden, missing, inexpressible, problems); if (!rules.isEmpty()) { - policies.add(toResource(gateway, rules)); + yaml.append(HttpRouteRenderer.writeYaml(toResource(gateway, List.copyOf(rules)))); + } + } + missing.forEach((path, m) -> problems.error(path + " " + m.reason() + " on " + gatewayNames(m.gateways()) + + ": add @ForbiddenRoute({" + m.gateways().stream().sorted(Comparator.reverseOrder()).map(Enum::name) + .collect(Collectors.joining(", ")) + "}) to the element mapped to " + path + + ", or set autoGenerateAuthorizationPolicies to generate the DENY rules")); + inexpressible.forEach(path -> problems.error(path + " is a forbidden path or overlaps one, and an " + + "AuthorizationPolicy can't express it: variables must take up whole path segments, and * wildcards " + + "aren't supported")); + return yaml.toString(); + } + + private Collection denyRules(HttpRoute.Type gateway, List border, + Set forbidden, + Map missing, + Set inexpressible, Problems problems) { + Set allowed = border.stream().filter(r -> r.type().compareTo(gateway) >= 0) + .map(HttpRoute::gatewayPath).collect(Collectors.toCollection(TreeSet::new)); + Set allowedTemplates = allowed.stream().map(RoutePaths::template).collect(Collectors.toSet()); + Set implicit = border.stream().filter(r -> r.type().compareTo(gateway) < 0) + .map(HttpRoute::gatewayPath).filter(p -> !allowedTemplates.contains(RoutePaths.template(p))) + .collect(Collectors.toCollection(TreeSet::new)); + Set explicit = forbidden.stream().filter(f -> f.gateways().contains(gateway)) + .map(ForbiddenPath::gatewayPath).collect(Collectors.toCollection(TreeSet::new)); + explicit.stream().filter(p -> allowedTemplates.contains(RoutePaths.template(p))).forEach(p -> problems.error( + "@ForbiddenRoute forbids " + p + " on " + gateway.gatewayName() + ", where a route with this gateway path is exposed")); + + Map needed = new TreeMap<>(); + Set prefixes = allowed.stream().map(RoutePaths::cut).collect(Collectors.toCollection(TreeSet::new)); + for (String path : implicit) { + prefixes.stream().filter(prefix -> RoutePaths.covers(prefix, path)).findFirst().ifPresent(prefix -> needed.put(path, + "is forbidden by legacy, as its route type is narrower, but Istio routes it by PathPrefix " + prefix)); + } + for (String path : allowed) { + String prefix = RoutePaths.cut(path); + if (!RoutePaths.hasVariable(path) || needed.containsKey(prefix)) { + continue; + } + if (legacyRoute(prefix, allowed, implicit).filter(allowed::contains).isEmpty()) { + needed.put(prefix, "is not routed by legacy, but Istio routes it by PathPrefix " + prefix + " cut from " + path); } } - return policies.stream() - .map(ResourceLabels::writeYaml) - .collect(Collectors.joining()); + + Set explicitTemplates = explicit.stream().map(RoutePaths::template).collect(Collectors.toSet()); + Set rulePaths = new TreeSet<>(explicit); + needed.forEach((path, reason) -> { + if (autoGenerateAuthorizationPolicies) { + rulePaths.add(path); + } else if (!explicitTemplates.contains(RoutePaths.template(path))) { + missing.computeIfAbsent(path, p -> new Missing(reason, EnumSet.noneOf(HttpRoute.Type.class))) + .gateways().add(gateway); + } + }); + + Map rules = new TreeMap<>(); + for (String path : rulePaths) { + List overlapping = allowed.stream() + .filter(route -> route.length() > path.length() && RoutePaths.overlaps(path, route)).toList(); + // Probes the rule path and its intersection with every overlapping allowed route: the route legacy routes + // a probe by, if the rule denies the probe, is excluded too, so that the rule denies nothing legacy routed + Set excluded = new TreeSet<>(overlapping); + Stream.concat(Stream.of(path), allowed.stream().filter(route -> RoutePaths.overlaps(path, route))) + .map(route -> RoutePaths.sample(path, route)) + .filter(request -> overlapping.stream().noneMatch(route -> RoutePaths.covers(route, request))) + .forEach(request -> legacyRoute(request, allowed, implicit).filter(allowed::contains) + .ifPresent(excluded::add)); + List invalid = Stream.concat(Stream.of(path), excluded.stream()) + .filter(p -> !RoutePaths.expressible(p)).toList(); + if (!invalid.isEmpty()) { + inexpressible.addAll(invalid); + continue; + } + List notPaths = excluded.stream().map(RoutePaths::template).distinct().sorted() + .flatMap(t -> subtree(t).stream()).toList(); + rules.putIfAbsent(RoutePaths.template(path), new AuthorizationPolicyResource.Spec.Rule(List.of( + new AuthorizationPolicyResource.Spec.Rule.To(new AuthorizationPolicyResource.Spec.Rule.Operation( + PORTS, subtree(RoutePaths.template(path)), notPaths))))); + } + return rules.values(); + } + + /** + * @return the route legacy matches a literal request path by: the longest covering one, the forbidden one on a tie + */ + private static Optional legacyRoute(String request, Set allowed, Set implicit) { + return Stream.concat(allowed.stream(), implicit.stream()).filter(route -> RoutePaths.covers(route, request)) + .max(Comparator.comparingInt(String::length).thenComparing(route -> !allowed.contains(route))); + } + + private static List subtree(String template) { + return List.of(template, template.equals("/") ? "/{**}" : template + "/{**}"); + } + + private static String gatewayNames(Set gateways) { + return gateways.stream().sorted(Comparator.reverseOrder()).map(HttpRoute.Type::gatewayName) + .collect(Collectors.joining(", ")); } - private AuthorizationPolicyResource toResource(Gateway gateway, List rules) { + private AuthorizationPolicyResource toResource(HttpRoute.Type gateway, List rules) { AuthorizationPolicyResource.Metadata metadata = new AuthorizationPolicyResource.Metadata( - "{{ .Values.SERVICE_NAME }}-java-annotations-deny-" + gateway.shortName(), - ResourceLabels.of(labels)); + "{{ .Values.SERVICE_NAME }}-java-annotations-deny-" + gateway.name().toLowerCase(), + HttpRouteRenderer.buildRouteLabels(labels)); + HttpRouteRenderer.HTTPRouteResource.Spec.ParentRef ref = gateway == HttpRoute.Type.INTERNAL + ? HttpRouteRenderer.serviceParentRef(gateway.gatewayName()) + : HttpRouteRenderer.gatewayParentRef(gateway.gatewayName()); AuthorizationPolicyResource.Spec.TargetRef targetRef = - new AuthorizationPolicyResource.Spec.TargetRef(gateway.refGroup(), gateway.refKind(), gateway.refName()); - List gatewayPorts = ports.getOrDefault(gateway, DEFAULT_PORTS); - List ruleList = rules.stream() - .map(rule -> new AuthorizationPolicyResource.Spec.Rule(List.of(new AuthorizationPolicyResource.Spec.Rule.To( - new AuthorizationPolicyResource.Spec.Rule.Operation(gatewayPorts, rule.paths(), rule.notPaths()))))) - .toList(); + new AuthorizationPolicyResource.Spec.TargetRef(ref.group(), ref.kind(), ref.name()); return new AuthorizationPolicyResource(metadata, - new AuthorizationPolicyResource.Spec(List.of(targetRef), ACTION_DENY, ruleList)); + new AuthorizationPolicyResource.Spec(List.of(targetRef), ACTION_DENY, rules)); } } diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/DeclaredRoute.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/DeclaredRoute.java deleted file mode 100644 index 3c043b0617..0000000000 --- a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/DeclaredRoute.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.netcracker.routes.gateway.plugin; - -import java.util.Collections; -import java.util.Objects; -import java.util.SortedSet; -import java.util.TreeSet; - -/** - * A scanned route together with the elements that declared it. Each origin is {@code class#method} or {@code class}. - */ -public record DeclaredRoute(HttpRoute route, SortedSet origins) { - - public DeclaredRoute { - Objects.requireNonNull(route, "route"); - origins = Collections.unmodifiableSortedSet(new TreeSet<>(origins)); - } - - public String originsText() { - return String.join(", ", origins); - } - - public String describe() { - HttpRoute r = route; - String target = r.gatewayPath().equals(r.path()) ? r.gatewayPath() : r.gatewayPath() + " -> " + r.path(); - return r.type() + " route " + target + " (from " + originsText() + ")"; - } -} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/DenyRule.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/DenyRule.java deleted file mode 100644 index ae4eaa87b2..0000000000 --- a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/DenyRule.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.netcracker.routes.gateway.plugin; - -import java.util.List; -import java.util.Objects; -import java.util.regex.Pattern; - -/** - * One rule of a gateway's DENY AuthorizationPolicy. - * - * @param gateway the border gateway the policy targets - * @param paths path templates the rule denies - * @param notPaths path templates excluded from the rule, may be empty - * @param origin the {@code @ForbiddenRoute} element, or a synthetic {@code auto: ...} origin for automatic rules - */ -public record DenyRule(Gateway gateway, List paths, List notPaths, String origin) { - - public static final String AUTO_ORIGIN_PREFIX = "auto: "; - - public DenyRule { - Objects.requireNonNull(gateway, "gateway"); - paths = List.copyOf(paths); - notPaths = List.copyOf(notPaths); - } - - public boolean isAutomatic() { - return origin.startsWith(AUTO_ORIGIN_PREFIX); - } - - /** - * @return whether Istio denies the request path: it matches {@code paths} and doesn't match {@code notPaths} - */ - public boolean denies(String requestPath) { - return paths.stream().anyMatch(t -> templateMatches(t, requestPath)) - && notPaths.stream().noneMatch(t -> templateMatches(t, requestPath)); - } - - /** - * @return the template for a path and the template for everything below it: {@code [T, T/{**}]}, {@code ["/", "/{**}"]} for the root - */ - public static List pathAndSubtree(String template) { - return template.equals("/") ? List.of("/", "/{**}") : List.of(template, template + "/{**}"); - } - - /** - * Istio path template matching: {@code {*}} matches one segment, {@code {**}} (last only) zero or more segments. - */ - static boolean templateMatches(String template, String requestPath) { - StringBuilder regex = new StringBuilder(); - String[] parts = template.split("/", -1); - for (int i = 0; i < parts.length; i++) { - if (i > 0) { - regex.append('/'); - } - switch (parts[i]) { - case "{*}" -> regex.append("[^/]+"); - case "{**}" -> regex.append(".*"); - default -> regex.append(Pattern.quote(parts[i])); - } - } - return Pattern.matches(regex.toString(), requestPath); - } - - public String describe() { - String text = "DENY " + String.join(", ", paths); - if (!notPaths.isEmpty()) { - text += " except " + String.join(", ", notPaths); - } - return text + " (from " + origin + ")"; - } -} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Finding.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Finding.java deleted file mode 100644 index 757e797a6c..0000000000 --- a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Finding.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.netcracker.routes.gateway.plugin; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Optional; -import java.util.Set; - -/** - * A problem found while scanning, planning or validating routes. - * - * @param kind what kind of problem it is - * @param target gateway name, {@link #SERVICE_TARGET}, several gateway names, or {@code null} for scan-time findings - * @param details labelled report lines, in report order - * @param gatewayPaths gateway paths of the routes whose rewrites or precedence collide, empty for other findings; - * {@link RouteMigration} drops the finding if one of them is {@code LEGACY_INVALID} - */ -public record Finding(Kind kind, String target, List details, Set gatewayPaths) { - - public static final String SERVICE_TARGET = "service-bound HTTPRoute"; - - public Finding { - details = List.copyOf(details); - gatewayPaths = Set.copyOf(gatewayPaths); - } - - public Finding(Kind kind, String target, List details) { - this(kind, target, details, Set.of()); - } - - public static Finding of(Kind kind, String target, String... labelTextPairs) { - if (labelTextPairs.length % 2 != 0) { - throw new IllegalArgumentException("labels and texts must come in pairs"); - } - List details = new ArrayList<>(); - for (int i = 0; i < labelTextPairs.length; i += 2) { - details.add(new Detail(labelTextPairs[i], labelTextPairs[i + 1])); - } - return new Finding(kind, target, details); - } - - /** - * @return this finding about the routes with these gateway paths - */ - public Finding about(Collection gatewayPaths) { - return new Finding(kind, target, details, Set.copyOf(gatewayPaths)); - } - - public boolean isError() { - return kind.severity() == Severity.ERROR; - } - - public Optional detail(String label) { - return details.stream().filter(d -> d.label().equals(label)).map(Detail::text).findFirst(); - } - - public enum Severity { - ERROR, WARNING - } - - public enum Kind { - INVALID_FORBIDDEN_ROUTE(Severity.ERROR), - LEGACY_INVALID(Severity.ERROR), - CONTRADICTORY_DECLARATION(Severity.ERROR), - LEGACY_PRECEDENCE_UNDEFINED(Severity.ERROR), - CONFLICT(Severity.ERROR), - EXPOSURE(Severity.ERROR), - LOST_ROUTE(Severity.ERROR), - TIMEOUT_CHANGE(Severity.WARNING), - TIMEOUT_MERGE(Severity.WARNING), - TIMEOUT_NOT_APPLIED(Severity.WARNING); - - private final Severity severity; - - Kind(Severity severity) { - this.severity = severity; - } - - public Severity severity() { - return severity; - } - } - - public record Detail(String label, String text) { - } -} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/FindingReport.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/FindingReport.java deleted file mode 100644 index 0e7ee80bea..0000000000 --- a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/FindingReport.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.netcracker.routes.gateway.plugin; - -import org.apache.maven.plugin.logging.Log; - -import java.util.Collection; -import java.util.Comparator; -import java.util.EnumMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * Formats findings for the build log, grouped by gateway (design D11). - */ -public final class FindingReport { - - static final String PREFIX = "[ROUTE-MIGRATION]"; - - private FindingReport() { - } - - /** - * @return findings in report order: scan-time findings, then public, private and internal gateway, - * then the service-bound HTTPRoute; the original order is kept within a group - */ - public static List sorted(Collection findings) { - return findings.stream().sorted(Comparator.comparingInt(FindingReport::rank)).toList(); - } - - private static int rank(Finding finding) { - String target = finding.target(); - if (target == null) { - return 0; - } - if (target.equals(Finding.SERVICE_TARGET)) { - return Gateway.values().length + 1; - } - for (Gateway gateway : Gateway.values()) { - if (target.startsWith(gateway.refName())) { - return gateway.ordinal() + 1; - } - } - return Gateway.values().length + 1; - } - - /** - * @return for example {@code [ROUTE-MIGRATION] EXPOSURE on public-gateway} followed by indented {@code label: text} lines - */ - public static String format(Finding finding) { - StringBuilder text = new StringBuilder(PREFIX).append(' ').append(finding.kind()); - if (finding.target() != null) { - text.append(" on ").append(finding.target()); - } - int width = finding.details().stream().mapToInt(d -> d.label().length()).max().orElse(0) + 1; - for (Finding.Detail detail : finding.details()) { - String label = detail.label() + ":"; - text.append(System.lineSeparator()).append(" ").append(label) - .append(" ".repeat(width - label.length() + 1)).append(detail.text()); - } - return text.toString(); - } - - /** - * Logs all findings in report order: errors as errors, warnings as warnings. - */ - public static void log(Log log, Collection findings) { - for (Finding finding : sorted(findings)) { - if (finding.isError()) { - log.error(format(finding)); - } else { - log.warn(format(finding)); - } - } - } - - /** - * @return the build failure message with the error counts by kind, for example - * {@code Route migration validation failed with 3 errors (1 CONFLICT, 2 EXPOSURE), see the [ROUTE-MIGRATION] errors in the build log} - */ - public static String summary(Collection findings) { - Map counts = new EnumMap<>(Finding.Kind.class); - findings.stream().filter(Finding::isError).forEach(f -> counts.merge(f.kind(), 1L, Long::sum)); - long total = counts.values().stream().mapToLong(Long::longValue).sum(); - String byKind = counts.entrySet().stream().map(e -> e.getValue() + " " + e.getKey()).collect(Collectors.joining(", ")); - return "Route migration validation failed with " + total + (total == 1 ? " error" : " errors") + " (" + byKind - + "), see the " + PREFIX + " errors in the build log"; - } -} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenDeclaration.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenDeclaration.java deleted file mode 100644 index 312605dab6..0000000000 --- a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenDeclaration.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.netcracker.routes.gateway.plugin; - -import java.util.Collections; -import java.util.EnumSet; -import java.util.Set; - -/** - * A gateway path forbidden by {@code @ForbiddenRoute} on the listed border gateways. - * - * @param origin {@code class#method} or {@code class} that carries the annotation - */ -public record ForbiddenDeclaration(String gatewayPath, Set gateways, String origin) { - - public ForbiddenDeclaration { - gatewayPath = PathPattern.normalize(gatewayPath); - gateways = Collections.unmodifiableSet(gateways.isEmpty() ? EnumSet.noneOf(Gateway.class) : EnumSet.copyOf(gateways)); - } -} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenPath.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenPath.java new file mode 100644 index 0000000000..3fff4b3a14 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenPath.java @@ -0,0 +1,14 @@ +package com.netcracker.routes.gateway.plugin; + +import java.util.Set; + +/** + * A gateway path that {@code @ForbiddenRoute} forbids on the given border gateways. + */ +public record ForbiddenPath(String gatewayPath, Set gateways) { + + public ForbiddenPath { + gatewayPath = gatewayPath.startsWith("/") ? gatewayPath : "/" + gatewayPath; + gateways = Set.copyOf(gateways); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Gateway.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Gateway.java deleted file mode 100644 index 3724046d0a..0000000000 --- a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Gateway.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.netcracker.routes.gateway.plugin; - -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.EnumSet; -import java.util.Optional; -import java.util.Set; - -/** - * Border gateway of the legacy mesh together with the Istio resource that replaces it. - * The declaration order (public, private, internal) is the order used for generated policies and reports. - */ -public enum Gateway { - // qualified names: enum constants can't refer to the constants below by simple name - PUBLIC("public-gateway-service", "public", HttpRoute.Type.PUBLIC, - Gateway.PARENT_REF_GROUP_GATEWAY, Gateway.PARENT_REF_KIND_GATEWAY, "public-gateway"), - PRIVATE("private-gateway-service", "private", HttpRoute.Type.PRIVATE, - Gateway.PARENT_REF_GROUP_GATEWAY, Gateway.PARENT_REF_KIND_GATEWAY, "private-gateway"), - INTERNAL("internal-gateway-service", "internal", HttpRoute.Type.INTERNAL, - Gateway.PARENT_REF_GROUP_SERVICE, Gateway.PARENT_REF_KIND_SERVICE, "internal-gateway-service"); - - public static final String PARENT_REF_KIND_SERVICE = "Service"; - public static final String PARENT_REF_KIND_GATEWAY = "Gateway"; - public static final String PARENT_REF_GROUP_GATEWAY = "gateway.networking.k8s.io"; - public static final String PARENT_REF_GROUP_SERVICE = ""; - - private final String legacyName; - private final String shortName; - private final HttpRoute.Type routeType; - private final String refGroup; - private final String refKind; - private final String refName; - - Gateway(String legacyName, String shortName, HttpRoute.Type routeType, String refGroup, String refKind, String refName) { - this.legacyName = legacyName; - this.shortName = shortName; - this.routeType = routeType; - this.refGroup = refGroup; - this.refKind = refKind; - this.refName = refName; - } - - /** - * @return gateway name used by legacy route registration, for example in {@code @Route(gateways = ...)} - */ - public String legacyName() { - return legacyName; - } - - /** - * @return short name used in generated resource names, for example {@code public} - */ - public String shortName() { - return shortName; - } - - /** - * @return route type whose routes are registered with this gateway by legacy {@code RouteType.fromGatewayName} - */ - public HttpRoute.Type routeType() { - return routeType; - } - - /** - * @return group of the Istio parentRef / targetRef - */ - public String refGroup() { - return refGroup; - } - - /** - * @return kind of the Istio parentRef / targetRef - */ - public String refKind() { - return refKind; - } - - /** - * @return name of the Istio parentRef / targetRef, also used to name the gateway in reports - */ - public String refName() { - return refName; - } - - public static Optional fromLegacyName(String name) { - for (Gateway gateway : values()) { - if (gateway.legacyName.equals(name)) { - return Optional.of(gateway); - } - } - return Optional.empty(); - } - - /** - * Maps a {@code RouteType} constant name (as found in annotations) to a gateway. {@code FACADE} maps to no gateway. - */ - public static Optional fromRouteTypeName(String routeTypeName) { - for (Gateway gateway : values()) { - if (gateway.routeType.name().equals(routeTypeName)) { - return Optional.of(gateway); - } - } - return Optional.empty(); - } - - /** - * Legacy exposure chain: the gateways on which a route of the given type is allowed. - * PUBLIC → public, private, internal; PRIVATE → private, internal; INTERNAL → internal; FACADE → none. - */ - public static Set exposedBy(HttpRoute.Type type) { - return Collections.unmodifiableSet(switch (type) { - case PUBLIC -> EnumSet.of(PUBLIC, PRIVATE, INTERNAL); - case PRIVATE -> EnumSet.of(PRIVATE, INTERNAL); - case INTERNAL -> EnumSet.of(INTERNAL); - case FACADE -> EnumSet.noneOf(Gateway.class); - }); - } - - /** - * @return the type with the widest exposure (PUBLIC > PRIVATE > INTERNAL) among the given border types - */ - public static HttpRoute.Type widest(Collection types) { - return types.stream() - .max(Comparator.comparingInt(type -> exposedBy(type).size())) - .orElseThrow(); - } -} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojo.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojo.java index 0de09a52ec..b97a3751ab 100644 --- a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojo.java +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojo.java @@ -10,13 +10,9 @@ import java.nio.file.Files; import java.nio.file.Path; -import java.util.Arrays; import java.util.Collections; -import java.util.EnumMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; @Mojo( @@ -48,79 +44,38 @@ public class GenerateRoutesMojo extends AbstractMojo { private List