Background and Motivation
Aspire.Hosting.Kubernetes ships a good Gateway API model (AddGateway().WithRoute().WithHostname().WithTls()),
but it can only drive a Gateway it generates. Two things block the most common production
topology — a shared, platform-owned Gateway that application teams attach routes to.
1. HttpRouteParentRefV1 carries only Name (Resources/HttpRouteV1.cs:51) — no
namespace, sectionName, group or kind. The emission hardcodes a name-only parentRef at
KubernetesEnvironmentResource.cs:1155:
httpRoute.Spec.ParentRefs.Add(new HttpRouteParentRefV1 { Name = gatewayName });
where gatewayName is always resolved from an in-model KubernetesGatewayResource. There is also
no AsExisting mode on AddGateway, so a Gateway object is always generated.
2. HttpRouteRuleV1 has no Filters (Resources/HttpRouteV1.cs, the type carries only
Matches and BackendRefs). So URLRewrite (and RequestHeaderModifier, RequestRedirect,
RequestMirror, ExtensionRef) cannot be expressed at all. Prefix-stripping is required whenever a
service is exposed under a path prefix on a shared Gateway, which is the normal case for (1).
These are filed together because they are the same file and the same scenario: our own notes had
them as separate gaps until it became obvious they fold into one change.
allowedRoutes does not block this — the parentRef fields are the only thing missing
Worth stating up front, because it is the obvious first objection. Aspire hardcodes
AllowedRoutes.Namespaces.From = "Same" on the listeners of Gateways it generates
(KubernetesEnvironmentResource.cs:1080, :1109, :1133). That governs who may attach to an
Aspire-created Gateway, and it is irrelevant to this proposal: in the existing-gateway case Aspire
emits no Gateway object at all, and the platform's own Gateway owns its allowedRoutes policy.
We have confirmed this end to end. An HTTPRoute in a workload's own namespace, attached to a
shared platform Gateway in a different namespace, reports Accepted=True / ResolvedRefs=True
and serves live traffic — because the platform Gateway's listener already permits routes from other
namespaces, as shared gateways generally do. Cross-namespace attachment works today; the only
thing that cannot be expressed is the parentRef itself. That makes the model additions below
not just necessary but sufficient.
(For completeness: the "Same" default does mean an Aspire-generated Gateway cannot currently
accept routes from other namespaces. That is a separate limitation, out of scope here.)
Is this the right ask? (division of responsibility)
This is squarely the Gateway API's own role model, which is why we think it is the right ask:
| Gateway API role |
Owns |
In Aspire today |
| Infrastructure provider / cluster operator |
the Gateway |
✅ AddGateway generates one |
| Application developer |
the HTTPRoute, attached to a Gateway they did not create |
❌ blocked |
The Gateway API explicitly separates these so app teams can attach routes to infrastructure they
do not own — that is the entire point of parentRefs, cross-namespace ReferenceGrant, and
sectionName. Aspire currently supports only the case where the app team owns both, which is the
less common production shape.
We are not asking Aspire to manage a Gateway it does not own — the opposite. We are asking it to
stop requiring ownership in order to emit a route.
Proposed API
1. Gateway-API-standard optional fields on the parent ref (omitted when null):
public sealed class HttpRouteParentRefV1
{
[YamlMember(Alias = "name")]
public string Name { get; set; } = null!;
+
+ [YamlMember(Alias = "namespace")] public string? Namespace { get; set; }
+ [YamlMember(Alias = "sectionName")] public string? SectionName { get; set; }
+ [YamlMember(Alias = "group")] public string? Group { get; set; }
+ [YamlMember(Alias = "kind")] public string? Kind { get; set; }
}
2. An AsExisting mode on the gateway, mirroring the AsExisting / PublishAsExisting
annotation pattern already used in Aspire.Hosting.Azure — skip emitting the Gateway, emit
route-only:
+public static IResourceBuilder<KubernetesGatewayResource> AsExisting(
+ this IResourceBuilder<KubernetesGatewayResource> builder,
+ string gatewayName,
+ string? gatewayNamespace = null);
3. Filters on the rule, serialized after matches and before backendRefs (a
readability and CRD-consistency choice, not a functional requirement — see Risks):
public sealed class HttpRouteRuleV1
{
[YamlMember(Alias = "matches")] public List<HttpRouteMatchV1> Matches { get; } = [];
+ [YamlMember(Alias = "filters")] public List<HttpRouteFilterV1> Filters { get; } = [];
[YamlMember(Alias = "backendRefs")] public List<HttpRouteBackendRefV1> BackendRefs { get; } = [];
}
+[YamlSerializable]
+public sealed class HttpRouteFilterV1
+{
+ [YamlMember(Alias = "type")] public string Type { get; set; } = null!; // e.g. "URLRewrite"
+ [YamlMember(Alias = "urlRewrite")] public HttpUrlRewriteFilterV1? UrlRewrite { get; set; }
+}
+
+[YamlSerializable]
+public sealed class HttpUrlRewriteFilterV1
+{
+ [YamlMember(Alias = "path")] public HttpPathModifierV1? Path { get; set; }
+}
+
+[YamlSerializable]
+public sealed class HttpPathModifierV1
+{
+ [YamlMember(Alias = "type")] public string Type { get; set; } = "ReplacePrefixMatch";
+ [YamlMember(Alias = "replacePrefixMatch")] public string? ReplacePrefixMatch { get; set; }
+ [YamlMember(Alias = "replaceFullPath")] public string? ReplaceFullPath { get; set; }
+}
Start with URLRewrite; the other filter kinds can be added later as null-omitted properties.
Usage Examples
var gateway = env.AddGateway("platform")
.AsExisting("platform-gateway", "gateway-system");
gateway.WithRoute("/my-app", myService, rewritePrefix: "/");
spec:
parentRefs:
- name: platform-gateway
namespace: gateway-system
sectionName: https
rules:
- matches:
- path: { type: PathPrefix, value: /my-app }
filters:
- type: URLRewrite
urlRewrite:
path: { type: ReplacePrefixMatch, replacePrefixMatch: / }
backendRefs:
- name: my-app-service
port: 8080
Alternative Designs
- Extend only the parent ref, not filters. Insufficient in practice: a workload sharing a host
on a shared Gateway needs a unique path prefix externally while the pod keeps serving /.
- Hand-roll the HTTPRoute YAML. What we do today — a
StringBuilder that emits
matches → filters → backendRefs — because the model cannot represent filters and the
environment's serialization is not extensible from outside the package. It works and is
snapshot-tested, but it is the single most divergent part of our output and we would delete it
immediately.
Risks
Low. All additions are optional and null-omitted, so no existing generated manifest changes.
AsExisting follows an established pattern in the codebase.
The behaviour is not speculative — see the note above on cross-namespace attachment already working
against a live shared gateway once the parentRef can express it.
Correction (post-filing). An earlier revision of this issue claimed filters must precede
backendRefs because "Istio and most controllers expect" it. That is wrong: Kubernetes
deserializes these documents into Go structs, so mapping-key order carries no semantics and
no controller can depend on it. The ordering is still worth keeping — it matches the upstream CRD
field order and keeps generated manifests and snapshots stable — but it is a style choice, not a
conformance constraint.
What is semantically significant is the order of elements within the filters list, which
this proposal preserves by modelling it as an ordered List<T>. Per the Gateway API spec
(apis/v1/httproute_types.go): "Wherever possible, implementations SHOULD implement filters in
the order they are specified."
Implementation note — scope is wider than "additive to Aspire.Hosting.Kubernetes". Prototyping
this surfaced two prerequisites that are not visible from the API surface above, and that a reviewer
should weigh:
- The new
group field could not round-trip until a bug in a shared YAML serializer was fixed.
string implements IEnumerable, so src/Shared/Yaml/YamlIEnumerableSkipEmptyObjectGraphVisitor.cs
treated an empty string as an empty collection and dropped it. That matters here because the
Gateway API assigns empty strings distinct meaning: parentRef.group: "" means "the core API
group", and replacePrefixMatch: "" means "strip the matched prefix". This file is shared with
other publishers, not Kubernetes-specific.
- Pre-existing rewrite-prefix validation contradicted the Gateway API spec on two counts and had
to be corrected before the URLRewrite filter behaved to spec.
Neither alters the proposed API surface, and no previously generated manifest changed — verified by
zero pre-existing snapshot drift across both the Kubernetes and Docker suites, the latter exercising
that same shared serializer.
Background and Motivation
Aspire.Hosting.Kubernetesships a good Gateway API model (AddGateway().WithRoute().WithHostname().WithTls()),but it can only drive a Gateway it generates. Two things block the most common production
topology — a shared, platform-owned Gateway that application teams attach routes to.
1.
HttpRouteParentRefV1carries onlyName(Resources/HttpRouteV1.cs:51) — nonamespace,sectionName,grouporkind. The emission hardcodes a name-only parentRef atKubernetesEnvironmentResource.cs:1155:where
gatewayNameis always resolved from an in-modelKubernetesGatewayResource. There is alsono
AsExistingmode onAddGateway, so aGatewayobject is always generated.2.
HttpRouteRuleV1has noFilters(Resources/HttpRouteV1.cs, the type carries onlyMatchesandBackendRefs). SoURLRewrite(andRequestHeaderModifier,RequestRedirect,RequestMirror,ExtensionRef) cannot be expressed at all. Prefix-stripping is required whenever aservice is exposed under a path prefix on a shared Gateway, which is the normal case for (1).
These are filed together because they are the same file and the same scenario: our own notes had
them as separate gaps until it became obvious they fold into one change.
allowedRoutesdoes not block this — the parentRef fields are the only thing missingWorth stating up front, because it is the obvious first objection. Aspire hardcodes
AllowedRoutes.Namespaces.From = "Same"on the listeners of Gateways it generates(
KubernetesEnvironmentResource.cs:1080,:1109,:1133). That governs who may attach to anAspire-created Gateway, and it is irrelevant to this proposal: in the existing-gateway case Aspire
emits no
Gatewayobject at all, and the platform's own Gateway owns itsallowedRoutespolicy.We have confirmed this end to end. An
HTTPRoutein a workload's own namespace, attached to ashared platform Gateway in a different namespace, reports
Accepted=True/ResolvedRefs=Trueand serves live traffic — because the platform Gateway's listener already permits routes from other
namespaces, as shared gateways generally do. Cross-namespace attachment works today; the only
thing that cannot be expressed is the
parentRefitself. That makes the model additions belownot just necessary but sufficient.
(For completeness: the
"Same"default does mean an Aspire-generated Gateway cannot currentlyaccept routes from other namespaces. That is a separate limitation, out of scope here.)
Is this the right ask? (division of responsibility)
This is squarely the Gateway API's own role model, which is why we think it is the right ask:
GatewayAddGatewaygenerates oneHTTPRoute, attached to a Gateway they did not createThe Gateway API explicitly separates these so app teams can attach routes to infrastructure they
do not own — that is the entire point of
parentRefs, cross-namespaceReferenceGrant, andsectionName. Aspire currently supports only the case where the app team owns both, which is theless common production shape.
We are not asking Aspire to manage a Gateway it does not own — the opposite. We are asking it to
stop requiring ownership in order to emit a route.
Proposed API
1. Gateway-API-standard optional fields on the parent ref (omitted when null):
public sealed class HttpRouteParentRefV1 { [YamlMember(Alias = "name")] public string Name { get; set; } = null!; + + [YamlMember(Alias = "namespace")] public string? Namespace { get; set; } + [YamlMember(Alias = "sectionName")] public string? SectionName { get; set; } + [YamlMember(Alias = "group")] public string? Group { get; set; } + [YamlMember(Alias = "kind")] public string? Kind { get; set; } }2. An
AsExistingmode on the gateway, mirroring theAsExisting/PublishAsExistingannotation pattern already used in
Aspire.Hosting.Azure— skip emitting theGateway, emitroute-only:
3. Filters on the rule, serialized after
matchesand beforebackendRefs(areadability and CRD-consistency choice, not a functional requirement — see Risks):
public sealed class HttpRouteRuleV1 { [YamlMember(Alias = "matches")] public List<HttpRouteMatchV1> Matches { get; } = []; + [YamlMember(Alias = "filters")] public List<HttpRouteFilterV1> Filters { get; } = []; [YamlMember(Alias = "backendRefs")] public List<HttpRouteBackendRefV1> BackendRefs { get; } = []; } +[YamlSerializable] +public sealed class HttpRouteFilterV1 +{ + [YamlMember(Alias = "type")] public string Type { get; set; } = null!; // e.g. "URLRewrite" + [YamlMember(Alias = "urlRewrite")] public HttpUrlRewriteFilterV1? UrlRewrite { get; set; } +} + +[YamlSerializable] +public sealed class HttpUrlRewriteFilterV1 +{ + [YamlMember(Alias = "path")] public HttpPathModifierV1? Path { get; set; } +} + +[YamlSerializable] +public sealed class HttpPathModifierV1 +{ + [YamlMember(Alias = "type")] public string Type { get; set; } = "ReplacePrefixMatch"; + [YamlMember(Alias = "replacePrefixMatch")] public string? ReplacePrefixMatch { get; set; } + [YamlMember(Alias = "replaceFullPath")] public string? ReplaceFullPath { get; set; } +}Start with
URLRewrite; the other filter kinds can be added later as null-omitted properties.Usage Examples
Alternative Designs
on a shared Gateway needs a unique path prefix externally while the pod keeps serving
/.StringBuilderthat emitsmatches→filters→backendRefs— because the model cannot represent filters and theenvironment's serialization is not extensible from outside the package. It works and is
snapshot-tested, but it is the single most divergent part of our output and we would delete it
immediately.
Risks
Low. All additions are optional and null-omitted, so no existing generated manifest changes.
AsExistingfollows an established pattern in the codebase.The behaviour is not speculative — see the note above on cross-namespace attachment already working
against a live shared gateway once the
parentRefcan express it.Correction (post-filing). An earlier revision of this issue claimed
filtersmust precedebackendRefsbecause "Istio and most controllers expect" it. That is wrong: Kubernetesdeserializes these documents into Go structs, so mapping-key order carries no semantics and
no controller can depend on it. The ordering is still worth keeping — it matches the upstream CRD
field order and keeps generated manifests and snapshots stable — but it is a style choice, not a
conformance constraint.
What is semantically significant is the order of elements within the
filterslist, whichthis proposal preserves by modelling it as an ordered
List<T>. Per the Gateway API spec(
apis/v1/httproute_types.go): "Wherever possible, implementations SHOULD implement filters inthe order they are specified."
Implementation note — scope is wider than "additive to
Aspire.Hosting.Kubernetes". Prototypingthis surfaced two prerequisites that are not visible from the API surface above, and that a reviewer
should weigh:
groupfield could not round-trip until a bug in a shared YAML serializer was fixed.stringimplementsIEnumerable, sosrc/Shared/Yaml/YamlIEnumerableSkipEmptyObjectGraphVisitor.cstreated an empty string as an empty collection and dropped it. That matters here because the
Gateway API assigns empty strings distinct meaning:
parentRef.group: ""means "the core APIgroup", and
replacePrefixMatch: ""means "strip the matched prefix". This file is shared withother publishers, not Kubernetes-specific.
to be corrected before the URLRewrite filter behaved to spec.
Neither alters the proposed API surface, and no previously generated manifest changed — verified by
zero pre-existing snapshot drift across both the Kubernetes and Docker suites, the latter exercising
that same shared serializer.