diff --git a/apis/inferenceclusters/definition.yaml b/apis/inferenceclusters/definition.yaml index 23042c6f4..5147bd774 100644 --- a/apis/inferenceclusters/definition.yaml +++ b/apis/inferenceclusters/definition.yaml @@ -330,6 +330,38 @@ spec: enum: - Standard - Dynamo + placement: + type: object + description: >- + Facts about where this cluster is, projected onto everything + Modelplane composes here. + properties: + metadata: + type: object + description: Metadata to project. + properties: + labels: + type: object + description: >- + Labels stamped onto every ModelReplica and + ModelEndpoint composed on this cluster, so a fact about + the cluster is declared once here rather than repeated + on each of them. + + This is how a self-hosted endpoint gets its region: a + ModelService selects endpoints by label, so a service + scoped to a region selects only the endpoints in it. + These are your labels, under your own prefix. + Modelplane carries and matches them, and never + interprets them, so "eu" means no more to it than + "prod". + additionalProperties: + type: string + maxLength: 63 + maxProperties: 16 + x-kubernetes-validations: + - rule: "self.all(k, !k.startsWith('modelplane.ai/'))" + message: spec.placement.metadata.labels must not use the reserved modelplane.ai/ prefix. taints: type: array description: >- @@ -499,8 +531,29 @@ spec: address: type: string description: >- - External IP of the inference gateway on the remote cluster. - Used by ModelDeployment for unified endpoint routing. + External address of the inference gateway on the remote + cluster. Modelplane resolves status.gateway.hostname to + this itself, on each InferenceGateway's cluster, so a + platform publishes no DNS for it. + hostname: + type: string + description: >- + The internal name an InferenceGateway addresses this + cluster's gateway by, derived by Modelplane and resolved to + status.gateway.address on each gateway's cluster. Published + once the gateway has an address and traffic to it is + mutually authenticated. ModelDeployment composes a + ModelEndpoint origin from it, and withholds the endpoint + while it's unset. + caCertificate: + type: string + maxLength: 16384 + description: >- + PEM certificate of the CA that signed this gateway's + serving certificate. An InferenceGateway validates against + it, so it reaches the cluster it meant to rather than + whatever else answers on that address. Written once + cert-manager on the cluster has issued. cache: type: object description: >- diff --git a/apis/inferencegateways/definition.yaml b/apis/inferencegateways/definition.yaml index 786bc38e6..2ec7b9f14 100644 --- a/apis/inferencegateways/definition.yaml +++ b/apis/inferencegateways/definition.yaml @@ -15,66 +15,199 @@ spec: served: true referenceable: true additionalPrinterColumns: - - name: GATEWAY + - name: CLUSTER type: string - jsonPath: .spec.backend + jsonPath: .spec.clusterName + - name: HOSTNAME + type: string + jsonPath: .spec.hostname - name: ADDRESS type: string jsonPath: .status.address schema: openAPIV3Schema: + description: >- + An InferenceGateway is the front door for inference requests: the only + address a caller sees. It speaks the OpenAI and Anthropic APIs, + authenticates callers, resolves the model a request names to a + ModelService, and forwards to whichever of that service's endpoints + should serve it, translating the request for the backend that won. + + A Modelplane can run several, each on an InferenceCluster of its own. + Run one per region to keep a caller's traffic in its jurisdiction, or + two in a region to survive losing a cluster. Modelplane runs no global + load balancer: distributing callers across gateways is yours to + configure, whether by geo DNS, an anycast address, or an edge of your + own with these gateways as origins. type: object required: [spec] properties: spec: type: object - required: [backend] + required: [clusterName] x-kubernetes-validations: - - rule: "self.backend != 'Traefik' || has(self.traefik)" - message: spec.traefik is required when spec.backend is Traefik. + # Serving HTTPS needs somewhere to get a certificate, and a + # certificate is only meaningful for a name. + - rule: "!has(self.tls) || has(self.hostname)" + message: spec.hostname is required when spec.tls is set. properties: - backend: + clusterName: + type: string + description: >- + The InferenceCluster this gateway runs on, which decides its + region and its address. A gateway doesn't move: unlike a + ModelDeployment, whose replicas re-place when their cluster + goes away, a gateway stays where it was put. Availability + comes from running more of them, because failing over would + change the address callers use and could move traffic out of + the jurisdiction the gateway exists to hold. + + The cluster needs no GPU pools. A cluster with none is a + gateway and nothing else, which is what a region with callers + but no accelerators wants. A cluster that serves models can + host a gateway too, and does so at most once. + minLength: 1 + maxLength: 253 + hostname: type: string - description: Gateway implementation. - enum: [Traefik] - traefik: - type: object description: >- - Traefik Proxy configuration. Required when backend is - Traefik. - required: [version] + The name this gateway answers on. Point it at + status.address once the gateway has one. + + Omit it and the gateway answers on its address alone, over + plain HTTP. That is the getting-started shape, and also the + shape for anyone terminating TLS on an edge of their own in + front of the gateway. + minLength: 1 + maxLength: 253 x-kubernetes-validations: - - rule: "!has(self.loadBalancer) || self.loadBalancer != 'MetalLB' || has(self.metallb)" - message: spec.traefik.metallb is required when spec.traefik.loadBalancer is MetalLB. + - rule: "!self.contains('/') && !self.contains(':')" + message: spec.hostname must be a bare DNS name, with no scheme, port or path. + tls: + type: object + description: >- + Serves callers over HTTPS. Without it the caller's hop is + unencrypted, so anything reachable from an untrusted network + wants this or an edge that terminates TLS in front. + required: [certificateRefs] properties: - version: - type: string - description: Traefik Helm chart version. - loadBalancer: - type: string + certificateRefs: + type: array description: >- - Load balancer implementation for the gateway Service. - Omit for cloud environments where a native LB - controller is available. - enum: [MetalLB] - metallb: + Secrets holding the gateway's certificate, of type + kubernetes.io/tls, in the same namespace as this + Modelplane's other gateway Secrets. Modelplane copies them + to the gateway's cluster. + minItems: 1 + maxItems: 8 + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: [name] + items: + type: object + required: [name] + properties: + name: + type: string + minLength: 1 + maxLength: 253 + auth: + type: object + description: >- + Authenticates callers against keys this gateway holds. Omit it + and the gateway authenticates nobody, so anything that can + reach the address can invoke any ModelService it serves. That + is only appropriate behind something that has already + established who is calling. + + Modelplane authenticates callers; it does not authorize them. + Every accepted key can reach every ModelService this gateway + serves, and /v1/models lists them all regardless of key. To + narrow what a key can reach, narrow the gateway with + serviceSelector or run a separate gateway with its own keys. + required: [secretSelector] + properties: + secretSelector: type: object description: >- - MetalLB configuration. Required when loadBalancer is - MetalLB. Use for kind or bare-metal clusters. - required: [addressPool] + Selects Secrets holding caller API keys. Each key in a + selected Secret is one caller: the entry's name is the + caller's identity and its value is the key. So adding a + caller means writing a Secret, not editing this gateway. + + The gateway stamps the resolved identity onto every + request and every usage record, and never forwards the + caller's key. Ranking one caller above another is not + Modelplane's decision to make, so it publishes the + identity and leaves acting on it to whatever does decide. + required: [matchLabels] properties: - addressPool: - type: string - description: >- - IP address range for the MetalLB pool - (e.g. "172.18.255.200-172.18.255.250"). - Must be within the cluster's network CIDR. + matchLabels: + type: object + additionalProperties: + type: string + maxLength: 63 + minProperties: 1 + maxProperties: 16 + serviceSelector: + type: object + description: >- + Selects the ModelServices this gateway serves, by their + labels. Absent, it serves every one. + + This is how a gateway is scoped: to a region, so an EU service + is only reachable through EU gateways; to your public services + on an internet-facing front door; or to a named set on a + dedicated gateway. These are your labels, under your own + prefix. Modelplane matches them and never interprets them, so + a region means no more to it than any other label. + required: [matchLabels] + properties: + matchLabels: + type: object + additionalProperties: + type: string + maxLength: 63 + minProperties: 1 + maxProperties: 16 status: type: object properties: address: type: string description: >- - External address of the control plane gateway. - Backend-agnostic โ€” works for any routing implementation. + The address this gateway answers on, and what spec.hostname + should point at. It is also the target to health check, at + /healthz, to decide whether this gateway is in rotation. + + /healthz answers 200 whenever this gateway's proxy is running + and serving. It says nothing about whether any ModelService is + reachable through it, so a gateway with no healthy backend + stays in rotation and answers requests with a 503. Read each + ModelService's RoutingReady for that. + clientCACertificate: + type: string + maxLength: 16384 + description: >- + PEM certificate of the CA that signs this gateway's client + certificate. Every InferenceCluster accepts client + certificates from it, which is how this gateway proves itself + to a cluster gateway and how anything else is refused. + + One CA per gateway rather than one per Modelplane, so that no + private key has to be distributed: each is generated on the + cluster that uses it and only its certificate travels. Note + that every cluster gateway trusts every fleet gateway's CA and + checks the signing CA rather than the subject, so this bounds + where the keys live, not what one of them can reach. + endpoints: + type: object + description: The paths this gateway serves. + properties: + openAI: + type: string + description: >- + Base URL for the OpenAI API. A caller sets its SDK's + base_url to this and names a ModelService as the model. + anthropic: + type: string + description: Base URL for Anthropic's Messages API. diff --git a/apis/modeldeployments/definition.yaml b/apis/modeldeployments/definition.yaml index bd333f788..2f751916d 100644 --- a/apis/modeldeployments/definition.yaml +++ b/apis/modeldeployments/definition.yaml @@ -369,7 +369,7 @@ spec: semver() helpers, e.g. device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("141Gi")) >= 0. minLength: 1 - maxLength: 10240 + maxLength: 8192 # This object has no schema default on purpose: the # apiserver applies defaults before CEL validation, so # defaulting it would inject it onto Standalone and @@ -500,6 +500,20 @@ spec: serving engine. Includes the model identifier (e.g. --model=...) and any parallelism flags. + + Pass --served-model-name + $(MODELPLANE_SERVED_MODEL_NAME), the + variable Modelplane injects, so the + engine answers to the name a gateway + routes to. A caller names a + ModelService and the gateway rewrites + the request's model to the + deployment's, so an engine started + under a literal name returns 404 for + every request. Nothing enforces this: + a CEL rule requiring the reference + exceeds the schema's rule cost budget + however tightly args is bounded. items: type: string env: diff --git a/apis/modelendpoints/definition.yaml b/apis/modelendpoints/definition.yaml index 383b1b8ec..efb327985 100644 --- a/apis/modelendpoints/definition.yaml +++ b/apis/modelendpoints/definition.yaml @@ -15,46 +15,121 @@ spec: served: true referenceable: true additionalPrinterColumns: - - name: URL + - name: ORIGIN type: string - jsonPath: .spec.url + jsonPath: .spec.origin + - name: MODEL + type: string + jsonPath: .spec.model schema: openAPIV3Schema: + description: >- + A ModelEndpoint is somewhere a request can be served: one replica of a + ModelDeployment, or a model at a provider like Together or Groq. It + describes a backend well enough for a gateway to talk to it without + knowing where it came from, which is what lets a ModelService fan over + endpoints Modelplane runs and endpoints it merely buys from. + + Modelplane composes one per replica. You write them by hand for + anything it doesn't run. type: object required: [spec] properties: spec: type: object - required: [url] + required: [origin] properties: - url: + origin: type: string description: >- - URL of the inference endpoint. Used to configure - routing to this endpoint. + Scheme and host of the backend, with no path: an https origin + gets TLS originated to it. A port is only needed for a + non-default one. + + The host must be a name, never an address. Envoy AI Gateway + applies per-backend model rewriting, credentials and priority + failover only when every backend in a route is addressed by + hostname; given an address it keeps passing traffic but + silently stops applying them, which would send a caller's own + model name to a provider with no credential attached. minLength: 1 - rewritePath: + maxLength: 2048 + x-kubernetes-validations: + - rule: "self.startsWith('http://') || self.startsWith('https://')" + message: spec.origin must start with http:// or https://. + # Every rule on a field is evaluated, so this one has to tolerate + # an origin the rule above already rejected rather than indexing + # past the end of the split and reporting a CEL runtime error. + # It also catches a trailing slash, which would otherwise join + # with api.prefix to make a double slash. + - rule: "!self.contains('://') || self.split('://')[1].split('/').size() == 1" + message: spec.origin must be scheme and host only; put the API's path in spec.api.prefix. + api: + type: object + description: >- + The API this backend speaks, and where it serves it. Defaults + to the OpenAI API under /v1, which is what most providers and + every Modelplane-composed endpoint serve. + properties: + schema: + type: string + description: >- + The API the backend speaks. A gateway translates between + this and whatever the caller sent, so an OpenAI client can + reach an Anthropic backend and the reverse. + default: OpenAI + enum: [OpenAI, Anthropic] + prefix: + type: string + description: >- + The path the backend serves that API under: /v1 for most, + /openai/v1 for Groq, and a per-replica path for a + Modelplane-composed endpoint, whose cluster gateway + distinguishes replicas by path. + default: /v1 + minLength: 1 + maxLength: 512 + x-kubernetes-validations: + - rule: "self.startsWith('/')" + message: spec.api.prefix must start with a slash. + model: type: string description: >- - Path prefix that requests should be rewritten to when - routed through this endpoint. Used by ModelService to - configure URLRewrite on its HTTPRoute. For Modelplane- - composed endpoints this is the per-replica serving path - on the remote cluster's gateway, e.g. /ml-team/qwen-demo/. - status: - type: object - properties: - routing: + The name this backend knows the model by, which a gateway + rewrites the request's model to on the way out. Unset, the + caller's model name passes through unchanged. + + A caller names a ModelService and gets back whichever model + actually served, the way asking OpenAI for gpt-4o returns + gpt-4o-2024-08-06. + minLength: 1 + maxLength: 253 + credentialRef: type: object description: >- - Routing details for this endpoint. ModelService reads - backendName to build HTTPRoute backendRefs. + Secret holding this backend's credential, which the gateway + attaches on the way out. The credential never reaches the + caller, and the caller's own credential never reaches the + backend. An endpoint whose Secret is missing carries no + traffic and says so in its conditions. + required: [name] properties: - backendName: + name: type: string description: >- - Crossplane-generated name of the Backend resource - composed by this endpoint. + Secret in this ModelEndpoint's namespace, with the + credential under the key named by key. + minLength: 1 + maxLength: 253 + key: + type: string + description: The Secret key holding the credential. + default: apiKey + minLength: 1 + maxLength: 253 + status: + type: object + properties: conditions: type: array items: diff --git a/apis/modelservices/definition.yaml b/apis/modelservices/definition.yaml index 5b44d1fce..54dc2365b 100644 --- a/apis/modelservices/definition.yaml +++ b/apis/modelservices/definition.yaml @@ -15,11 +15,20 @@ spec: served: true referenceable: true additionalPrinterColumns: - - name: ADDRESS + - name: MODEL type: string - jsonPath: .status.address + jsonPath: .status.model schema: openAPIV3Schema: + description: >- + A ModelService is one model as a caller sees it: a stable name that + resolves to whichever ModelEndpoint should serve the next request. The + endpoints behind it can be replicas Modelplane runs, models bought from + a provider, or both, in more than one region. + + A caller reaches it by naming it as the model in an ordinary OpenAI or + Anthropic request to any InferenceGateway that serves it. There is no + per-service address. type: object required: [spec] properties: @@ -30,41 +39,108 @@ spec: endpoints: type: array description: >- - Endpoints to route traffic to. Each entry selects a - set of ModelEndpoints by label. Traffic is split across - entries in proportion to their weights, and load-balanced - as evenly as possible across the endpoints an entry matches. + A priority order over ModelEndpoints, each entry selecting a + set of them by label. + + The two knobs work on different timescales. priority is + failure: a tier is only used once the tiers above it have no + healthy endpoints left. weight is everything that isn't + failure, and is how you shift traffic deliberately, whether + canarying a new deployment or preferring capacity you've + already paid for until it stops keeping up. + + Modelplane never adjusts a weight. It is whatever it was last + written to be, by a person or by something watching the fleet's + load and cost. minItems: 1 + maxItems: 32 items: type: object required: [selector] properties: + priority: + type: integer + description: >- + Lower is preferred. Entries at the same priority share + traffic by weight; a higher number is only tried when + nothing below it has a healthy endpoint, which is what + makes a provider a failover for capacity you run. + + A request that fails over is retried against the next + endpoint and gets that endpoint's own model name, + credential and path. Retrying is only possible until the + first byte reaches the caller, because after that the + tokens are already sent, so a backend that dies + mid-stream truncates the response instead. + minimum: 0 + maximum: 63 + default: 0 weight: type: integer description: >- - Weight determines the share of traffic sent to this - entry's endpoints, relative to the other entries. An - entry with weight 2 receives twice the traffic of an - entry with weight 1. The weight is spread as evenly as - possible across all endpoints the entry matches. + Share of traffic for this entry relative to the other + entries at the same priority, spread as evenly as + possible across the endpoints it matches. A pair of + entries weighted 90 and 10 is a canary. + + At least 1. A weight of 0 doesn't deprioritise a + backend, it drops it from the gateway's load assignment + entirely, which is indistinguishable from removing the + entry and easy to mistake for parking it. Remove the + entry instead. minimum: 1 maximum: 1000000 default: 1 selector: type: object + description: >- + Selects ModelEndpoints in this ModelService's namespace. + Scope a service to a region by selecting only endpoints + in it; Modelplane stamps an InferenceCluster's labels + onto every endpoint composed there, so the region is + declared once on the cluster. required: [matchLabels] properties: matchLabels: type: object additionalProperties: type: string + maxLength: 63 + minProperties: 1 + maxProperties: 16 status: type: object properties: - address: + model: type: string description: >- - Public address where this service is reachable. + The name a caller passes as the request's model. Namespaced, so + two services can't collide and the namespace serving a caller + is legible in what it passes. + gateways: + type: array + description: >- + The InferenceGateways serving this service, which is every + gateway whose serviceSelector matches it. Empty means no + gateway serves this service and no caller can reach it. + items: + type: object + properties: + name: + type: string + hostname: + type: string + description: The name that gateway answers on, if it has one. + address: + type: string + endpoints: + type: object + description: Observed endpoint counts, across all priorities. + properties: + total: + type: integer + ready: + type: integer conditions: type: array items: diff --git a/apis/servingstacks/definition.yaml b/apis/servingstacks/definition.yaml index c4b74536c..6dcd04405 100644 --- a/apis/servingstacks/definition.yaml +++ b/apis/servingstacks/definition.yaml @@ -123,26 +123,33 @@ spec: tested combination. Override individual versions to upgrade components independently. properties: - gatewayApi: + certManager: type: string - default: "v1.5.1" - description: Gateway API CRD version. + default: "v1.21.1" + description: cert-manager chart version. minLength: 1 maxLength: 32 - certManager: + trustManager: type: string - default: "v1.17.1" - description: cert-manager chart version. + default: "v0.24.0" + description: >- + trust-manager chart version. trust-manager distributes the + cluster gateway's CA certificate without its private key, + which is what lets the control plane read the certificate + to hand to a fleet gateway. minLength: 1 maxLength: 32 envoyGateway: type: string - default: "v1.8.1" + default: "v1.8.4" description: >- Envoy Gateway chart version. Must support InferencePool backend resources (the disaggregated-serving routing path), which requires v1.8.x or newer; older releases lack the Gateway API CRDs (ListenerSet) the AI Gateway needs. + Envoy AI Gateway v1.1.x is tested against Envoy Gateway + v1.8.x with Gateway API v1.5.x, so v1.9.x is out of range + until the AI Gateway release that pairs with it. minLength: 1 maxLength: 32 prometheus: @@ -230,6 +237,44 @@ spec: GatewayClass named envoy. minLength: 1 maxLength: 63 + hostname: + type: string + description: >- + The name this cluster's gateway is reached by, projected + from the InferenceCluster. The gateway serves a + certificate for it, so an InferenceGateway can originate + TLS and know it reached the right cluster. Without it the + gateway serves plain HTTP and carries no traffic, since an + InferenceGateway addresses a cluster by name. + minLength: 1 + maxLength: 253 + clientCAs: + type: array + description: >- + PEM certificates of the CAs whose client certificates this + gateway accepts, one per InferenceGateway in the fleet. + Projected from the InferenceCluster, which reads them from + each gateway's status. + + Presenting one of these is how a caller proves it is a + fleet gateway. Requests without one are refused, which is + what makes a fleet gateway the only thing that can reach + the engines behind this cluster's gateway. + maxItems: 32 + items: + type: object + required: [name, certificate] + properties: + name: + type: string + description: The InferenceGateway this CA belongs to. + minLength: 1 + maxLength: 253 + certificate: + type: string + description: The CA certificate, PEM encoded. + minLength: 1 + maxLength: 16384 listeners: type: array description: >- @@ -276,6 +321,14 @@ spec: description: >- The gateway's external address, once assigned by the cloud load balancer. + caCertificate: + type: string + maxLength: 16384 + description: >- + PEM certificate of the CA that signed this gateway's + serving certificate. An InferenceGateway validates the + gateway against it, so it reaches the cluster it meant to + and not whatever answers on that address. type: object required: - spec diff --git a/docs/content/getting-started/build-the-platform.md b/docs/content/getting-started/build-the-platform.md index 90eabee7c..af9ca740d 100644 --- a/docs/content/getting-started/build-the-platform.md +++ b/docs/content/getting-started/build-the-platform.md @@ -49,26 +49,6 @@ against this capacity without knowing which cluster it runs on. {{< /tab >}} {{< /tabs >}} -## Set up the InferenceGateway - - -The `InferenceGateway` installs Traefik Proxy and MetalLB on the control plane. -Traefik routes inference traffic to model replicas. MetalLB assigns Traefik's -`LoadBalancer` service an external IP on kind, which doesn't have a cloud load -balancer. You need one named `default` per control plane. - - -If you run the control plane on a cloud cluster with native `LoadBalancer` -support, omit the `loadBalancer` field. - -{{< manifests "getting-started/inference-gateway.yaml" >}} - -Wait until the gateway is ready: - -```bash -kubectl wait --for=condition=Ready ig/default --timeout=5m -``` - ## Configure cloud credentials Give the control plane credentials so it can provision clusters in your cloud @@ -258,13 +238,42 @@ This is the same reconciliation loop Crossplane uses to configure other infrastructure, extended to the inference layer. {{< /hint >}} -Once the cluster is `Ready` the ML team can deploy a model on it. - {{< hint "note" >}} A cloud GPU cluster costs money while it runs. To stop the tour and resume later, follow [Clean up]({{< ref "getting-started/clean-up.md" >}}). {{< /hint >}} +## Set up the InferenceGateway + + +The `InferenceGateway` is the address callers reach your models through. It +speaks the OpenAI and Anthropic APIs, authenticates callers, and resolves the +model a request names to a `ModelService`. + + +It runs on an `InferenceCluster` rather than on your control plane, named by +`spec.clusterName`, because that cluster already runs the gateway software. +It comes after registering the cluster because it needs one to run on. Here +it shares the cluster serving the model, which is fine; in a real fleet you'd +more often give a gateway a cluster of its own. + +This one is the smallest useful shape: no hostname, no certificate and no caller +keys, so it answers on its address over plain HTTP and authenticates nobody. +Fine here, wrong on a network you don't trust. See +[Set Up the Gateway]({{< ref "/platform/inference-gateway" >}}) for the +production shape. + +{{< manifests "getting-started/inference-gateway.yaml" >}} + +Wait until the gateway is ready: + +```bash +kubectl wait --for=condition=Ready ig/local --timeout=5m +``` + +With the cluster registered and a gateway in front of it, the ML team can deploy +a model. + ## Next step Now that the platform is provisioned, the ML team can [deploy a model]({{< ref diff --git a/docs/content/getting-started/deploying-a-model.md b/docs/content/getting-started/deploying-a-model.md index a86e826fd..d4a546d9d 100644 --- a/docs/content/getting-started/deploying-a-model.md +++ b/docs/content/getting-started/deploying-a-model.md @@ -58,23 +58,23 @@ the placement. ## Expose the model -A `ModelService` selects `ModelEndpoints` by label and creates a Gateway API -`HTTPRoute` that routes to them. Modelplane creates one `ModelEndpoint` per -replica, labeled with the deployment name: +A `ModelService` selects `ModelEndpoints` by label and publishes them as one +model a caller can name. Modelplane creates one `ModelEndpoint` per replica, +labeled with the deployment name: {{< manifests "getting-started/model-service.yaml" >}} -The request path is `///...` (`/ml-team/qwen/` in -this example), from the `ModelService` named `qwen`. The `model` field in the -request body is the Hugging Face id `Qwen/Qwen2.5-0.5B-Instruct`, since this -deployment doesn't set `--served-model-name`. +Callers name the model rather than a path: it's `/`, so +`ml-team/qwen` here. The gateway rewrites that to whatever the engine was +started as, so the Hugging Face id this deployment serves under never reaches +the caller. ## Send a request -Read the endpoint's public address from the `ModelService` status: +Read the OpenAI base URL from the gateway: ```bash -ADDRESS=$(kubectl get ms qwen -n ml-team -o jsonpath='{.status.address}') +ADDRESS=$(kubectl get ig local -o jsonpath='{.status.endpoints.openAI}') ``` Send a request to it: @@ -84,9 +84,9 @@ kubectl run -i --rm curl-test \ --image=curlimages/curl \ --restart=Never \ --env="ADDRESS=$ADDRESS" \ - -- sh -c 'curl -v "$ADDRESS/v1/chat/completions" \ + -- sh -c 'curl -v "$ADDRESS/chat/completions" \ -H "Content-Type: application/json" \ - -d "{\"model\":\"Qwen/Qwen2.5-0.5B-Instruct\",\"messages\":[{\"role\":\"user\",\"content\":\"What is Kubernetes in one sentence?\"}],\"max_tokens\":100}"' + -d "{\"model\":\"ml-team/qwen\",\"messages\":[{\"role\":\"user\",\"content\":\"What is Kubernetes in one sentence?\"}],\"max_tokens\":100}"' ``` The request routes to the replica on the cluster Modelplane placed it on. diff --git a/docs/content/getting-started/scale-the-model.md b/docs/content/getting-started/scale-the-model.md index 82514925e..cce39e947 100644 --- a/docs/content/getting-started/scale-the-model.md +++ b/docs/content/getting-started/scale-the-model.md @@ -69,12 +69,12 @@ Update the `ModelService` to select both deployments. Each entry in {{< manifests "getting-started/model-service-multi.yaml" >}} -The endpoint URL doesn't change. Clients that had this URL before still have it; -they don't know the fleet changed. The gateway load-balances across both regions, -and losing one region keeps the other serving. Send the same request as before: +The model name doesn't change. Callers that had it before still have it; they +don't know the fleet changed. The gateway load-balances across both regions, and +losing one region keeps the other serving. Send the same request as before: ```bash -ADDRESS=$(kubectl get ms qwen -n ml-team -o jsonpath='{.status.address}') +ADDRESS=$(kubectl get ig local -o jsonpath='{.status.endpoints.openAI}') ``` ```bash @@ -82,9 +82,9 @@ kubectl run -i --rm curl-test \ --image=curlimages/curl \ --restart=Never \ --env="ADDRESS=$ADDRESS" \ - -- sh -c 'curl -v "$ADDRESS/v1/chat/completions" \ + -- sh -c 'curl -v "$ADDRESS/chat/completions" \ -H "Content-Type: application/json" \ - -d "{\"model\":\"Qwen/Qwen2.5-0.5B-Instruct\",\"messages\":[{\"role\":\"user\",\"content\":\"What is Kubernetes in one sentence?\"}],\"max_tokens\":100}"' + -d "{\"model\":\"ml-team/qwen\",\"messages\":[{\"role\":\"user\",\"content\":\"What is Kubernetes in one sentence?\"}],\"max_tokens\":100}"' ``` ## That's the tour diff --git a/docs/content/guides/anthropic-messages-api.md b/docs/content/guides/anthropic-messages-api.md index a914238d3..d6e784fdf 100644 --- a/docs/content/guides/anthropic-messages-api.md +++ b/docs/content/guides/anthropic-messages-api.md @@ -31,70 +31,78 @@ has ample headroom. Apply the platform side first, then the ML side. ## Send a request -Read the endpoint's public address from the `ModelService` status: +Read the Messages API base URL from the gateway serving the service. The gateway +publishes one per API it speaks: ```bash -ADDRESS=$(kubectl get ms qwen3-8b -n ml-team -o jsonpath='{.status.address}') +ADDRESS=$(kubectl get ig local -o jsonpath='{.status.endpoints.anthropic}') ``` -Post to `/v1/messages` in the Messages API shape. The `model` field is the -engine's `--served-model-name` (`qwen`); `max_tokens` is required: +Post to `/messages` under it. The `model` field is the `ModelService`, as +`/`, and the gateway rewrites it to whatever the engine was +started as; `max_tokens` is required: ```bash -curl "$ADDRESS/v1/messages" \ +curl "$ADDRESS/messages" \ -H "Content-Type: application/json" \ -H "anthropic-version: 2023-06-01" \ -d '{ - "model": "qwen", + "model": "ml-team/qwen3-8b", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello!"}] }' ``` -`status.address` is the in-cluster gateway address, so run the request from -inside the cluster if your shell can't reach it directly: +A gateway with no `hostname` answers on its load balancer address, which may be +in-cluster only, so run the request from inside the cluster if your shell can't +reach it: ```bash kubectl run -i --rm curl-test \ --image=curlimages/curl \ --restart=Never \ --env="ADDRESS=$ADDRESS" \ - -- sh -c 'curl -s "$ADDRESS/v1/messages" \ + -- sh -c 'curl -s "$ADDRESS/messages" \ -H "Content-Type: application/json" \ -H "anthropic-version: 2023-06-01" \ - -d "{\"model\":\"qwen\",\"max_tokens\":1024,\"messages\":[{\"role\":\"user\",\"content\":\"Hello!\"}]}"' + -d "{\"model\":\"ml-team/qwen3-8b\",\"max_tokens\":1024,\"messages\":[{\"role\":\"user\",\"content\":\"Hello!\"}]}"' ``` ## Point Claude Code at it -Claude Code appends `/v1/messages` to `ANTHROPIC_BASE_URL`, so point it at the -service address and map its model tiers onto the served name. vLLM doesn't check -the auth token, so any non-empty value works. Claude Code reserves 32000 output +Claude Code appends `/v1/messages` to `ANTHROPIC_BASE_URL`. The gateway's +Anthropic base URL already ends in `/anthropic/v1`, so strip that suffix and let +Claude Code add its own. Map every model tier onto the `ModelService`, since one +model serves them all here. This gateway authenticates nobody and vLLM ignores +the token, so any non-empty value works. Claude Code reserves 32000 output tokens by default, which alone leaves little context room on a small model; cap it with `CLAUDE_CODE_MAX_OUTPUT_TOKENS` so the input and output fit under the engine's `--max-model-len` (40960 here): ```bash -export ANTHROPIC_BASE_URL="$ADDRESS" +export ANTHROPIC_BASE_URL="${ADDRESS%/anthropic/v1}/anthropic" export ANTHROPIC_AUTH_TOKEN=dummy -export ANTHROPIC_DEFAULT_OPUS_MODEL=qwen -export ANTHROPIC_DEFAULT_SONNET_MODEL=qwen -export ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen +export ANTHROPIC_DEFAULT_OPUS_MODEL=ml-team/qwen3-8b +export ANTHROPIC_DEFAULT_SONNET_MODEL=ml-team/qwen3-8b +export ANTHROPIC_DEFAULT_HAIKU_MODEL=ml-team/qwen3-8b export CLAUDE_CODE_MAX_OUTPUT_TOKENS=8192 claude ``` -The gateway must be reachable from wherever `claude` runs. If `$ADDRESS` is -in-cluster only, forward the Traefik gateway service to a local port: +The gateway must be reachable from wherever `claude` runs. If its address is +in-cluster only, forward the gateway's service to a local port. Envoy Gateway +names that service after the `Gateway` it belongs to and appends a hash, so +select it by label rather than by name, against the cluster the gateway runs on: ```bash -kubectl -n traefik-system port-forward svc/traefik 8080:80 +kubectl -n envoy-gateway-system port-forward 8080:80 \ + "$(kubectl -n envoy-gateway-system get svc -o name \ + -l gateway.envoyproxy.io/owning-gateway-name=fleet-gateway)" ``` -Then point `ANTHROPIC_BASE_URL` at the local port, keeping the service's -`//` path prefix so Claude Code's `/v1/messages` still routes: +Then point `ANTHROPIC_BASE_URL` at the local port: ```bash -export ANTHROPIC_BASE_URL="http://localhost:8080/ml-team/qwen3-8b" +export ANTHROPIC_BASE_URL="http://localhost:8080/anthropic" ``` diff --git a/docs/content/models/model-endpoint.md b/docs/content/models/model-endpoint.md index 568976e19..f5ddef1d5 100644 --- a/docs/content/models/model-endpoint.md +++ b/docs/content/models/model-endpoint.md @@ -26,7 +26,8 @@ over to the provider alongside your own replicas: {{< manifests "concepts/model-service-external.yaml" >}} -The provider must speak the OpenAI API, since that's the contract a -`ModelService` exposes. Anything OpenAI-compatible works; `url` and `rewritePath` -are all that change between providers. +Anything speaking the OpenAI or Anthropic API works. `origin` is the scheme and +host to reach it at, with no path; `api.prefix` is the path the provider serves +those APIs under, and `api.schema` which of the two it speaks. Only those change +between providers. diff --git a/docs/content/models/model-service.md b/docs/content/models/model-service.md index 21db3c7cf..f9f9587a5 100644 --- a/docs/content/models/model-service.md +++ b/docs/content/models/model-service.md @@ -115,48 +115,55 @@ spec: modelplane.ai/external-provider: together ``` -Endpoints with different path layouts coexist behind the one URL. +Endpoints served by different providers, on different paths, coexist behind the +one model name. ## Sending a request -The service's public address is on `status.address`, in the form -`http:////`: +A caller names the model rather than a path. The name is +`/`, and `status.gateways` lists the gateways serving it; +each publishes a base URL per API it speaks: ```bash -ADDRESS=$(kubectl get ms qwen -n ml-team -o jsonpath='{.status.address}') +ADDRESS=$(kubectl get ig local -o jsonpath='{.status.endpoints.openAI}') ``` -Append the OpenAI path and send a request. The `model` field is the name the -engine serves (its `--served-model-name`, or the model's Hugging Face id if you -didn't set one): +Send a request naming the service. The gateway rewrites the name to whatever +each endpoint's engine or provider expects, so one name reaches replicas and +third-party providers alike: ```bash -curl "$ADDRESS/v1/chat/completions" \ +curl "$ADDRESS/chat/completions" \ -H "Content-Type: application/json" \ -d '{ - "model": "qwen", + "model": "ml-team/qwen", "messages": [{"role": "user", "content": "Hello!"}] }' ``` +`GET $ADDRESS/models` lists every model that gateway will route, which is how a +caller discovers the name. + ## Alternate APIs -We call the endpoint OpenAI-compatible because the engines are, not because -Modelplane imposes it. The route matches the `///` prefix and -preserves the path below it on the way to the engine, so any API the engine serves -is reachable on the same URL. +The gateway speaks the OpenAI API and Anthropic's Messages API, and translates +between them and whatever an endpoint speaks, so a caller can use either +regardless of the engine behind it: `status.endpoints.anthropic` is the base URL +for the Messages API, and a client that speaks it, including Claude Code via +`ANTHROPIC_BASE_URL`, needs nothing else. See +[the Messages API guide]({{< ref "/guides/anthropic-messages-api" >}}). -Take a vLLM replica that also serves the Anthropic Messages API. It answers on -`.../v1/messages`, so a client that speaks it (including Claude Code, via -`ANTHROPIC_BASE_URL`) talks to it directly. The engine's operational paths come -through the same way: `.../health` and the Prometheus `.../metrics` are reachable -on the service URL. +Because the gateway resolves a model name rather than forwarding a path, an +engine's own operational paths are not exposed through it. Scrape `/metrics` and +`/health` from the replica, not through the gateway. See +[Collecting engine metrics]({{< ref "/guides/collecting-engine-metrics" >}}). -There's one exception, and it's set by the deployment rather than the service. +There's one exception to the translation, and it's set by the deployment rather +than the service. [Disaggregated serving]({{< ref "model-deployment.md#disaggregated-serving" >}}) reads OpenAI-format request bodies to pick a prefill and decode worker, so a -request in another API shape still reaches the engine but skips that -cache-aware routing. Unified serving forwards every API shape the same way. +request that arrives in another API shape still reaches the engine but skips +that cache-aware routing. Unified serving forwards every API shape the same way. ## Example diff --git a/docs/content/platform/inference-gateway.md b/docs/content/platform/inference-gateway.md index 6bab60eef..0421c52c4 100644 --- a/docs/content/platform/inference-gateway.md +++ b/docs/content/platform/inference-gateway.md @@ -1,35 +1,50 @@ --- title: Set Up the Gateway weight: 10 -description: Unified OpenAI-compatible endpoint on the control plane cluster. +description: The OpenAI-compatible front door callers reach your models through. --- **API:** [`modelplane.ai/v1alpha1` ยท InferenceGateway]({{< ref "/reference/inferencegateways" >}}) -The `InferenceGateway` sets up the control plane's front door: one unified, -OpenAI-compatible address that every `ModelService` is exposed through, routing -each request on to the inference cluster serving it. +The `InferenceGateway` is the front door for inference requests: the +OpenAI-compatible address a caller sees, which routes each request on to a +cluster serving the model it asked for. -The `InferenceGateway` is a singleton: create exactly one, named `default`, on -your Modelplane control plane. It fronts every inference cluster in the fleet, so -you don't create one per cluster. +It runs on an `InferenceCluster`, named by `spec.clusterName`, because that +cluster already runs the gateway software. It installs nothing on your control +plane. The cluster it runs on needs no GPU pools: one with none is a gateway and +nothing else. -The `backend` field selects which gateway runs it. `Traefik` is the only value -today. +Create as many as you need. A gateway is where a request enters your fleet, so +you want one per place requests should enter from, and `spec.serviceSelector` +decides which `ModelService`s each one serves. Scoping a gateway to a region is +how residency is expressed: a service labelled for the EU reaches only EU +gateways, and from there only the endpoints it selects. Left unset, a gateway +serves every service. -On a cloud cluster with a native LoadBalancer controller, the gateway's `Service` -gets an external address on its own. On kind or bare-metal, where there's no such -controller, set `spec.traefik.loadBalancer: MetalLB` and give it an address pool -in `spec.traefik.metallb.addressPool` so the gateway gets an IP. See the example -below. +A gateway doesn't fail over. Availability comes from running more of them, +because failing over would change the address callers use and could move traffic +out of the jurisdiction the gateway exists to hold. -Once the gateway is ready, read its external address from `status.address`: +Set `spec.hostname` and `spec.tls.certificateRefs` to answer on a name over +TLS, which is the shape you want in production. Point that name at the address +the gateway publishes: ```bash -kubectl get ig default -o jsonpath='{.status.address}' +kubectl get ig eu -o jsonpath='{.status.address}' ``` -That address is the host of every `ModelService` URL -(`http://
//`), so it's what you hand to ML teams. +Callers reach a model by naming it, not by path: the model in an OpenAI request +body is `/`, and the gateway rewrites it to whatever the +engine was started as, so one address serves every model. And +`GET /v1/models` lists what this gateway will route. + +Use `spec.auth.secretSelector` to authenticate callers. Each key in a selected +Secret is one caller: the entry's name is the identity and its value is the key, +so adding a caller means writing a Secret rather than editing the gateway. The +gateway stamps the identity onto every request and usage record, and never +forwards the caller's key to a model. Without `auth` the gateway authenticates +nobody, which is deliberate: it's the shape for running behind something that +already has. ## Example {{< manifests "concepts/inference-gateway.yaml" >}} diff --git a/docs/content/recipes/qwen3-8b.md b/docs/content/recipes/qwen3-8b.md index 603588575..da7b402e6 100644 --- a/docs/content/recipes/qwen3-8b.md +++ b/docs/content/recipes/qwen3-8b.md @@ -78,9 +78,9 @@ the copy-heavy case n-gram accelerates, so most output tokens are matched straig from the prompt: ```bash -ADDR=$(kubectl get ms qwen3-8b-spec -n ml-team -o jsonpath='{.status.address}') -curl -s "$ADDR/v1/chat/completions" -H 'Content-Type: application/json' -d '{ - "model": "qwen3-8b-spec", +ADDR=$(kubectl get ig local -o jsonpath='{.status.endpoints.openAI}') +curl -s "$ADDR/chat/completions" -H 'Content-Type: application/json' -d '{ + "model": "ml-team/qwen3-8b-spec", "messages": [{"role":"user","content":"Return this Python function unchanged except rename the variable `total` to `subtotal`. Output only the code.\n\ndef cart(items):\n total = 0\n for item in items:\n total += item.price\n return total"}], "max_tokens": 200, "temperature": 0 }' ``` diff --git a/docs/manifests/concepts/inference-gateway.yaml b/docs/manifests/concepts/inference-gateway.yaml index 9bba907d4..1463a4eb5 100644 --- a/docs/manifests/concepts/inference-gateway.yaml +++ b/docs/manifests/concepts/inference-gateway.yaml @@ -1,23 +1,34 @@ -# The InferenceGateway creates a unified, OpenAI-compatible endpoint on the -# control plane cluster. It installs Traefik Proxy and creates a Gateway that -# routes traffic to model replicas on remote inference clusters. +# An InferenceGateway is the front door for inference requests: the only address +# a caller sees. It runs on an InferenceCluster, which already runs the gateway +# software, so it installs nothing on your control plane. # -# Create one InferenceGateway per control plane. It must be named "default". -# -# For kind or bare-metal clusters, set loadBalancer to MetalLB and configure an -# address pool. For cloud clusters with native LoadBalancer support, omit the -# loadBalancer field entirely. +# This one is a production shape: it answers on a name, over TLS, and +# authenticates callers against keys it holds. Point eu.example.com at +# status.address once the gateway reports one. apiVersion: modelplane.ai/v1alpha1 kind: InferenceGateway metadata: - name: default + name: eu spec: - backend: Traefik - traefik: - version: "40.2.0" - - # Remove the loadBalancer section if your cluster supports LoadBalancer - # services natively (e.g. GKE, EKS). - loadBalancer: MetalLB - metallb: - addressPool: "172.18.255.200-172.18.255.250" + # A gateway doesn't move. Availability comes from running more of them, since + # failing over would change the address callers use and could move traffic out + # of the jurisdiction the gateway exists to hold. + clusterName: gw-gcp-eu + hostname: eu.example.com + tls: + certificateRefs: + - name: eu-example-com-tls + auth: + # Each key in a selected Secret is one caller: the entry's name is the + # identity, its value is the key. So adding a caller means writing a Secret + # rather than editing this gateway. The gateway stamps the identity onto + # every request and usage record, and never forwards the caller's key. + secretSelector: + matchLabels: + modelplane.ai/inference-keys: "true" + # Which ModelServices this gateway serves. Absent, it serves every one. + # Scoping it to a region is how residency is expressed: an EU service reaches + # only EU gateways, and from there only the endpoints it selects. + serviceSelector: + matchLabels: + example.org/region: eu diff --git a/docs/manifests/concepts/model-deployment-multinode.yaml b/docs/manifests/concepts/model-deployment-multinode.yaml index be7544020..54f7b2bb0 100644 --- a/docs/manifests/concepts/model-deployment-multinode.yaml +++ b/docs/manifests/concepts/model-deployment-multinode.yaml @@ -66,7 +66,7 @@ spec: - -c - >- exec vllm serve Qwen/Qwen3-Coder-480B-A35B-Instruct - --served-model-name=qwen3-coder + --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) --tensor-parallel-size=8 --pipeline-parallel-size=2 --distributed-executor-backend=mp @@ -94,7 +94,7 @@ spec: - -c - >- exec vllm serve Qwen/Qwen3-Coder-480B-A35B-Instruct - --served-model-name=qwen3-coder + --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) --tensor-parallel-size=8 --pipeline-parallel-size=2 --distributed-executor-backend=mp diff --git a/docs/manifests/concepts/model-deployment.yaml b/docs/manifests/concepts/model-deployment.yaml index afebafffb..755685fa6 100644 --- a/docs/manifests/concepts/model-deployment.yaml +++ b/docs/manifests/concepts/model-deployment.yaml @@ -59,7 +59,7 @@ spec: image: vllm/vllm-openai:v0.23.0 args: - "--model=Qwen/Qwen3-8B" - - "--served-model-name=qwen" + - "--served-model-name=$(MODELPLANE_SERVED_MODEL_NAME)" - "--reasoning-parser=qwen3" - "--default-chat-template-kwargs={\"enable_thinking\": false}" - "--enable-auto-tool-choice" diff --git a/docs/manifests/concepts/model-endpoint.yaml b/docs/manifests/concepts/model-endpoint.yaml index 8a6b18245..cc89b83be 100644 --- a/docs/manifests/concepts/model-endpoint.yaml +++ b/docs/manifests/concepts/model-endpoint.yaml @@ -1,20 +1,34 @@ -# Modelplane composes a ModelEndpoint per ModelReplica automatically. Create one -# manually only to register an external inference endpoint with a ModelService, -# for example a SaaS provider like Together or BaseTen. +# Modelplane composes a ModelEndpoint per ModelReplica. Write one by hand only +# to register a model it doesn't run, like this one at Together, so a +# ModelService can fan over both. apiVersion: modelplane.ai/v1alpha1 kind: ModelEndpoint metadata: name: kimi-k2-together namespace: ml-team labels: - # 1. A label of your own for a ModelService to select on. Any label - # works; modelplane.ai/external-provider is a readable convention. - modelplane.ai/external-provider: together + # 1. A label of your own for a ModelService to select on. Any label works. + modelplane.ai/endpoint: kimi-k2-together spec: - # 2. The provider's base URL. - url: https://api.together.xyz/ - # 3. The path to rewrite requests to. A ModelService receives requests at - # ///v1/... and strips only the /// - # prefix, so an OpenAI-compatible provider that already serves /v1/... - # takes just /. - rewritePath: / + # 2. Scheme and host, no path. An https origin gets TLS originated to it. + # This must be a name rather than an address: Envoy AI Gateway only applies + # per-backend model rewriting, credentials and priority failover when every + # backend in a route is addressed by hostname. + origin: https://api.together.xyz + api: + # 3. The API this backend speaks, and the path it serves it under. The + # gateway translates between this and whatever the caller sent, so an + # Anthropic client can reach an OpenAI backend and the reverse. Most + # providers serve /v1; Groq serves /openai/v1. + schema: OpenAI + prefix: /v1 + # 4. The name Together knows this model by. The gateway rewrites the request + # body's model to it, so a caller keeps naming the ModelService and gets + # back whichever model actually served. + model: moonshotai/Kimi-K2-Instruct + # 5. Together's API key, which the gateway attaches on the way out. It never + # reaches the caller, and the caller's own key never reaches Together. Nor + # does the caller's identity: Modelplane strips that header for any backend + # it doesn't operate, while still recording the caller in the usage record. + credentialRef: + name: together-api-key diff --git a/docs/manifests/getting-started/inference-gateway.yaml b/docs/manifests/getting-started/inference-gateway.yaml index 9bba907d4..ae468ac7d 100644 --- a/docs/manifests/getting-started/inference-gateway.yaml +++ b/docs/manifests/getting-started/inference-gateway.yaml @@ -1,23 +1,20 @@ -# The InferenceGateway creates a unified, OpenAI-compatible endpoint on the -# control plane cluster. It installs Traefik Proxy and creates a Gateway that -# routes traffic to model replicas on remote inference clusters. +# An InferenceGateway is the front door for inference requests: the only address +# a caller sees. It speaks the OpenAI and Anthropic APIs, authenticates callers, +# and resolves the model a request names to a ModelService. # -# Create one InferenceGateway per control plane. It must be named "default". +# It runs on an InferenceCluster, which already runs the gateway software, so +# this installs nothing on your control plane. The cluster needs no GPU pools: a +# cluster with none is a gateway and nothing else, and a cluster that serves +# models can host one too. # -# For kind or bare-metal clusters, set loadBalancer to MetalLB and configure an -# address pool. For cloud clusters with native LoadBalancer support, omit the -# loadBalancer field entirely. +# You can run several, one per region, and distributing callers across them is +# yours to configure. This one is the smallest useful shape: no hostname, no +# certificate and no caller keys, so it answers on its address over plain HTTP +# and authenticates nobody. Fine for getting started, not for an untrusted +# network. apiVersion: modelplane.ai/v1alpha1 kind: InferenceGateway metadata: - name: default + name: local spec: - backend: Traefik - traefik: - version: "40.2.0" - - # Remove the loadBalancer section if your cluster supports LoadBalancer - # services natively (e.g. GKE, EKS). - loadBalancer: MetalLB - metallb: - addressPool: "172.18.255.200-172.18.255.250" + clusterName: local diff --git a/docs/manifests/getting-started/prerequisites.yaml b/docs/manifests/getting-started/prerequisites.yaml index f1cf95163..c3fbc5288 100644 --- a/docs/manifests/getting-started/prerequisites.yaml +++ b/docs/manifests/getting-started/prerequisites.yaml @@ -20,23 +20,10 @@ rules: - apiGroups: [""] resources: ["namespaces"] verbs: ["*"] -# Selectorless Service plus EndpointSlice composed by ModelEndpoint to route -# the control plane gateway to a remote model endpoint. -- apiGroups: [""] - resources: ["services"] - verbs: ["*"] -- apiGroups: ["discovery.k8s.io"] - resources: ["endpointslices"] - verbs: ["*"] -- apiGroups: ["gateway.networking.k8s.io"] - resources: ["gateways", "gatewayclasses", "httproutes"] - verbs: ["*"] -- apiGroups: ["gateway.envoyproxy.io"] - resources: ["backends"] - verbs: ["*"] -- apiGroups: ["metallb.io"] - resources: ["ipaddresspools", "l2advertisements"] - verbs: ["*"] +# Usages, which order teardown between composed resources. Everything else +# Modelplane composes lands on a workload cluster inside a provider-kubernetes +# Object or a provider-helm Release, so it needs no permission here: the +# providers carry their own, and reach the cluster with its kubeconfig. - apiGroups: ["protection.crossplane.io"] resources: ["usages"] verbs: ["*"] @@ -80,3 +67,41 @@ spec: runtime: configRef: name: provider-helm-modelplane +--- +# Stop provider-kubernetes copying Secret data into Object status. +# +# It writes an observed object's whole manifest to status.atProvider.manifest, +# so for an Object whose manifest is a Secret, that Secret's data is readable by +# anyone who can get objects, which is a wider audience than can get secrets. +# Modelplane composes Secrets holding caller API keys and serving certificate +# private keys, so this redacts them. +# +# Redaction applies to a copy used only for status. Drift detection still reads +# the unredacted object, so this doesn't cause an Object to be perpetually out +# of date. +apiVersion: pkg.crossplane.io/v1beta1 +kind: DeploymentRuntimeConfig +metadata: + name: provider-kubernetes-modelplane +spec: + deploymentTemplate: + spec: + selector: {} + template: + spec: + containers: + - name: package-runtime + args: + - --sanitize-secrets +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: ImageConfig +metadata: + name: provider-kubernetes-modelplane +spec: + matchImages: + - type: Prefix + prefix: xpkg.upbound.io/upbound/provider-kubernetes + runtime: + configRef: + name: provider-kubernetes-modelplane diff --git a/docs/manifests/guides/anthropic-messages-api/inference-gateway.yaml b/docs/manifests/guides/anthropic-messages-api/inference-gateway.yaml index 9bba907d4..ae468ac7d 100644 --- a/docs/manifests/guides/anthropic-messages-api/inference-gateway.yaml +++ b/docs/manifests/guides/anthropic-messages-api/inference-gateway.yaml @@ -1,23 +1,20 @@ -# The InferenceGateway creates a unified, OpenAI-compatible endpoint on the -# control plane cluster. It installs Traefik Proxy and creates a Gateway that -# routes traffic to model replicas on remote inference clusters. +# An InferenceGateway is the front door for inference requests: the only address +# a caller sees. It speaks the OpenAI and Anthropic APIs, authenticates callers, +# and resolves the model a request names to a ModelService. # -# Create one InferenceGateway per control plane. It must be named "default". +# It runs on an InferenceCluster, which already runs the gateway software, so +# this installs nothing on your control plane. The cluster needs no GPU pools: a +# cluster with none is a gateway and nothing else, and a cluster that serves +# models can host one too. # -# For kind or bare-metal clusters, set loadBalancer to MetalLB and configure an -# address pool. For cloud clusters with native LoadBalancer support, omit the -# loadBalancer field entirely. +# You can run several, one per region, and distributing callers across them is +# yours to configure. This one is the smallest useful shape: no hostname, no +# certificate and no caller keys, so it answers on its address over plain HTTP +# and authenticates nobody. Fine for getting started, not for an untrusted +# network. apiVersion: modelplane.ai/v1alpha1 kind: InferenceGateway metadata: - name: default + name: local spec: - backend: Traefik - traefik: - version: "40.2.0" - - # Remove the loadBalancer section if your cluster supports LoadBalancer - # services natively (e.g. GKE, EKS). - loadBalancer: MetalLB - metallb: - addressPool: "172.18.255.200-172.18.255.250" + clusterName: local diff --git a/docs/manifests/guides/anthropic-messages-api/model-deployment.yaml b/docs/manifests/guides/anthropic-messages-api/model-deployment.yaml index b24263671..4c30100ef 100644 --- a/docs/manifests/guides/anthropic-messages-api/model-deployment.yaml +++ b/docs/manifests/guides/anthropic-messages-api/model-deployment.yaml @@ -58,7 +58,7 @@ spec: image: vllm/vllm-openai:v0.23.0 args: - "--model=Qwen/Qwen3-8B" - - "--served-model-name=qwen" + - "--served-model-name=$(MODELPLANE_SERVED_MODEL_NAME)" - "--max-model-len=40960" - "--gpu-memory-utilization=0.92" - "--reasoning-parser=qwen3" diff --git a/docs/manifests/guides/collecting-engine-metrics/model-deployment.yaml b/docs/manifests/guides/collecting-engine-metrics/model-deployment.yaml index 4cd06648d..5f089ac49 100644 --- a/docs/manifests/guides/collecting-engine-metrics/model-deployment.yaml +++ b/docs/manifests/guides/collecting-engine-metrics/model-deployment.yaml @@ -7,7 +7,10 @@ # # --max-model-len=16384 caps the context; the default 32K KV cache is wasteful # for a model this size and a demo this small. -# --served-model-name the id clients pass as "model" in OpenAI requests. +# --served-model-name the name this engine answers to. Reference the env +# var Modelplane injects, so the engine comes up under +# the name the gateway routes to. A caller names a +# ModelService, not this. # # vLLM exposes Prometheus metrics at /metrics on its serving port (:8000) with no # extra flag, which is what the example's PodMonitor scrapes. @@ -48,5 +51,5 @@ spec: image: vllm/vllm-openai:v0.23.0 args: - "--model=Qwen/Qwen2.5-0.5B-Instruct" - - "--served-model-name=qwen2.5-0.5b" + - "--served-model-name=$(MODELPLANE_SERVED_MODEL_NAME)" - "--max-model-len=16384" diff --git a/docs/manifests/recipes/kimi-k2/model-deployment.yaml b/docs/manifests/recipes/kimi-k2/model-deployment.yaml index f3d926129..e010d4a7a 100644 --- a/docs/manifests/recipes/kimi-k2/model-deployment.yaml +++ b/docs/manifests/recipes/kimi-k2/model-deployment.yaml @@ -55,7 +55,7 @@ spec: image: vllm/vllm-openai:v0.23.0 command: ["vllm", "serve", "RedHatAI/Kimi-K2-Instruct-quantized.w4a16"] args: - - --served-model-name=kimi-k2 + - --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) - --quantization=compressed-tensors - --tensor-parallel-size=1 - --data-parallel-size=8 @@ -98,7 +98,7 @@ spec: image: vllm/vllm-openai:v0.23.0 command: ["vllm", "serve", "RedHatAI/Kimi-K2-Instruct-quantized.w4a16"] args: - - --served-model-name=kimi-k2 + - --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) - --quantization=compressed-tensors - --tensor-parallel-size=1 - --data-parallel-size=8 diff --git a/docs/manifests/recipes/laguna/model-deployment-sglang.yaml b/docs/manifests/recipes/laguna/model-deployment-sglang.yaml index 72c5a0ed7..8aa8bdc7a 100644 --- a/docs/manifests/recipes/laguna/model-deployment-sglang.yaml +++ b/docs/manifests/recipes/laguna/model-deployment-sglang.yaml @@ -47,7 +47,7 @@ spec: command: ["python3", "-m", "sglang.launch_server"] args: - --model-path=poolside/Laguna-S-2.1-FP8 - - --served-model-name=laguna + - --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) - --tp-size=8 - --context-length=262144 - --trust-remote-code diff --git a/docs/manifests/recipes/laguna/model-deployment.yaml b/docs/manifests/recipes/laguna/model-deployment.yaml index 241e35e2b..de4e260f8 100644 --- a/docs/manifests/recipes/laguna/model-deployment.yaml +++ b/docs/manifests/recipes/laguna/model-deployment.yaml @@ -50,7 +50,7 @@ spec: image: vllm/vllm-openai:v0.25.1 command: ["vllm", "serve", "poolside/Laguna-S-2.1-FP8"] args: - - --served-model-name=laguna + - --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) - --tensor-parallel-size=8 - --max-model-len=262144 - --gpu-memory-utilization=0.9 diff --git a/docs/manifests/recipes/llama-3.1-8b/model-deployment.yaml b/docs/manifests/recipes/llama-3.1-8b/model-deployment.yaml index 4feb4c925..f7a9a0bb1 100644 --- a/docs/manifests/recipes/llama-3.1-8b/model-deployment.yaml +++ b/docs/manifests/recipes/llama-3.1-8b/model-deployment.yaml @@ -48,6 +48,6 @@ spec: args: - "--model=NousResearch/Meta-Llama-3.1-8B-Instruct" # The id clients pass as "model" in OpenAI requests. - - "--served-model-name=llama-3.1-8b" + - "--served-model-name=$(MODELPLANE_SERVED_MODEL_NAME)" # Cap the context so the KV cache fits beside the weights on the L4. - "--max-model-len=8192" diff --git a/docs/manifests/recipes/nemotron-3.5-lightning/model-deployment.yaml b/docs/manifests/recipes/nemotron-3.5-lightning/model-deployment.yaml index 5df483991..75840c3a7 100644 --- a/docs/manifests/recipes/nemotron-3.5-lightning/model-deployment.yaml +++ b/docs/manifests/recipes/nemotron-3.5-lightning/model-deployment.yaml @@ -45,7 +45,7 @@ spec: image: vllm/vllm-openai:v0.27.1 command: ["vllm", "serve", "nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4"] args: - - --served-model-name=nemotron-3.5-lightning + - --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) - --moe-backend=humming - --linear-backend=humming - --max-num-seqs=256 diff --git a/docs/manifests/recipes/qwen2.5-72b/model-deployment.yaml b/docs/manifests/recipes/qwen2.5-72b/model-deployment.yaml index a83f826ad..ddab63a2f 100644 --- a/docs/manifests/recipes/qwen2.5-72b/model-deployment.yaml +++ b/docs/manifests/recipes/qwen2.5-72b/model-deployment.yaml @@ -37,5 +37,5 @@ spec: image: vllm/vllm-openai:v0.23.0 args: - --model=Qwen/Qwen2.5-72B-Instruct-AWQ - - --served-model-name=qwen-72b + - --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) - --max-model-len=8192 diff --git a/docs/manifests/recipes/qwen2.5-7b/model-deployment.yaml b/docs/manifests/recipes/qwen2.5-7b/model-deployment.yaml index 80c799b1a..e403c4671 100644 --- a/docs/manifests/recipes/qwen2.5-7b/model-deployment.yaml +++ b/docs/manifests/recipes/qwen2.5-7b/model-deployment.yaml @@ -33,7 +33,7 @@ spec: image: vllm/vllm-openai:v0.9.2 args: - --model=Qwen/Qwen2.5-7B-Instruct-AWQ - - --served-model-name=qwen-7b + - --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) - --max-model-len=8192 - --gpu-memory-utilization=0.85 - --enforce-eager diff --git a/docs/manifests/recipes/qwen3-8b-speculative-decoding/model-deployment.yaml b/docs/manifests/recipes/qwen3-8b-speculative-decoding/model-deployment.yaml index 2453937eb..f1e553527 100644 --- a/docs/manifests/recipes/qwen3-8b-speculative-decoding/model-deployment.yaml +++ b/docs/manifests/recipes/qwen3-8b-speculative-decoding/model-deployment.yaml @@ -61,7 +61,7 @@ spec: args: - "--model=Qwen/Qwen3-8B" # The id clients pass as "model" in OpenAI requests. - - "--served-model-name=qwen3-8b-spec" + - "--served-model-name=$(MODELPLANE_SERVED_MODEL_NAME)" # Cap the context so the KV cache fits beside the weights on the L4. - "--max-model-len=16384" - "--gpu-memory-utilization=0.92" diff --git a/docs/manifests/recipes/qwen3-8b/model-deployment.yaml b/docs/manifests/recipes/qwen3-8b/model-deployment.yaml index 6cbe5c18a..98ccdbe51 100644 --- a/docs/manifests/recipes/qwen3-8b/model-deployment.yaml +++ b/docs/manifests/recipes/qwen3-8b/model-deployment.yaml @@ -46,7 +46,7 @@ spec: image: vllm/vllm-openai:v0.23.0 args: - "--model=Qwen/Qwen3-8B" - - "--served-model-name=qwen" + - "--served-model-name=$(MODELPLANE_SERVED_MODEL_NAME)" - "--max-model-len=16384" - "--gpu-memory-utilization=0.92" - "--reasoning-parser=qwen3" diff --git a/docs/manifests/recipes/qwen3-coder/model-deployment-fp8.yaml b/docs/manifests/recipes/qwen3-coder/model-deployment-fp8.yaml index 51e055ad7..a348ff89e 100644 --- a/docs/manifests/recipes/qwen3-coder/model-deployment-fp8.yaml +++ b/docs/manifests/recipes/qwen3-coder/model-deployment-fp8.yaml @@ -50,7 +50,7 @@ spec: - >- exec python3 -m sglang.launch_server --model-path Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8 - --served-model-name qwen3-coder + --served-model-name $(MODELPLANE_SERVED_MODEL_NAME) --tp-size 8 --ep-size 8 --context-length 32768 diff --git a/docs/manifests/recipes/qwen3-coder/model-deployment.yaml b/docs/manifests/recipes/qwen3-coder/model-deployment.yaml index 511f307d4..a29fbb878 100644 --- a/docs/manifests/recipes/qwen3-coder/model-deployment.yaml +++ b/docs/manifests/recipes/qwen3-coder/model-deployment.yaml @@ -70,7 +70,7 @@ spec: - -c - >- exec vllm serve Qwen/Qwen3-Coder-480B-A35B-Instruct - --served-model-name=qwen3-coder + --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) --tensor-parallel-size=8 --pipeline-parallel-size=2 --distributed-executor-backend=mp @@ -111,7 +111,7 @@ spec: - -c - >- exec vllm serve Qwen/Qwen3-Coder-480B-A35B-Instruct - --served-model-name=qwen3-coder + --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) --tensor-parallel-size=8 --pipeline-parallel-size=2 --distributed-executor-backend=mp diff --git a/docs/manifests/reference/inferencegateways.yaml b/docs/manifests/reference/inferencegateways.yaml index e826f759d..894a104c7 100644 --- a/docs/manifests/reference/inferencegateways.yaml +++ b/docs/manifests/reference/inferencegateways.yaml @@ -1,11 +1,25 @@ apiVersion: modelplane.ai/v1alpha1 kind: InferenceGateway metadata: - name: default + name: eu spec: - backend: Traefik - traefik: - version: "40.2.0" - loadBalancer: MetalLB - metallb: - addressPool: "172.18.255.200-172.18.255.250" + # The InferenceCluster this gateway runs on, which decides its region and its + # address. The cluster needs no GPU pools. + clusterName: gw-gcp-eu + # The name the gateway answers on. Point it at status.address. + hostname: eu.example.com + tls: + certificateRefs: + - name: eu-example-com-tls + auth: + # Each key in a selected Secret is one caller: the entry's name is the + # caller's identity, its value is the key. Adding a caller means writing a + # Secret, not editing this gateway. + secretSelector: + matchLabels: + modelplane.ai/inference-keys: "true" + # The ModelServices this gateway serves. Absent, it serves every one. Scoped + # here to a region, which is how residency is expressed. + serviceSelector: + matchLabels: + example.org/region: eu diff --git a/docs/manifests/reference/modelendpoints.yaml b/docs/manifests/reference/modelendpoints.yaml index 23f1d302e..cf12aebde 100644 --- a/docs/manifests/reference/modelendpoints.yaml +++ b/docs/manifests/reference/modelendpoints.yaml @@ -1,10 +1,28 @@ -# ModelEndpoints are composed automatically by ModelDeployment. -# Create manually only to register an external inference endpoint. +# ModelDeployment composes a ModelEndpoint per replica. Write one by hand only +# to register a model Modelplane doesn't run, like this one at Together. apiVersion: modelplane.ai/v1alpha1 kind: ModelEndpoint metadata: - name: qwen-72b-replica-0 + name: together-qwen-72b namespace: ml-team + labels: + modelplane.ai/endpoint: together-qwen-72b spec: - url: http://10.0.1.50/ml-team/qwen-72b/ - rewritePath: /ml-team/qwen-72b/ + # Scheme and host, no path. An https origin gets TLS originated to it. The + # host must be a name; an address stops the gateway applying the model + # rewrite, the credential and priority failover. + origin: https://api.together.xyz + api: + # OpenAI (the default) or Anthropic. The gateway translates between this + # and whatever the caller sent. + schema: OpenAI + # The path this backend serves that API under. /v1 for most, /openai/v1 for + # Groq, a per-replica path for a Modelplane-composed endpoint. + prefix: /v1 + # The name this backend knows the model by. Unset, the caller's model name + # passes through unchanged. + model: Qwen/Qwen2.5-72B-Instruct-Turbo + # This backend's credential, attached by the gateway on the way out. It never + # reaches the caller, and the caller's own credential never reaches here. + credentialRef: + name: together-api-key diff --git a/docs/manifests/reference/modelservices.yaml b/docs/manifests/reference/modelservices.yaml index e7c6a1a11..a40833c69 100644 --- a/docs/manifests/reference/modelservices.yaml +++ b/docs/manifests/reference/modelservices.yaml @@ -3,8 +3,27 @@ kind: ModelService metadata: name: qwen-72b namespace: ml-team + labels: + # Matched by an InferenceGateway's serviceSelector. Your label, under your + # own prefix; Modelplane matches it and never interprets it. + example.org/region: eu spec: endpoints: - - selector: + # Entries at the same priority share traffic by weight, so this pair is a + # 90/10 canary across two deployments. + - priority: 0 + weight: 90 + selector: matchLabels: modelplane.ai/deployment: qwen-72b + - priority: 0 + weight: 10 + selector: + matchLabels: + modelplane.ai/deployment: qwen-72b-next + # A higher priority is only tried when nothing below it has a healthy + # endpoint, which makes this provider a failover for the capacity above. + - priority: 1 + selector: + matchLabels: + modelplane.ai/endpoint: together-qwen-72b diff --git a/docs/manifests/reference/servingstacks.yaml b/docs/manifests/reference/servingstacks.yaml index 8e5695d67..d057e4b0d 100644 --- a/docs/manifests/reference/servingstacks.yaml +++ b/docs/manifests/reference/servingstacks.yaml @@ -12,9 +12,8 @@ spec: name: west-gke-sa-key key: private_key versions: - gatewayApi: "v1.5.1" - certManager: "v1.17.1" - envoyGateway: "v1.8.1" + certManager: "v1.21.1" + envoyGateway: "v1.8.4" gateway: listeners: - name: http diff --git a/e2e/README.md b/e2e/README.md index c7aff122c..153530a59 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -12,17 +12,16 @@ can gate a merge where the cloud e2e can't. It uses **two clusters**, mirroring a real deployment: -- a **control-plane** cluster (crossplane + the Configuration + the - `InferenceGateway`), managed by `crossplane project run`; +- a **control-plane** cluster (crossplane + the Configuration), managed by + `crossplane project run`; - a **workload** cluster registered via `source: Existing`, where the serving - stack and the model run. + stack, both gateways and the model run. -Two clusters rather than one because the control-plane `InferenceGateway` -(Traefik) and the workload `ServingStack` (Envoy) both install the Gateway API -CRDs โ€” co-located on one cluster they race for the same cluster-scoped CRDs and -the gateway wedges (`encountered composed resource without required -composition-resource-name annotation`). Separate clusters, as in production, -avoid it. +Two clusters rather than one because that's the shape Modelplane is for: a +control plane that installs nothing on itself, and clusters that run everything. +Both gateways now live on the workload cluster, the fleet gateway fronting the +fleet and the cluster gateway fronting the engines, so the control plane runs +only Crossplane and the providers. Two Modelplane primitives make it cloud-free: @@ -130,21 +129,23 @@ run` flags can't express: 1. Create the **workload** kind cluster (pinned v1.34). 2. Install MetalLB on it (the serving stack doesn't) with a pool inside the - detected kind subnet and disjoint from the InferenceGateway's, install the - **dra-example-driver** (fake GPUs), and label its node for the `gpu-synthetic` - pool. + detected kind subnet, install the **dra-example-driver** (fake GPUs), and + label its node for the `gpu-synthetic` pool. 3. `crossplane project run` for the **control plane**, with `lean-control-plane.yaml` as `--init-resources` so the provider trims land before the providers install. 4. Finish the setup the getting-started flow does by hand (as the nix run app does since #375): `kubectl apply` the RBAC prerequisites, point provider-helm - at its DeploymentRuntimeConfig, add the workload kubeconfig Secret (`kind get - kubeconfig --internal`, reachable from control-plane pods over the shared kind - network), then apply the subnet-templated Modelplane manifests. + and provider-kubernetes at their DeploymentRuntimeConfigs, add the workload + kubeconfig Secret (`kind get kubeconfig --internal`, reachable from + control-plane pods over the shared kind network), then apply the + subnet-templated Modelplane manifests. Everything the control plane needs is a declarative manifest; the shell in `run.sh` is only the irreducible cross-cluster setup (a second cluster, its -MetalLB and DRA driver, the cross-cluster kubeconfig). +MetalLB and DRA driver, and the cross-cluster kubeconfig). Modelplane derives +the cluster gateway's name and composes the Service that resolves it, so the +harness publishes no DNS. ``` e2e/ @@ -161,14 +162,12 @@ e2e/ ## Why the extra moving parts -- **MetalLB on both clusters.** Both gateways โ€” control-plane Traefik and the - workload Envoy Gateway (whose readiness the serving stack gates on, - `_GATEWAY_READY_CEL`) โ€” need `LoadBalancer` addresses kind can't provide. The - `InferenceGateway` installs MetalLB on the control plane itself - (`compose_metallb`, pool `.200-.250`); the serving stack does *not*, so `run.sh` - installs MetalLB on the workload cluster with a **disjoint** pool (`.100-.149`). - Both pools sit inside the detected kind Docker subnet (see caveat) so the - control plane can route across it to the workload gateway's IP. +- **MetalLB on the workload cluster.** Both gateways run there, and both need + `LoadBalancer` addresses kind can't provide: the serving stack gates the + cluster gateway's readiness on having one (`_GATEWAY_READY_CEL`). Nothing + Modelplane composes installs MetalLB, so `run.sh` does, with a pool inside the + detected kind Docker subnet (see caveat) so the control plane can route to the + addresses it hands out. - **Fake DRA driver.** A `claim: DRA` engine emits a `ResourceClaim`; with no DRA driver it stays Pending and the pod never schedules. `run.sh` applies the vendored **dra-example-driver**, which publishes fake `gpu.example.com` devices diff --git a/e2e/manifests/10-inference-gateway.yaml b/e2e/manifests/10-inference-gateway.yaml index d2bbe7cc8..177e24cdc 100644 --- a/e2e/manifests/10-inference-gateway.yaml +++ b/e2e/manifests/10-inference-gateway.yaml @@ -1,21 +1,18 @@ -# Control-plane front door. On kind there's no cloud LoadBalancer, so Traefik's -# Service gets its external IP from MetalLB. Setting loadBalancer: MetalLB makes -# the InferenceGateway composition install MetalLB on the control-plane cluster -# and configure this pool itself (see compose_metallb), so no separate MetalLB -# install is needed here. The workload cluster runs its own MetalLB, installed -# by run.sh, for the serving stack's Envoy gateway. +# The front door. It runs on the workload InferenceCluster, which already runs +# Envoy Gateway and Envoy AI Gateway for its own cluster gateway, so nothing is +# installed on the control plane and the two clusters no longer race for the +# Gateway API CRDs. +# +# Co-locating the fleet gateway with the models it serves is a supported shape, +# and the one this test uses: the workload cluster hosts both gateways, each with +# its own LoadBalancer address from the MetalLB run.sh installs there. +# +# No hostname, TLS or caller keys. That's the getting-started shape: the gateway +# answers on its address over plain HTTP and authenticates nobody. Caller +# authentication is exercised separately, since it needs Secrets. apiVersion: modelplane.ai/v1alpha1 kind: InferenceGateway metadata: - name: default + name: local spec: - backend: Traefik - traefik: - version: "40.2.0" - loadBalancer: MetalLB - metallb: - # run.sh rewrites the 172.18 prefix to the actual kind Docker subnet before - # applying (kind bumps off 172.18 when earlier networks hold it โ€” see the - # subnet detection there). Kept disjoint from the workload cluster's MetalLB - # pool (.100-.149) since both clusters share the subnet. - addressPool: "172.18.255.200-172.18.255.250" + clusterName: local diff --git a/e2e/manifests/30-inference-cluster.yaml b/e2e/manifests/30-inference-cluster.yaml index d44d9f1f0..2fb3c3b93 100644 --- a/e2e/manifests/30-inference-cluster.yaml +++ b/e2e/manifests/30-inference-cluster.yaml @@ -16,6 +16,9 @@ spec: secretRef: name: local-cluster-kubeconfig key: kubeconfig + # No gateway hostname: Modelplane derives the cluster gateway's internal name + # and resolves it itself (compose-inference-gateway composes a Service for it + # on the fleet gateway's cluster). A platform publishes no DNS per cluster. nodePools: - name: gpu-synthetic className: synthetic-gpu diff --git a/e2e/manifests/40-model-deployment.yaml b/e2e/manifests/40-model-deployment.yaml index f0d76ef14..04d48166b 100644 --- a/e2e/manifests/40-model-deployment.yaml +++ b/e2e/manifests/40-model-deployment.yaml @@ -35,7 +35,13 @@ spec: command: ["python", "-u", "-c"] args: - | - import json, http.server + import json, os, sys, http.server + served = None + for a in sys.argv[1:]: + if a.startswith("--served-model-name="): + served = a.split("=", 1)[1] + if not served: + raise SystemExit("--served-model-name is required") class H(http.server.BaseHTTPRequestHandler): def _s(self, o, c=200): b = json.dumps(o).encode() @@ -48,31 +54,68 @@ spec: if self.path == "/health": self._s({"status": "ok"}) elif self.path.startswith("/v1/models"): - self._s({"object": "list", "data": [{"id": "mock", "object": "model"}]}) + self._s({"object": "list", "data": [{"id": served, "object": "model"}]}) else: self._s({"error": "not found"}, 404) + def _read_body(self): + # The gateway's ext_proc rewrites the request body, and + # Envoy forwards the result chunked, so content-length + # is often absent. A real engine's HTTP stack handles + # both; BaseHTTPRequestHandler doesn't, and reading + # content-length alone yields an empty body and a + # confusing 404 for a model the gateway did rewrite. + if self.headers.get("transfer-encoding", "").lower() == "chunked": + chunks = [] + while True: + size = int(self.rfile.readline().split(b";")[0] or b"0", 16) + if size == 0: + self.rfile.readline() + break + chunks.append(self.rfile.read(size)) + self.rfile.readline() + return b"".join(chunks) + return self.rfile.read(int(self.headers.get("content-length") or 0)) def do_POST(self): - n = int(self.headers.get("content-length") or 0) - self.rfile.read(n) + raw = self._read_body() + try: + body = json.loads(raw or b"{}") + except Exception: + body = {} + # A real engine only answers to the name it was started + # with. Rejecting anything else is what makes the fleet + # gateway's model rewriting observable: without it the + # caller's own model name would reach here and pass. + if body.get("model") != served: + self._s({"error": {"message": "model %r not found, this engine serves %r" + % (body.get("model"), served), + "type": "NotFoundError"}}, 404) + return text = "Hello from the Modelplane local mock engine." if self.path.startswith("/v1/messages"): # Anthropic Messages API; vLLM serves it alongside the OpenAI routes. self._s({ "id": "msg-mock", "type": "message", "role": "assistant", - "model": "mock", "stop_reason": "end_turn", + "model": served, "stop_reason": "end_turn", "content": [{"type": "text", "text": text}], - "usage": {"input_tokens": 1, "output_tokens": 1}, + "usage": {"input_tokens": 12, "output_tokens": 9}, }) else: self._s({ - "id": "chatcmpl-mock", "object": "chat.completion", "model": "mock", + "id": "chatcmpl-mock", "object": "chat.completion", "model": served, "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": text}}], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + "usage": {"prompt_tokens": 12, "completion_tokens": 9, "total_tokens": 21}, }) def log_message(self, *a): pass http.server.HTTPServer(("0.0.0.0", 8000), H).serve_forever() + # python -c takes the script as its first argument, so this follows + # it and arrives as sys.argv[1]. --served-model-name is how a real + # engine is told what to answer to, and referencing the env + # Modelplane injects is the pattern every ModelDeployment follows. + # The mock rejects a request naming anything else, so a 200 + # through the gateway proves the model was rewritten. + - --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) # The ModelDeployment container template is a curated subset # (name/image/command/args/env); the composition wires the port, # readiness, and any GPU resources itself (it assumes port 8000). So diff --git a/e2e/manifests/50-model-service.yaml b/e2e/manifests/50-model-service.yaml index 8d69e9c8d..5ee6f3daa 100644 --- a/e2e/manifests/50-model-service.yaml +++ b/e2e/manifests/50-model-service.yaml @@ -1,5 +1,7 @@ -# Fronts the mock deployment's replicas behind one OpenAI-compatible endpoint on -# the control-plane gateway. Its status.address is what you curl (see README). +# One model as a caller sees it. There's no per-service address any more: a +# caller names this service as the request's model at any InferenceGateway +# serving it, which is every gateway here since none sets a serviceSelector. +# status.model is the name to pass (see README). apiVersion: modelplane.ai/v1alpha1 kind: ModelService metadata: diff --git a/e2e/run.sh b/e2e/run.sh index 2f6fb954f..9307ee05b 100644 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -78,9 +78,10 @@ PREFIX="$(printf '%s' "$SUBNET" | cut -d. -f1-2)" } log "kind Docker subnet ${SUBNET} -> MetalLB pools ${PREFIX}.255.x" -# The serving stack doesn't install MetalLB, so the workload Envoy gateway needs -# it here. Use a range disjoint from the InferenceGateway's pool (.200-.250) โ€” -# both clusters share the subnet, so their pools must not overlap. +# The serving stack doesn't install MetalLB, so the workload cluster needs it +# here. Both gateways live on this cluster now โ€” the cluster gateway fronting the +# engine pods, and the fleet gateway callers reach โ€” so the pool has to be big +# enough for two LoadBalancer Services. log "Installing MetalLB on the workload cluster (pool ${PREFIX}.255.100-.149)" kubectl --context "$WLCTX" apply -f "$METALLB_URL" kubectl --context "$WLCTX" -n metallb-system rollout status deploy/controller --timeout=180s @@ -147,13 +148,16 @@ crossplane project run \ # Config healthy. Finish the setup the getting-started flow does by hand (as the # nix run app now does too, PR #375): apply the RBAC prerequisites, then point -# provider-helm at the DeploymentRuntimeConfig they define. Providers install -# before prerequisites.yaml, and an ImageConfig binds only at ProviderRevision -# creation, so provider-helm otherwise comes up without the granted RBAC. -log "Finishing control-plane setup: prerequisites + provider-helm runtime config" +# the two providers at the DeploymentRuntimeConfigs they define. Providers +# install before prerequisites.yaml, and an ImageConfig binds only at +# ProviderRevision creation, so provider-helm otherwise comes up without the +# granted RBAC and provider-kubernetes without --sanitize-secrets. +log "Finishing control-plane setup: prerequisites + provider runtime configs" kubectl --context "$cpctx" apply -f "$ROOT/docs/manifests/getting-started/prerequisites.yaml" kubectl --context "$cpctx" patch provider.pkg.crossplane.io upbound-provider-helm --type merge \ -p '{"spec":{"runtimeConfigRef":{"apiVersion":"pkg.crossplane.io/v1beta1","kind":"DeploymentRuntimeConfig","name":"provider-helm-modelplane"}}}' +kubectl --context "$cpctx" patch provider.pkg.crossplane.io upbound-provider-kubernetes --type merge \ + -p '{"spec":{"runtimeConfigRef":{"apiVersion":"pkg.crossplane.io/v1beta1","kind":"DeploymentRuntimeConfig","name":"provider-kubernetes-modelplane"}}}' # The InferenceCluster (source: Existing) reads this kubeconfig to reach the # workload cluster; --internal gives an address routable from the control plane's @@ -169,8 +173,9 @@ if [ "$apply_manifests" = 0 ]; then exit 0 fi -# RBAC is in place, so the InferenceGateway composes its native cluster resources -# without wedging. Apply the model manifests. +# RBAC is in place, so the compositions can reach the workload cluster. Apply the +# model manifests. Modelplane derives the cluster gateway's name and composes the +# Service that resolves it, so nothing here publishes DNS. kubectl --context "$cpctx" apply -f "$rendered/" if [ "$verify" = 0 ]; then @@ -187,17 +192,44 @@ log "Verifying the model serves end to end" ns=ml-team svc=mock -addr="" +# Wait for the ModelService to report RoutingReady, which means its route is +# composed and applied on every gateway serving it. status.model and the +# gateway's endpoints both publish long before that - neither depends on a +# replica existing - so gating on either would start curling while the engine is +# still rolling out. +ready="" for _ in $(seq 1 80); do - addr="$(kubectl --context "$cpctx" -n "$ns" get modelservice "$svc" -o jsonpath='{.status.address}' 2>/dev/null || true)" - [ -n "$addr" ] && break + ready="$(kubectl --context "$cpctx" -n "$ns" get modelservice "$svc" \ + -o jsonpath='{.status.conditions[?(@.type=="RoutingReady")].status}' 2>/dev/null || true)" + [ "$ready" = "True" ] && break sleep 15 done -[ -n "$addr" ] || { - echo "verify: ModelService $ns/$svc never published an address" >&2 +[ "$ready" = "True" ] || { + echo "verify: ModelService $ns/$svc never became RoutingReady" >&2 + kubectl --context "$cpctx" -n "$ns" get modelservice "$svc" -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}: {.message}{"\n"}{end}' >&2 || true + kubectl --context "$cpctx" -n "$ns" get modelendpoint -o wide >&2 || true + kubectl --context "$cpctx" -n "$ns" get modelreplica -o wide >&2 || true exit 1 } -log "ModelService address: $addr" + +# A caller names a ModelService as the request's model, so read it from status. +model="$(kubectl --context "$cpctx" -n "$ns" get modelservice "$svc" -o jsonpath='{.status.model}')" +[ -n "$model" ] || { + echo "verify: ModelService $ns/$svc published no model name" >&2 + exit 1 +} + +base="" +for _ in $(seq 1 80); do + base="$(kubectl --context "$cpctx" get inferencegateway local -o jsonpath='{.status.endpoints.openAI}' 2>/dev/null || true)" + [ -n "$base" ] && break + sleep 15 +done +[ -n "$base" ] || { + echo "verify: InferenceGateway local never published an OpenAI endpoint" >&2 + exit 1 +} +log "Gateway ${base}, model ${model}" # The address is on the kind Docker subnet the host can't route to on macOS, so # curl from a pod on the control plane, reading the status from the pod's logs @@ -205,6 +237,26 @@ log "ModelService address: $addr" # runs one throwaway pod per call and echoes the HTTP code; it polls the logs # (curl writes the code once, then exits) so a failed attempt costs seconds, and # a unique pod name per call keeps retries from reading a prior pod's output. +# GET a URL from the workload cluster, reporting curl's own exit code rather +# than an HTTP status. Used to assert a request is refused before there is any +# HTTP response to report. -k skips server verification, so a non-zero exit is +# the server rejecting us rather than us rejecting its certificate. +wl_curl_exit() { + local pod="$1" url="$2" + kubectl --context "$WLCTX" -n default run "$pod" --restart=Never \ + --labels=app.kubernetes.io/name=e2e-verify --image="$CURL_IMAGE" \ + --command -- sh -c "curl -sS -k --max-time 15 -o /dev/null \"$url\"; echo EXIT=\$?" \ + >/dev/null 2>&1 || true + local c="" + for _ in $(seq 1 30); do + c="$(kubectl --context "$WLCTX" -n default logs "$pod" 2>/dev/null | sed -n 's/.*EXIT=\([0-9]*\).*/\1/p' || true)" + [ -n "$c" ] && break + sleep 2 + done + kubectl --context "$WLCTX" -n default delete pod "$pod" --now >/dev/null 2>&1 || true + printf '%s' "$c" +} + curl_status() { local pod="$1" url="$2" shift 2 @@ -221,31 +273,188 @@ curl_status() { printf '%s' "$c" } -# OpenAI /v1/chat/completions, retried: the address can publish a moment before -# the cross-cluster route is serving, and a slower CI runner widens that gap. -oai='{"model":"'"$svc"'","messages":[{"role":"user","content":"ping"}]}' +# curl_body is the same, but returns the response body. Used where the assertion +# is about what came back rather than only that something did. +curl_body() { + local pod="$1" url="$2" + shift 2 + kubectl --context "$cpctx" -n "$ns" run "$pod" --restart=Never \ + --labels=app.kubernetes.io/name=e2e-verify --image="$CURL_IMAGE" \ + --command -- curl -sS --max-time 15 "$url" "$@" >/dev/null 2>&1 || true + local b="" + for _ in $(seq 1 30); do + b="$(kubectl --context "$cpctx" -n "$ns" logs "$pod" 2>/dev/null || true)" + [ -n "$b" ] && break + sleep 2 + done + printf '%s' "$b" +} + +cleanup_verify_pods() { + kubectl --context "$cpctx" -n "$ns" delete pod -l app.kubernetes.io/name=e2e-verify --now >/dev/null 2>&1 || true +} + +# OpenAI /v1/chat/completions, retried: the gateway can publish an endpoint a +# moment before the route is serving, and a slower CI runner widens that gap. +oai='{"model":"'"$model"'","messages":[{"role":"user","content":"ping"}]}' code="" for attempt in $(seq 1 10); do - code="$(curl_status "e2e-verify-oai-$attempt" "$addr/v1/chat/completions" -H 'content-type: application/json' -d "$oai")" + code="$(curl_status "e2e-verify-oai-$attempt" "$base/chat/completions" -H 'content-type: application/json' -d "$oai")" log "verify attempt $attempt (OpenAI): HTTP ${code:-none}" [ "$code" = "200" ] && break sleep 10 done [ "$code" = "200" ] || { - echo "verify: $addr/v1/chat/completions did not return 200 within retries (last: ${code:-none})" >&2 - kubectl --context "$cpctx" -n "$ns" delete pod -l app.kubernetes.io/name=e2e-verify --now >/dev/null 2>&1 || true + echo "verify: $base/chat/completions did not return 200 within retries (last: ${code:-none})" >&2 + cleanup_verify_pods exit 1 } -# Anthropic Messages API on the same address: vLLM serves /v1/messages alongside -# the OpenAI routes (PR #360) and the route preserves the path. Serving is up by -# now, so one attempt suffices. -ant='{"model":"'"$svc"'","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}' -mcode="$(curl_status e2e-verify-anthropic "$addr/v1/messages" -H 'content-type: application/json' -H 'anthropic-version: 2023-06-01' -d "$ant")" +# The engine only answers to the name Modelplane started it under, and rejects +# anything else with a 404. So a 200 above already proves the gateway rewrote the +# caller's ModelService name to the deployment's. Assert the response reports the +# served model rather than what the caller asked for, which is the visible half +# of the same mechanism. +body="$(curl_body e2e-verify-served "$base/chat/completions" -H 'content-type: application/json' -d "$oai")" +case "$body" in +*'"model": "ml-team/mock-demo"'* | *'"model":"ml-team/mock-demo"'*) + log "verify (model rewriting): caller asked for ${model}, engine served ml-team/mock-demo" + ;; +*) + echo "verify: response did not report the served model; got: $body" >&2 + cleanup_verify_pods + exit 1 + ;; +esac + +# A model no ModelService claims must not route anywhere. Catches a route +# matching too broadly, which would send a caller to an arbitrary backend. +ncode="$(curl_status e2e-verify-unknown "$base/chat/completions" -H 'content-type: application/json' \ + -d '{"model":"ml-team/nope","messages":[{"role":"user","content":"ping"}]}')" +log "verify (unknown model): HTTP ${ncode:-none}" +[ "$ncode" = "200" ] && { + echo "verify: an unclaimed model name was routed and served" >&2 + cleanup_verify_pods + exit 1 +} + +# The cluster gateway must refuse a caller that presents no client certificate. +# This is the property the whole mTLS design exists for, and every check above +# goes through the fleet gateway, which does hold a certificate, so none of them +# would notice it lapsing. A ClientTrafficPolicy that stopped applying, or an +# HTTP listener creeping back, would leave the engines open to anything that can +# reach the load balancer. +# +# Run from the workload cluster, where the Service compose-inference-gateway +# composed resolves the gateway's name. A plain GET is enough: the handshake +# fails before any request is sent. The trailing dot skips the pod's search +# domains, which ndots:5 would otherwise try ahead of the name itself. A resolve +# failure returns curl 6, which the checks below reject rather than pass. +cluster_gw_name="$(kubectl --context "$cpctx" get inferencecluster local -o jsonpath='{.status.gateway.hostname}')" +cluster_gw="https://${cluster_gw_name}./v1/models" +ecode="$(wl_curl_exit e2e-verify-nocert "$cluster_gw")" +log "verify (cluster gateway, no client certificate): curl exit ${ecode:-none}" +case "$ecode" in +0) + echo "verify: the cluster gateway served a caller presenting no client certificate" >&2 + cleanup_verify_pods + exit 1 + ;; +35 | 52 | 55 | 56) ;; +*) + echo "verify: expected the cluster gateway to refuse an uncertified caller mid-handshake," >&2 + echo "verify: but curl failed with ${ecode:-no exit code}, which is a different failure" >&2 + cleanup_verify_pods + exit 1 + ;; +esac + +# And nothing on port 80. HTTPS replaces the HTTP listener rather than joining +# it, because the serving HTTPRoutes carry no sectionName and so attach to every +# listener there is. The load balancer publishes a port per listener, so with +# only an HTTPS listener nothing is listening on 80 and the connection is +# refused. +hcode="$(wl_curl_exit e2e-verify-plaintext "http://${cluster_gw_name}./v1/models")" +log "verify (cluster gateway, plaintext): curl exit ${hcode:-none}" +case "$hcode" in +0) + echo "verify: the cluster gateway served plaintext HTTP on port 80" >&2 + cleanup_verify_pods + exit 1 + ;; +7 | 28 | 35 | 52 | 56) ;; +*) + echo "verify: expected no listener on port 80, but curl failed with ${hcode:-no exit code}," >&2 + echo "verify: which is a different failure" >&2 + cleanup_verify_pods + exit 1 + ;; +esac + +# /v1/models lists what this gateway serves. Only exact model matches appear, so +# this also proves the route matches exactly rather than by pattern. +models="$(curl_body e2e-verify-models "$base/models")" +case "$models" in +*"$model"*) log "verify (/v1/models): lists ${model}" ;; +*) + echo "verify: /v1/models did not list $model; got: $models" >&2 + cleanup_verify_pods + exit 1 + ;; +esac + +# Anthropic's Messages API on the same gateway, translated to the OpenAI the mock +# engine speaks. The engine has no Anthropic route, so a 200 here is translation +# rather than passthrough. +anthropic_base="${base%/v1}/anthropic/v1" +ant='{"model":"'"$model"'","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}' +mcode="$(curl_status e2e-verify-anthropic "$anthropic_base/messages" -H 'content-type: application/json' -H 'anthropic-version: 2023-06-01' -d "$ant")" log "verify (Anthropic /v1/messages): HTTP ${mcode:-none}" -kubectl --context "$cpctx" -n "$ns" delete pod -l app.kubernetes.io/name=e2e-verify --now >/dev/null 2>&1 || true [ "$mcode" = "200" ] || { - echo "verify: $addr/v1/messages did not return 200 (last: ${mcode:-none})" >&2 + echo "verify: $anthropic_base/messages did not return 200 (got: ${mcode:-none})" >&2 + cleanup_verify_pods exit 1 } -log "End to end OK: $addr serves OpenAI (/v1/chat/completions) and Anthropic (/v1/messages)" + +# The usage record is the only place a token count and the tenant that incurred +# it are visible together, so the whole metering story rests on the gateway +# emitting one. Read it off the fleet gateway's Envoy. +envoy_pod="$(kubectl --context "$WLCTX" -n envoy-gateway-system get pods \ + -l gateway.envoyproxy.io/owning-gateway-name=fleet-gateway -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)" +[ -n "$envoy_pod" ] || { + echo "verify: could not find the fleet gateway's Envoy pod" >&2 + cleanup_verify_pods + exit 1 +} +usage="$(kubectl --context "$WLCTX" -n envoy-gateway-system logs "$envoy_pod" -c envoy --tail=200 2>/dev/null | + grep '"input_tokens":12' | tail -1 || true)" +# Check each field on its own. The access log serialises its keys +# alphabetically, so a single glob spanning two of them depends on that order. +# +# caller is deliberately not asserted: this gateway sets no auth, so no caller +# is authenticated and none is stamped. Metering per caller is covered by the +# unit tests and was verified by hand against a gateway that does authenticate. +missing="" +for want in \ + '"service":"'"$model"'"' \ + '"endpoint":"modelplane-system/ml-team-mock' \ + '"served_model":"ml-team/mock-demo"' \ + '"input_tokens":12' \ + '"output_tokens":9' \ + '"total_tokens":21' \ + '"status":200'; do + case "$usage" in + *"$want"*) ;; + *) missing="$missing $want" ;; + esac +done +[ -z "$missing" ] || { + echo "verify: usage record missing:$missing" >&2 + echo "verify: record was: ${usage:-none}" >&2 + cleanup_verify_pods + exit 1 +} +log "verify (usage record): ${usage}" + +cleanup_verify_pods +log "End to end OK: ${base} serves ${model} over OpenAI and Anthropic, rewrites the model, and meters it" diff --git a/functions/compose-inference-cluster/function/fn.py b/functions/compose-inference-cluster/function/fn.py index a3ef77886..31cbe37fd 100644 --- a/functions/compose-inference-cluster/function/fn.py +++ b/functions/compose-inference-cluster/function/fn.py @@ -36,6 +36,7 @@ from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 from models.ai.modelplane.inferenceclass import v1alpha1 as iclv1alpha1 from models.ai.modelplane.inferencecluster import v1alpha1 +from models.ai.modelplane.inferencegateway import v1alpha1 as igv1alpha1 from models.ai.modelplane.infrastructure.akscluster import v1alpha1 as aksv1alpha1 from models.ai.modelplane.infrastructure.ekscluster import v1alpha1 as eksv1alpha1 from models.ai.modelplane.infrastructure.gkecluster import v1alpha1 as gkev1alpha1 @@ -108,6 +109,27 @@ def _name(meta: metav1.ObjectMeta | None) -> str: return meta.name +def _gateway_hostname(cluster_name: str) -> str: + """The internal name a fleet gateway addresses this cluster's gateway by. + + Modelplane derives and resolves this itself, so a platform publishes no DNS + per cluster: compose-inference-gateway composes a Service of this name on + each fleet gateway's cluster, pointing at this cluster's gateway address. The + name doubles as the SNI a fleet gateway sends and the SAN on this cluster + gateway's serving certificate, so it is stable and identical from every + cluster. + + The first label is the Service's name, which must be a DNS-1035 label: + lowercase alphanumeric and '-', starting with a letter. A cluster name is a + DNS-1123 subdomain, so it may contain dots or start with a digit; leading + with a literal keeps the label valid, and dots become dashes so it stays a + single label. NOTE(negz): assumes the cluster's DNS domain is the default + cluster.local. + """ + label = resource.child_name("gateway", cluster_name.replace(".", "-")) + return f"{label}.{_NAMESPACE_SYSTEM}.svc.cluster.local" + + class FunctionRunner(grpcv1.FunctionRunnerServiceServicer): """A FunctionRunner handles gRPC RunFunctionRequests.""" @@ -136,6 +158,8 @@ def __init__(self, req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse) # Resolved InferenceClasses, keyed by class name. Populated by # resolve_classes(). self.classes: dict[str, iclv1alpha1.InferenceClass] = {} + # Client CA per InferenceGateway, keyed by gateway name. + self.gateway_cas: dict[str, str] = {} def compose(self) -> None: # The replica guard runs first, before any early return. It only @@ -146,6 +170,12 @@ def compose(self) -> None: # cluster be deleted while replicas still use it. self.compose_replica_guard() + # Like the replica guard, this runs before any early return: the CAs a + # cluster gateway accepts don't depend on the cluster's source or its + # classes resolving, and dropping them on a transient reconcile would + # narrow the trust bundle and refuse a gateway that was working. + self.resolve_gateway_cas() + cluster = self.xr.spec.cluster if not cluster: response.warning(self.rsp, "spec.cluster is required") @@ -220,6 +250,29 @@ def compose_replica_guard(self) -> None: ) self.rsp.desired.resources[_REPLICA_GUARD_RESOURCE_KEY].ready = fnv1.READY_TRUE + def resolve_gateway_cas(self) -> None: + """Collect the client CA of every InferenceGateway in the fleet. + + Any gateway may forward to this cluster, and each signs its client + certificate with a CA of its own, so the cluster gateway has to accept + all of them. Read here rather than on the ServingStack because a + ServingStack knows only its own cluster, and this is fleet-wide state. + + A gateway that hasn't published a CA yet is skipped. Its cluster's + cert-manager may still be installing, and refusing traffic from every + gateway because one isn't ready would be worse than accepting the rest. + """ + response.require_resources( + self.rsp, + name="gateways", + api_version="modelplane.ai/v1alpha1", + kind="InferenceGateway", + ) + for g in request.get_required_resources(self.req, "gateways"): + gw = igv1alpha1.InferenceGateway.model_validate(g) + if gw.status and gw.status.clientCACertificate: + self.gateway_cas[_name(gw.metadata)] = gw.status.clientCACertificate + def resolve_classes(self) -> bool: """Declare and fetch every InferenceClass referenced by spec.nodePools[].className. Returns False if any is missing, @@ -496,6 +549,20 @@ def compose_serving_stack( spec = ssv1alpha1.Spec(secrets=backend_secrets, stack=self.xr.spec.stack) if nvidia_driver_root is not None: spec.nvidiaDriverRoot = nvidia_driver_root + + # The gateway's name and the CAs it should accept client certificates + # from. The name is Modelplane's own, derived from this cluster's name; + # the CAs are from every InferenceGateway in the fleet, because any of + # them may forward here and each signs its client certificate with its + # own CA. Presenting one is how a caller proves it is a fleet gateway, + # which is what stops anything else reaching the engines behind this + # cluster's gateway. + gateway = ssv1alpha1.Gateway(hostname=_gateway_hostname(_name(self.xr.metadata))) + if self.gateway_cas: + gateway.clientCAs = [ + ssv1alpha1.ClientCA(name=name, certificate=cert) for name, cert in sorted(self.gateway_cas.items()) + ] + spec.gateway = gateway resource.update( self.rsp.desired.resources[BACKEND_RESOURCE_KEY], ssv1alpha1.ServingStack( @@ -569,6 +636,34 @@ def write_status(self, gpu_pools: list[dict[str, object]]) -> None: gateway_address = self.observed_gateway_address() if gateway_address: status.gateway = v1alpha1.Gateway(address=gateway_address) + # Republished from the ServingStack so an InferenceGateway can + # validate this cluster's gateway without reading a ServingStack, + # which is machine-generated and not something another composition + # should depend on the shape of. + ca = self.observed_gateway_ca() + if ca: + status.gateway.caCertificate = ca + # The hostname is what makes this cluster schedulable, so it is + # published only once traffic to it is mutually authenticated in + # both directions. That needs three things, and each is load + # bearing: + # + # An address, because an InferenceGateway addresses this cluster by + # name, so publishing a name that resolves to nothing strands every + # request routed to it. + # + # This cluster's CA, so a fleet gateway can tell it reached this + # cluster rather than whatever else answers on that address. + # + # At least one fleet gateway CA, because the cluster gateway only + # demands a client certificate when it has one to check against, and + # a fleet-facing gateway with nothing to trust serves nothing at all + # rather than serving in the clear (see the serving stack's + # serves_gateway). Publishing the hostname anyway would make the + # cluster schedulable when it has no front door, so every request + # routed to it would be stranded. + if ca and self.gateway_cas: + status.gateway.hostname = _gateway_hostname(_name(self.xr.metadata)) resource.update_status(self.rsp.desired.composite, status) def derive_conditions(self, *, cluster_ready: bool) -> None: @@ -1300,6 +1395,19 @@ def observed_gke_secret(self, secret_type: str) -> gkev1alpha1.Secret | None: return None return next((s for s in gke_secrets if s.type == secret_type), None) + def observed_gateway_ca(self) -> str | None: + """The cluster gateway's CA certificate, from the observed backend. + + Read by dict rather than through a typed model, matching + observed_gateway_address, so it works for any backend following the + status.gateway contract. + """ + observed = self.req.observed.resources.get(BACKEND_RESOURCE_KEY) + if not observed: + return None + d = resource.struct_to_dict(observed.resource) + return d.get("status", {}).get("gateway", {}).get("caCertificate") + def observed_gateway_address(self) -> str | None: """Read the backend's gateway address from observed state. Uses dict access instead of a typed model so it works for any diff --git a/functions/compose-inference-cluster/tests/test_fn.py b/functions/compose-inference-cluster/tests/test_fn.py index 79b2ac5a8..8c95b922b 100644 --- a/functions/compose-inference-cluster/tests/test_fn.py +++ b/functions/compose-inference-cluster/tests/test_fn.py @@ -26,6 +26,11 @@ from models.ai.modelplane.inferencecluster import v1alpha1 from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 +# The internal name Modelplane derives for this cluster's gateway, which +# compose-inference-gateway resolves. Built from the SDK's own child_name, so the +# namespace and suffix are asserted independently of the function under test. +_GATEWAY_HOSTNAME = f"{resource.child_name('gateway', 'test-cluster')}.modelplane-system.svc.cluster.local" + @dataclasses.dataclass class Case: @@ -49,6 +54,12 @@ def _eks_ready_extras(want: fnv1.RunFunctionResponse, storage_class: str) -> Non status.fields["cache"].struct_value.fields["storageClassName"].string_value = storage_class +def _gateways_selector() -> fnv1.ResourceSelector: + """Every InferenceGateway. A cluster gateway accepts client certificates + from each of their CAs, which is how a fleet gateway proves itself.""" + return fnv1.ResourceSelector(api_version="modelplane.ai/v1alpha1", kind="InferenceGateway") + + def _replicas_selector(cluster_name: str) -> fnv1.ResourceSelector: """The ModelReplica guard requirement: replicas scheduled to a cluster, across all namespaces.""" @@ -166,6 +177,7 @@ def _early_return_guard_case() -> tuple[fnv1.RunFunctionRequest, fnv1.RunFunctio desired=fnv1.State(resources={"usage-replicas": _guard_clusterusage()}), context=structpb.Struct(), ) + want.requirements.resources["gateways"].CopyFrom(_gateways_selector()) want.requirements.resources["model-replicas"].CopyFrom(_replicas_selector("test-cluster")) want.requirements.resources["class-gpu-l4"].CopyFrom( fnv1.ResourceSelector(api_version="modelplane.ai/v1alpha1", kind="InferenceClass", match_name="gpu-l4") @@ -331,6 +343,7 @@ async def test_compose(self) -> None: # noqa: PLR0915 "namespace": "modelplane-system", }, "spec": { + "gateway": {"hostname": _GATEWAY_HOSTNAME}, "stack": "Standard", "secrets": [ { @@ -430,6 +443,7 @@ async def test_compose(self) -> None: # noqa: PLR0915 backend1b.resource.CopyFrom(resource.dict_to_struct(backend1b_dict)) # want1 gains the replica-guard requirement in place from the guard cases # below, after this snapshot; add it here so want1b matches on its own. + want1b.requirements.resources["gateways"].CopyFrom(_gateways_selector()) want1b.requirements.resources["model-replicas"].CopyFrom(_replicas_selector("test-cluster")) # --- Case 2: GKE cluster first pass - no observed GKE, classes resolved. --- @@ -663,6 +677,7 @@ async def test_compose(self) -> None: # noqa: PLR0915 "namespace": "modelplane-system", }, "spec": { + "gateway": {"hostname": _GATEWAY_HOSTNAME}, "stack": "Standard", "secrets": [ { @@ -1290,6 +1305,7 @@ async def test_compose(self) -> None: # noqa: PLR0915 "namespace": "modelplane-system", }, "spec": { + "gateway": {"hostname": _GATEWAY_HOSTNAME}, "stack": "Standard", "secrets": [ { @@ -1441,6 +1457,7 @@ async def test_compose(self) -> None: # noqa: PLR0915 "namespace": "modelplane-system", }, "spec": { + "gateway": {"hostname": _GATEWAY_HOSTNAME}, "stack": "Standard", "secrets": [ { @@ -1759,6 +1776,7 @@ async def test_compose(self) -> None: # noqa: PLR0915 "namespace": "modelplane-system", }, "spec": { + "gateway": {"hostname": _GATEWAY_HOSTNAME}, "stack": "Standard", "secrets": [ { @@ -2066,6 +2084,7 @@ async def test_compose(self) -> None: # noqa: PLR0915 "namespace": "modelplane-system", }, "spec": { + "gateway": {"hostname": _GATEWAY_HOSTNAME}, "stack": "Standard", "secrets": [ { @@ -2458,6 +2477,7 @@ async def test_compose(self) -> None: # noqa: PLR0915 "namespace": "modelplane-system", }, "spec": { + "gateway": {"hostname": _GATEWAY_HOSTNAME}, "stack": "Standard", "secrets": [ { @@ -2537,6 +2557,7 @@ async def test_compose(self) -> None: # noqa: PLR0915 want_creds_vultr, want15, ): + want.requirements.resources["gateways"].CopyFrom(_gateways_selector()) want.requirements.resources["model-replicas"].CopyFrom(_replicas_selector("test-cluster")) # The guard cases reuse case 1's request and response. @@ -2673,6 +2694,7 @@ async def test_compose(self) -> None: # noqa: PLR0915 context=structpb.Struct(), ) want_creds.requirements.resources["class-gpu-l4"].CopyFrom(class_selector) + want_creds.requirements.resources["gateways"].CopyFrom(_gateways_selector()) want_creds.requirements.resources["model-replicas"].CopyFrom(_replicas_selector("test-cluster")) cases = [ @@ -2729,3 +2751,110 @@ async def test_compose(self) -> None: # noqa: PLR0915 json_format.MessageToDict(got), "-want, +got", ) + + +class TestGatewayStatus(unittest.IsolatedAsyncioTestCase): + """The hostname gate, which is what keeps a cluster off the schedule until + traffic to it is mutually authenticated in both directions.""" + + @classmethod + def setUpClass(cls) -> None: + cls.runner = fn.FunctionRunner() + + @staticmethod + def _request(*, address: str | None, ca: str | None, gateway_cas: list[str]) -> fnv1.RunFunctionRequest: + """A cluster and whatever its serving stack and the fleet's gateways have + published so far. The gateway name is Modelplane's own, so nothing + configures it.""" + xr = v1alpha1.InferenceCluster( + metadata=metav1.ObjectMeta(name="test-cluster", namespace="modelplane-system"), + spec=v1alpha1.Spec( + cluster=v1alpha1.Cluster( + source="Existing", + existing=v1alpha1.Existing(secretRef=v1alpha1.SecretRef(name="my-kubeconfig")), + ), + ), + ) + stack_status: dict = {"conditions": [{"type": "Ready", "status": "True"}]} + gateway: dict = {} + if address: + gateway["address"] = address + if ca: + gateway["caCertificate"] = ca + if gateway: + stack_status["gateway"] = gateway + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct(xr.model_dump(exclude_none=True, mode="json")) + ), + resources={ + "serving-stack": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "infrastructure.modelplane.ai/v1alpha1", + "kind": "ServingStack", + "metadata": {"name": "test-cluster-serving-stack-fd00b"}, + "status": stack_status, + } + ), + ), + }, + ), + ) + for i, cert in enumerate(gateway_cas): + req.required_resources["gateways"].items.append( + fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "InferenceGateway", + "metadata": {"name": f"fleet-{i}"}, + "spec": {"clusterName": "test-cluster"}, + "status": {"clientCACertificate": cert}, + } + ), + ) + ) + return req + + async def _gateway_status(self, req: fnv1.RunFunctionRequest) -> dict: + got = await self.runner.RunFunction(req, None) + return resource.struct_to_dict(got.desired.composite.resource).get("status", {}).get("gateway", {}) + + async def test_hostname_published_once_both_directions_are_authenticated(self) -> None: + """An address to reach, this cluster's CA so a fleet gateway can tell it + reached the right cluster, and a fleet gateway CA so the cluster gateway + demands a client certificate.""" + status = await self._gateway_status( + self._request(address="34.55.100.10", ca="cluster-ca", gateway_cas=["fleet-ca"]) + ) + self.assertEqual( + status, + { + "address": "34.55.100.10", + "caCertificate": "cluster-ca", + "hostname": _GATEWAY_HOSTNAME, + }, + ) + + async def test_no_hostname_without_a_fleet_gateway_ca(self) -> None: + """The case that matters: the cluster gateway only demands a client + certificate when it has a CA to check against, and with none it serves no + Gateway at all. Publishing the hostname anyway would make the cluster + schedulable when nothing is listening on it, so every request routed + there would be stranded.""" + status = await self._gateway_status(self._request(address="34.55.100.10", ca="cluster-ca", gateway_cas=[])) + self.assertEqual(status, {"address": "34.55.100.10", "caCertificate": "cluster-ca"}) + + async def test_no_hostname_without_this_clusters_ca(self) -> None: + """Without it a fleet gateway can't validate the cluster gateway it + reaches, so it would have to fall back to the public trust store.""" + status = await self._gateway_status(self._request(address="34.55.100.10", ca=None, gateway_cas=["fleet-ca"])) + self.assertEqual(status, {"address": "34.55.100.10"}) + + async def test_no_gateway_status_before_an_address(self) -> None: + """A hostname that resolves to nothing strands every request routed to + it, and the CA is republished from the same status.""" + status = await self._gateway_status(self._request(address=None, ca="cluster-ca", gateway_cas=["fleet-ca"])) + self.assertEqual(status, {}) diff --git a/functions/compose-inference-gateway/function/fn.py b/functions/compose-inference-gateway/function/fn.py index 9a1572f8e..b5a6d0fd2 100644 --- a/functions/compose-inference-gateway/function/fn.py +++ b/functions/compose-inference-gateway/function/fn.py @@ -12,151 +12,235 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Compose the control plane routing gateway. - -This function installs Traefik Proxy on the control plane cluster via Helm, -creates a GatewayClass and Gateway for unified endpoint routing, and -optionally installs MetalLB for kind/bare-metal clusters. The gateway address -is surfaced in status for compose-model-deployment to use. - -Traefik is used (instead of e.g. Envoy Gateway) because it supports -per-backendRef URLRewrite filters. This is a Gateway API Extended feature -that allows each backend in a weighted traffic split to have its own path -rewrite, which Modelplane needs to route across endpoints with different -path conventions (e.g. a self-hosted model at /v1/ alongside Groq at -/openai/v1/). Envoy Gateway does not support this โ€” see -envoyproxy/gateway#7099. +"""Compose the fleet gateway: the front door for inference requests. + +The gateway runs on an InferenceCluster, which already runs Envoy Gateway and +Envoy AI Gateway for its own cluster gateway, so this function composes only +Gateway API and Envoy objects onto that cluster. It installs nothing. + +A cluster hosts at most one fleet gateway, because a second would contend for +the same listener. Where two InferenceGateways name one cluster the earlier +name wins and the other reports why rather than fighting over it. + +What a request meets here, in order: TLS terminates on the listener; the +caller's key is matched against the Secrets auth selects and resolved to an +identity stamped on the request; the model named in the body picks an +AIGatewayRoute that compose-model-service composed for a ModelService; and that +route's backends, also composed there, translate the request for whichever +endpoint wins. This function owns everything gateway-scoped, and nothing +per-service. """ -import pathlib +import ipaddress import grpc -import yaml -from crossplane.function import logging, resource, response +from crossplane.function import logging, request, resource, response from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 +from models.ai.modelplane.inferencecluster import v1alpha1 as icv1alpha1 from models.ai.modelplane.inferencegateway import v1alpha1 -from models.io.crossplane.m.helm.release import v1beta1 as helmv1beta1 -from models.io.crossplane.protection.clusterusage import v1beta1 as clusterusagev1beta1 -from models.io.crossplane.protection.usage import v1beta1 as usagev1beta1 +from models.io.crossplane.m.kubernetes.object import v1alpha1 as k8sobjv1alpha1 from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 -_HERE = pathlib.Path(__file__).parent +# Condition types this function sets on the InferenceGateway. +CONDITION_TYPE_GATEWAY_READY = "GatewayReady" + +CONDITION_REASON_GATEWAY_PROGRAMMED = "GatewayProgrammed" +CONDITION_REASON_WAITING_FOR_CLUSTER = "WaitingForCluster" +CONDITION_REASON_WAITING_FOR_GATEWAY = "WaitingForGateway" +CONDITION_REASON_CLUSTER_TAKEN = "ClusterAlreadyHasGateway" +CONDITION_REASON_SECRETS_MISSING = "SecretsMissing" +CONDITION_REASON_AUTH_NOT_ACCEPTED = "CallerAuthNotAccepted" + +# Namespace every composed object lands in on the gateway's cluster. The +# ServingStack already creates it there. +REMOTE_NAMESPACE = "modelplane-system" + +# The namespace on the control plane holding a gateway's Secrets: caller keys +# and TLS certificates. An InferenceGateway is cluster-scoped, so it has no +# namespace of its own to read them from. +CONTROL_PLANE_NAMESPACE = "modelplane-system" + +# Names of the objects composed onto the gateway's cluster. One fleet gateway +# per cluster, so these are fixed rather than derived from the XR's name, which +# keeps them stable if a gateway is renamed. +_GATEWAY_NAME = "fleet-gateway" +_HEALTHZ_NAME = "fleet-gateway-healthz" +_CALLERS_NAME = "fleet-gateway-callers" +_FAILOVER_NAME = "fleet-gateway-failover" + +# The GatewayClass the ServingStack installs. Its XRD defaults className to +# this, and a cluster hosting a fleet gateway is one the ServingStack has +# already reconciled, so the class exists. +_GATEWAY_CLASS = "envoy" -# Gateway API CRDs (standard channel, v1.5.1) vendored from upstream: -# https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml +# The header the gateway stamps the resolved caller identity onto. Requests to +# an endpoint Modelplane doesn't operate have it removed again, by the +# AIServiceBackend compose-model-service composes for that endpoint. +_CALLER_HEADER = "x-modelplane-caller" + +# Where the gateway serves each API. These follow the AI Gateway chart's +# endpointConfig defaults (rootPrefix "/", openai "", anthropic "/anthropic"), +# which the ServingStack leaves alone. +_OPENAI_PREFIX = "/v1" +_ANTHROPIC_PREFIX = "/anthropic/v1" + +# The path a geo-DNS record or a fronting edge health checks to decide whether +# this gateway is in rotation. +_HEALTHZ_PATH = "/healthz" + +# The cluster gateway serves HTTPS here. The resolving Service and its +# EndpointSlice carry the port so it reads coherently, though Envoy resolves the +# name to an address and connects on the Backend's own port regardless. +_CLUSTER_GATEWAY_PORT = 443 + +# This gateway's own PKI, issued by cert-manager on its cluster, which the +# serving stack installs there. A composition function runs on every reconcile +# and must be a pure function of its inputs, so it can't generate key material. # -# Traefik's Helm chart does not ship the Gateway API CRDs, and on a fresh -# control plane nothing else installs them, so the Traefik release fails to -# render its GatewayClass. We compose the CRDs directly onto the control -# plane before the Traefik release. v1.5.1 is the version Traefik v3.7 -# supports, and its standard channel serves TLSRoute and BackendTLSPolicy as -# v1, which Traefik watches. +# A self-signed issuer signs a CA, the CA signs the client certificate the +# gateway presents to a cluster gateway, and the CA's certificate is published +# in status. Every InferenceCluster accepts client certificates from it, which +# is how this gateway proves itself and how anything else is refused. The +# private key never leaves this cluster. +_SELFSIGNED_ISSUER = "fleet-gateway-selfsigned" +_CLIENT_CA_ISSUER = "fleet-gateway-ca" +_CLIENT_CA_SECRET = "fleet-gateway-ca" +_CLIENT_CERT_SECRET = "fleet-gateway-client" + +# The trust-manager Bundle republishing the client CA's certificate, and so also +# the ConfigMap it syncs, which is what the control plane reads. See +# compose_client_pki. +_CLIENT_CA_BUNDLE = "fleet-gateway-ca" + +# A cert-manager Certificate is Ready once it has issued. +_CERTIFICATE_READY_CEL = ( + "has(object.status) && has(object.status.conditions) && " + "object.status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')" +) + +# A trust-manager Bundle is Synced once it has written its target ConfigMaps. +_BUNDLE_SYNCED_CEL = ( + "has(object.status) && has(object.status.conditions) && " + "object.status.conditions.exists(c, c.type == 'Synced' && c.status == 'True')" +) + +# A Gateway is ready once it has an address to hand out. +_GATEWAY_READY_CEL = "has(object.status) && has(object.status.addresses) && object.status.addresses.size() > 0" + +# A Gateway API policy reports acceptance per attachment, under +# status.ancestors[].conditions rather than status.conditions. +_POLICY_ACCEPTED_CEL = ( + "has(object.status) && has(object.status.ancestors) && " + "object.status.ancestors.exists(a, has(a.conditions) && " + "a.conditions.exists(c, c.type == 'Accepted' && c.status == 'True'))" +) + +# Kubernetes injects ndots:5 into every pod, which tells the resolver to try each +# search domain before a name with fewer than five dots. Every hostname this +# gateway resolves has fewer: a provider like api.together.xyz has two, a cluster +# gateway's name three or four. So each lookup first issues one query per search +# domain, and any that a cluster's upstream resolver answers slowly or not at all +# stalls the whole resolution. Envoy then warms the cluster with no endpoints and +# answers 503, having logged only DNS timeouts. +# +# ndots:1 makes these names absolute, so the search domains are skipped. It costs +# the ability to reach a bare single-label name, which no backend uses. +# +# Envoy Gateway has no field for a pod's dnsConfig, so this goes through its +# deployment patch. +_NDOTS_PATCH = {"spec": {"template": {"spec": {"dnsConfig": {"options": [{"name": "ndots", "value": "1"}]}}}}} + +# The metadata namespace the AI Gateway's ext-proc writes per-request values to. +_AI_METADATA = "io.envoy.ai_gateway" + + +def _md(key: str) -> str: + """An access log command operator reading one AI Gateway metadata key.""" + return f"%DYNAMIC_METADATA({_AI_METADATA}:{key})%" + + +# One usage record per request. This is the only place a token count and the +# tenant that incurred it are visible together: engine metrics are per-model +# with no caller dimension, and a provider's are not ours to read. +# +# The token counts come from metadata rather than the response body because the +# ext-proc has already parsed them, including from a streamed response's final +# usage frame, which it asks the backend for on our behalf. # -# We install only the CustomResourceDefinitions, not the -# ValidatingAdmissionPolicy ("safe-upgrades") that the upstream bundle also -# ships. Composing a policy that governs CRD writes alongside the very CRD -# writes it governs is needlessly fragile. -_GATEWAY_API_CRDS = [ - doc - for doc in yaml.safe_load_all((_HERE / "gateway_api_crds.yaml").read_text()) - if doc and doc.get("kind") == "CustomResourceDefinition" -] - -# Condition types and reasons for the InferenceGateway XR. -CONDITION_TYPE_CONTROLLER_READY = "ControllerReady" - -CONDITION_REASON_CONTROLLER_HEALTHY = "ControllerHealthy" -CONDITION_REASON_INSTALLING = "Installing" - -# ProviderConfig name for in-cluster Helm releases on the control plane. -_PC_NAME = "modelplane-in-cluster" - -# The modelplane-system namespace. Used for Helm release metadata, -# Usage resources, and the Gateway resource. -_NAMESPACE_SYSTEM = "modelplane-system" - -# The control plane gateway name. Used as the Gateway resource name -# and the MetalLB IP pool / L2Advertisement name. -_GATEWAY_NAME = "modelplane" - -# Label key for Helm releases, used in Usage selectors to protect -# ProviderConfigs from premature deletion. -_LABEL_RELEASE = "modelplane.ai/release" - -# Traefik Helm chart coordinates. -_TRAEFIK_CHART = "traefik" -_TRAEFIK_REPO = "https://traefik.github.io/charts" -_TRAEFIK_NAMESPACE = "traefik-system" -_TRAEFIK_SERVICE_NAME = "traefik" - -# Traefik's GatewayClass controllerName and the GatewayClass name we -# compose for it. -_TRAEFIK_GATEWAY_CLASS = "traefik" -_TRAEFIK_CONTROLLER_NAME = "traefik.io/gateway-controller" - -# Traefik's default "web" entryPoint listens on this port internally. -# The Gateway listener port must match the entryPoint's internal port, -# not the Service's exposed port. The Helm chart exposes the same -# entryPoint at port 80 on the Service by default. -_TRAEFIK_WEB_ENTRYPOINT_PORT = 8000 - - -def _crd_key(doc: dict) -> str: - """Stable composed-resource key for a Gateway API CRD.""" - name = doc["metadata"]["name"] - return f"gateway-api-crd-{name}" - - -def _helm_release( - chart: str, - repo: str, - version: str, - namespace: str, - provider_config: str, - values: dict | None = None, - labels: dict | None = None, - metadata_namespace: str | None = None, -) -> helmv1beta1.Release: - """Build a Helm Release targeting a remote (or local) cluster. - - Args: - chart: The Helm chart name. - repo: The chart repository URL. - version: The chart version. - namespace: The namespace to install the chart into on the target cluster. - provider_config: Name of the ProviderConfig to use. - values: Optional Helm values dict. - labels: Optional labels for the Release metadata. - metadata_namespace: Optional namespace for the Release resource itself. - Set this explicitly when composing from a cluster-scoped XR, since - cluster-scoped XRs don't auto-populate namespace on composed - namespaced resources. +# The caller comes from metadata for a different reason. The header carrying it +# is removed before a request reaches a backend Modelplane doesn't operate, so +# as not to disclose a tenant to a third-party provider. Reading the header here +# would drop the caller from exactly the records that price provider spend. +_USAGE_RECORD = { + "caller": _md("caller"), + "service": "%REQ(X-AI-EG-MODEL)%", + # The AIServiceBackend that served, as "/". That is the + # per-service copy of a ModelEndpoint rather than the endpoint itself, so + # it reads as "/--". The + # ModelEndpoint's own identity isn't available to the gateway: it has no + # notion of one. Joining a record back to a ModelEndpoint therefore means + # matching on this and the service, and a name long enough to have been + # hashed can only be matched by recomputing it. + "endpoint": _md("ai_service_backend_name"), + "served_model": _md("model_name_override"), + "response_model": _md("response_model"), + "input_tokens": _md("llm_input_token"), + "output_tokens": _md("llm_output_token"), + "total_tokens": _md("llm_total_token"), + "status": "%RESPONSE_CODE%", + "duration_ms": "%DURATION%", + "start_time": "%START_TIME%", +} + + +def _name(md) -> str: # noqa: ANN001 # generated ObjectMeta models vary by kind + """The name of a resource, from its generated ObjectMeta.""" + return md.name if md and md.name else "" + + +def _ip_version(address: str) -> int | None: + """The IP version of an address, or None if it isn't an IP literal. + + A cluster gateway's address is either an IP literal or a load balancer's own + DNS name, and the two are resolved differently. Parsing rather than matching, + because IPv6 is not a thing to write a regex for. + """ + try: + return ipaddress.ip_address(address).version + except ValueError: + return None + + +def _wrap(provider_config: str, manifest: dict, *, cel_query: str | None = None) -> k8sobjv1alpha1.Object: + """Wrap a manifest in a provider-kubernetes Object for the gateway's cluster. + + The Object's own namespace is set explicitly because an InferenceGateway is + cluster-scoped, and Crossplane only defaults a composed namespaced + resource's namespace from a namespaced composite. Left unset, every reconcile + fails with "an empty namespace may not be set when a resource name is + provided" before composing anything. + + Readiness defaults to SuccessfulCreate, which is right for the policies and + Secrets that have no runtime status worth waiting on. The Gateway passes a + cel_query so its readiness reflects having been programmed. """ - md = None - if labels or metadata_namespace: - md = metav1.ObjectMeta(namespace=metadata_namespace, labels=labels) - - release = helmv1beta1.Release( - metadata=md, - spec=helmv1beta1.Spec( - providerConfigRef=helmv1beta1.ProviderConfigRef( - kind="ProviderConfig", + readiness = ( + k8sobjv1alpha1.Readiness(policy="DeriveFromCelQuery", celQuery=cel_query) + if cel_query is not None + else k8sobjv1alpha1.Readiness(policy="SuccessfulCreate") + ) + return k8sobjv1alpha1.Object( + metadata=metav1.ObjectMeta(namespace=CONTROL_PLANE_NAMESPACE), + spec=k8sobjv1alpha1.Spec( + providerConfigRef=k8sobjv1alpha1.ProviderConfigRef( + kind="ClusterProviderConfig", name=provider_config, ), - forProvider=helmv1beta1.ForProvider( - chart=helmv1beta1.Chart( - name=chart, - repository=repo, - version=version, - ), - namespace=namespace, - ), + readiness=readiness, + forProvider=k8sobjv1alpha1.ForProvider(manifest=manifest), ), ) - if values: - release.spec.forProvider.values = values - return release class FunctionRunner(grpcv1.FunctionRunnerServiceServicer): @@ -174,8 +258,7 @@ async def RunFunction( log.info("Running function") rsp = response.to(req) - c = Composer(req, rsp) - c.compose() + Composer(req, rsp).compose() return rsp @@ -184,353 +267,866 @@ def __init__(self, req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse) self.req = req self.rsp = rsp self.xr = v1alpha1.InferenceGateway(**resource.struct_to_dict(req.observed.composite.resource)) + self.cluster: icv1alpha1.InferenceCluster | None = None + self.caller_secrets: list[dict] = [] + self.tls_secrets: list[dict] = [] def compose(self) -> None: - self.compose_provider_config() - self.compose_gateway_api_crds() - self.compose_metallb() - self.compose_traefik() + if not self.resolve_inputs(): + return + self.compose_secrets() + self.compose_envoy_proxy() self.compose_gateway() - self.compose_gateway_usages() + self.compose_client_pki() + self.compose_cluster_names() + self.compose_caller_auth() + self.compose_failover_policy() + self.compose_healthz() self.write_status() + self.mark_ready() self.derive_conditions() - def compose_provider_config(self) -> None: - """Namespaced ProviderConfig for provider-helm targeting the control - plane using the pod's own service account (in-cluster identity). - Namespaced (not ClusterProviderConfig) so the Usage can protect it.""" - resource.update( - self.rsp.desired.resources["provider-config-helm"], - { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "ProviderConfig", - "metadata": {"name": _PC_NAME, "namespace": _NAMESPACE_SYSTEM}, - "spec": {"credentials": {"source": "InjectedIdentity"}}, - }, + def mark_ready(self) -> None: + """Mark each composed resource ready once its observed counterpart is. + + Nothing else does this. The composition pipeline has no auto-ready + function, so a desired resource's readiness is whatever the function + says, and a function that says nothing leaves the XR permanently + not-Ready however healthy everything under it is. + """ + for key, res in self.rsp.desired.resources.items(): + if resource.get_condition(self.req.observed.resources.get(key), "Ready").status == "True": + res.ready = fnv1.READY_TRUE + + def resolve_inputs(self) -> bool: + """Require the gateway's cluster, its Secrets, and the other gateways. + + Returns False, having set conditions explaining why, when the gateway + can't be composed yet. + """ + response.require_resources( + self.rsp, + name="cluster", + api_version="modelplane.ai/v1alpha1", + kind="InferenceCluster", + match_name=self.xr.spec.clusterName, ) - self.rsp.desired.resources["provider-config-helm"].ready = fnv1.READY_TRUE - - def compose_gateway_api_crds(self) -> None: - """Compose the Gateway API CRDs onto the control plane. - - These must exist before the Traefik release renders its resources and - before Traefik watches the Gateway API types.""" - for doc in _GATEWAY_API_CRDS: - key = _crd_key(doc) - resource.update(self.rsp.desired.resources[key], doc) - if resource.get_condition(self.req.observed.resources.get(key), "Established").status == "True": - self.rsp.desired.resources[key].ready = fnv1.READY_TRUE - - def gateway_api_crds_ready(self) -> bool: - """True once every composed Gateway API CRD is Established, so Traefik - can render its resources and watch the Gateway API types.""" - return all( - resource.get_condition(self.req.observed.resources.get(_crd_key(doc)), "Established").status == "True" - for doc in _GATEWAY_API_CRDS + # Every InferenceGateway, to settle which one owns this cluster. + response.require_resources( + self.rsp, + name="gateways", + api_version="modelplane.ai/v1alpha1", + kind="InferenceGateway", ) + # Every InferenceCluster, to resolve each cluster gateway's name to its + # address on this gateway's cluster (see compose_cluster_names). + response.require_resources( + self.rsp, + name="clusters", + api_version="modelplane.ai/v1alpha1", + kind="InferenceCluster", + ) + if self.xr.spec.auth: + response.require_resources( + self.rsp, + name="caller-secrets", + api_version="v1", + kind="Secret", + namespace=CONTROL_PLANE_NAMESPACE, + match_labels=dict(self.xr.spec.auth.secretSelector.matchLabels), + ) + for i, ref in enumerate(self.xr.spec.tls.certificateRefs if self.xr.spec.tls else []): + response.require_resources( + self.rsp, + name=f"tls-secret-{i}", + api_version="v1", + kind="Secret", + namespace=CONTROL_PLANE_NAMESPACE, + match_name=ref.name, + ) - def compose_metallb(self) -> None: - """Optional MetalLB for kind/bare-metal clusters that don't have a - cloud load balancer controller to assign Gateway addresses.""" - t = self.xr.spec.traefik - if not (t and t.loadBalancer == "MetalLB" and t.metallb and t.metallb.addressPool): - return + # A requirement key is absent until it resolves, which is how the SDK + # distinguishes unresolved from resolved-empty. + if "cluster" not in self.req.required_resources or "gateways" not in self.req.required_resources: + self.not_ready( + CONDITION_REASON_WAITING_FOR_CLUSTER, + "Waiting for the gateway's cluster and the other gateways to resolve", + ) + return False - metallb_ns = "metallb-system" + clusters = request.get_required_resources(self.req, "cluster") + if not clusters: + self.not_ready( + CONDITION_REASON_WAITING_FOR_CLUSTER, + f"InferenceCluster {self.xr.spec.clusterName} does not exist", + ) + return False + self.cluster = icv1alpha1.InferenceCluster.model_validate(clusters[0]) - resource.update( - self.rsp.desired.resources["namespace-metallb"], - { - "apiVersion": "v1", - "kind": "Namespace", - "metadata": {"name": metallb_ns}, - }, - ) - self.rsp.desired.resources["namespace-metallb"].ready = fnv1.READY_TRUE + if not self.owns_cluster(): + return False + + if not ( + self.cluster.status and self.cluster.status.providerConfigRef and self.cluster.status.providerConfigRef.name + ): + self.not_ready( + CONDITION_REASON_WAITING_FOR_CLUSTER, + f"InferenceCluster {self.xr.spec.clusterName} has not published a providerConfigRef", + ) + return False + + return self.resolve_secrets() + + def owns_cluster(self) -> bool: + """Whether this gateway is the one that runs on its cluster. + + Two gateways on one cluster would contend for the same listener, so the + incumbent wins: whichever already has an address keeps it. A gateway + created later reports why rather than taking the cluster over, because + taking it over would delete the winner's Gateway and bring its load + balancer back on a different address, which is the thing a gateway is + never allowed to do to its callers. + + With no incumbent, the oldest wins, and the lowest name breaks a tie in + creation time. Both are stable across reconciles and identical in every + gateway's function, so nobody flaps. + """ + mine = _name(self.xr.metadata) + rivals: list[tuple[int, str, str]] = [] + for g in request.get_required_resources(self.req, "gateways"): + gw = v1alpha1.InferenceGateway.model_validate(g) + if gw.spec.clusterName != self.xr.spec.clusterName: + continue + serving = bool(gw.status and gw.status.address) + # creationTimestamp is a RootModel wrapping a datetime, so age + # comes off the datetime it holds. An unset one sorts oldest, + # which only happens before the API server has stamped it. + stamp = gw.metadata.creationTimestamp if gw.metadata else None + created = stamp.root.isoformat() if stamp else "" + # Sorts incumbents first, then by age, then by name. + rivals.append((0 if serving else 1, created, _name(gw.metadata))) + rivals.sort() + if rivals and rivals[0][2] != mine: + self.not_ready( + CONDITION_REASON_CLUSTER_TAKEN, + f"InferenceCluster {self.xr.spec.clusterName} already hosts InferenceGateway {rivals[0][2]}", + ) + return False + return True + + def resolve_secrets(self) -> bool: + """Resolve the caller-key and TLS Secrets this gateway propagates.""" + if self.xr.spec.auth: + if "caller-secrets" not in self.req.required_resources: + self.not_ready(CONDITION_REASON_SECRETS_MISSING, "Waiting for caller key Secrets to resolve") + return False + self.caller_secrets = request.get_required_resources(self.req, "caller-secrets") + if not self.caller_secrets: + # Composing auth that selects nothing would accept no caller at + # all, which looks identical to a broken key from outside. + self.not_ready( + CONDITION_REASON_SECRETS_MISSING, + "spec.auth.secretSelector matches no Secret, so no caller could authenticate", + ) + return False - pc_observed = "provider-config-helm" in self.req.observed.resources - if pc_observed or "metallb" in self.req.observed.resources: + for i, ref in enumerate(self.xr.spec.tls.certificateRefs if self.xr.spec.tls else []): + key = f"tls-secret-{i}" + if key not in self.req.required_resources: + self.not_ready(CONDITION_REASON_SECRETS_MISSING, f"Waiting for TLS Secret {ref.name} to resolve") + return False + found = request.get_required_resources(self.req, key) + if not found: + self.not_ready(CONDITION_REASON_SECRETS_MISSING, f"TLS Secret {ref.name} does not exist") + return False + self.tls_secrets.append(found[0]) + + return True + + @property + def pc(self) -> str: + """The ClusterProviderConfig targeting the gateway's cluster.""" + assert self.cluster and self.cluster.status and self.cluster.status.providerConfigRef + return self.cluster.status.providerConfigRef.name or "" + + def compose_secrets(self) -> None: + """Copy the gateway's Secrets to its cluster. + + The data is copied verbatim, base64 and all, so re-encoding can't + corrupt a value. Caller keys land under a prefixed name; each certificate + keeps the name the XR referenced it by, since the Gateway's + certificateRefs name them. + + Keyed by the source Secret's name, never by its position in the selector's + results. Those come back in API server order, so keying by index means + deleting one Secret repoints every later Object at a different Secret and + drops the last one. Deleting an Object deletes the remote object it + manages, so a Secret the SecurityPolicy still names can vanish, and Envoy + Gateway fails an unresolvable credential ref closed with a 500 on every + route of the gateway. + """ + for secret in self.caller_secrets: + src = secret.get("metadata", {}).get("name", "") resource.update( - self.rsp.desired.resources["metallb"], - _helm_release( - chart="metallb", - repo="https://metallb.github.io/metallb", - version="0.14.9", - namespace=metallb_ns, - provider_config=_PC_NAME, - labels={_LABEL_RELEASE: "metallb"}, - metadata_namespace=_NAMESPACE_SYSTEM, + self.rsp.desired.resources[f"caller-secret-{src}"], + _wrap( + self.pc, + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": f"callers-{src}", "namespace": REMOTE_NAMESPACE}, + "type": "Opaque", + "data": secret.get("data", {}), + }, + ), + ) + for secret in self.tls_secrets: + src = secret.get("metadata", {}).get("name", "") + resource.update( + self.rsp.desired.resources[f"tls-secret-{src}"], + _wrap( + self.pc, + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": src, "namespace": REMOTE_NAMESPACE}, + "type": "kubernetes.io/tls", + "data": secret.get("data", {}), + }, ), ) - self.compose_pc_usage("metallb") - # Gate the IPAddressPool and L2Advertisement on MetalLB being ready. - metallb_ready = resource.get_condition(self.req.observed.resources.get("metallb"), "Ready").status == "True" - if not (metallb_ready or "metallb-pool" in self.req.observed.resources): - return + def compose_envoy_proxy(self) -> None: + """An EnvoyProxy carrying this gateway's usage-record access log. + Attached to the Gateway rather than the GatewayClass, because the class + is shared with the cluster gateway, whose per-pod routing has nothing to + log here. + + Every token field reads request metadata rather than a header or the + response body. The AI Gateway's ext-proc writes the counts there, having + also asked the backend for usage on streamed responses, which otherwise + report none. The caller is read from metadata for a different reason: + the header carrying it is removed before the request reaches a + third-party backend, so a log reading the header would drop the caller + from exactly the records that attribute provider spend. + """ resource.update( - self.rsp.desired.resources["metallb-pool"], - { - "apiVersion": "metallb.io/v1beta1", - "kind": "IPAddressPool", - "metadata": {"name": _GATEWAY_NAME, "namespace": metallb_ns}, - "spec": {"addresses": [t.metallb.addressPool]}, - }, + self.rsp.desired.resources["envoy-proxy"], + _wrap( + self.pc, + { + "apiVersion": "gateway.envoyproxy.io/v1alpha1", + "kind": "EnvoyProxy", + "metadata": {"name": _GATEWAY_NAME, "namespace": REMOTE_NAMESPACE}, + "spec": { + "provider": { + "type": "Kubernetes", + "kubernetes": { + "envoyService": {"externalTrafficPolicy": "Cluster"}, + "envoyDeployment": {"patch": {"type": "StrategicMerge", "value": _NDOTS_PATCH}}, + }, + }, + "telemetry": { + "accessLog": { + "settings": [ + { + "format": { + "type": "JSON", + "json": _USAGE_RECORD, + }, + "sinks": [{"type": "File", "file": {"path": "/dev/stdout"}}], + } + ] + } + }, + }, + }, + ), ) - self.rsp.desired.resources["metallb-pool"].ready = fnv1.READY_TRUE - resource.update( - self.rsp.desired.resources["metallb-l2"], + def compose_gateway(self) -> None: + """The Gateway callers connect to. + + Always an HTTP listener, so a gateway with neither hostname nor + certificate still answers, which is the getting-started shape and the + shape behind someone else's edge. An HTTPS listener joins it when the XR + carries TLS. + + The HTTP listener deliberately carries no hostname even when the XR has + one. A listener hostname is matched against the request's Host, so + setting it would 404 anything addressed by IP, and status.address is + exactly what a geo-DNS record or fronting edge health checks at /healthz. + The HTTPS listener does need one, to choose a certificate. + + Routes are accepted only from this namespace. Every route Modelplane + composes lands here, and accepting them from anywhere would let anyone + who can create an HTTPRoute on this cluster attach to the authenticated + front door, overriding its SecurityPolicy the way /healthz does. + """ + listeners: list[dict] = [ { - "apiVersion": "metallb.io/v1beta1", - "kind": "L2Advertisement", - "metadata": {"name": _GATEWAY_NAME, "namespace": metallb_ns}, - "spec": {"ipAddressPools": [_GATEWAY_NAME]}, - }, - ) - self.rsp.desired.resources["metallb-l2"].ready = fnv1.READY_TRUE - - def compose_traefik(self) -> None: - """Compose Traefik Proxy. Gated on the ProviderConfig being observed - (so provider-helm can act on the Release) and the Gateway API CRDs - being established (so the release can render its resources and Traefik - can watch the Gateway API types without erroring).""" - pc_observed = "provider-config-helm" in self.req.observed.resources - gate = pc_observed and self.gateway_api_crds_ready() - if not (gate or "traefik" in self.req.observed.resources): - return + "name": "http", + "protocol": "HTTP", + "port": 80, + "allowedRoutes": {"namespaces": {"from": "Same"}}, + } + ] + if self.xr.spec.tls: + listeners.append( + { + "name": "https", + "protocol": "HTTPS", + "port": 443, + "hostname": self.xr.spec.hostname, + "tls": { + "mode": "Terminate", + "certificateRefs": [{"name": r.name} for r in self.xr.spec.tls.certificateRefs], + }, + "allowedRoutes": {"namespaces": {"from": "Same"}}, + } + ) resource.update( - self.rsp.desired.resources["traefik"], - _helm_release( - chart=_TRAEFIK_CHART, - repo=_TRAEFIK_REPO, - version=self.xr.spec.traefik.version, # ty: ignore[unresolved-attribute] # XRD guarantees traefik when backend is Traefik, the only backend - namespace=_TRAEFIK_NAMESPACE, - provider_config=_PC_NAME, - values={ - "providers": { - "kubernetesGateway": { - "enabled": True, - "statusAddress": { - "service": { - "namespace": _TRAEFIK_NAMESPACE, - "name": _TRAEFIK_SERVICE_NAME, - }, - }, + self.rsp.desired.resources["gateway"], + _wrap( + self.pc, + { + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "Gateway", + "metadata": {"name": _GATEWAY_NAME, "namespace": REMOTE_NAMESPACE}, + "spec": { + "gatewayClassName": _GATEWAY_CLASS, + "infrastructure": { + "parametersRef": { + "group": "gateway.envoyproxy.io", + "kind": "EnvoyProxy", + "name": _GATEWAY_NAME, + } }, - "kubernetesIngress": {"enabled": False}, + "listeners": listeners, }, - # Give the Traefik Service a predictable name so - # statusAddress.service can reference it. The default - # name includes Crossplane's generated release name. - "service": {"nameOverride": _TRAEFIK_SERVICE_NAME}, - # Disable Traefik's built-in Gateway creation. Crossplane - # composes the Gateway so it appears in observed resources - # and we can read status.addresses. - "gateway": {"enabled": False}, - # Disable the chart's GatewayClass too. The chart renders - # it even when gateway.enabled is false; Crossplane - # composes its own GatewayClass instead. - "gatewayClass": {"enabled": False}, }, - labels={_LABEL_RELEASE: "traefik"}, - metadata_namespace=_NAMESPACE_SYSTEM, + cel_query=_GATEWAY_READY_CEL, ), ) - self.compose_pc_usage("traefik") - def compose_gateway(self) -> None: - """Compose GatewayClass and Gateway. Gated on Traefik being ready.""" - traefik_ready = resource.get_condition(self.req.observed.resources.get("traefik"), "Ready").status == "True" + def compose_client_pki(self) -> None: + """Compose the certificate this gateway presents to a cluster gateway. - if traefik_ready or "gateway-class" in self.req.observed.resources: - resource.update( - self.rsp.desired.resources["gateway-class"], + A cluster gateway refuses a request that arrives without one, so this is + what lets the fleet gateway reach the engines behind it and stops + anything else. cert-manager on this gateway's cluster does the issuing. + + The certificate's subject is this gateway's name. Nothing matches on it: + a cluster gateway checks the signing CA, not the subject, because what it + needs to know is that a fleet gateway is calling rather than which one. + """ + gateway = _name(self.xr.metadata) + objects: list[tuple[str, dict, str | None]] = [ + ( + "client-selfsigned-issuer", { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "GatewayClass", - "metadata": {"name": _TRAEFIK_GATEWAY_CLASS}, + "apiVersion": "cert-manager.io/v1", + "kind": "Issuer", + "metadata": {"name": _SELFSIGNED_ISSUER, "namespace": REMOTE_NAMESPACE}, + "spec": {"selfSigned": {}}, + }, + None, + ), + ( + "client-ca-certificate", + { + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": {"name": _CLIENT_CA_ISSUER, "namespace": REMOTE_NAMESPACE}, "spec": { - "controllerName": _TRAEFIK_CONTROLLER_NAME, + "isCA": True, + # Bounded to the 64-byte X.509 commonName limit; the + # gateway name is a cluster-scoped resource name. The CN + # is cosmetic, since a cluster gateway trusts this CA by + # its certificate rather than its name. + "commonName": f"modelplane fleet gateway CA {gateway}"[:64], + "secretName": _CLIENT_CA_SECRET, + "duration": "87600h", + "renewBefore": "8760h", + "privateKey": {"algorithm": "ECDSA", "size": 256}, + "issuerRef": {"name": _SELFSIGNED_ISSUER, "kind": "Issuer", "group": "cert-manager.io"}, }, }, - ) + _CERTIFICATE_READY_CEL, + ), + ( + "client-ca-issuer", + { + "apiVersion": "cert-manager.io/v1", + "kind": "Issuer", + "metadata": {"name": _CLIENT_CA_ISSUER, "namespace": REMOTE_NAMESPACE}, + "spec": {"ca": {"secretName": _CLIENT_CA_SECRET}}, + }, + None, + ), + ( + "client-certificate", + { + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": {"name": _CLIENT_CERT_SECRET, "namespace": REMOTE_NAMESPACE}, + "spec": { + "secretName": _CLIENT_CERT_SECRET, + "commonName": f"fleet-gateway-{gateway}"[:64], + "usages": ["client auth", "digital signature", "key encipherment"], + "duration": "2160h", + "renewBefore": "720h", + "privateKey": {"algorithm": "ECDSA", "size": 256, "rotationPolicy": "Always"}, + "issuerRef": {"name": _CLIENT_CA_ISSUER, "kind": "Issuer", "group": "cert-manager.io"}, + }, + }, + _CERTIFICATE_READY_CEL, + ), + # Republish the CA certificate on its own, so the control plane can + # read it without reading the private key next to it. A cluster + # gateway needs this certificate to know a fleet gateway is calling, + # and the only route to it is through this gateway's status. + # + # cert-manager writes ca.crt and tls.key into one Secret. Observing + # that Secret would mean provider-kubernetes copying the whole thing + # into the Object's status, private key included, where anyone who + # can get objects could read it and mint a client certificate every + # cluster trusts. A Bundle takes one named key from a Secret and + # writes it to a ConfigMap, so the key is read once, in-cluster, by a + # controller already entitled to it. + ( + "client-ca-bundle", + { + "apiVersion": "trust.cert-manager.io/v1alpha1", + "kind": "Bundle", + # Cluster-scoped, and it names the ConfigMap it syncs. + "metadata": {"name": _CLIENT_CA_BUNDLE}, + "spec": { + "sources": [{"secret": {"name": _CLIENT_CA_SECRET, "key": "ca.crt"}}], + "target": { + "configMap": {"key": "ca.crt"}, + # A target syncs to every namespace by default. Only + # modelplane-system reads it. + "namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": REMOTE_NAMESPACE}}, + }, + }, + }, + _BUNDLE_SYNCED_CEL, + ), + # Observed, not managed: trust-manager owns this ConfigMap, and + # status only needs to read the CA certificate back out of it. + ( + "client-ca-configmap", + { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": _CLIENT_CA_BUNDLE, "namespace": REMOTE_NAMESPACE}, + }, + None, + ), + ] + for key, manifest, cel in objects: + obj = _wrap(self.pc, manifest, cel_query=cel) + if key == "client-ca-configmap": + obj.spec.managementPolicies = ["Observe"] + resource.update(self.rsp.desired.resources[key], obj) + + def observed_client_ca(self) -> str | None: + """This gateway's client CA certificate, read off the ConfigMap + trust-manager syncs. A ConfigMap holds it as plain text, so unlike a + Secret there is nothing to decode.""" + obj = self.req.observed.resources.get("client-ca-configmap") + if obj is None: + return None + d = resource.struct_to_dict(obj.resource) + data = d.get("status", {}).get("atProvider", {}).get("manifest", {}).get("data", {}) + return data.get("ca.crt") or None - if traefik_ready or "gateway" in self.req.observed.resources: - # The Gateway listener port must match Traefik's "web" - # entryPoint internal port, not the Service's exposed port. + def compose_cluster_names(self) -> None: + """Resolve each cluster gateway's internal name to its address, here. + + A ModelService's backends address a cluster gateway by the name + compose-inference-cluster derives, carried on ModelEndpoint.spec.origin, + and Envoy resolves that name itself. So this gateway's cluster needs a + Service of that name pointing at the cluster gateway's address, for every + cluster this gateway might route to, its own included when that cluster + serves models too. A platform publishes no DNS for any of them. + + A cluster that hasn't published both an address and its name has no + gateway to reach yet, so it gets no Service. + """ + if "clusters" not in self.req.required_resources: + return + for c in request.get_required_resources(self.req, "clusters"): + cluster = icv1alpha1.InferenceCluster.model_validate(c) + gw = cluster.status.gateway if cluster.status else None + if not (gw and gw.address and gw.hostname): + continue + self.compose_cluster_name(gw.hostname, gw.address) + + def compose_cluster_name(self, hostname: str, address: str) -> None: + """Compose the Service that resolves one cluster gateway's name. + + The Service's name is the hostname's first label, so its cluster-DNS name + is the whole hostname; the derivation lives in compose-inference-cluster + and this only splits the label back off. An address that is an IP is + served by a headless Service and an EndpointSlice carrying it; a hostname, + which is how a cloud load balancer names itself, by an ExternalName + Service. The resolvable name is identical either way, so the Backend that + points at it, the SNI, and the certificate SAN never branch on this. + """ + label = hostname.split(".", 1)[0] + version = _ip_version(address) + if version is None: resource.update( - self.rsp.desired.resources["gateway"], + self.rsp.desired.resources[f"cluster-name-{label}"], + _wrap( + self.pc, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": {"name": label, "namespace": REMOTE_NAMESPACE}, + "spec": {"type": "ExternalName", "externalName": address}, + }, + ), + ) + return + # Selectorless and headless: cluster DNS answers with the EndpointSlice's + # address directly, so Envoy connects to the load balancer rather than + # hairpinning through a ClusterIP. + resource.update( + self.rsp.desired.resources[f"cluster-name-{label}"], + _wrap( + self.pc, { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "Gateway", + "apiVersion": "v1", + "kind": "Service", + "metadata": {"name": label, "namespace": REMOTE_NAMESPACE}, + "spec": {"clusterIP": "None", "ports": [{"name": "https", "port": _CLUSTER_GATEWAY_PORT}]}, + }, + ), + ) + resource.update( + self.rsp.desired.resources[f"cluster-name-slice-{label}"], + _wrap( + self.pc, + { + "apiVersion": "discovery.k8s.io/v1", + "kind": "EndpointSlice", "metadata": { - "name": _GATEWAY_NAME, - "namespace": _NAMESPACE_SYSTEM, + "name": label, + "namespace": REMOTE_NAMESPACE, + "labels": {"kubernetes.io/service-name": label}, }, + "addressType": f"IPv{version}", + "ports": [{"name": "https", "port": _CLUSTER_GATEWAY_PORT}], + "endpoints": [{"addresses": [address], "conditions": {"ready": True}}], + }, + ), + ) + + def compose_caller_auth(self) -> None: + """A SecurityPolicy authenticating callers against the selected Secrets. + + Each key in a Secret is one caller: the entry's name is the identity, + which the policy resolves and forwards as a header, and the value is the + key, which it strips so it travels no further. The filter also accepts a + key sent as "Bearer ", which is how an OpenAI client sends it. + + Composed only when the XR asks for auth. Without it the gateway + authenticates nobody, which is a deliberate shape for running behind + something that already has. + """ + if not self.xr.spec.auth: + return + refs = [{"name": f"callers-{s.get('metadata', {}).get('name', '')}"} for s in self.caller_secrets] + resource.update( + self.rsp.desired.resources["caller-auth"], + _wrap( + self.pc, + { + "apiVersion": "gateway.envoyproxy.io/v1alpha1", + "kind": "SecurityPolicy", + "metadata": {"name": _CALLERS_NAME, "namespace": REMOTE_NAMESPACE}, "spec": { - "gatewayClassName": _TRAEFIK_GATEWAY_CLASS, - "listeners": [ + "targetRefs": [ { - "name": "web", - "protocol": "HTTP", - "port": _TRAEFIK_WEB_ENTRYPOINT_PORT, - "allowedRoutes": {"namespaces": {"from": "All"}}, + "group": "gateway.networking.k8s.io", + "kind": "Gateway", + "name": _GATEWAY_NAME, } ], + "apiKeyAuth": { + "credentialRefs": refs, + "extractFrom": [{"headers": ["Authorization"]}], + "forwardClientIDHeader": _CALLER_HEADER, + "sanitize": True, + }, }, }, - ) - - def write_status(self) -> None: - """Surface the gateway's external address. Only the address โ€” no - gateway-specific fields. This contract works for any routing backend.""" - status = v1alpha1.Status() - - gw_observed = self.req.observed.resources.get("gateway") - if gw_observed: - gw_dict = resource.struct_to_dict(gw_observed.resource) - addresses = gw_dict.get("status", {}).get("addresses", []) - if addresses: - status.address = addresses[0].get("value") + # Readiness tracks the policy being Accepted, not merely applied. + # Envoy Gateway rejects the whole policy when two selected + # Secrets share a key value, and an unresolvable credential ref + # fails closed with a 500 on every route. Both would otherwise + # leave the gateway reporting Ready while refusing every caller. + cel_query=_POLICY_ACCEPTED_CEL, + ), + ) - resource.update_status(self.rsp.desired.composite, status) + def compose_failover_policy(self) -> None: + """A BackendTrafficPolicy that makes a ModelService's priorities mean + something, and ejects an endpoint that keeps failing. - def derive_conditions(self) -> None: - """Derive readiness for all composed resources and set custom - conditions.""" - # MetalLB readiness. - t = self.xr.spec.traefik - if ( - t - and t.loadBalancer == "MetalLB" - and t.metallb - and t.metallb.addressPool - and resource.get_condition(self.req.observed.resources.get("metallb"), "Ready").status == "True" - ): - self.rsp.desired.resources["metallb"].ready = fnv1.READY_TRUE + A ModelService's priority is stamped as an endpoint locality priority, + which on its own changes nothing: Envoy only tries a lower priority when + a retry predicate tells it to, and numAttemptsPerPriority is what + installs one. Without this policy every endpoint in a service shares + traffic regardless of priority, so failover silently doesn't happen. - # Traefik readiness. - traefik_ready = resource.get_condition(self.req.observed.resources.get("traefik"), "Ready").status == "True" - if traefik_ready: - self.rsp.desired.resources["traefik"].ready = fnv1.READY_TRUE - # Transition: Traefik just became ready. - if "gateway" not in self.req.observed.resources: - response.normal(self.rsp, "Traefik ready, composing Gateway") + It targets the Gateway rather than each route because a + BackendTrafficPolicy can only target a Gateway or a route, never a + backend, so per-endpoint tuning isn't available either way, and one + policy per gateway beats one per ModelService. - # ControllerReady condition. - response.set_conditions( - self.rsp, - resource.Condition( - typ=CONDITION_TYPE_CONTROLLER_READY, - status="True" if traefik_ready else "False", - reason=CONDITION_REASON_CONTROLLER_HEALTHY if traefik_ready else CONDITION_REASON_INSTALLING, + Retrying is bounded by the first byte reaching the caller. Past that the + tokens are sent and a retry would duplicate them, so a backend dying + mid-stream truncates the response rather than failing over. + """ + resource.update( + self.rsp.desired.resources["failover-policy"], + _wrap( + self.pc, + { + "apiVersion": "gateway.envoyproxy.io/v1alpha1", + "kind": "BackendTrafficPolicy", + "metadata": {"name": _FAILOVER_NAME, "namespace": REMOTE_NAMESPACE}, + "spec": { + "targetRefs": [ + { + "group": "gateway.networking.k8s.io", + "kind": "Gateway", + "name": _GATEWAY_NAME, + } + ], + "retry": { + "numAttemptsPerPriority": 1, + "numRetries": 3, + "retryOn": { + # retriable-status-codes has to be among the + # triggers for httpStatusCodes to do anything. + # Envoy only consults retriable_status_codes when + # retry_on names it, and Envoy Gateway replaces + # retry_on wholesale with whatever triggers says, + # so listing a status code without this trigger + # is silently inert. A provider answering 503, + # which is the case this exists for, would not be + # retried and would not fail over. + "triggers": [ + "connect-failure", + "refused-stream", + "reset", + "retriable-status-codes", + ], + "httpStatusCodes": [503], + }, + }, + "healthCheck": { + "passive": { + "baseEjectionTime": "30s", + "consecutive5XxErrors": 5, + "interval": "5s", + "maxEjectionPercent": 100, + }, + # A sibling of passive, not a field inside it. Nested + # wrongly the API server prunes it, the policy still + # applies, and panic mode silently stays at its + # default. + # + # That default is 50%: once that share of a cluster's + # endpoints is unhealthy Envoy ignores health and + # spreads traffic over all of them, ejected ones + # included. Every endpoint of a ModelService shares + # one cluster, so ejecting a whole priority tier + # usually crosses it, and failover would stop working + # in exactly the case it exists for. Disabled, + # because a request is better refused than sent + # somewhere known dead. + "panicThreshold": 0, + }, + }, + }, + cel_query=_POLICY_ACCEPTED_CEL, ), ) - # GatewayClass and Gateway use Accepted (not Ready) โ€” on kind the - # Gateway won't be Programmed (no LoadBalancer), but Accepted means - # the controller has scheduled it and it's usable. - if resource.get_condition(self.req.observed.resources.get("gateway-class"), "Accepted").status == "True": - self.rsp.desired.resources["gateway-class"].ready = fnv1.READY_TRUE - - if resource.get_condition(self.req.observed.resources.get("gateway"), "Accepted").status == "True": - self.rsp.desired.resources["gateway"].ready = fnv1.READY_TRUE + def compose_healthz(self) -> None: + """A /healthz returning 200 while the gateway is live and able to route. - def compose_pc_usage(self, release_key: str) -> None: - """Compose a Usage protecting the ProviderConfig from deletion until - the given Helm release is gone.""" + This is the target a geo-DNS record or a fronting edge checks to decide + whether this address is in rotation, so it must answer without a + credential. A Gateway-level SecurityPolicy covers every route on the + listener, including this one, so the route carries its own policy to + override it. Allow-all on this route only; inference still authenticates. + """ resource.update( - self.rsp.desired.resources[f"usage-pc-by-{release_key}"], - usagev1beta1.Usage( - metadata=metav1.ObjectMeta(namespace=_NAMESPACE_SYSTEM), - spec=usagev1beta1.Spec( - of=usagev1beta1.Of( - apiVersion="helm.m.crossplane.io/v1beta1", - kind="ProviderConfig", - resourceRef=usagev1beta1.ResourceRefModel(name=_PC_NAME), - ), - by=usagev1beta1.By( - apiVersion="helm.m.crossplane.io/v1beta1", - kind="Release", - resourceSelector=usagev1beta1.ResourceSelector( - matchControllerRef=True, - matchLabels={_LABEL_RELEASE: release_key}, - ), - ), - replayDeletion=True, - ), + self.rsp.desired.resources["healthz-filter"], + _wrap( + self.pc, + { + "apiVersion": "gateway.envoyproxy.io/v1alpha1", + "kind": "HTTPRouteFilter", + "metadata": {"name": _HEALTHZ_NAME, "namespace": REMOTE_NAMESPACE}, + "spec": { + "directResponse": { + "statusCode": 200, + "contentType": "application/json", + "body": {"type": "Inline", "inline": '{"status":"ok"}'}, + } + }, + }, + ), + ) + resource.update( + self.rsp.desired.resources["healthz-route"], + _wrap( + self.pc, + { + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": {"name": _HEALTHZ_NAME, "namespace": REMOTE_NAMESPACE}, + "spec": { + "parentRefs": [ + { + "group": "gateway.networking.k8s.io", + "kind": "Gateway", + "name": _GATEWAY_NAME, + } + ], + "rules": [ + { + "matches": [{"path": {"type": "Exact", "value": _HEALTHZ_PATH}}], + "filters": [ + { + "type": "ExtensionRef", + "extensionRef": { + "group": "gateway.envoyproxy.io", + "kind": "HTTPRouteFilter", + "name": _HEALTHZ_NAME, + }, + } + ], + } + ], + }, + }, ), ) - self.rsp.desired.resources[f"usage-pc-by-{release_key}"].ready = fnv1.READY_TRUE - - def compose_gateway_usages(self) -> None: - """Compose Usages so the Traefik release outlives the GatewayClass and - Gateway it controls. - - On XR deletion every composed resource is deleted concurrently. The - Traefik controller sets a finalizer on the GatewayClass (and Gateway); - if the release (and thus the controller) is uninstalled first, that - finalizer is never cleared and deletion wedges. These Usages hold the - release until the GatewayClass and Gateway are gone, so the controller - is still running to clear their finalizers. - - The GatewayClass is cluster-scoped, so it needs a ClusterUsage; the - Gateway is namespaced. Both are gated on Traefik being composed this - pass. The Usages select the Release by label rather than referencing - it directly, so they don't need it to exist yet; composing them - alongside the Release puts deletion-order protection in place from the - moment the Release is first emitted as desired state.""" - if "traefik" not in self.rsp.desired.resources: + if not self.xr.spec.auth: return - - release_by = clusterusagev1beta1.By( - apiVersion="helm.m.crossplane.io/v1beta1", - kind="Release", - resourceSelector=clusterusagev1beta1.ResourceSelector( - matchControllerRef=True, - matchLabels={_LABEL_RELEASE: "traefik"}, + resource.update( + self.rsp.desired.resources["healthz-auth"], + _wrap( + self.pc, + { + "apiVersion": "gateway.envoyproxy.io/v1alpha1", + "kind": "SecurityPolicy", + "metadata": {"name": f"{_HEALTHZ_NAME}-open", "namespace": REMOTE_NAMESPACE}, + "spec": { + "targetRefs": [ + { + "group": "gateway.networking.k8s.io", + "kind": "HTTPRoute", + "name": _HEALTHZ_NAME, + } + ], + "authorization": {"defaultAction": "Allow"}, + }, + }, + cel_query=_POLICY_ACCEPTED_CEL, ), ) - resource.update( - self.rsp.desired.resources["usage-gateway-class-by-traefik"], - clusterusagev1beta1.ClusterUsage( - spec=clusterusagev1beta1.Spec( - of=clusterusagev1beta1.Of( - apiVersion="gateway.networking.k8s.io/v1", - kind="GatewayClass", - resourceRef=clusterusagev1beta1.ResourceRef(name=_TRAEFIK_GATEWAY_CLASS), - ), - by=release_by, - replayDeletion=True, - ), + def observed_gateway_address(self) -> str | None: + """The gateway's address, read back from the composed remote Gateway.""" + obj = self.req.observed.resources.get("gateway") + if obj is None: + return None + d = resource.struct_to_dict(obj.resource) + addresses = d.get("status", {}).get("atProvider", {}).get("manifest", {}).get("status", {}).get("addresses", []) + return addresses[0].get("value") if addresses else None + + def write_status(self) -> None: + """Publish the gateway's address and the URLs callers use. + + The endpoints are built from the hostname when there is one, so what + status reports is what a caller can actually put in an SDK's base_url, + and from the address otherwise. + """ + address = self.observed_gateway_address() + status = v1alpha1.Status() + if address: + status.address = address + base = None + if self.xr.spec.hostname: + base = f"https://{self.xr.spec.hostname}" if self.xr.spec.tls else f"http://{self.xr.spec.hostname}" + elif address: + base = f"http://{address}" + ca = self.observed_client_ca() + if ca: + status.clientCACertificate = ca + if base: + status.endpoints = v1alpha1.Endpoints( + openAI=f"{base}{_OPENAI_PREFIX}", + anthropic=f"{base}{_ANTHROPIC_PREFIX}", + ) + resource.update_status(self.rsp.desired.composite, status) + + def not_ready(self, reason: str, message: str) -> None: + """Report that the gateway isn't ready, and why.""" + response.set_conditions( + self.rsp, + resource.Condition( + typ=CONDITION_TYPE_GATEWAY_READY, + status="False", + reason=reason, + message=message, ), ) - self.rsp.desired.resources["usage-gateway-class-by-traefik"].ready = fnv1.READY_TRUE + response.normal(self.rsp, message) - resource.update( - self.rsp.desired.resources["usage-gateway-by-traefik"], - usagev1beta1.Usage( - metadata=metav1.ObjectMeta(namespace=_NAMESPACE_SYSTEM), - spec=usagev1beta1.Spec( - of=usagev1beta1.Of( - apiVersion="gateway.networking.k8s.io/v1", - kind="Gateway", - resourceRef=usagev1beta1.ResourceRefModel(name=_GATEWAY_NAME), - ), - by=usagev1beta1.By( - apiVersion="helm.m.crossplane.io/v1beta1", - kind="Release", - resourceSelector=usagev1beta1.ResourceSelector( - matchControllerRef=True, - matchLabels={_LABEL_RELEASE: "traefik"}, - ), - ), - replayDeletion=True, + def derive_conditions(self) -> None: + """GatewayReady tracks the Gateway being programmed and, where the XR + asks for auth, its caller policy being accepted. + + Both, because a gateway whose policy was rejected answers every request + with a 500 while its Gateway is perfectly healthy. Reporting Ready then + would say the front door works when nothing can get through it. + """ + waiting = [ + key + for key in (["gateway", "caller-auth"] if self.xr.spec.auth else ["gateway"]) + if resource.get_condition(self.req.observed.resources.get(key), "Ready").status != "True" + ] + if not waiting: + response.set_conditions( + self.rsp, + resource.Condition( + typ=CONDITION_TYPE_GATEWAY_READY, + status="True", + reason=CONDITION_REASON_GATEWAY_PROGRAMMED, ), - ), + ) + return + if waiting == ["caller-auth"]: + self.not_ready( + CONDITION_REASON_AUTH_NOT_ACCEPTED, + "The gateway's caller authentication policy has not been accepted, so every request is refused. " + "Two selected Secrets sharing a key value will do this.", + ) + return + self.not_ready( + CONDITION_REASON_WAITING_FOR_GATEWAY, + f"Waiting for the Gateway on cluster {self.xr.spec.clusterName} to be programmed", ) - self.rsp.desired.resources["usage-gateway-by-traefik"].ready = fnv1.READY_TRUE diff --git a/functions/compose-inference-gateway/function/gateway_api_crds.yaml b/functions/compose-inference-gateway/function/gateway_api_crds.yaml deleted file mode 100644 index f007f90a7..000000000 --- a/functions/compose-inference-gateway/function/gateway_api_crds.yaml +++ /dev/null @@ -1,17527 +0,0 @@ -# Copyright The Kubernetes Authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# -# Gateway API Standard channel install -# ---- -# -# config/crd/standard/gateway.networking.k8s.io_backendtlspolicies.yaml -# -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - api-approved.kubernetes.io: https://github.com/kubernetes-sigs/gateway-api/pull/4530 - gateway.networking.k8s.io/bundle-version: v1.5.1 - gateway.networking.k8s.io/channel: standard - labels: - gateway.networking.k8s.io/policy: Direct - name: backendtlspolicies.gateway.networking.k8s.io -spec: - group: gateway.networking.k8s.io - names: - categories: - - gateway-api - kind: BackendTLSPolicy - listKind: BackendTLSPolicyList - plural: backendtlspolicies - shortNames: - - btlspolicy - singular: backendtlspolicy - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1 - schema: - openAPIV3Schema: - description: |- - BackendTLSPolicy provides a way to configure how a Gateway - connects to a Backend via TLS. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of BackendTLSPolicy. - properties: - options: - additionalProperties: - description: |- - AnnotationValue is the value of an annotation in Gateway API. This is used - for validation of maps such as TLS options. This roughly matches Kubernetes - annotation validation, although the length validation in that case is based - on the entire size of the annotations struct. - maxLength: 4096 - minLength: 0 - type: string - description: |- - Options are a list of key/value pairs to enable extended TLS - configuration for each implementation. For example, configuring the - minimum TLS version or supported cipher suites. - - A set of common keys MAY be defined by the API in the future. To avoid - any ambiguity, implementation-specific definitions MUST use - domain-prefixed names, such as `example.com/my-custom-option`. - Un-prefixed names are reserved for key names defined by Gateway API. - - Support: Implementation-specific - maxProperties: 16 - type: object - targetRefs: - description: |- - TargetRefs identifies an API object to apply the policy to. - Note that this config applies to the entire referenced resource - by default, but this default may change in the future to provide - a more granular application of the policy. - - TargetRefs must be _distinct_. This means either that: - - * They select different targets. If this is the case, then targetRef - entries are distinct. In terms of fields, this means that the - multi-part key defined by `group`, `kind`, and `name` must - be unique across all targetRef entries in the BackendTLSPolicy. - * They select different sectionNames in the same target. - - When more than one BackendTLSPolicy selects the same target and - sectionName, implementations MUST determine precedence using the - following criteria, continuing on ties: - - * The older policy by creation timestamp takes precedence. For - example, a policy with a creation timestamp of "2021-07-15 - 01:02:03" MUST be given precedence over a policy with a - creation timestamp of "2021-07-15 01:02:04". - * The policy appearing first in alphabetical order by {namespace}/{name}. - For example, a policy named `foo/bar` is given precedence over a - policy named `foo/baz`. - - For any BackendTLSPolicy that does not take precedence, the - implementation MUST ensure the `Accepted` Condition is set to - `status: False`, with Reason `Conflicted`. - - Implementations SHOULD NOT support more than one targetRef at this - time. Although the API technically allows for this, the current guidance - for conflict resolution and status handling is lacking. Until that can be - clarified in a future release, the safest approach is to support a single - targetRef. - - Support Levels: - - * Extended: Kubernetes Service referenced by HTTPRoute backendRefs. - - * Implementation-Specific: Services not connected via HTTPRoute, and any - other kind of backend. Implementations MAY use BackendTLSPolicy for: - - Services not referenced by any Route (e.g., infrastructure services) - - Gateway feature backends (e.g., ExternalAuth, rate-limiting services) - - Service mesh workload-to-service communication - - Other resource types beyond Service - - Implementations SHOULD aim to ensure that BackendTLSPolicy behavior is consistent, - even outside of the extended HTTPRoute -(backendRef) -> Service path. - They SHOULD clearly document how BackendTLSPolicy is interpreted in these - scenarios, including: - - Which resources beyond Service are supported - - How the policy is discovered and applied - - Any implementation-specific semantics or restrictions - - Note that this config applies to the entire referenced resource - by default, but this default may change in the future to provide - a more granular application of the policy. - items: - description: |- - LocalPolicyTargetReferenceWithSectionName identifies an API object to apply a - direct policy to. This should be used as part of Policy resources that can - target single resources. For more information on how this policy attachment - mode works, and a sample Policy resource, refer to the policy attachment - documentation for Gateway API. - - Note: This should only be used for direct policy attachment when references - to SectionName are actually needed. In all other cases, - LocalPolicyTargetReference should be used. - properties: - group: - description: Group is the group of the target resource. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the target resource. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the target resource. - maxLength: 253 - minLength: 1 - type: string - sectionName: - description: |- - SectionName is the name of a section within the target resource. When - unspecified, this targetRef targets the entire resource. In the following - resources, SectionName is interpreted as the following: - - * Gateway: Listener name - * HTTPRoute: HTTPRouteRule name - * Service: Port name - - If a SectionName is specified, but does not exist on the targeted object, - the Policy must fail to attach, and the policy implementation should record - a `ResolvedRefs` or similar Condition in the Policy's status. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - group - - kind - - name - type: object - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: sectionName must be specified when targetRefs includes - 2 or more references to the same target - rule: 'self.all(p1, self.all(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name ? ((!has(p1.sectionName) || p1.sectionName - == '''') == (!has(p2.sectionName) || p2.sectionName == '''')) - : true))' - - message: sectionName must be unique when targetRefs includes 2 or - more references to the same target - rule: self.all(p1, self.exists_one(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.sectionName) || - p1.sectionName == '') && (!has(p2.sectionName) || p2.sectionName - == '')) || (has(p1.sectionName) && has(p2.sectionName) && p1.sectionName - == p2.sectionName)))) - validation: - description: Validation contains backend TLS validation configuration. - properties: - caCertificateRefs: - description: |- - CACertificateRefs contains one or more references to Kubernetes objects that - contain a PEM-encoded TLS CA certificate bundle, which is used to - validate a TLS handshake between the Gateway and backend Pod. - - If CACertificateRefs is empty or unspecified, then WellKnownCACertificates must be - specified. Only one of CACertificateRefs or WellKnownCACertificates may be specified, - not both. If CACertificateRefs is empty or unspecified, the configuration for - WellKnownCACertificates MUST be honored instead if supported by the implementation. - - A CACertificateRef is invalid if: - - * It refers to a resource that cannot be resolved (e.g., the referenced resource - does not exist) or is misconfigured (e.g., a ConfigMap does not contain a key - named `ca.crt`). In this case, the Reason must be set to `InvalidCACertificateRef` - and the Message of the Condition must indicate which reference is invalid and why. - - * It refers to an unknown or unsupported kind of resource. In this case, the Reason - must be set to `InvalidKind` and the Message of the Condition must explain which - kind of resource is unknown or unsupported. - - * It refers to a resource in another namespace. This may change in future - spec updates. - - Implementations MAY choose to perform further validation of the certificate - content (e.g., checking expiry or enforcing specific formats). In such cases, - an implementation-specific Reason and Message must be set for the invalid reference. - - In all cases, the implementation MUST ensure the `ResolvedRefs` Condition on - the BackendTLSPolicy is set to `status: False`, with a Reason and Message - that indicate the cause of the error. Connections using an invalid - CACertificateRef MUST fail, and the client MUST receive an HTTP 5xx error - response. If ALL CACertificateRefs are invalid, the implementation MUST also - ensure the `Accepted` Condition on the BackendTLSPolicy is set to - `status: False`, with a Reason `NoValidCACertificate`. - - A single CACertificateRef to a Kubernetes ConfigMap kind has "Core" support. - Implementations MAY choose to support attaching multiple certificates to - a backend, but this behavior is implementation-specific. - - Support: Core - An optional single reference to a Kubernetes ConfigMap, - with the CA certificate in a key named `ca.crt`. - - Support: Implementation-specific - More than one reference, other kinds - of resources, or a single reference that includes multiple certificates. - items: - description: |- - LocalObjectReference identifies an API object within the namespace of the - referrer. - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - - References to objects with invalid Group and Kind are not valid, and must - be rejected by the implementation, with appropriate Conditions set - on the containing object. - properties: - group: - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. For example "HTTPRoute" - or "Service". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - required: - - group - - kind - - name - type: object - maxItems: 8 - type: array - x-kubernetes-list-type: atomic - hostname: - description: |- - Hostname is used for two purposes in the connection between Gateways and - backends: - - 1. Hostname MUST be used as the SNI to connect to the backend (RFC 6066). - 2. Hostname MUST be used for authentication and MUST match the certificate - served by the matching backend, unless SubjectAltNames is specified. - 3. If SubjectAltNames are specified, Hostname can be used for certificate selection - but MUST NOT be used for authentication. If you want to use the value - of the Hostname field for authentication, you MUST add it to the SubjectAltNames list. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - subjectAltNames: - description: |- - SubjectAltNames contains one or more Subject Alternative Names. - When specified the certificate served from the backend MUST - have at least one Subject Alternate Name matching one of the specified SubjectAltNames. - - Support: Extended - items: - description: SubjectAltName represents Subject Alternative Name. - properties: - hostname: - description: |- - Hostname contains Subject Alternative Name specified in DNS name format. - Required when Type is set to Hostname, ignored otherwise. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - type: - description: |- - Type determines the format of the Subject Alternative Name. Always required. - - Support: Core - enum: - - Hostname - - URI - type: string - uri: - description: |- - URI contains Subject Alternative Name specified in a full URI format. - It MUST include both a scheme (e.g., "http" or "ftp") and a scheme-specific-part. - Common values include SPIFFE IDs like "spiffe://mycluster.example.com/ns/myns/sa/svc1sa". - Required when Type is set to URI, ignored otherwise. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^(([^:/?#]+):)(//([^/?#]*))([^?#]*)(\?([^#]*))?(#(.*))? - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: SubjectAltName element must contain Hostname, if - Type is set to Hostname - rule: '!(self.type == "Hostname" && (!has(self.hostname) || - self.hostname == ""))' - - message: SubjectAltName element must not contain Hostname, - if Type is not set to Hostname - rule: '!(self.type != "Hostname" && has(self.hostname) && - self.hostname != "")' - - message: SubjectAltName element must contain URI, if Type - is set to URI - rule: '!(self.type == "URI" && (!has(self.uri) || self.uri - == ""))' - - message: SubjectAltName element must not contain URI, if Type - is not set to URI - rule: '!(self.type != "URI" && has(self.uri) && self.uri != - "")' - maxItems: 5 - type: array - x-kubernetes-list-type: atomic - wellKnownCACertificates: - description: |- - WellKnownCACertificates specifies whether a well-known set of CA certificates - may be used in the TLS handshake between the gateway and backend pod. - - If WellKnownCACertificates is unspecified or empty (""), then CACertificateRefs - must be specified with at least one entry for a valid configuration. Only one of - CACertificateRefs or WellKnownCACertificates may be specified, not both. - If an implementation does not support the WellKnownCACertificates field, or - the supplied value is not recognized, the implementation MUST ensure the - `Accepted` Condition on the BackendTLSPolicy is set to `status: False`, with - a Reason `Invalid`. - - Valid values include: - * "System" - indicates that well-known system CA certificates should be used. - - Implementations MAY define their own sets of CA certificates. Such definitions - MUST use an implementation-specific, prefixed name, such as - `mycompany.com/my-custom-ca-certificates`. - - Support: Implementation-specific - maxLength: 253 - minLength: 1 - pattern: ^(System|([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9]))$ - type: string - required: - - hostname - type: object - x-kubernetes-validations: - - message: must not contain both CACertificateRefs and WellKnownCACertificates - rule: '!(has(self.caCertificateRefs) && size(self.caCertificateRefs) - > 0 && has(self.wellKnownCACertificates) && self.wellKnownCACertificates - != "")' - - message: must specify either CACertificateRefs or WellKnownCACertificates - rule: (has(self.caCertificateRefs) && size(self.caCertificateRefs) - > 0 || has(self.wellKnownCACertificates) && self.wellKnownCACertificates - != "") - required: - - targetRefs - - validation - type: object - status: - description: Status defines the current state of BackendTLSPolicy. - properties: - ancestors: - description: |- - Ancestors is a list of ancestor resources (usually Gateways) that are - associated with the policy, and the status of the policy with respect to - each ancestor. When this policy attaches to a parent, the controller that - manages the parent and the ancestors MUST add an entry to this list when - the controller first sees the policy and SHOULD update the entry as - appropriate when the relevant ancestor is modified. - - Note that choosing the relevant ancestor is left to the Policy designers; - an important part of Policy design is designing the right object level at - which to namespace this status. - - Note also that implementations MUST ONLY populate ancestor status for - the Ancestor resources they are responsible for. Implementations MUST - use the ControllerName field to uniquely identify the entries in this list - that they are responsible for. - - Note that to achieve this, the list of PolicyAncestorStatus structs - MUST be treated as a map with a composite key, made up of the AncestorRef - and ControllerName fields combined. - - A maximum of 16 ancestors will be represented in this list. An empty list - means the Policy is not relevant for any ancestors. - - If this slice is full, implementations MUST NOT add further entries. - Instead they MUST consider the policy unimplementable and signal that - on any related resources such as the ancestor that would be referenced - here. For example, if this list was full on BackendTLSPolicy, no - additional Gateways would be able to reference the Service targeted by - the BackendTLSPolicy. - items: - description: |- - PolicyAncestorStatus describes the status of a route with respect to an - associated Ancestor. - - Ancestors refer to objects that are either the Target of a policy or above it - in terms of object hierarchy. For example, if a policy targets a Service, the - Policy's Ancestors are, in order, the Service, the HTTPRoute, the Gateway, and - the GatewayClass. Almost always, in this hierarchy, the Gateway will be the most - useful object to place Policy status on, so we recommend that implementations - SHOULD use Gateway as the PolicyAncestorStatus object unless the designers - have a _very_ good reason otherwise. - - In the context of policy attachment, the Ancestor is used to distinguish which - resource results in a distinct application of this policy. For example, if a policy - targets a Service, it may have a distinct result per attached Gateway. - - Policies targeting the same resource may have different effects depending on the - ancestors of those resources. For example, different Gateways targeting the same - Service may have different capabilities, especially if they have different underlying - implementations. - - For example, in BackendTLSPolicy, the Policy attaches to a Service that is - used as a backend in a HTTPRoute that is itself attached to a Gateway. - In this case, the relevant object for status is the Gateway, and that is the - ancestor object referred to in this status. - - Note that a parent is also an ancestor, so for objects where the parent is the - relevant object for status, this struct SHOULD still be used. - - This struct is intended to be used in a slice that's effectively a map, - with a composite key made up of the AncestorRef and the ControllerName. - properties: - ancestorRef: - description: |- - AncestorRef corresponds with a ParentRef in the spec that this - PolicyAncestorStatus struct describes the status of. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - conditions: - description: Conditions describes the status of the Policy with - respect to the given Ancestor. - items: - description: Condition contains details for one aspect of - the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, - Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - controllerName: - description: |- - ControllerName is a domain/path string that indicates the name of the - controller that wrote this status. This corresponds with the - controllerName field on GatewayClass. - - Example: "example.net/gateway-controller". - - The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are - valid Kubernetes names - (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). - - Controllers MUST populate this field when writing status. Controllers should ensure that - entries to status populated with their ControllerName are cleaned up when they are no - longer necessary. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - required: - - ancestorRef - - conditions - - controllerName - type: object - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - required: - - ancestors - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} - - deprecated: true - deprecationWarning: The v1alpha3 version of BackendTLSPolicy has been deprecated - and will be removed in a future release of the API. Please upgrade to v1. - name: v1alpha3 - schema: - openAPIV3Schema: - description: |- - BackendTLSPolicy provides a way to configure how a Gateway - connects to a Backend via TLS. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of BackendTLSPolicy. - properties: - options: - additionalProperties: - description: |- - AnnotationValue is the value of an annotation in Gateway API. This is used - for validation of maps such as TLS options. This roughly matches Kubernetes - annotation validation, although the length validation in that case is based - on the entire size of the annotations struct. - maxLength: 4096 - minLength: 0 - type: string - description: |- - Options are a list of key/value pairs to enable extended TLS - configuration for each implementation. For example, configuring the - minimum TLS version or supported cipher suites. - - A set of common keys MAY be defined by the API in the future. To avoid - any ambiguity, implementation-specific definitions MUST use - domain-prefixed names, such as `example.com/my-custom-option`. - Un-prefixed names are reserved for key names defined by Gateway API. - - Support: Implementation-specific - maxProperties: 16 - type: object - targetRefs: - description: |- - TargetRefs identifies an API object to apply the policy to. - Note that this config applies to the entire referenced resource - by default, but this default may change in the future to provide - a more granular application of the policy. - - TargetRefs must be _distinct_. This means either that: - - * They select different targets. If this is the case, then targetRef - entries are distinct. In terms of fields, this means that the - multi-part key defined by `group`, `kind`, and `name` must - be unique across all targetRef entries in the BackendTLSPolicy. - * They select different sectionNames in the same target. - - When more than one BackendTLSPolicy selects the same target and - sectionName, implementations MUST determine precedence using the - following criteria, continuing on ties: - - * The older policy by creation timestamp takes precedence. For - example, a policy with a creation timestamp of "2021-07-15 - 01:02:03" MUST be given precedence over a policy with a - creation timestamp of "2021-07-15 01:02:04". - * The policy appearing first in alphabetical order by {namespace}/{name}. - For example, a policy named `foo/bar` is given precedence over a - policy named `foo/baz`. - - For any BackendTLSPolicy that does not take precedence, the - implementation MUST ensure the `Accepted` Condition is set to - `status: False`, with Reason `Conflicted`. - - Implementations SHOULD NOT support more than one targetRef at this - time. Although the API technically allows for this, the current guidance - for conflict resolution and status handling is lacking. Until that can be - clarified in a future release, the safest approach is to support a single - targetRef. - - Support Levels: - - * Extended: Kubernetes Service referenced by HTTPRoute backendRefs. - - * Implementation-Specific: Services not connected via HTTPRoute, and any - other kind of backend. Implementations MAY use BackendTLSPolicy for: - - Services not referenced by any Route (e.g., infrastructure services) - - Gateway feature backends (e.g., ExternalAuth, rate-limiting services) - - Service mesh workload-to-service communication - - Other resource types beyond Service - - Implementations SHOULD aim to ensure that BackendTLSPolicy behavior is consistent, - even outside of the extended HTTPRoute -(backendRef) -> Service path. - They SHOULD clearly document how BackendTLSPolicy is interpreted in these - scenarios, including: - - Which resources beyond Service are supported - - How the policy is discovered and applied - - Any implementation-specific semantics or restrictions - - Note that this config applies to the entire referenced resource - by default, but this default may change in the future to provide - a more granular application of the policy. - items: - description: |- - LocalPolicyTargetReferenceWithSectionName identifies an API object to apply a - direct policy to. This should be used as part of Policy resources that can - target single resources. For more information on how this policy attachment - mode works, and a sample Policy resource, refer to the policy attachment - documentation for Gateway API. - - Note: This should only be used for direct policy attachment when references - to SectionName are actually needed. In all other cases, - LocalPolicyTargetReference should be used. - properties: - group: - description: Group is the group of the target resource. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the target resource. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the target resource. - maxLength: 253 - minLength: 1 - type: string - sectionName: - description: |- - SectionName is the name of a section within the target resource. When - unspecified, this targetRef targets the entire resource. In the following - resources, SectionName is interpreted as the following: - - * Gateway: Listener name - * HTTPRoute: HTTPRouteRule name - * Service: Port name - - If a SectionName is specified, but does not exist on the targeted object, - the Policy must fail to attach, and the policy implementation should record - a `ResolvedRefs` or similar Condition in the Policy's status. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - group - - kind - - name - type: object - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: sectionName must be specified when targetRefs includes - 2 or more references to the same target - rule: 'self.all(p1, self.all(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name ? ((!has(p1.sectionName) || p1.sectionName - == '''') == (!has(p2.sectionName) || p2.sectionName == '''')) - : true))' - - message: sectionName must be unique when targetRefs includes 2 or - more references to the same target - rule: self.all(p1, self.exists_one(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.sectionName) || - p1.sectionName == '') && (!has(p2.sectionName) || p2.sectionName - == '')) || (has(p1.sectionName) && has(p2.sectionName) && p1.sectionName - == p2.sectionName)))) - validation: - description: Validation contains backend TLS validation configuration. - properties: - caCertificateRefs: - description: |- - CACertificateRefs contains one or more references to Kubernetes objects that - contain a PEM-encoded TLS CA certificate bundle, which is used to - validate a TLS handshake between the Gateway and backend Pod. - - If CACertificateRefs is empty or unspecified, then WellKnownCACertificates must be - specified. Only one of CACertificateRefs or WellKnownCACertificates may be specified, - not both. If CACertificateRefs is empty or unspecified, the configuration for - WellKnownCACertificates MUST be honored instead if supported by the implementation. - - A CACertificateRef is invalid if: - - * It refers to a resource that cannot be resolved (e.g., the referenced resource - does not exist) or is misconfigured (e.g., a ConfigMap does not contain a key - named `ca.crt`). In this case, the Reason must be set to `InvalidCACertificateRef` - and the Message of the Condition must indicate which reference is invalid and why. - - * It refers to an unknown or unsupported kind of resource. In this case, the Reason - must be set to `InvalidKind` and the Message of the Condition must explain which - kind of resource is unknown or unsupported. - - * It refers to a resource in another namespace. This may change in future - spec updates. - - Implementations MAY choose to perform further validation of the certificate - content (e.g., checking expiry or enforcing specific formats). In such cases, - an implementation-specific Reason and Message must be set for the invalid reference. - - In all cases, the implementation MUST ensure the `ResolvedRefs` Condition on - the BackendTLSPolicy is set to `status: False`, with a Reason and Message - that indicate the cause of the error. Connections using an invalid - CACertificateRef MUST fail, and the client MUST receive an HTTP 5xx error - response. If ALL CACertificateRefs are invalid, the implementation MUST also - ensure the `Accepted` Condition on the BackendTLSPolicy is set to - `status: False`, with a Reason `NoValidCACertificate`. - - A single CACertificateRef to a Kubernetes ConfigMap kind has "Core" support. - Implementations MAY choose to support attaching multiple certificates to - a backend, but this behavior is implementation-specific. - - Support: Core - An optional single reference to a Kubernetes ConfigMap, - with the CA certificate in a key named `ca.crt`. - - Support: Implementation-specific - More than one reference, other kinds - of resources, or a single reference that includes multiple certificates. - items: - description: |- - LocalObjectReference identifies an API object within the namespace of the - referrer. - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - - References to objects with invalid Group and Kind are not valid, and must - be rejected by the implementation, with appropriate Conditions set - on the containing object. - properties: - group: - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. For example "HTTPRoute" - or "Service". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - required: - - group - - kind - - name - type: object - maxItems: 8 - type: array - x-kubernetes-list-type: atomic - hostname: - description: |- - Hostname is used for two purposes in the connection between Gateways and - backends: - - 1. Hostname MUST be used as the SNI to connect to the backend (RFC 6066). - 2. Hostname MUST be used for authentication and MUST match the certificate - served by the matching backend, unless SubjectAltNames is specified. - 3. If SubjectAltNames are specified, Hostname can be used for certificate selection - but MUST NOT be used for authentication. If you want to use the value - of the Hostname field for authentication, you MUST add it to the SubjectAltNames list. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - subjectAltNames: - description: |- - SubjectAltNames contains one or more Subject Alternative Names. - When specified the certificate served from the backend MUST - have at least one Subject Alternate Name matching one of the specified SubjectAltNames. - - Support: Extended - items: - description: SubjectAltName represents Subject Alternative Name. - properties: - hostname: - description: |- - Hostname contains Subject Alternative Name specified in DNS name format. - Required when Type is set to Hostname, ignored otherwise. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - type: - description: |- - Type determines the format of the Subject Alternative Name. Always required. - - Support: Core - enum: - - Hostname - - URI - type: string - uri: - description: |- - URI contains Subject Alternative Name specified in a full URI format. - It MUST include both a scheme (e.g., "http" or "ftp") and a scheme-specific-part. - Common values include SPIFFE IDs like "spiffe://mycluster.example.com/ns/myns/sa/svc1sa". - Required when Type is set to URI, ignored otherwise. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^(([^:/?#]+):)(//([^/?#]*))([^?#]*)(\?([^#]*))?(#(.*))? - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: SubjectAltName element must contain Hostname, if - Type is set to Hostname - rule: '!(self.type == "Hostname" && (!has(self.hostname) || - self.hostname == ""))' - - message: SubjectAltName element must not contain Hostname, - if Type is not set to Hostname - rule: '!(self.type != "Hostname" && has(self.hostname) && - self.hostname != "")' - - message: SubjectAltName element must contain URI, if Type - is set to URI - rule: '!(self.type == "URI" && (!has(self.uri) || self.uri - == ""))' - - message: SubjectAltName element must not contain URI, if Type - is not set to URI - rule: '!(self.type != "URI" && has(self.uri) && self.uri != - "")' - maxItems: 5 - type: array - x-kubernetes-list-type: atomic - wellKnownCACertificates: - description: |- - WellKnownCACertificates specifies whether a well-known set of CA certificates - may be used in the TLS handshake between the gateway and backend pod. - - If WellKnownCACertificates is unspecified or empty (""), then CACertificateRefs - must be specified with at least one entry for a valid configuration. Only one of - CACertificateRefs or WellKnownCACertificates may be specified, not both. - If an implementation does not support the WellKnownCACertificates field, or - the supplied value is not recognized, the implementation MUST ensure the - `Accepted` Condition on the BackendTLSPolicy is set to `status: False`, with - a Reason `Invalid`. - - Valid values include: - * "System" - indicates that well-known system CA certificates should be used. - - Implementations MAY define their own sets of CA certificates. Such definitions - MUST use an implementation-specific, prefixed name, such as - `mycompany.com/my-custom-ca-certificates`. - - Support: Implementation-specific - maxLength: 253 - minLength: 1 - pattern: ^(System|([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9]))$ - type: string - required: - - hostname - type: object - x-kubernetes-validations: - - message: must not contain both CACertificateRefs and WellKnownCACertificates - rule: '!(has(self.caCertificateRefs) && size(self.caCertificateRefs) - > 0 && has(self.wellKnownCACertificates) && self.wellKnownCACertificates - != "")' - - message: must specify either CACertificateRefs or WellKnownCACertificates - rule: (has(self.caCertificateRefs) && size(self.caCertificateRefs) - > 0 || has(self.wellKnownCACertificates) && self.wellKnownCACertificates - != "") - required: - - targetRefs - - validation - type: object - status: - description: Status defines the current state of BackendTLSPolicy. - properties: - ancestors: - description: |- - Ancestors is a list of ancestor resources (usually Gateways) that are - associated with the policy, and the status of the policy with respect to - each ancestor. When this policy attaches to a parent, the controller that - manages the parent and the ancestors MUST add an entry to this list when - the controller first sees the policy and SHOULD update the entry as - appropriate when the relevant ancestor is modified. - - Note that choosing the relevant ancestor is left to the Policy designers; - an important part of Policy design is designing the right object level at - which to namespace this status. - - Note also that implementations MUST ONLY populate ancestor status for - the Ancestor resources they are responsible for. Implementations MUST - use the ControllerName field to uniquely identify the entries in this list - that they are responsible for. - - Note that to achieve this, the list of PolicyAncestorStatus structs - MUST be treated as a map with a composite key, made up of the AncestorRef - and ControllerName fields combined. - - A maximum of 16 ancestors will be represented in this list. An empty list - means the Policy is not relevant for any ancestors. - - If this slice is full, implementations MUST NOT add further entries. - Instead they MUST consider the policy unimplementable and signal that - on any related resources such as the ancestor that would be referenced - here. For example, if this list was full on BackendTLSPolicy, no - additional Gateways would be able to reference the Service targeted by - the BackendTLSPolicy. - items: - description: |- - PolicyAncestorStatus describes the status of a route with respect to an - associated Ancestor. - - Ancestors refer to objects that are either the Target of a policy or above it - in terms of object hierarchy. For example, if a policy targets a Service, the - Policy's Ancestors are, in order, the Service, the HTTPRoute, the Gateway, and - the GatewayClass. Almost always, in this hierarchy, the Gateway will be the most - useful object to place Policy status on, so we recommend that implementations - SHOULD use Gateway as the PolicyAncestorStatus object unless the designers - have a _very_ good reason otherwise. - - In the context of policy attachment, the Ancestor is used to distinguish which - resource results in a distinct application of this policy. For example, if a policy - targets a Service, it may have a distinct result per attached Gateway. - - Policies targeting the same resource may have different effects depending on the - ancestors of those resources. For example, different Gateways targeting the same - Service may have different capabilities, especially if they have different underlying - implementations. - - For example, in BackendTLSPolicy, the Policy attaches to a Service that is - used as a backend in a HTTPRoute that is itself attached to a Gateway. - In this case, the relevant object for status is the Gateway, and that is the - ancestor object referred to in this status. - - Note that a parent is also an ancestor, so for objects where the parent is the - relevant object for status, this struct SHOULD still be used. - - This struct is intended to be used in a slice that's effectively a map, - with a composite key made up of the AncestorRef and the ControllerName. - properties: - ancestorRef: - description: |- - AncestorRef corresponds with a ParentRef in the spec that this - PolicyAncestorStatus struct describes the status of. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - conditions: - description: Conditions describes the status of the Policy with - respect to the given Ancestor. - items: - description: Condition contains details for one aspect of - the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, - Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - controllerName: - description: |- - ControllerName is a domain/path string that indicates the name of the - controller that wrote this status. This corresponds with the - controllerName field on GatewayClass. - - Example: "example.net/gateway-controller". - - The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are - valid Kubernetes names - (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). - - Controllers MUST populate this field when writing status. Controllers should ensure that - entries to status populated with their ControllerName are cleaned up when they are no - longer necessary. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - required: - - ancestorRef - - conditions - - controllerName - type: object - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - required: - - ancestors - type: object - required: - - spec - type: object - served: false - storage: false - subresources: - status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: null - storedVersions: null ---- -# -# config/crd/standard/gateway.networking.k8s.io_gatewayclasses.yaml -# -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - api-approved.kubernetes.io: https://github.com/kubernetes-sigs/gateway-api/pull/4530 - gateway.networking.k8s.io/bundle-version: v1.5.1 - gateway.networking.k8s.io/channel: standard - name: gatewayclasses.gateway.networking.k8s.io -spec: - group: gateway.networking.k8s.io - names: - categories: - - gateway-api - kind: GatewayClass - listKind: GatewayClassList - plural: gatewayclasses - shortNames: - - gc - singular: gatewayclass - scope: Cluster - versions: - - additionalPrinterColumns: - - jsonPath: .spec.controllerName - name: Controller - type: string - - jsonPath: .status.conditions[?(@.type=="Accepted")].status - name: Accepted - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - - jsonPath: .spec.description - name: Description - priority: 1 - type: string - name: v1 - schema: - openAPIV3Schema: - description: |- - GatewayClass describes a class of Gateways available to the user for creating - Gateway resources. - - It is recommended that this resource be used as a template for Gateways. This - means that a Gateway is based on the state of the GatewayClass at the time it - was created and changes to the GatewayClass or associated parameters are not - propagated down to existing Gateways. This recommendation is intended to - limit the blast radius of changes to GatewayClass or associated parameters. - If implementations choose to propagate GatewayClass changes to existing - Gateways, that MUST be clearly documented by the implementation. - - Whenever one or more Gateways are using a GatewayClass, implementations SHOULD - add the `gateway-exists-finalizer.gateway.networking.k8s.io` finalizer on the - associated GatewayClass. This ensures that a GatewayClass associated with a - Gateway is not deleted while in use. - - GatewayClass is a Cluster level resource. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of GatewayClass. - properties: - controllerName: - description: |- - ControllerName is the name of the controller that is managing Gateways of - this class. The value of this field MUST be a domain prefixed path. - - Example: "example.net/gateway-controller". - - This field is not mutable and cannot be empty. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - x-kubernetes-validations: - - message: Value is immutable - rule: self == oldSelf - description: - description: Description helps describe a GatewayClass with more details. - maxLength: 64 - type: string - parametersRef: - description: |- - ParametersRef is a reference to a resource that contains the configuration - parameters corresponding to the GatewayClass. This is optional if the - controller does not require any additional configuration. - - ParametersRef can reference a standard Kubernetes resource, i.e. ConfigMap, - or an implementation-specific custom resource. The resource can be - cluster-scoped or namespace-scoped. - - If the referent cannot be found, refers to an unsupported kind, or when - the data within that resource is malformed, the GatewayClass SHOULD be - rejected with the "Accepted" status condition set to "False" and an - "InvalidParameters" reason. - - A Gateway for this GatewayClass may provide its own `parametersRef`. When both are specified, - the merging behavior is implementation specific. - It is generally recommended that GatewayClass provides defaults that can be overridden by a Gateway. - - Support: Implementation-specific - properties: - group: - description: Group is the group of the referent. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. - This field is required when referring to a Namespace-scoped resource and - MUST be unset when referring to a Cluster-scoped resource. - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - group - - kind - - name - type: object - required: - - controllerName - type: object - status: - default: - conditions: - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Accepted - description: |- - Status defines the current state of GatewayClass. - - Implementations MUST populate status on all GatewayClass resources which - specify their controller name. - properties: - conditions: - default: - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Accepted - description: |- - Conditions is the current status from the controller for - this GatewayClass. - - Controllers should prefer to publish conditions using values - of GatewayClassConditionType for the type of each Condition. - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - supportedFeatures: - description: |- - SupportedFeatures is the set of features the GatewayClass support. - It MUST be sorted in ascending alphabetical order by the Name key. - items: - properties: - name: - description: |- - FeatureName is used to describe distinct features that are covered by - conformance tests. - type: string - required: - - name - type: object - maxItems: 64 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} - - additionalPrinterColumns: - - jsonPath: .spec.controllerName - name: Controller - type: string - - jsonPath: .status.conditions[?(@.type=="Accepted")].status - name: Accepted - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - - jsonPath: .spec.description - name: Description - priority: 1 - type: string - name: v1beta1 - schema: - openAPIV3Schema: - description: |- - GatewayClass describes a class of Gateways available to the user for creating - Gateway resources. - - It is recommended that this resource be used as a template for Gateways. This - means that a Gateway is based on the state of the GatewayClass at the time it - was created and changes to the GatewayClass or associated parameters are not - propagated down to existing Gateways. This recommendation is intended to - limit the blast radius of changes to GatewayClass or associated parameters. - If implementations choose to propagate GatewayClass changes to existing - Gateways, that MUST be clearly documented by the implementation. - - Whenever one or more Gateways are using a GatewayClass, implementations SHOULD - add the `gateway-exists-finalizer.gateway.networking.k8s.io` finalizer on the - associated GatewayClass. This ensures that a GatewayClass associated with a - Gateway is not deleted while in use. - - GatewayClass is a Cluster level resource. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of GatewayClass. - properties: - controllerName: - description: |- - ControllerName is the name of the controller that is managing Gateways of - this class. The value of this field MUST be a domain prefixed path. - - Example: "example.net/gateway-controller". - - This field is not mutable and cannot be empty. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - x-kubernetes-validations: - - message: Value is immutable - rule: self == oldSelf - description: - description: Description helps describe a GatewayClass with more details. - maxLength: 64 - type: string - parametersRef: - description: |- - ParametersRef is a reference to a resource that contains the configuration - parameters corresponding to the GatewayClass. This is optional if the - controller does not require any additional configuration. - - ParametersRef can reference a standard Kubernetes resource, i.e. ConfigMap, - or an implementation-specific custom resource. The resource can be - cluster-scoped or namespace-scoped. - - If the referent cannot be found, refers to an unsupported kind, or when - the data within that resource is malformed, the GatewayClass SHOULD be - rejected with the "Accepted" status condition set to "False" and an - "InvalidParameters" reason. - - A Gateway for this GatewayClass may provide its own `parametersRef`. When both are specified, - the merging behavior is implementation specific. - It is generally recommended that GatewayClass provides defaults that can be overridden by a Gateway. - - Support: Implementation-specific - properties: - group: - description: Group is the group of the referent. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. - This field is required when referring to a Namespace-scoped resource and - MUST be unset when referring to a Cluster-scoped resource. - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - group - - kind - - name - type: object - required: - - controllerName - type: object - status: - default: - conditions: - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Accepted - description: |- - Status defines the current state of GatewayClass. - - Implementations MUST populate status on all GatewayClass resources which - specify their controller name. - properties: - conditions: - default: - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Accepted - description: |- - Conditions is the current status from the controller for - this GatewayClass. - - Controllers should prefer to publish conditions using values - of GatewayClassConditionType for the type of each Condition. - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - supportedFeatures: - description: |- - SupportedFeatures is the set of features the GatewayClass support. - It MUST be sorted in ascending alphabetical order by the Name key. - items: - properties: - name: - description: |- - FeatureName is used to describe distinct features that are covered by - conformance tests. - type: string - required: - - name - type: object - maxItems: 64 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - required: - - spec - type: object - served: true - storage: false - subresources: - status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: null - storedVersions: null ---- -# -# config/crd/standard/gateway.networking.k8s.io_gateways.yaml -# -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - api-approved.kubernetes.io: https://github.com/kubernetes-sigs/gateway-api/pull/4530 - gateway.networking.k8s.io/bundle-version: v1.5.1 - gateway.networking.k8s.io/channel: standard - name: gateways.gateway.networking.k8s.io -spec: - group: gateway.networking.k8s.io - names: - categories: - - gateway-api - kind: Gateway - listKind: GatewayList - plural: gateways - shortNames: - - gtw - singular: gateway - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .spec.gatewayClassName - name: Class - type: string - - jsonPath: .status.addresses[*].value - name: Address - type: string - - jsonPath: .status.conditions[?(@.type=="Programmed")].status - name: Programmed - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1 - schema: - openAPIV3Schema: - description: |- - Gateway represents an instance of a service-traffic handling infrastructure - by binding Listeners to a set of IP addresses. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of Gateway. - properties: - addresses: - description: |- - Addresses requested for this Gateway. This is optional and behavior can - depend on the implementation. If a value is set in the spec and the - requested address is invalid or unavailable, the implementation MUST - indicate this in an associated entry in GatewayStatus.Conditions. - - The Addresses field represents a request for the address(es) on the - "outside of the Gateway", that traffic bound for this Gateway will use. - This could be the IP address or hostname of an external load balancer or - other networking infrastructure, or some other address that traffic will - be sent to. - - If no Addresses are specified, the implementation MAY schedule the - Gateway in an implementation-specific manner, assigning an appropriate - set of Addresses. - - The implementation MUST bind all Listeners to every GatewayAddress that - it assigns to the Gateway and add a corresponding entry in - GatewayStatus.Addresses. - - Support: Extended - items: - description: GatewaySpecAddress describes an address that can be - bound to a Gateway. - oneOf: - - properties: - type: - enum: - - IPAddress - value: - anyOf: - - format: ipv4 - - format: ipv6 - - properties: - type: - not: - enum: - - IPAddress - properties: - type: - default: IPAddress - description: Type of the address. - maxLength: 253 - minLength: 1 - pattern: ^Hostname|IPAddress|NamedAddress|[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - value: - description: |- - When a value is unspecified, an implementation SHOULD automatically - assign an address matching the requested type if possible. - - If an implementation does not support an empty value, they MUST set the - "Programmed" condition in status to False with a reason of "AddressNotAssigned". - - Examples: `1.2.3.4`, `128::1`, `my-ip-address`. - maxLength: 253 - type: string - type: object - x-kubernetes-validations: - - message: Hostname value must be empty or contain only valid characters - (matching ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$) - rule: 'self.type == ''Hostname'' ? (!has(self.value) || self.value.matches(r"""^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$""")): - true' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: IPAddress values must be unique - rule: 'self.all(a1, a1.type == ''IPAddress'' && has(a1.value) ? - self.exists_one(a2, a2.type == a1.type && has(a2.value) && a2.value - == a1.value) : true )' - - message: Hostname values must be unique - rule: 'self.all(a1, a1.type == ''Hostname'' && has(a1.value) ? - self.exists_one(a2, a2.type == a1.type && has(a2.value) && a2.value - == a1.value) : true )' - allowedListeners: - description: |- - AllowedListeners defines which ListenerSets can be attached to this Gateway. - The default value is to allow no ListenerSets. - properties: - namespaces: - default: - from: None - description: |- - Namespaces defines which namespaces ListenerSets can be attached to this Gateway. - The default value is to allow no ListenerSets. - properties: - from: - default: None - description: |- - From indicates where ListenerSets can attach to this Gateway. Possible - values are: - - * Same: Only ListenerSets in the same namespace may be attached to this Gateway. - * Selector: ListenerSets in namespaces selected by the selector may be attached to this Gateway. - * All: ListenerSets in all namespaces may be attached to this Gateway. - * None: Only listeners defined in the Gateway's spec are allowed - - The default value None - enum: - - All - - Selector - - Same - - None - type: string - selector: - description: |- - Selector must be specified when From is set to "Selector". In that case, - only ListenerSets in Namespaces matching this Selector will be selected by this - Gateway. This field is ignored for other values of "From". - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - type: object - type: object - gatewayClassName: - description: |- - GatewayClassName used for this Gateway. This is the name of a - GatewayClass resource. - maxLength: 253 - minLength: 1 - type: string - infrastructure: - description: |- - Infrastructure defines infrastructure level attributes about this Gateway instance. - - Support: Extended - properties: - annotations: - additionalProperties: - description: |- - AnnotationValue is the value of an annotation in Gateway API. This is used - for validation of maps such as TLS options. This roughly matches Kubernetes - annotation validation, although the length validation in that case is based - on the entire size of the annotations struct. - maxLength: 4096 - minLength: 0 - type: string - description: |- - Annotations that SHOULD be applied to any resources created in response to this Gateway. - - For implementations creating other Kubernetes objects, this should be the `metadata.annotations` field on resources. - For other implementations, this refers to any relevant (implementation specific) "annotations" concepts. - - An implementation may chose to add additional implementation-specific annotations as they see fit. - - Support: Extended - maxProperties: 8 - type: object - x-kubernetes-validations: - - message: Annotation keys must be in the form of an optional - DNS subdomain prefix followed by a required name segment of - up to 63 characters. - rule: self.all(key, key.matches(r"""^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9]$""")) - - message: If specified, the annotation key's prefix must be a - DNS subdomain not longer than 253 characters in total. - rule: self.all(key, key.split("/")[0].size() < 253) - labels: - additionalProperties: - description: |- - LabelValue is the value of a label in the Gateway API. This is used for validation - of maps such as Gateway infrastructure labels. This matches the Kubernetes - label validation rules: - * must be 63 characters or less (can be empty), - * unless empty, must begin and end with an alphanumeric character ([a-z0-9A-Z]), - * could contain dashes (-), underscores (_), dots (.), and alphanumerics between. - - Valid values include: - - * MyValue - * my.name - * 123-my-value - maxLength: 63 - minLength: 0 - pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ - type: string - description: |- - Labels that SHOULD be applied to any resources created in response to this Gateway. - - For implementations creating other Kubernetes objects, this should be the `metadata.labels` field on resources. - For other implementations, this refers to any relevant (implementation specific) "labels" concepts. - - An implementation may chose to add additional implementation-specific labels as they see fit. - - If an implementation maps these labels to Pods, or any other resource that would need to be recreated when labels - change, it SHOULD clearly warn about this behavior in documentation. - - Support: Extended - maxProperties: 8 - type: object - x-kubernetes-validations: - - message: Label keys must be in the form of an optional DNS subdomain - prefix followed by a required name segment of up to 63 characters. - rule: self.all(key, key.matches(r"""^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9]$""")) - - message: If specified, the label key's prefix must be a DNS - subdomain not longer than 253 characters in total. - rule: self.all(key, key.split("/")[0].size() < 253) - parametersRef: - description: |- - ParametersRef is a reference to a resource that contains the configuration - parameters corresponding to the Gateway. This is optional if the - controller does not require any additional configuration. - - This follows the same semantics as GatewayClass's `parametersRef`, but on a per-Gateway basis - - The Gateway's GatewayClass may provide its own `parametersRef`. When both are specified, - the merging behavior is implementation specific. - It is generally recommended that GatewayClass provides defaults that can be overridden by a Gateway. - - If the referent cannot be found, refers to an unsupported kind, or when - the data within that resource is malformed, the Gateway SHOULD be - rejected with the "Accepted" status condition set to "False" and an - "InvalidParameters" reason. - - Support: Implementation-specific - properties: - group: - description: Group is the group of the referent. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - required: - - group - - kind - - name - type: object - type: object - listeners: - description: |- - Listeners associated with this Gateway. Listeners define - logical endpoints that are bound on this Gateway's addresses. - At least one Listener MUST be specified. - - ## Distinct Listeners - - Each Listener in a set of Listeners (for example, in a single Gateway) - MUST be _distinct_, in that a traffic flow MUST be able to be assigned to - exactly one listener. (This section uses "set of Listeners" rather than - "Listeners in a single Gateway" because implementations MAY merge configuration - from multiple Gateways onto a single data plane, and these rules _also_ - apply in that case). - - Practically, this means that each listener in a set MUST have a unique - combination of Port, Protocol, and, if supported by the protocol, Hostname. - - Some combinations of port, protocol, and TLS settings are considered - Core support and MUST be supported by implementations based on the objects - they support: - - HTTPRoute - - 1. HTTPRoute, Port: 80, Protocol: HTTP - 2. HTTPRoute, Port: 443, Protocol: HTTPS, TLS Mode: Terminate, TLS keypair provided - - TLSRoute - - 1. TLSRoute, Port: 443, Protocol: TLS, TLS Mode: Passthrough - - "Distinct" Listeners have the following property: - - **The implementation can match inbound requests to a single distinct - Listener**. - - When multiple Listeners share values for fields (for - example, two Listeners with the same Port value), the implementation - can match requests to only one of the Listeners using other - Listener fields. - - When multiple listeners have the same value for the Protocol field, then - each of the Listeners with matching Protocol values MUST have different - values for other fields. - - The set of fields that MUST be different for a Listener differs per protocol. - The following rules define the rules for what fields MUST be considered for - Listeners to be distinct with each protocol currently defined in the - Gateway API spec. - - The set of listeners that all share a protocol value MUST have _different_ - values for _at least one_ of these fields to be distinct: - - * **HTTP, HTTPS, TLS**: Port, Hostname - * **TCP, UDP**: Port - - One **very** important rule to call out involves what happens when an - implementation: - - * Supports TCP protocol Listeners, as well as HTTP, HTTPS, or TLS protocol - Listeners, and - * sees HTTP, HTTPS, or TLS protocols with the same `port` as one with TCP - Protocol. - - In this case all the Listeners that share a port with the - TCP Listener are not distinct and so MUST NOT be accepted. - - If an implementation does not support TCP Protocol Listeners, then the - previous rule does not apply, and the TCP Listeners SHOULD NOT be - accepted. - - Note that the `tls` field is not used for determining if a listener is distinct, because - Listeners that _only_ differ on TLS config will still conflict in all cases. - - ### Listeners that are distinct only by Hostname - - When the Listeners are distinct based only on Hostname, inbound request - hostnames MUST match from the most specific to least specific Hostname - values to choose the correct Listener and its associated set of Routes. - - Exact matches MUST be processed before wildcard matches, and wildcard - matches MUST be processed before fallback (empty Hostname value) - matches. For example, `"foo.example.com"` takes precedence over - `"*.example.com"`, and `"*.example.com"` takes precedence over `""`. - - Additionally, if there are multiple wildcard entries, more specific - wildcard entries must be processed before less specific wildcard entries. - For example, `"*.foo.example.com"` takes precedence over `"*.example.com"`. - - The precise definition here is that the higher the number of dots in the - hostname to the right of the wildcard character, the higher the precedence. - - The wildcard character will match any number of characters _and dots_ to - the left, however, so `"*.example.com"` will match both - `"foo.bar.example.com"` _and_ `"bar.example.com"`. - - ## Handling indistinct Listeners - - If a set of Listeners contains Listeners that are not distinct, then those - Listeners are _Conflicted_, and the implementation MUST set the "Conflicted" - condition in the Listener Status to "True". - - The words "indistinct" and "conflicted" are considered equivalent for the - purpose of this documentation. - - Implementations MAY choose to accept a Gateway with some Conflicted - Listeners only if they only accept the partial Listener set that contains - no Conflicted Listeners. - - Specifically, an implementation MAY accept a partial Listener set subject to - the following rules: - - * The implementation MUST NOT pick one conflicting Listener as the winner. - ALL indistinct Listeners must not be accepted for processing. - * At least one distinct Listener MUST be present, or else the Gateway effectively - contains _no_ Listeners, and must be rejected from processing as a whole. - - The implementation MUST set a "ListenersNotValid" condition on the - Gateway Status when the Gateway contains Conflicted Listeners whether or - not they accept the Gateway. That Condition SHOULD clearly - indicate in the Message which Listeners are conflicted, and which are - Accepted. Additionally, the Listener status for those listeners SHOULD - indicate which Listeners are conflicted and not Accepted. - - ## General Listener behavior - - Note that, for all distinct Listeners, requests SHOULD match at most one Listener. - For example, if Listeners are defined for "foo.example.com" and "*.example.com", a - request to "foo.example.com" SHOULD only be routed using routes attached - to the "foo.example.com" Listener (and not the "*.example.com" Listener). - - This concept is known as "Listener Isolation", and it is an Extended feature - of Gateway API. Implementations that do not support Listener Isolation MUST - clearly document this, and MUST NOT claim support for the - `GatewayHTTPListenerIsolation` feature. - - Implementations that _do_ support Listener Isolation SHOULD claim support - for the Extended `GatewayHTTPListenerIsolation` feature and pass the associated - conformance tests. - - ## Compatible Listeners - - A Gateway's Listeners are considered _compatible_ if: - - 1. They are distinct. - 2. The implementation can serve them in compliance with the Addresses - requirement that all Listeners are available on all assigned - addresses. - - Compatible combinations in Extended support are expected to vary across - implementations. A combination that is compatible for one implementation - may not be compatible for another. - - For example, an implementation that cannot serve both TCP and UDP listeners - on the same address, or cannot mix HTTPS and generic TLS listens on the same port - would not consider those cases compatible, even though they are distinct. - - Implementations MAY merge separate Gateways onto a single set of - Addresses if all Listeners across all Gateways are compatible. - - In a future release the MinItems=1 requirement MAY be dropped. - - Support: Core - items: - description: |- - Listener embodies the concept of a logical endpoint where a Gateway accepts - network connections. - properties: - allowedRoutes: - default: - namespaces: - from: Same - description: |- - AllowedRoutes defines the types of routes that MAY be attached to a - Listener and the trusted namespaces where those Route resources MAY be - present. - - Although a client request may match multiple route rules, only one rule - may ultimately receive the request. Matching precedence MUST be - determined in order of the following criteria: - - * The most specific match as defined by the Route type. - * The oldest Route based on creation timestamp. For example, a Route with - a creation timestamp of "2020-09-08 01:02:03" is given precedence over - a Route with a creation timestamp of "2020-09-08 01:02:04". - * If everything else is equivalent, the Route appearing first in - alphabetical order (namespace/name) should be given precedence. For - example, foo/bar is given precedence over foo/baz. - - All valid rules within a Route attached to this Listener should be - implemented. Invalid Route rules can be ignored (sometimes that will mean - the full Route). If a Route rule transitions from valid to invalid, - support for that Route rule should be dropped to ensure consistency. For - example, even if a filter specified by a Route rule is invalid, the rest - of the rules within that Route should still be supported. - - Support: Core - properties: - kinds: - description: |- - Kinds specifies the groups and kinds of Routes that are allowed to bind - to this Gateway Listener. When unspecified or empty, the kinds of Routes - selected are determined using the Listener protocol. - - A RouteGroupKind MUST correspond to kinds of Routes that are compatible - with the application protocol specified in the Listener's Protocol field. - If an implementation does not support or recognize this resource type, it - MUST set the "ResolvedRefs" condition to False for this Listener with the - "InvalidRouteKinds" reason. - - Support: Core - items: - description: RouteGroupKind indicates the group and kind - of a Route resource. - properties: - group: - default: gateway.networking.k8s.io - description: Group is the group of the Route. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is the kind of the Route. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - required: - - kind - type: object - maxItems: 8 - type: array - x-kubernetes-list-type: atomic - namespaces: - default: - from: Same - description: |- - Namespaces indicates namespaces from which Routes may be attached to this - Listener. This is restricted to the namespace of this Gateway by default. - - Support: Core - properties: - from: - default: Same - description: |- - From indicates where Routes will be selected for this Gateway. Possible - values are: - - * All: Routes in all namespaces may be used by this Gateway. - * Selector: Routes in namespaces selected by the selector may be used by - this Gateway. - * Same: Only Routes in the same namespace may be used by this Gateway. - - Support: Core - enum: - - All - - Selector - - Same - type: string - selector: - description: |- - Selector must be specified when From is set to "Selector". In that case, - only Routes in Namespaces matching this Selector will be selected by this - Gateway. This field is ignored for other values of "From". - - Support: Core - properties: - matchExpressions: - description: matchExpressions is a list of label - selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the - selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - type: object - type: object - hostname: - description: |- - Hostname specifies the virtual hostname to match for protocol types that - define this concept. When unspecified, all hostnames are matched. This - field is ignored for protocols that don't require hostname based - matching. - - Implementations MUST apply Hostname matching appropriately for each of - the following protocols: - - * TLS: The Listener Hostname MUST match the SNI. - * HTTP: The Listener Hostname MUST match the Host header of the request. - * HTTPS: The Listener Hostname SHOULD match both the SNI and Host header. - Note that this does not require the SNI and Host header to be the same. - The semantics of this are described in more detail below. - - To ensure security, Section 11.1 of RFC-6066 emphasizes that server - implementations that rely on SNI hostname matching MUST also verify - hostnames within the application protocol. - - Section 9.1.2 of RFC-7540 provides a mechanism for servers to reject the - reuse of a connection by responding with the HTTP 421 Misdirected Request - status code. This indicates that the origin server has rejected the - request because it appears to have been misdirected. - - To detect misdirected requests, Gateways SHOULD match the authority of - the requests with all the SNI hostname(s) configured across all the - Gateway Listeners on the same port and protocol: - - * If another Listener has an exact match or more specific wildcard entry, - the Gateway SHOULD return a 421. - * If the current Listener (selected by SNI matching during ClientHello) - does not match the Host: - * If another Listener does match the Host, the Gateway SHOULD return a - 421. - * If no other Listener matches the Host, the Gateway MUST return a - 404. - - For HTTPRoute and TLSRoute resources, there is an interaction with the - `spec.hostnames` array. When both listener and route specify hostnames, - there MUST be an intersection between the values for a Route to be - accepted. For more information, refer to the Route specific Hostnames - documentation. - - Hostnames that are prefixed with a wildcard label (`*.`) are interpreted - as a suffix match. That means that a match for `*.example.com` would match - both `test.example.com`, and `foo.test.example.com`, but not `example.com`. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - name: - description: |- - Name is the name of the Listener. This name MUST be unique within a - Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - port: - description: |- - Port is the network port. Multiple listeners may use the - same port, subject to the Listener compatibility rules. - - Support: Core - format: int32 - maximum: 65535 - minimum: 1 - type: integer - protocol: - description: |- - Protocol specifies the network protocol this listener expects to receive. - - Support: Core - maxLength: 255 - minLength: 1 - pattern: ^[a-zA-Z0-9]([-a-zA-Z0-9]*[a-zA-Z0-9])?$|[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9]+$ - type: string - tls: - description: |- - TLS is the TLS configuration for the Listener. This field is required if - the Protocol field is "HTTPS" or "TLS". It is invalid to set this field - if the Protocol field is "HTTP", "TCP", or "UDP". - - The association of SNIs to Certificate defined in ListenerTLSConfig is - defined based on the Hostname field for this listener. - - The GatewayClass MUST use the longest matching SNI out of all - available certificates for any TLS handshake. - - Support: Core - properties: - certificateRefs: - description: |- - CertificateRefs contains a series of references to Kubernetes objects that - contains TLS certificates and private keys. These certificates are used to - establish a TLS handshake for requests that match the hostname of the - associated listener. - - A single CertificateRef to a Kubernetes Secret has "Core" support. - Implementations MAY choose to support attaching multiple certificates to - a Listener, but this behavior is implementation-specific. - - References to a resource in different namespace are invalid UNLESS there - is a ReferenceGrant in the target namespace that allows the certificate - to be attached. If a ReferenceGrant does not allow this reference, the - "ResolvedRefs" condition MUST be set to False for this listener with the - "RefNotPermitted" reason. - - This field is required to have at least one element when the mode is set - to "Terminate" (default) and is optional otherwise. - - CertificateRefs can reference to standard Kubernetes resources, i.e. - Secret, or implementation-specific custom resources. - - Support: Core - A single reference to a Kubernetes Secret of type kubernetes.io/tls - - Support: Implementation-specific (More than one reference or other resource types) - items: - description: |- - SecretObjectReference identifies an API object including its namespace, - defaulting to Secret. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - - References to objects with invalid Group and Kind are not valid, and must - be rejected by the implementation, with appropriate Conditions set - on the containing object. - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Secret - description: Kind is kind of the referent. For example - "Secret". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referenced object. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - name - type: object - maxItems: 64 - type: array - x-kubernetes-list-type: atomic - mode: - default: Terminate - description: |- - Mode defines the TLS behavior for the TLS session initiated by the client. - There are two possible modes: - - - Terminate: The TLS session between the downstream client and the - Gateway is terminated at the Gateway. This mode requires certificates - to be specified in some way, such as populating the certificateRefs - field. - - Passthrough: The TLS session is NOT terminated by the Gateway. This - implies that the Gateway can't decipher the TLS stream except for - the ClientHello message of the TLS protocol. The certificateRefs field - is ignored in this mode. - - Support: Core - enum: - - Terminate - - Passthrough - type: string - options: - additionalProperties: - description: |- - AnnotationValue is the value of an annotation in Gateway API. This is used - for validation of maps such as TLS options. This roughly matches Kubernetes - annotation validation, although the length validation in that case is based - on the entire size of the annotations struct. - maxLength: 4096 - minLength: 0 - type: string - description: |- - Options are a list of key/value pairs to enable extended TLS - configuration for each implementation. For example, configuring the - minimum TLS version or supported cipher suites. - - A set of common keys MAY be defined by the API in the future. To avoid - any ambiguity, implementation-specific definitions MUST use - domain-prefixed names, such as `example.com/my-custom-option`. - Un-prefixed names are reserved for key names defined by Gateway API. - - Support: Implementation-specific - maxProperties: 16 - type: object - type: object - x-kubernetes-validations: - - message: certificateRefs or options must be specified when - mode is Terminate - rule: 'self.mode == ''Terminate'' ? size(self.certificateRefs) - > 0 || size(self.options) > 0 : true' - required: - - name - - port - - protocol - type: object - maxItems: 64 - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - x-kubernetes-validations: - - message: tls must not be specified for protocols ['HTTP', 'TCP', - 'UDP'] - rule: 'self.all(l, l.protocol in [''HTTP'', ''TCP'', ''UDP''] ? - !has(l.tls) : true)' - - message: tls mode must be Terminate for protocol HTTPS - rule: 'self.all(l, (l.protocol == ''HTTPS'' && has(l.tls)) ? (l.tls.mode - == '''' || l.tls.mode == ''Terminate'') : true)' - - message: tls mode must be set for protocol TLS - rule: 'self.all(l, (l.protocol == ''TLS'' ? has(l.tls) && has(l.tls.mode) - && l.tls.mode != '''' : true))' - - message: hostname must not be specified for protocols ['TCP', 'UDP'] - rule: 'self.all(l, l.protocol in [''TCP'', ''UDP''] ? (!has(l.hostname) - || l.hostname == '''') : true)' - - message: Listener name must be unique within the Gateway - rule: self.all(l1, self.exists_one(l2, l1.name == l2.name)) - - message: Combination of port, protocol and hostname must be unique - for each listener - rule: 'self.all(l1, self.exists_one(l2, l1.port == l2.port && l1.protocol - == l2.protocol && (has(l1.hostname) && has(l2.hostname) ? l1.hostname - == l2.hostname : !has(l1.hostname) && !has(l2.hostname))))' - tls: - description: |- - TLS specifies frontend and backend tls configuration for entire gateway. - - Support: Extended - properties: - backend: - description: |- - Backend describes TLS configuration for gateway when connecting - to backends. - - Note that this contains only details for the Gateway as a TLS client, - and does _not_ imply behavior about how to choose which backend should - get a TLS connection. That is determined by the presence of a BackendTLSPolicy. - - Support: Core - properties: - clientCertificateRef: - description: |- - ClientCertificateRef references an object that contains a client certificate - and its associated private key. It can reference standard Kubernetes resources, - i.e., Secret, or implementation-specific custom resources. - - A ClientCertificateRef is considered invalid if: - - * It refers to a resource that cannot be resolved (e.g., the referenced resource - does not exist) or is misconfigured (e.g., a Secret does not contain the keys - named `tls.crt` and `tls.key`). In this case, the `ResolvedRefs` condition - on the Gateway MUST be set to False with the Reason `InvalidClientCertificateRef` - and the Message of the Condition MUST indicate why the reference is invalid. - - * It refers to a resource in another namespace UNLESS there is a ReferenceGrant - in the target namespace that allows the certificate to be attached. - If a ReferenceGrant does not allow this reference, the `ResolvedRefs` condition - on the Gateway MUST be set to False with the Reason `RefNotPermitted`. - - Implementations MAY choose to perform further validation of the certificate - content (e.g., checking expiry or enforcing specific formats). In such cases, - an implementation-specific Reason and Message MUST be set. - - Support: Core - Reference to a Kubernetes TLS Secret (with the type `kubernetes.io/tls`). - Support: Implementation-specific - Other resource kinds or Secrets with a - different type (e.g., `Opaque`). - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Secret - description: Kind is kind of the referent. For example - "Secret". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referenced object. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - name - type: object - type: object - frontend: - description: |- - Frontend describes TLS config when client connects to Gateway. - Support: Core - properties: - default: - description: |- - Default specifies the default client certificate validation configuration - for all Listeners handling HTTPS traffic, unless a per-port configuration - is defined. - - support: Core - properties: - validation: - description: |- - Validation holds configuration information for validating the frontend (client). - Setting this field will result in mutual authentication when connecting to the gateway. - In browsers this may result in a dialog appearing - that requests a user to specify the client certificate. - The maximum depth of a certificate chain accepted in verification is Implementation specific. - - Support: Core - properties: - caCertificateRefs: - description: |- - CACertificateRefs contains one or more references to Kubernetes - objects that contain a PEM-encoded TLS CA certificate bundle, which - is used as a trust anchor to validate the certificates presented by - the client. - - A CACertificateRef is invalid if: - - * It refers to a resource that cannot be resolved (e.g., the - referenced resource does not exist) or is misconfigured (e.g., a - ConfigMap does not contain a key named `ca.crt`). In this case, the - Reason on all matching HTTPS listeners must be set to `InvalidCACertificateRef` - and the Message of the Condition must indicate which reference is invalid and why. - - * It refers to an unknown or unsupported kind of resource. In this - case, the Reason on all matching HTTPS listeners must be set to - `InvalidCACertificateKind` and the Message of the Condition must explain - which kind of resource is unknown or unsupported. - - * It refers to a resource in another namespace UNLESS there is a - ReferenceGrant in the target namespace that allows the CA - certificate to be attached. If a ReferenceGrant does not allow this - reference, the `ResolvedRefs` on all matching HTTPS listeners condition - MUST be set with the Reason `RefNotPermitted`. - - Implementations MAY choose to perform further validation of the - certificate content (e.g., checking expiry or enforcing specific formats). - In such cases, an implementation-specific Reason and Message MUST be set. - - In all cases, the implementation MUST ensure that the `ResolvedRefs` - condition is set to `status: False` on all targeted listeners (i.e., - listeners serving HTTPS on a matching port). The condition MUST - include a Reason and Message that indicate the cause of the error. If - ALL CACertificateRefs are invalid, the implementation MUST also ensure - the `Accepted` condition on the listener is set to `status: False`, with - the Reason `NoValidCACertificate`. - Implementations MAY choose to support attaching multiple CA certificates - to a listener, but this behavior is implementation-specific. - - Support: Core - A single reference to a Kubernetes ConfigMap, with the - CA certificate in a key named `ca.crt`. - - Support: Implementation-specific - More than one reference, other kinds - of resources, or a single reference that includes multiple certificates. - items: - description: |- - ObjectReference identifies an API object including its namespace. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - - References to objects with invalid Group and Kind are not valid, and must - be rejected by the implementation, with appropriate Conditions set - on the containing object. - properties: - group: - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When set to the empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. For - example "ConfigMap" or "Service". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referenced object. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - group - - kind - - name - type: object - maxItems: 8 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - mode: - default: AllowValidOnly - description: |- - FrontendValidationMode defines the mode for validating the client certificate. - There are two possible modes: - - - AllowValidOnly: In this mode, the gateway will accept connections only if - the client presents a valid certificate. This certificate must successfully - pass validation against the CA certificates specified in `CACertificateRefs`. - - AllowInsecureFallback: In this mode, the gateway will accept connections - even if the client certificate is not presented or fails verification. - - This approach delegates client authorization to the backend and introduce - a significant security risk. It should be used in testing environments or - on a temporary basis in non-testing environments. - - Defaults to AllowValidOnly. - - Support: Core - enum: - - AllowValidOnly - - AllowInsecureFallback - type: string - required: - - caCertificateRefs - type: object - type: object - perPort: - description: |- - PerPort specifies tls configuration assigned per port. - Per port configuration is optional. Once set this configuration overrides - the default configuration for all Listeners handling HTTPS traffic - that match this port. - Each override port requires a unique TLS configuration. - - support: Core - items: - properties: - port: - description: |- - The Port indicates the Port Number to which the TLS configuration will be - applied. This configuration will be applied to all Listeners handling HTTPS - traffic that match this port. - - Support: Core - format: int32 - maximum: 65535 - minimum: 1 - type: integer - tls: - description: |- - TLS store the configuration that will be applied to all Listeners handling - HTTPS traffic and matching given port. - - Support: Core - properties: - validation: - description: |- - Validation holds configuration information for validating the frontend (client). - Setting this field will result in mutual authentication when connecting to the gateway. - In browsers this may result in a dialog appearing - that requests a user to specify the client certificate. - The maximum depth of a certificate chain accepted in verification is Implementation specific. - - Support: Core - properties: - caCertificateRefs: - description: |- - CACertificateRefs contains one or more references to Kubernetes - objects that contain a PEM-encoded TLS CA certificate bundle, which - is used as a trust anchor to validate the certificates presented by - the client. - - A CACertificateRef is invalid if: - - * It refers to a resource that cannot be resolved (e.g., the - referenced resource does not exist) or is misconfigured (e.g., a - ConfigMap does not contain a key named `ca.crt`). In this case, the - Reason on all matching HTTPS listeners must be set to `InvalidCACertificateRef` - and the Message of the Condition must indicate which reference is invalid and why. - - * It refers to an unknown or unsupported kind of resource. In this - case, the Reason on all matching HTTPS listeners must be set to - `InvalidCACertificateKind` and the Message of the Condition must explain - which kind of resource is unknown or unsupported. - - * It refers to a resource in another namespace UNLESS there is a - ReferenceGrant in the target namespace that allows the CA - certificate to be attached. If a ReferenceGrant does not allow this - reference, the `ResolvedRefs` on all matching HTTPS listeners condition - MUST be set with the Reason `RefNotPermitted`. - - Implementations MAY choose to perform further validation of the - certificate content (e.g., checking expiry or enforcing specific formats). - In such cases, an implementation-specific Reason and Message MUST be set. - - In all cases, the implementation MUST ensure that the `ResolvedRefs` - condition is set to `status: False` on all targeted listeners (i.e., - listeners serving HTTPS on a matching port). The condition MUST - include a Reason and Message that indicate the cause of the error. If - ALL CACertificateRefs are invalid, the implementation MUST also ensure - the `Accepted` condition on the listener is set to `status: False`, with - the Reason `NoValidCACertificate`. - Implementations MAY choose to support attaching multiple CA certificates - to a listener, but this behavior is implementation-specific. - - Support: Core - A single reference to a Kubernetes ConfigMap, with the - CA certificate in a key named `ca.crt`. - - Support: Implementation-specific - More than one reference, other kinds - of resources, or a single reference that includes multiple certificates. - items: - description: |- - ObjectReference identifies an API object including its namespace. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - - References to objects with invalid Group and Kind are not valid, and must - be rejected by the implementation, with appropriate Conditions set - on the containing object. - properties: - group: - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When set to the empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. - For example "ConfigMap" or "Service". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referenced object. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - group - - kind - - name - type: object - maxItems: 8 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - mode: - default: AllowValidOnly - description: |- - FrontendValidationMode defines the mode for validating the client certificate. - There are two possible modes: - - - AllowValidOnly: In this mode, the gateway will accept connections only if - the client presents a valid certificate. This certificate must successfully - pass validation against the CA certificates specified in `CACertificateRefs`. - - AllowInsecureFallback: In this mode, the gateway will accept connections - even if the client certificate is not presented or fails verification. - - This approach delegates client authorization to the backend and introduce - a significant security risk. It should be used in testing environments or - on a temporary basis in non-testing environments. - - Defaults to AllowValidOnly. - - Support: Core - enum: - - AllowValidOnly - - AllowInsecureFallback - type: string - required: - - caCertificateRefs - type: object - type: object - required: - - port - - tls - type: object - maxItems: 64 - type: array - x-kubernetes-list-map-keys: - - port - x-kubernetes-list-type: map - x-kubernetes-validations: - - message: Port for TLS configuration must be unique within - the Gateway - rule: self.all(t1, self.exists_one(t2, t1.port == t2.port)) - required: - - default - type: object - type: object - required: - - gatewayClassName - - listeners - type: object - status: - default: - conditions: - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Accepted - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Programmed - description: Status defines the current state of Gateway. - properties: - addresses: - description: |- - Addresses lists the network addresses that have been bound to the - Gateway. - - This list may differ from the addresses provided in the spec under some - conditions: - - * no addresses are specified, all addresses are dynamically assigned - * a combination of specified and dynamic addresses are assigned - * a specified address was unusable (e.g. already in use) - items: - description: GatewayStatusAddress describes a network address that - is bound to a Gateway. - oneOf: - - properties: - type: - enum: - - IPAddress - value: - anyOf: - - format: ipv4 - - format: ipv6 - - properties: - type: - not: - enum: - - IPAddress - properties: - type: - default: IPAddress - description: Type of the address. - maxLength: 253 - minLength: 1 - pattern: ^Hostname|IPAddress|NamedAddress|[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - value: - description: |- - Value of the address. The validity of the values will depend - on the type and support by the controller. - - Examples: `1.2.3.4`, `128::1`, `my-ip-address`. - maxLength: 253 - minLength: 1 - type: string - required: - - value - type: object - x-kubernetes-validations: - - message: Hostname value must only contain valid characters (matching - ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$) - rule: 'self.type == ''Hostname'' ? self.value.matches(r"""^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"""): - true' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - attachedListenerSets: - description: |- - AttachedListenerSets represents the total number of ListenerSets that have been - successfully attached to this Gateway. - - A ListenerSet is successfully attached to a Gateway when all the following conditions are met: - - The ListenerSet is selected by the Gateway's AllowedListeners field - - The ListenerSet has a valid ParentRef selecting the Gateway - - The ListenerSet's status has the condition "Accepted: true" - - Uses for this field include troubleshooting AttachedListenerSets attachment and - measuring blast radius/impact of changes to a Gateway. - format: int32 - type: integer - conditions: - default: - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Accepted - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Programmed - description: |- - Conditions describe the current conditions of the Gateway. - - Implementations should prefer to express Gateway conditions - using the `GatewayConditionType` and `GatewayConditionReason` - constants so that operators and tools can converge on a common - vocabulary to describe Gateway state. - - Known condition types are: - - * "Accepted" - * "Programmed" - * "Ready" - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - listeners: - description: Listeners provide status for each unique listener port - defined in the Spec. - items: - description: ListenerStatus is the status associated with a Listener. - properties: - attachedRoutes: - description: |- - AttachedRoutes represents the total number of Routes that have been - successfully attached to this Listener. - - Successful attachment of a Route to a Listener is based solely on the - combination of the AllowedRoutes field on the corresponding Listener - and the Route's ParentRefs field. A Route is successfully attached to - a Listener when it is selected by the Listener's AllowedRoutes field - AND the Route has a valid ParentRef selecting the whole Gateway - resource or a specific Listener as a parent resource (more detail on - attachment semantics can be found in the documentation on the various - Route kinds ParentRefs fields). Listener or Route status does not impact - successful attachment, i.e. the AttachedRoutes field count MUST be set - for Listeners, even if the Accepted condition of an individual Listener is set - to "False". The AttachedRoutes number represents the number of Routes with - the Accepted condition set to "True" that have been attached to this Listener. - Routes with any other value for the Accepted condition MUST NOT be included - in this count. - - Uses for this field include troubleshooting Route attachment and - measuring blast radius/impact of changes to a Listener. - format: int32 - type: integer - conditions: - description: Conditions describe the current condition of this - listener. - items: - description: Condition contains details for one aspect of - the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, - Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - name: - description: Name is the name of the Listener that this status - corresponds to. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - supportedKinds: - description: |- - SupportedKinds is the list indicating the Kinds supported by this - listener. This MUST represent the kinds supported by an implementation for - that Listener configuration. - - If kinds are specified in Spec that are not supported, they MUST NOT - appear in this list and an implementation MUST set the "ResolvedRefs" - condition to "False" with the "InvalidRouteKinds" reason. If both valid - and invalid Route kinds are specified, the implementation MUST - reference the valid Route kinds that have been specified. - items: - description: RouteGroupKind indicates the group and kind of - a Route resource. - properties: - group: - default: gateway.networking.k8s.io - description: Group is the group of the Route. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is the kind of the Route. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - required: - - kind - type: object - maxItems: 8 - type: array - x-kubernetes-list-type: atomic - required: - - attachedRoutes - - conditions - - name - type: object - maxItems: 64 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} - - additionalPrinterColumns: - - jsonPath: .spec.gatewayClassName - name: Class - type: string - - jsonPath: .status.addresses[*].value - name: Address - type: string - - jsonPath: .status.conditions[?(@.type=="Programmed")].status - name: Programmed - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1beta1 - schema: - openAPIV3Schema: - description: |- - Gateway represents an instance of a service-traffic handling infrastructure - by binding Listeners to a set of IP addresses. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of Gateway. - properties: - addresses: - description: |- - Addresses requested for this Gateway. This is optional and behavior can - depend on the implementation. If a value is set in the spec and the - requested address is invalid or unavailable, the implementation MUST - indicate this in an associated entry in GatewayStatus.Conditions. - - The Addresses field represents a request for the address(es) on the - "outside of the Gateway", that traffic bound for this Gateway will use. - This could be the IP address or hostname of an external load balancer or - other networking infrastructure, or some other address that traffic will - be sent to. - - If no Addresses are specified, the implementation MAY schedule the - Gateway in an implementation-specific manner, assigning an appropriate - set of Addresses. - - The implementation MUST bind all Listeners to every GatewayAddress that - it assigns to the Gateway and add a corresponding entry in - GatewayStatus.Addresses. - - Support: Extended - items: - description: GatewaySpecAddress describes an address that can be - bound to a Gateway. - oneOf: - - properties: - type: - enum: - - IPAddress - value: - anyOf: - - format: ipv4 - - format: ipv6 - - properties: - type: - not: - enum: - - IPAddress - properties: - type: - default: IPAddress - description: Type of the address. - maxLength: 253 - minLength: 1 - pattern: ^Hostname|IPAddress|NamedAddress|[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - value: - description: |- - When a value is unspecified, an implementation SHOULD automatically - assign an address matching the requested type if possible. - - If an implementation does not support an empty value, they MUST set the - "Programmed" condition in status to False with a reason of "AddressNotAssigned". - - Examples: `1.2.3.4`, `128::1`, `my-ip-address`. - maxLength: 253 - type: string - type: object - x-kubernetes-validations: - - message: Hostname value must be empty or contain only valid characters - (matching ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$) - rule: 'self.type == ''Hostname'' ? (!has(self.value) || self.value.matches(r"""^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$""")): - true' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: IPAddress values must be unique - rule: 'self.all(a1, a1.type == ''IPAddress'' && has(a1.value) ? - self.exists_one(a2, a2.type == a1.type && has(a2.value) && a2.value - == a1.value) : true )' - - message: Hostname values must be unique - rule: 'self.all(a1, a1.type == ''Hostname'' && has(a1.value) ? - self.exists_one(a2, a2.type == a1.type && has(a2.value) && a2.value - == a1.value) : true )' - allowedListeners: - description: |- - AllowedListeners defines which ListenerSets can be attached to this Gateway. - The default value is to allow no ListenerSets. - properties: - namespaces: - default: - from: None - description: |- - Namespaces defines which namespaces ListenerSets can be attached to this Gateway. - The default value is to allow no ListenerSets. - properties: - from: - default: None - description: |- - From indicates where ListenerSets can attach to this Gateway. Possible - values are: - - * Same: Only ListenerSets in the same namespace may be attached to this Gateway. - * Selector: ListenerSets in namespaces selected by the selector may be attached to this Gateway. - * All: ListenerSets in all namespaces may be attached to this Gateway. - * None: Only listeners defined in the Gateway's spec are allowed - - The default value None - enum: - - All - - Selector - - Same - - None - type: string - selector: - description: |- - Selector must be specified when From is set to "Selector". In that case, - only ListenerSets in Namespaces matching this Selector will be selected by this - Gateway. This field is ignored for other values of "From". - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - type: object - type: object - gatewayClassName: - description: |- - GatewayClassName used for this Gateway. This is the name of a - GatewayClass resource. - maxLength: 253 - minLength: 1 - type: string - infrastructure: - description: |- - Infrastructure defines infrastructure level attributes about this Gateway instance. - - Support: Extended - properties: - annotations: - additionalProperties: - description: |- - AnnotationValue is the value of an annotation in Gateway API. This is used - for validation of maps such as TLS options. This roughly matches Kubernetes - annotation validation, although the length validation in that case is based - on the entire size of the annotations struct. - maxLength: 4096 - minLength: 0 - type: string - description: |- - Annotations that SHOULD be applied to any resources created in response to this Gateway. - - For implementations creating other Kubernetes objects, this should be the `metadata.annotations` field on resources. - For other implementations, this refers to any relevant (implementation specific) "annotations" concepts. - - An implementation may chose to add additional implementation-specific annotations as they see fit. - - Support: Extended - maxProperties: 8 - type: object - x-kubernetes-validations: - - message: Annotation keys must be in the form of an optional - DNS subdomain prefix followed by a required name segment of - up to 63 characters. - rule: self.all(key, key.matches(r"""^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9]$""")) - - message: If specified, the annotation key's prefix must be a - DNS subdomain not longer than 253 characters in total. - rule: self.all(key, key.split("/")[0].size() < 253) - labels: - additionalProperties: - description: |- - LabelValue is the value of a label in the Gateway API. This is used for validation - of maps such as Gateway infrastructure labels. This matches the Kubernetes - label validation rules: - * must be 63 characters or less (can be empty), - * unless empty, must begin and end with an alphanumeric character ([a-z0-9A-Z]), - * could contain dashes (-), underscores (_), dots (.), and alphanumerics between. - - Valid values include: - - * MyValue - * my.name - * 123-my-value - maxLength: 63 - minLength: 0 - pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ - type: string - description: |- - Labels that SHOULD be applied to any resources created in response to this Gateway. - - For implementations creating other Kubernetes objects, this should be the `metadata.labels` field on resources. - For other implementations, this refers to any relevant (implementation specific) "labels" concepts. - - An implementation may chose to add additional implementation-specific labels as they see fit. - - If an implementation maps these labels to Pods, or any other resource that would need to be recreated when labels - change, it SHOULD clearly warn about this behavior in documentation. - - Support: Extended - maxProperties: 8 - type: object - x-kubernetes-validations: - - message: Label keys must be in the form of an optional DNS subdomain - prefix followed by a required name segment of up to 63 characters. - rule: self.all(key, key.matches(r"""^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9]$""")) - - message: If specified, the label key's prefix must be a DNS - subdomain not longer than 253 characters in total. - rule: self.all(key, key.split("/")[0].size() < 253) - parametersRef: - description: |- - ParametersRef is a reference to a resource that contains the configuration - parameters corresponding to the Gateway. This is optional if the - controller does not require any additional configuration. - - This follows the same semantics as GatewayClass's `parametersRef`, but on a per-Gateway basis - - The Gateway's GatewayClass may provide its own `parametersRef`. When both are specified, - the merging behavior is implementation specific. - It is generally recommended that GatewayClass provides defaults that can be overridden by a Gateway. - - If the referent cannot be found, refers to an unsupported kind, or when - the data within that resource is malformed, the Gateway SHOULD be - rejected with the "Accepted" status condition set to "False" and an - "InvalidParameters" reason. - - Support: Implementation-specific - properties: - group: - description: Group is the group of the referent. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - required: - - group - - kind - - name - type: object - type: object - listeners: - description: |- - Listeners associated with this Gateway. Listeners define - logical endpoints that are bound on this Gateway's addresses. - At least one Listener MUST be specified. - - ## Distinct Listeners - - Each Listener in a set of Listeners (for example, in a single Gateway) - MUST be _distinct_, in that a traffic flow MUST be able to be assigned to - exactly one listener. (This section uses "set of Listeners" rather than - "Listeners in a single Gateway" because implementations MAY merge configuration - from multiple Gateways onto a single data plane, and these rules _also_ - apply in that case). - - Practically, this means that each listener in a set MUST have a unique - combination of Port, Protocol, and, if supported by the protocol, Hostname. - - Some combinations of port, protocol, and TLS settings are considered - Core support and MUST be supported by implementations based on the objects - they support: - - HTTPRoute - - 1. HTTPRoute, Port: 80, Protocol: HTTP - 2. HTTPRoute, Port: 443, Protocol: HTTPS, TLS Mode: Terminate, TLS keypair provided - - TLSRoute - - 1. TLSRoute, Port: 443, Protocol: TLS, TLS Mode: Passthrough - - "Distinct" Listeners have the following property: - - **The implementation can match inbound requests to a single distinct - Listener**. - - When multiple Listeners share values for fields (for - example, two Listeners with the same Port value), the implementation - can match requests to only one of the Listeners using other - Listener fields. - - When multiple listeners have the same value for the Protocol field, then - each of the Listeners with matching Protocol values MUST have different - values for other fields. - - The set of fields that MUST be different for a Listener differs per protocol. - The following rules define the rules for what fields MUST be considered for - Listeners to be distinct with each protocol currently defined in the - Gateway API spec. - - The set of listeners that all share a protocol value MUST have _different_ - values for _at least one_ of these fields to be distinct: - - * **HTTP, HTTPS, TLS**: Port, Hostname - * **TCP, UDP**: Port - - One **very** important rule to call out involves what happens when an - implementation: - - * Supports TCP protocol Listeners, as well as HTTP, HTTPS, or TLS protocol - Listeners, and - * sees HTTP, HTTPS, or TLS protocols with the same `port` as one with TCP - Protocol. - - In this case all the Listeners that share a port with the - TCP Listener are not distinct and so MUST NOT be accepted. - - If an implementation does not support TCP Protocol Listeners, then the - previous rule does not apply, and the TCP Listeners SHOULD NOT be - accepted. - - Note that the `tls` field is not used for determining if a listener is distinct, because - Listeners that _only_ differ on TLS config will still conflict in all cases. - - ### Listeners that are distinct only by Hostname - - When the Listeners are distinct based only on Hostname, inbound request - hostnames MUST match from the most specific to least specific Hostname - values to choose the correct Listener and its associated set of Routes. - - Exact matches MUST be processed before wildcard matches, and wildcard - matches MUST be processed before fallback (empty Hostname value) - matches. For example, `"foo.example.com"` takes precedence over - `"*.example.com"`, and `"*.example.com"` takes precedence over `""`. - - Additionally, if there are multiple wildcard entries, more specific - wildcard entries must be processed before less specific wildcard entries. - For example, `"*.foo.example.com"` takes precedence over `"*.example.com"`. - - The precise definition here is that the higher the number of dots in the - hostname to the right of the wildcard character, the higher the precedence. - - The wildcard character will match any number of characters _and dots_ to - the left, however, so `"*.example.com"` will match both - `"foo.bar.example.com"` _and_ `"bar.example.com"`. - - ## Handling indistinct Listeners - - If a set of Listeners contains Listeners that are not distinct, then those - Listeners are _Conflicted_, and the implementation MUST set the "Conflicted" - condition in the Listener Status to "True". - - The words "indistinct" and "conflicted" are considered equivalent for the - purpose of this documentation. - - Implementations MAY choose to accept a Gateway with some Conflicted - Listeners only if they only accept the partial Listener set that contains - no Conflicted Listeners. - - Specifically, an implementation MAY accept a partial Listener set subject to - the following rules: - - * The implementation MUST NOT pick one conflicting Listener as the winner. - ALL indistinct Listeners must not be accepted for processing. - * At least one distinct Listener MUST be present, or else the Gateway effectively - contains _no_ Listeners, and must be rejected from processing as a whole. - - The implementation MUST set a "ListenersNotValid" condition on the - Gateway Status when the Gateway contains Conflicted Listeners whether or - not they accept the Gateway. That Condition SHOULD clearly - indicate in the Message which Listeners are conflicted, and which are - Accepted. Additionally, the Listener status for those listeners SHOULD - indicate which Listeners are conflicted and not Accepted. - - ## General Listener behavior - - Note that, for all distinct Listeners, requests SHOULD match at most one Listener. - For example, if Listeners are defined for "foo.example.com" and "*.example.com", a - request to "foo.example.com" SHOULD only be routed using routes attached - to the "foo.example.com" Listener (and not the "*.example.com" Listener). - - This concept is known as "Listener Isolation", and it is an Extended feature - of Gateway API. Implementations that do not support Listener Isolation MUST - clearly document this, and MUST NOT claim support for the - `GatewayHTTPListenerIsolation` feature. - - Implementations that _do_ support Listener Isolation SHOULD claim support - for the Extended `GatewayHTTPListenerIsolation` feature and pass the associated - conformance tests. - - ## Compatible Listeners - - A Gateway's Listeners are considered _compatible_ if: - - 1. They are distinct. - 2. The implementation can serve them in compliance with the Addresses - requirement that all Listeners are available on all assigned - addresses. - - Compatible combinations in Extended support are expected to vary across - implementations. A combination that is compatible for one implementation - may not be compatible for another. - - For example, an implementation that cannot serve both TCP and UDP listeners - on the same address, or cannot mix HTTPS and generic TLS listens on the same port - would not consider those cases compatible, even though they are distinct. - - Implementations MAY merge separate Gateways onto a single set of - Addresses if all Listeners across all Gateways are compatible. - - In a future release the MinItems=1 requirement MAY be dropped. - - Support: Core - items: - description: |- - Listener embodies the concept of a logical endpoint where a Gateway accepts - network connections. - properties: - allowedRoutes: - default: - namespaces: - from: Same - description: |- - AllowedRoutes defines the types of routes that MAY be attached to a - Listener and the trusted namespaces where those Route resources MAY be - present. - - Although a client request may match multiple route rules, only one rule - may ultimately receive the request. Matching precedence MUST be - determined in order of the following criteria: - - * The most specific match as defined by the Route type. - * The oldest Route based on creation timestamp. For example, a Route with - a creation timestamp of "2020-09-08 01:02:03" is given precedence over - a Route with a creation timestamp of "2020-09-08 01:02:04". - * If everything else is equivalent, the Route appearing first in - alphabetical order (namespace/name) should be given precedence. For - example, foo/bar is given precedence over foo/baz. - - All valid rules within a Route attached to this Listener should be - implemented. Invalid Route rules can be ignored (sometimes that will mean - the full Route). If a Route rule transitions from valid to invalid, - support for that Route rule should be dropped to ensure consistency. For - example, even if a filter specified by a Route rule is invalid, the rest - of the rules within that Route should still be supported. - - Support: Core - properties: - kinds: - description: |- - Kinds specifies the groups and kinds of Routes that are allowed to bind - to this Gateway Listener. When unspecified or empty, the kinds of Routes - selected are determined using the Listener protocol. - - A RouteGroupKind MUST correspond to kinds of Routes that are compatible - with the application protocol specified in the Listener's Protocol field. - If an implementation does not support or recognize this resource type, it - MUST set the "ResolvedRefs" condition to False for this Listener with the - "InvalidRouteKinds" reason. - - Support: Core - items: - description: RouteGroupKind indicates the group and kind - of a Route resource. - properties: - group: - default: gateway.networking.k8s.io - description: Group is the group of the Route. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is the kind of the Route. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - required: - - kind - type: object - maxItems: 8 - type: array - x-kubernetes-list-type: atomic - namespaces: - default: - from: Same - description: |- - Namespaces indicates namespaces from which Routes may be attached to this - Listener. This is restricted to the namespace of this Gateway by default. - - Support: Core - properties: - from: - default: Same - description: |- - From indicates where Routes will be selected for this Gateway. Possible - values are: - - * All: Routes in all namespaces may be used by this Gateway. - * Selector: Routes in namespaces selected by the selector may be used by - this Gateway. - * Same: Only Routes in the same namespace may be used by this Gateway. - - Support: Core - enum: - - All - - Selector - - Same - type: string - selector: - description: |- - Selector must be specified when From is set to "Selector". In that case, - only Routes in Namespaces matching this Selector will be selected by this - Gateway. This field is ignored for other values of "From". - - Support: Core - properties: - matchExpressions: - description: matchExpressions is a list of label - selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the - selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - type: object - type: object - hostname: - description: |- - Hostname specifies the virtual hostname to match for protocol types that - define this concept. When unspecified, all hostnames are matched. This - field is ignored for protocols that don't require hostname based - matching. - - Implementations MUST apply Hostname matching appropriately for each of - the following protocols: - - * TLS: The Listener Hostname MUST match the SNI. - * HTTP: The Listener Hostname MUST match the Host header of the request. - * HTTPS: The Listener Hostname SHOULD match both the SNI and Host header. - Note that this does not require the SNI and Host header to be the same. - The semantics of this are described in more detail below. - - To ensure security, Section 11.1 of RFC-6066 emphasizes that server - implementations that rely on SNI hostname matching MUST also verify - hostnames within the application protocol. - - Section 9.1.2 of RFC-7540 provides a mechanism for servers to reject the - reuse of a connection by responding with the HTTP 421 Misdirected Request - status code. This indicates that the origin server has rejected the - request because it appears to have been misdirected. - - To detect misdirected requests, Gateways SHOULD match the authority of - the requests with all the SNI hostname(s) configured across all the - Gateway Listeners on the same port and protocol: - - * If another Listener has an exact match or more specific wildcard entry, - the Gateway SHOULD return a 421. - * If the current Listener (selected by SNI matching during ClientHello) - does not match the Host: - * If another Listener does match the Host, the Gateway SHOULD return a - 421. - * If no other Listener matches the Host, the Gateway MUST return a - 404. - - For HTTPRoute and TLSRoute resources, there is an interaction with the - `spec.hostnames` array. When both listener and route specify hostnames, - there MUST be an intersection between the values for a Route to be - accepted. For more information, refer to the Route specific Hostnames - documentation. - - Hostnames that are prefixed with a wildcard label (`*.`) are interpreted - as a suffix match. That means that a match for `*.example.com` would match - both `test.example.com`, and `foo.test.example.com`, but not `example.com`. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - name: - description: |- - Name is the name of the Listener. This name MUST be unique within a - Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - port: - description: |- - Port is the network port. Multiple listeners may use the - same port, subject to the Listener compatibility rules. - - Support: Core - format: int32 - maximum: 65535 - minimum: 1 - type: integer - protocol: - description: |- - Protocol specifies the network protocol this listener expects to receive. - - Support: Core - maxLength: 255 - minLength: 1 - pattern: ^[a-zA-Z0-9]([-a-zA-Z0-9]*[a-zA-Z0-9])?$|[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9]+$ - type: string - tls: - description: |- - TLS is the TLS configuration for the Listener. This field is required if - the Protocol field is "HTTPS" or "TLS". It is invalid to set this field - if the Protocol field is "HTTP", "TCP", or "UDP". - - The association of SNIs to Certificate defined in ListenerTLSConfig is - defined based on the Hostname field for this listener. - - The GatewayClass MUST use the longest matching SNI out of all - available certificates for any TLS handshake. - - Support: Core - properties: - certificateRefs: - description: |- - CertificateRefs contains a series of references to Kubernetes objects that - contains TLS certificates and private keys. These certificates are used to - establish a TLS handshake for requests that match the hostname of the - associated listener. - - A single CertificateRef to a Kubernetes Secret has "Core" support. - Implementations MAY choose to support attaching multiple certificates to - a Listener, but this behavior is implementation-specific. - - References to a resource in different namespace are invalid UNLESS there - is a ReferenceGrant in the target namespace that allows the certificate - to be attached. If a ReferenceGrant does not allow this reference, the - "ResolvedRefs" condition MUST be set to False for this listener with the - "RefNotPermitted" reason. - - This field is required to have at least one element when the mode is set - to "Terminate" (default) and is optional otherwise. - - CertificateRefs can reference to standard Kubernetes resources, i.e. - Secret, or implementation-specific custom resources. - - Support: Core - A single reference to a Kubernetes Secret of type kubernetes.io/tls - - Support: Implementation-specific (More than one reference or other resource types) - items: - description: |- - SecretObjectReference identifies an API object including its namespace, - defaulting to Secret. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - - References to objects with invalid Group and Kind are not valid, and must - be rejected by the implementation, with appropriate Conditions set - on the containing object. - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Secret - description: Kind is kind of the referent. For example - "Secret". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referenced object. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - name - type: object - maxItems: 64 - type: array - x-kubernetes-list-type: atomic - mode: - default: Terminate - description: |- - Mode defines the TLS behavior for the TLS session initiated by the client. - There are two possible modes: - - - Terminate: The TLS session between the downstream client and the - Gateway is terminated at the Gateway. This mode requires certificates - to be specified in some way, such as populating the certificateRefs - field. - - Passthrough: The TLS session is NOT terminated by the Gateway. This - implies that the Gateway can't decipher the TLS stream except for - the ClientHello message of the TLS protocol. The certificateRefs field - is ignored in this mode. - - Support: Core - enum: - - Terminate - - Passthrough - type: string - options: - additionalProperties: - description: |- - AnnotationValue is the value of an annotation in Gateway API. This is used - for validation of maps such as TLS options. This roughly matches Kubernetes - annotation validation, although the length validation in that case is based - on the entire size of the annotations struct. - maxLength: 4096 - minLength: 0 - type: string - description: |- - Options are a list of key/value pairs to enable extended TLS - configuration for each implementation. For example, configuring the - minimum TLS version or supported cipher suites. - - A set of common keys MAY be defined by the API in the future. To avoid - any ambiguity, implementation-specific definitions MUST use - domain-prefixed names, such as `example.com/my-custom-option`. - Un-prefixed names are reserved for key names defined by Gateway API. - - Support: Implementation-specific - maxProperties: 16 - type: object - type: object - x-kubernetes-validations: - - message: certificateRefs or options must be specified when - mode is Terminate - rule: 'self.mode == ''Terminate'' ? size(self.certificateRefs) - > 0 || size(self.options) > 0 : true' - required: - - name - - port - - protocol - type: object - maxItems: 64 - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - x-kubernetes-validations: - - message: tls must not be specified for protocols ['HTTP', 'TCP', - 'UDP'] - rule: 'self.all(l, l.protocol in [''HTTP'', ''TCP'', ''UDP''] ? - !has(l.tls) : true)' - - message: tls mode must be Terminate for protocol HTTPS - rule: 'self.all(l, (l.protocol == ''HTTPS'' && has(l.tls)) ? (l.tls.mode - == '''' || l.tls.mode == ''Terminate'') : true)' - - message: tls mode must be set for protocol TLS - rule: 'self.all(l, (l.protocol == ''TLS'' ? has(l.tls) && has(l.tls.mode) - && l.tls.mode != '''' : true))' - - message: hostname must not be specified for protocols ['TCP', 'UDP'] - rule: 'self.all(l, l.protocol in [''TCP'', ''UDP''] ? (!has(l.hostname) - || l.hostname == '''') : true)' - - message: Listener name must be unique within the Gateway - rule: self.all(l1, self.exists_one(l2, l1.name == l2.name)) - - message: Combination of port, protocol and hostname must be unique - for each listener - rule: 'self.all(l1, self.exists_one(l2, l1.port == l2.port && l1.protocol - == l2.protocol && (has(l1.hostname) && has(l2.hostname) ? l1.hostname - == l2.hostname : !has(l1.hostname) && !has(l2.hostname))))' - tls: - description: |- - TLS specifies frontend and backend tls configuration for entire gateway. - - Support: Extended - properties: - backend: - description: |- - Backend describes TLS configuration for gateway when connecting - to backends. - - Note that this contains only details for the Gateway as a TLS client, - and does _not_ imply behavior about how to choose which backend should - get a TLS connection. That is determined by the presence of a BackendTLSPolicy. - - Support: Core - properties: - clientCertificateRef: - description: |- - ClientCertificateRef references an object that contains a client certificate - and its associated private key. It can reference standard Kubernetes resources, - i.e., Secret, or implementation-specific custom resources. - - A ClientCertificateRef is considered invalid if: - - * It refers to a resource that cannot be resolved (e.g., the referenced resource - does not exist) or is misconfigured (e.g., a Secret does not contain the keys - named `tls.crt` and `tls.key`). In this case, the `ResolvedRefs` condition - on the Gateway MUST be set to False with the Reason `InvalidClientCertificateRef` - and the Message of the Condition MUST indicate why the reference is invalid. - - * It refers to a resource in another namespace UNLESS there is a ReferenceGrant - in the target namespace that allows the certificate to be attached. - If a ReferenceGrant does not allow this reference, the `ResolvedRefs` condition - on the Gateway MUST be set to False with the Reason `RefNotPermitted`. - - Implementations MAY choose to perform further validation of the certificate - content (e.g., checking expiry or enforcing specific formats). In such cases, - an implementation-specific Reason and Message MUST be set. - - Support: Core - Reference to a Kubernetes TLS Secret (with the type `kubernetes.io/tls`). - Support: Implementation-specific - Other resource kinds or Secrets with a - different type (e.g., `Opaque`). - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Secret - description: Kind is kind of the referent. For example - "Secret". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referenced object. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - name - type: object - type: object - frontend: - description: |- - Frontend describes TLS config when client connects to Gateway. - Support: Core - properties: - default: - description: |- - Default specifies the default client certificate validation configuration - for all Listeners handling HTTPS traffic, unless a per-port configuration - is defined. - - support: Core - properties: - validation: - description: |- - Validation holds configuration information for validating the frontend (client). - Setting this field will result in mutual authentication when connecting to the gateway. - In browsers this may result in a dialog appearing - that requests a user to specify the client certificate. - The maximum depth of a certificate chain accepted in verification is Implementation specific. - - Support: Core - properties: - caCertificateRefs: - description: |- - CACertificateRefs contains one or more references to Kubernetes - objects that contain a PEM-encoded TLS CA certificate bundle, which - is used as a trust anchor to validate the certificates presented by - the client. - - A CACertificateRef is invalid if: - - * It refers to a resource that cannot be resolved (e.g., the - referenced resource does not exist) or is misconfigured (e.g., a - ConfigMap does not contain a key named `ca.crt`). In this case, the - Reason on all matching HTTPS listeners must be set to `InvalidCACertificateRef` - and the Message of the Condition must indicate which reference is invalid and why. - - * It refers to an unknown or unsupported kind of resource. In this - case, the Reason on all matching HTTPS listeners must be set to - `InvalidCACertificateKind` and the Message of the Condition must explain - which kind of resource is unknown or unsupported. - - * It refers to a resource in another namespace UNLESS there is a - ReferenceGrant in the target namespace that allows the CA - certificate to be attached. If a ReferenceGrant does not allow this - reference, the `ResolvedRefs` on all matching HTTPS listeners condition - MUST be set with the Reason `RefNotPermitted`. - - Implementations MAY choose to perform further validation of the - certificate content (e.g., checking expiry or enforcing specific formats). - In such cases, an implementation-specific Reason and Message MUST be set. - - In all cases, the implementation MUST ensure that the `ResolvedRefs` - condition is set to `status: False` on all targeted listeners (i.e., - listeners serving HTTPS on a matching port). The condition MUST - include a Reason and Message that indicate the cause of the error. If - ALL CACertificateRefs are invalid, the implementation MUST also ensure - the `Accepted` condition on the listener is set to `status: False`, with - the Reason `NoValidCACertificate`. - Implementations MAY choose to support attaching multiple CA certificates - to a listener, but this behavior is implementation-specific. - - Support: Core - A single reference to a Kubernetes ConfigMap, with the - CA certificate in a key named `ca.crt`. - - Support: Implementation-specific - More than one reference, other kinds - of resources, or a single reference that includes multiple certificates. - items: - description: |- - ObjectReference identifies an API object including its namespace. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - - References to objects with invalid Group and Kind are not valid, and must - be rejected by the implementation, with appropriate Conditions set - on the containing object. - properties: - group: - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When set to the empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. For - example "ConfigMap" or "Service". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referenced object. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - group - - kind - - name - type: object - maxItems: 8 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - mode: - default: AllowValidOnly - description: |- - FrontendValidationMode defines the mode for validating the client certificate. - There are two possible modes: - - - AllowValidOnly: In this mode, the gateway will accept connections only if - the client presents a valid certificate. This certificate must successfully - pass validation against the CA certificates specified in `CACertificateRefs`. - - AllowInsecureFallback: In this mode, the gateway will accept connections - even if the client certificate is not presented or fails verification. - - This approach delegates client authorization to the backend and introduce - a significant security risk. It should be used in testing environments or - on a temporary basis in non-testing environments. - - Defaults to AllowValidOnly. - - Support: Core - enum: - - AllowValidOnly - - AllowInsecureFallback - type: string - required: - - caCertificateRefs - type: object - type: object - perPort: - description: |- - PerPort specifies tls configuration assigned per port. - Per port configuration is optional. Once set this configuration overrides - the default configuration for all Listeners handling HTTPS traffic - that match this port. - Each override port requires a unique TLS configuration. - - support: Core - items: - properties: - port: - description: |- - The Port indicates the Port Number to which the TLS configuration will be - applied. This configuration will be applied to all Listeners handling HTTPS - traffic that match this port. - - Support: Core - format: int32 - maximum: 65535 - minimum: 1 - type: integer - tls: - description: |- - TLS store the configuration that will be applied to all Listeners handling - HTTPS traffic and matching given port. - - Support: Core - properties: - validation: - description: |- - Validation holds configuration information for validating the frontend (client). - Setting this field will result in mutual authentication when connecting to the gateway. - In browsers this may result in a dialog appearing - that requests a user to specify the client certificate. - The maximum depth of a certificate chain accepted in verification is Implementation specific. - - Support: Core - properties: - caCertificateRefs: - description: |- - CACertificateRefs contains one or more references to Kubernetes - objects that contain a PEM-encoded TLS CA certificate bundle, which - is used as a trust anchor to validate the certificates presented by - the client. - - A CACertificateRef is invalid if: - - * It refers to a resource that cannot be resolved (e.g., the - referenced resource does not exist) or is misconfigured (e.g., a - ConfigMap does not contain a key named `ca.crt`). In this case, the - Reason on all matching HTTPS listeners must be set to `InvalidCACertificateRef` - and the Message of the Condition must indicate which reference is invalid and why. - - * It refers to an unknown or unsupported kind of resource. In this - case, the Reason on all matching HTTPS listeners must be set to - `InvalidCACertificateKind` and the Message of the Condition must explain - which kind of resource is unknown or unsupported. - - * It refers to a resource in another namespace UNLESS there is a - ReferenceGrant in the target namespace that allows the CA - certificate to be attached. If a ReferenceGrant does not allow this - reference, the `ResolvedRefs` on all matching HTTPS listeners condition - MUST be set with the Reason `RefNotPermitted`. - - Implementations MAY choose to perform further validation of the - certificate content (e.g., checking expiry or enforcing specific formats). - In such cases, an implementation-specific Reason and Message MUST be set. - - In all cases, the implementation MUST ensure that the `ResolvedRefs` - condition is set to `status: False` on all targeted listeners (i.e., - listeners serving HTTPS on a matching port). The condition MUST - include a Reason and Message that indicate the cause of the error. If - ALL CACertificateRefs are invalid, the implementation MUST also ensure - the `Accepted` condition on the listener is set to `status: False`, with - the Reason `NoValidCACertificate`. - Implementations MAY choose to support attaching multiple CA certificates - to a listener, but this behavior is implementation-specific. - - Support: Core - A single reference to a Kubernetes ConfigMap, with the - CA certificate in a key named `ca.crt`. - - Support: Implementation-specific - More than one reference, other kinds - of resources, or a single reference that includes multiple certificates. - items: - description: |- - ObjectReference identifies an API object including its namespace. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - - References to objects with invalid Group and Kind are not valid, and must - be rejected by the implementation, with appropriate Conditions set - on the containing object. - properties: - group: - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When set to the empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. - For example "ConfigMap" or "Service". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referenced object. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - group - - kind - - name - type: object - maxItems: 8 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - mode: - default: AllowValidOnly - description: |- - FrontendValidationMode defines the mode for validating the client certificate. - There are two possible modes: - - - AllowValidOnly: In this mode, the gateway will accept connections only if - the client presents a valid certificate. This certificate must successfully - pass validation against the CA certificates specified in `CACertificateRefs`. - - AllowInsecureFallback: In this mode, the gateway will accept connections - even if the client certificate is not presented or fails verification. - - This approach delegates client authorization to the backend and introduce - a significant security risk. It should be used in testing environments or - on a temporary basis in non-testing environments. - - Defaults to AllowValidOnly. - - Support: Core - enum: - - AllowValidOnly - - AllowInsecureFallback - type: string - required: - - caCertificateRefs - type: object - type: object - required: - - port - - tls - type: object - maxItems: 64 - type: array - x-kubernetes-list-map-keys: - - port - x-kubernetes-list-type: map - x-kubernetes-validations: - - message: Port for TLS configuration must be unique within - the Gateway - rule: self.all(t1, self.exists_one(t2, t1.port == t2.port)) - required: - - default - type: object - type: object - required: - - gatewayClassName - - listeners - type: object - status: - default: - conditions: - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Accepted - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Programmed - description: Status defines the current state of Gateway. - properties: - addresses: - description: |- - Addresses lists the network addresses that have been bound to the - Gateway. - - This list may differ from the addresses provided in the spec under some - conditions: - - * no addresses are specified, all addresses are dynamically assigned - * a combination of specified and dynamic addresses are assigned - * a specified address was unusable (e.g. already in use) - items: - description: GatewayStatusAddress describes a network address that - is bound to a Gateway. - oneOf: - - properties: - type: - enum: - - IPAddress - value: - anyOf: - - format: ipv4 - - format: ipv6 - - properties: - type: - not: - enum: - - IPAddress - properties: - type: - default: IPAddress - description: Type of the address. - maxLength: 253 - minLength: 1 - pattern: ^Hostname|IPAddress|NamedAddress|[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - value: - description: |- - Value of the address. The validity of the values will depend - on the type and support by the controller. - - Examples: `1.2.3.4`, `128::1`, `my-ip-address`. - maxLength: 253 - minLength: 1 - type: string - required: - - value - type: object - x-kubernetes-validations: - - message: Hostname value must only contain valid characters (matching - ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$) - rule: 'self.type == ''Hostname'' ? self.value.matches(r"""^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"""): - true' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - attachedListenerSets: - description: |- - AttachedListenerSets represents the total number of ListenerSets that have been - successfully attached to this Gateway. - - A ListenerSet is successfully attached to a Gateway when all the following conditions are met: - - The ListenerSet is selected by the Gateway's AllowedListeners field - - The ListenerSet has a valid ParentRef selecting the Gateway - - The ListenerSet's status has the condition "Accepted: true" - - Uses for this field include troubleshooting AttachedListenerSets attachment and - measuring blast radius/impact of changes to a Gateway. - format: int32 - type: integer - conditions: - default: - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Accepted - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Programmed - description: |- - Conditions describe the current conditions of the Gateway. - - Implementations should prefer to express Gateway conditions - using the `GatewayConditionType` and `GatewayConditionReason` - constants so that operators and tools can converge on a common - vocabulary to describe Gateway state. - - Known condition types are: - - * "Accepted" - * "Programmed" - * "Ready" - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - listeners: - description: Listeners provide status for each unique listener port - defined in the Spec. - items: - description: ListenerStatus is the status associated with a Listener. - properties: - attachedRoutes: - description: |- - AttachedRoutes represents the total number of Routes that have been - successfully attached to this Listener. - - Successful attachment of a Route to a Listener is based solely on the - combination of the AllowedRoutes field on the corresponding Listener - and the Route's ParentRefs field. A Route is successfully attached to - a Listener when it is selected by the Listener's AllowedRoutes field - AND the Route has a valid ParentRef selecting the whole Gateway - resource or a specific Listener as a parent resource (more detail on - attachment semantics can be found in the documentation on the various - Route kinds ParentRefs fields). Listener or Route status does not impact - successful attachment, i.e. the AttachedRoutes field count MUST be set - for Listeners, even if the Accepted condition of an individual Listener is set - to "False". The AttachedRoutes number represents the number of Routes with - the Accepted condition set to "True" that have been attached to this Listener. - Routes with any other value for the Accepted condition MUST NOT be included - in this count. - - Uses for this field include troubleshooting Route attachment and - measuring blast radius/impact of changes to a Listener. - format: int32 - type: integer - conditions: - description: Conditions describe the current condition of this - listener. - items: - description: Condition contains details for one aspect of - the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, - Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - name: - description: Name is the name of the Listener that this status - corresponds to. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - supportedKinds: - description: |- - SupportedKinds is the list indicating the Kinds supported by this - listener. This MUST represent the kinds supported by an implementation for - that Listener configuration. - - If kinds are specified in Spec that are not supported, they MUST NOT - appear in this list and an implementation MUST set the "ResolvedRefs" - condition to "False" with the "InvalidRouteKinds" reason. If both valid - and invalid Route kinds are specified, the implementation MUST - reference the valid Route kinds that have been specified. - items: - description: RouteGroupKind indicates the group and kind of - a Route resource. - properties: - group: - default: gateway.networking.k8s.io - description: Group is the group of the Route. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is the kind of the Route. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - required: - - kind - type: object - maxItems: 8 - type: array - x-kubernetes-list-type: atomic - required: - - attachedRoutes - - conditions - - name - type: object - maxItems: 64 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - required: - - spec - type: object - served: true - storage: false - subresources: - status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: null - storedVersions: null ---- -# -# config/crd/standard/gateway.networking.k8s.io_grpcroutes.yaml -# -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - api-approved.kubernetes.io: https://github.com/kubernetes-sigs/gateway-api/pull/4530 - gateway.networking.k8s.io/bundle-version: v1.5.1 - gateway.networking.k8s.io/channel: standard - name: grpcroutes.gateway.networking.k8s.io -spec: - group: gateway.networking.k8s.io - names: - categories: - - gateway-api - kind: GRPCRoute - listKind: GRPCRouteList - plural: grpcroutes - singular: grpcroute - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .spec.hostnames - name: Hostnames - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1 - schema: - openAPIV3Schema: - description: |- - GRPCRoute provides a way to route gRPC requests. This includes the capability - to match requests by hostname, gRPC service, gRPC method, or HTTP/2 header. - Filters can be used to specify additional processing steps. Backends specify - where matching requests will be routed. - - GRPCRoute falls under extended support within the Gateway API. Within the - following specification, the word "MUST" indicates that an implementation - supporting GRPCRoute must conform to the indicated requirement, but an - implementation not supporting this route type need not follow the requirement - unless explicitly indicated. - - Implementations supporting `GRPCRoute` with the `HTTPS` `ProtocolType` MUST - accept HTTP/2 connections without an initial upgrade from HTTP/1.1, i.e. via - ALPN. If the implementation does not support this, then it MUST set the - "Accepted" condition to "False" for the affected listener with a reason of - "UnsupportedProtocol". Implementations MAY also accept HTTP/2 connections - with an upgrade from HTTP/1. - - Implementations supporting `GRPCRoute` with the `HTTP` `ProtocolType` MUST - support HTTP/2 over cleartext TCP (h2c, - https://www.rfc-editor.org/rfc/rfc7540#section-3.1) without an initial - upgrade from HTTP/1.1, i.e. with prior knowledge - (https://www.rfc-editor.org/rfc/rfc7540#section-3.4). If the implementation - does not support this, then it MUST set the "Accepted" condition to "False" - for the affected listener with a reason of "UnsupportedProtocol". - Implementations MAY also accept HTTP/2 connections with an upgrade from - HTTP/1, i.e. without prior knowledge. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of GRPCRoute. - properties: - hostnames: - description: |- - Hostnames defines a set of hostnames to match against the GRPC - Host header to select a GRPCRoute to process the request. This matches - the RFC 1123 definition of a hostname with 2 notable exceptions: - - 1. IPs are not allowed. - 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard - label MUST appear by itself as the first label. - - If a hostname is specified by both the Listener and GRPCRoute, there - MUST be at least one intersecting hostname for the GRPCRoute to be - attached to the Listener. For example: - - * A Listener with `test.example.com` as the hostname matches GRPCRoutes - that have either not specified any hostnames, or have specified at - least one of `test.example.com` or `*.example.com`. - * A Listener with `*.example.com` as the hostname matches GRPCRoutes - that have either not specified any hostnames or have specified at least - one hostname that matches the Listener hostname. For example, - `test.example.com` and `*.example.com` would both match. On the other - hand, `example.com` and `test.example.net` would not match. - - Hostnames that are prefixed with a wildcard label (`*.`) are interpreted - as a suffix match. That means that a match for `*.example.com` would match - both `test.example.com`, and `foo.test.example.com`, but not `example.com`. - - If both the Listener and GRPCRoute have specified hostnames, any - GRPCRoute hostnames that do not match the Listener hostname MUST be - ignored. For example, if a Listener specified `*.example.com`, and the - GRPCRoute specified `test.example.com` and `test.example.net`, - `test.example.net` MUST NOT be considered for a match. - - If both the Listener and GRPCRoute have specified hostnames, and none - match with the criteria above, then the GRPCRoute MUST NOT be accepted by - the implementation. The implementation MUST raise an 'Accepted' Condition - with a status of `False` in the corresponding RouteParentStatus. - - If a Route (A) of type HTTPRoute or GRPCRoute is attached to a - Listener and that listener already has another Route (B) of the other - type attached and the intersection of the hostnames of A and B is - non-empty, then the implementation MUST accept exactly one of these two - routes, determined by the following criteria, in order: - - * The oldest Route based on creation timestamp. - * The Route appearing first in alphabetical order by - "{namespace}/{name}". - - The rejected Route MUST raise an 'Accepted' condition with a status of - 'False' in the corresponding RouteParentStatus. - - Support: Core - items: - description: |- - Hostname is the fully qualified domain name of a network host. This matches - the RFC 1123 definition of a hostname with 2 notable exceptions: - - 1. IPs are not allowed. - 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard - label must appear by itself as the first label. - - Hostname can be "precise" which is a domain name without the terminating - dot of a network host (e.g. "foo.example.com") or "wildcard", which is a - domain name prefixed with a single wildcard label (e.g. `*.example.com`). - - Note that as per RFC1035 and RFC1123, a *label* must consist of lower case - alphanumeric characters or '-', and must start and end with an alphanumeric - character. No other punctuation is allowed. - maxLength: 253 - minLength: 1 - pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - parentRefs: - description: |- - ParentRefs references the resources (usually Gateways) that a Route wants - to be attached to. Note that the referenced parent resource needs to - allow this for the attachment to be complete. For Gateways, that means - the Gateway needs to allow attachment from Routes of this kind and - namespace. For Services, that means the Service must either be in the same - namespace for a "producer" route, or the mesh implementation must support - and allow "consumer" routes for the referenced Service. ReferenceGrant is - not applicable for governing ParentRefs to Services - it is not possible to - create a "producer" route for a Service in a different namespace from the - Route. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - This API may be extended in the future to support additional kinds of parent - resources. - - ParentRefs must be _distinct_. This means either that: - - * They select different objects. If this is the case, then parentRef - entries are distinct. In terms of fields, this means that the - multi-part key defined by `group`, `kind`, `namespace`, and `name` must - be unique across all parentRef entries in the Route. - * They do not select different objects, but for each optional field used, - each ParentRef that selects the same object must set the same set of - optional fields to different values. If one ParentRef sets a - combination of optional fields, all must set the same combination. - - Some examples: - - * If one ParentRef sets `sectionName`, all ParentRefs referencing the - same object must also set `sectionName`. - * If one ParentRef sets `port`, all ParentRefs referencing the same - object must also set `port`. - * If one ParentRef sets `sectionName` and `port`, all ParentRefs - referencing the same object must also set `sectionName` and `port`. - - It is possible to separately reference multiple distinct objects that may - be collapsed by an implementation. For example, some implementations may - choose to merge compatible Gateway Listeners together. If that is the - case, the list of routes attached to those resources should also be - merged. - - Note that for ParentRefs that cross namespace boundaries, there are specific - rules. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example, - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable other kinds of cross-namespace reference. - items: - description: |- - ParentReference identifies an API object (usually a Gateway) that can be considered - a parent of this resource (usually a route). There are two kinds of parent resources - with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - This API may be extended in the future to support additional kinds of parent - resources. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - maxItems: 32 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: sectionName must be specified when parentRefs includes - 2 or more references to the same parent - rule: 'self.all(p1, self.all(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.__namespace__) - || p1.__namespace__ == '''') && (!has(p2.__namespace__) || p2.__namespace__ - == '''')) || (has(p1.__namespace__) && has(p2.__namespace__) && - p1.__namespace__ == p2.__namespace__ )) ? ((!has(p1.sectionName) - || p1.sectionName == '''') == (!has(p2.sectionName) || p2.sectionName - == '''')) : true))' - - message: sectionName must be unique when parentRefs includes 2 or - more references to the same parent - rule: self.all(p1, self.exists_one(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.__namespace__) - || p1.__namespace__ == '') && (!has(p2.__namespace__) || p2.__namespace__ - == '')) || (has(p1.__namespace__) && has(p2.__namespace__) && - p1.__namespace__ == p2.__namespace__ )) && (((!has(p1.sectionName) - || p1.sectionName == '') && (!has(p2.sectionName) || p2.sectionName - == '')) || (has(p1.sectionName) && has(p2.sectionName) && p1.sectionName - == p2.sectionName)))) - rules: - description: Rules are a list of GRPC matchers, filters and actions. - items: - description: |- - GRPCRouteRule defines the semantics for matching a gRPC request based on - conditions (matches), processing it (filters), and forwarding the request to - an API object (backendRefs). - properties: - backendRefs: - description: |- - BackendRefs defines the backend(s) where matching requests should be - sent. - - Failure behavior here depends on how many BackendRefs are specified and - how many are invalid. - - If *all* entries in BackendRefs are invalid, and there are also no filters - specified in this route rule, *all* traffic which matches this rule MUST - receive an `UNAVAILABLE` status. - - See the GRPCBackendRef definition for the rules about what makes a single - GRPCBackendRef invalid. - - When a GRPCBackendRef is invalid, `UNAVAILABLE` statuses MUST be returned for - requests that would have otherwise been routed to an invalid backend. If - multiple backends are specified, and some are invalid, the proportion of - requests that would otherwise have been routed to an invalid backend - MUST receive an `UNAVAILABLE` status. - - For example, if two backends are specified with equal weights, and one is - invalid, 50 percent of traffic MUST receive an `UNAVAILABLE` status. - Implementations may choose how that 50 percent is determined. - - Support: Core for Kubernetes Service - - Support: Implementation-specific for any other resource - - Support for weight: Core - items: - description: |- - GRPCBackendRef defines how a GRPCRoute forwards a gRPC request. - - Note that when a namespace different than the local namespace is specified, a - ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - properties: - filters: - description: |- - Filters defined at this level MUST be executed if and only if the - request is being forwarded to the backend defined here. - - Support: Implementation-specific (For broader support of filters, use the - Filters field in GRPCRouteRule.) - items: - description: |- - GRPCRouteFilter defines processing steps that must be completed during the - request or response lifecycle. GRPCRouteFilters are meant as an extension - point to express processing that may be done in Gateway implementations. Some - examples include request or response modification, implementing - authentication strategies, rate-limiting, and traffic shaping. API - guarantee/conformance is defined based on the type of the filter. - properties: - extensionRef: - description: |- - ExtensionRef is an optional, implementation-specific extension to the - "filter" behavior. For example, resource "myroutefilter" in group - "networking.example.net"). ExtensionRef MUST NOT be used for core and - extended filters. - - Support: Implementation-specific - - This filter can be used multiple times within the same rule. - properties: - group: - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. For - example "HTTPRoute" or "Service". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - required: - - group - - kind - - name - type: object - requestHeaderModifier: - description: |- - RequestHeaderModifier defines a schema for a filter that modifies request - headers. - - Support: Core - properties: - add: - description: |- - Add adds the given header(s) (name, value) to the request - before the action. It appends to any existing values associated - with the header name. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - add: - - name: "my-header" - value: "bar,baz" - - Output: - GET /foo HTTP/1.1 - my-header: foo,bar,baz - items: - description: HTTPHeader represents an HTTP - Header name and value as defined by RFC - 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP - Header to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - remove: - description: |- - Remove the given header(s) from the HTTP request before the action. The - value of Remove is a list of HTTP header names. Note that the header - names are case-insensitive (see - https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). - - Input: - GET /foo HTTP/1.1 - my-header1: foo - my-header2: bar - my-header3: baz - - Config: - remove: ["my-header1", "my-header3"] - - Output: - GET /foo HTTP/1.1 - my-header2: bar - items: - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: set - set: - description: |- - Set overwrites the request with the given header (name, value) - before the action. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - set: - - name: "my-header" - value: "bar" - - Output: - GET /foo HTTP/1.1 - my-header: bar - items: - description: HTTPHeader represents an HTTP - Header name and value as defined by RFC - 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP - Header to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - requestMirror: - description: |- - RequestMirror defines a schema for a filter that mirrors requests. - Requests are sent to the specified destination, but responses from - that destination are ignored. - - This filter can be used multiple times within the same rule. Note that - not all implementations will be able to support mirroring to multiple - backends. - - Support: Extended - properties: - backendRef: - description: |- - BackendRef references a resource where mirrored requests are sent. - - Mirrored requests must be sent only to a single destination endpoint - within this BackendRef, irrespective of how many endpoints are present - within this BackendRef. - - If the referent cannot be found, this BackendRef is invalid and must be - dropped from the Gateway. The controller must ensure the "ResolvedRefs" - condition on the Route status is set to `status: False` and not configure - this backend in the underlying implementation. - - If there is a cross-namespace reference to an *existing* object - that is not allowed by a ReferenceGrant, the controller must ensure the - "ResolvedRefs" condition on the Route is set to `status: False`, - with the "RefNotPermitted" reason and not configure this backend in the - underlying implementation. - - In either error case, the Message of the `ResolvedRefs` Condition - should be used to provide more detail about the problem. - - Support: Extended for Kubernetes Service - - Support: Implementation-specific for any other resource - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations SHOULD NOT - support ExternalName Services. - - Support: Core (Services with a type other than ExternalName) - - Support: Implementation-specific (Services with type ExternalName) - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the backend. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port specifies the destination port number to use for this resource. - Port is required when the referent is a Kubernetes Service. In this - case, the port number is the service port number, not the target port. - For other resources, destination port might be derived from the referent - resource or this field. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - required: - - name - type: object - x-kubernetes-validations: - - message: Must have port for Service reference - rule: '(size(self.group) == 0 && self.kind - == ''Service'') ? has(self.port) : true' - fraction: - description: |- - Fraction represents the fraction of requests that should be - mirrored to BackendRef. - - Only one of Fraction or Percent may be specified. If neither field - is specified, 100% of requests will be mirrored. - properties: - denominator: - default: 100 - format: int32 - minimum: 1 - type: integer - numerator: - format: int32 - minimum: 0 - type: integer - required: - - numerator - type: object - x-kubernetes-validations: - - message: numerator must be less than or equal - to denominator - rule: self.numerator <= self.denominator - percent: - description: |- - Percent represents the percentage of requests that should be - mirrored to BackendRef. Its minimum value is 0 (indicating 0% of - requests) and its maximum value is 100 (indicating 100% of requests). - - Only one of Fraction or Percent may be specified. If neither field - is specified, 100% of requests will be mirrored. - format: int32 - maximum: 100 - minimum: 0 - type: integer - required: - - backendRef - type: object - x-kubernetes-validations: - - message: Only one of percent or fraction may be - specified in HTTPRequestMirrorFilter - rule: '!(has(self.percent) && has(self.fraction))' - responseHeaderModifier: - description: |- - ResponseHeaderModifier defines a schema for a filter that modifies response - headers. - - Support: Extended - properties: - add: - description: |- - Add adds the given header(s) (name, value) to the request - before the action. It appends to any existing values associated - with the header name. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - add: - - name: "my-header" - value: "bar,baz" - - Output: - GET /foo HTTP/1.1 - my-header: foo,bar,baz - items: - description: HTTPHeader represents an HTTP - Header name and value as defined by RFC - 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP - Header to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - remove: - description: |- - Remove the given header(s) from the HTTP request before the action. The - value of Remove is a list of HTTP header names. Note that the header - names are case-insensitive (see - https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). - - Input: - GET /foo HTTP/1.1 - my-header1: foo - my-header2: bar - my-header3: baz - - Config: - remove: ["my-header1", "my-header3"] - - Output: - GET /foo HTTP/1.1 - my-header2: bar - items: - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: set - set: - description: |- - Set overwrites the request with the given header (name, value) - before the action. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - set: - - name: "my-header" - value: "bar" - - Output: - GET /foo HTTP/1.1 - my-header: bar - items: - description: HTTPHeader represents an HTTP - Header name and value as defined by RFC - 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP - Header to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - type: - description: |- - Type identifies the type of filter to apply. As with other API fields, - types are classified into three conformance levels: - - - Core: Filter types and their corresponding configuration defined by - "Support: Core" in this package, e.g. "RequestHeaderModifier". All - implementations supporting GRPCRoute MUST support core filters. - - - Extended: Filter types and their corresponding configuration defined by - "Support: Extended" in this package, e.g. "RequestMirror". Implementers - are encouraged to support extended filters. - - - Implementation-specific: Filters that are defined and supported by specific vendors. - In the future, filters showing convergence in behavior across multiple - implementations will be considered for inclusion in extended or core - conformance levels. Filter-specific configuration for such filters - is specified using the ExtensionRef field. `Type` MUST be set to - "ExtensionRef" for custom filters. - - Implementers are encouraged to define custom implementation types to - extend the core API with implementation-specific behavior. - - If a reference to a custom filter type cannot be resolved, the filter - MUST NOT be skipped. Instead, requests that would have been processed by - that filter MUST receive a HTTP error response. - enum: - - ResponseHeaderModifier - - RequestHeaderModifier - - RequestMirror - - ExtensionRef - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: filter.requestHeaderModifier must be nil - if the filter.type is not RequestHeaderModifier - rule: '!(has(self.requestHeaderModifier) && self.type - != ''RequestHeaderModifier'')' - - message: filter.requestHeaderModifier must be specified - for RequestHeaderModifier filter.type - rule: '!(!has(self.requestHeaderModifier) && self.type - == ''RequestHeaderModifier'')' - - message: filter.responseHeaderModifier must be nil - if the filter.type is not ResponseHeaderModifier - rule: '!(has(self.responseHeaderModifier) && self.type - != ''ResponseHeaderModifier'')' - - message: filter.responseHeaderModifier must be specified - for ResponseHeaderModifier filter.type - rule: '!(!has(self.responseHeaderModifier) && self.type - == ''ResponseHeaderModifier'')' - - message: filter.requestMirror must be nil if the filter.type - is not RequestMirror - rule: '!(has(self.requestMirror) && self.type != ''RequestMirror'')' - - message: filter.requestMirror must be specified for - RequestMirror filter.type - rule: '!(!has(self.requestMirror) && self.type == - ''RequestMirror'')' - - message: filter.extensionRef must be nil if the filter.type - is not ExtensionRef - rule: '!(has(self.extensionRef) && self.type != ''ExtensionRef'')' - - message: filter.extensionRef must be specified for - ExtensionRef filter.type - rule: '!(!has(self.extensionRef) && self.type == ''ExtensionRef'')' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: RequestHeaderModifier filter cannot be repeated - rule: self.filter(f, f.type == 'RequestHeaderModifier').size() - <= 1 - - message: ResponseHeaderModifier filter cannot be repeated - rule: self.filter(f, f.type == 'ResponseHeaderModifier').size() - <= 1 - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations SHOULD NOT - support ExternalName Services. - - Support: Core (Services with a type other than ExternalName) - - Support: Implementation-specific (Services with type ExternalName) - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the backend. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port specifies the destination port number to use for this resource. - Port is required when the referent is a Kubernetes Service. In this - case, the port number is the service port number, not the target port. - For other resources, destination port might be derived from the referent - resource or this field. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - weight: - default: 1 - description: |- - Weight specifies the proportion of requests forwarded to the referenced - backend. This is computed as weight/(sum of all weights in this - BackendRefs list). For non-zero values, there may be some epsilon from - the exact proportion defined here depending on the precision an - implementation supports. Weight is not a percentage and the sum of - weights does not need to equal 100. - - If only one backend is specified and it has a weight greater than 0, 100% - of the traffic is forwarded to that backend. If weight is set to 0, no - traffic should be forwarded for this entry. If unspecified, weight - defaults to 1. - - Support for this field varies based on the context where used. - format: int32 - maximum: 1000000 - minimum: 0 - type: integer - required: - - name - type: object - x-kubernetes-validations: - - message: Must have port for Service reference - rule: '(size(self.group) == 0 && self.kind == ''Service'') - ? has(self.port) : true' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - filters: - description: |- - Filters define the filters that are applied to requests that match - this rule. - - The effects of ordering of multiple behaviors are currently unspecified. - This can change in the future based on feedback during the alpha stage. - - Conformance-levels at this level are defined based on the type of filter: - - - ALL core filters MUST be supported by all implementations that support - GRPCRoute. - - Implementers are encouraged to support extended filters. - - Implementation-specific custom filters have no API guarantees across - implementations. - - Specifying the same filter multiple times is not supported unless explicitly - indicated in the filter. - - If an implementation cannot support a combination of filters, it must clearly - document that limitation. In cases where incompatible or unsupported - filters are specified and cause the `Accepted` condition to be set to status - `False`, implementations may use the `IncompatibleFilters` reason to specify - this configuration error. - - Support: Core - items: - description: |- - GRPCRouteFilter defines processing steps that must be completed during the - request or response lifecycle. GRPCRouteFilters are meant as an extension - point to express processing that may be done in Gateway implementations. Some - examples include request or response modification, implementing - authentication strategies, rate-limiting, and traffic shaping. API - guarantee/conformance is defined based on the type of the filter. - properties: - extensionRef: - description: |- - ExtensionRef is an optional, implementation-specific extension to the - "filter" behavior. For example, resource "myroutefilter" in group - "networking.example.net"). ExtensionRef MUST NOT be used for core and - extended filters. - - Support: Implementation-specific - - This filter can be used multiple times within the same rule. - properties: - group: - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. For example - "HTTPRoute" or "Service". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - required: - - group - - kind - - name - type: object - requestHeaderModifier: - description: |- - RequestHeaderModifier defines a schema for a filter that modifies request - headers. - - Support: Core - properties: - add: - description: |- - Add adds the given header(s) (name, value) to the request - before the action. It appends to any existing values associated - with the header name. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - add: - - name: "my-header" - value: "bar,baz" - - Output: - GET /foo HTTP/1.1 - my-header: foo,bar,baz - items: - description: HTTPHeader represents an HTTP Header - name and value as defined by RFC 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - remove: - description: |- - Remove the given header(s) from the HTTP request before the action. The - value of Remove is a list of HTTP header names. Note that the header - names are case-insensitive (see - https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). - - Input: - GET /foo HTTP/1.1 - my-header1: foo - my-header2: bar - my-header3: baz - - Config: - remove: ["my-header1", "my-header3"] - - Output: - GET /foo HTTP/1.1 - my-header2: bar - items: - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: set - set: - description: |- - Set overwrites the request with the given header (name, value) - before the action. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - set: - - name: "my-header" - value: "bar" - - Output: - GET /foo HTTP/1.1 - my-header: bar - items: - description: HTTPHeader represents an HTTP Header - name and value as defined by RFC 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - requestMirror: - description: |- - RequestMirror defines a schema for a filter that mirrors requests. - Requests are sent to the specified destination, but responses from - that destination are ignored. - - This filter can be used multiple times within the same rule. Note that - not all implementations will be able to support mirroring to multiple - backends. - - Support: Extended - properties: - backendRef: - description: |- - BackendRef references a resource where mirrored requests are sent. - - Mirrored requests must be sent only to a single destination endpoint - within this BackendRef, irrespective of how many endpoints are present - within this BackendRef. - - If the referent cannot be found, this BackendRef is invalid and must be - dropped from the Gateway. The controller must ensure the "ResolvedRefs" - condition on the Route status is set to `status: False` and not configure - this backend in the underlying implementation. - - If there is a cross-namespace reference to an *existing* object - that is not allowed by a ReferenceGrant, the controller must ensure the - "ResolvedRefs" condition on the Route is set to `status: False`, - with the "RefNotPermitted" reason and not configure this backend in the - underlying implementation. - - In either error case, the Message of the `ResolvedRefs` Condition - should be used to provide more detail about the problem. - - Support: Extended for Kubernetes Service - - Support: Implementation-specific for any other resource - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations SHOULD NOT - support ExternalName Services. - - Support: Core (Services with a type other than ExternalName) - - Support: Implementation-specific (Services with type ExternalName) - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the backend. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port specifies the destination port number to use for this resource. - Port is required when the referent is a Kubernetes Service. In this - case, the port number is the service port number, not the target port. - For other resources, destination port might be derived from the referent - resource or this field. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - required: - - name - type: object - x-kubernetes-validations: - - message: Must have port for Service reference - rule: '(size(self.group) == 0 && self.kind == ''Service'') - ? has(self.port) : true' - fraction: - description: |- - Fraction represents the fraction of requests that should be - mirrored to BackendRef. - - Only one of Fraction or Percent may be specified. If neither field - is specified, 100% of requests will be mirrored. - properties: - denominator: - default: 100 - format: int32 - minimum: 1 - type: integer - numerator: - format: int32 - minimum: 0 - type: integer - required: - - numerator - type: object - x-kubernetes-validations: - - message: numerator must be less than or equal to - denominator - rule: self.numerator <= self.denominator - percent: - description: |- - Percent represents the percentage of requests that should be - mirrored to BackendRef. Its minimum value is 0 (indicating 0% of - requests) and its maximum value is 100 (indicating 100% of requests). - - Only one of Fraction or Percent may be specified. If neither field - is specified, 100% of requests will be mirrored. - format: int32 - maximum: 100 - minimum: 0 - type: integer - required: - - backendRef - type: object - x-kubernetes-validations: - - message: Only one of percent or fraction may be specified - in HTTPRequestMirrorFilter - rule: '!(has(self.percent) && has(self.fraction))' - responseHeaderModifier: - description: |- - ResponseHeaderModifier defines a schema for a filter that modifies response - headers. - - Support: Extended - properties: - add: - description: |- - Add adds the given header(s) (name, value) to the request - before the action. It appends to any existing values associated - with the header name. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - add: - - name: "my-header" - value: "bar,baz" - - Output: - GET /foo HTTP/1.1 - my-header: foo,bar,baz - items: - description: HTTPHeader represents an HTTP Header - name and value as defined by RFC 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - remove: - description: |- - Remove the given header(s) from the HTTP request before the action. The - value of Remove is a list of HTTP header names. Note that the header - names are case-insensitive (see - https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). - - Input: - GET /foo HTTP/1.1 - my-header1: foo - my-header2: bar - my-header3: baz - - Config: - remove: ["my-header1", "my-header3"] - - Output: - GET /foo HTTP/1.1 - my-header2: bar - items: - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: set - set: - description: |- - Set overwrites the request with the given header (name, value) - before the action. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - set: - - name: "my-header" - value: "bar" - - Output: - GET /foo HTTP/1.1 - my-header: bar - items: - description: HTTPHeader represents an HTTP Header - name and value as defined by RFC 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - type: - description: |- - Type identifies the type of filter to apply. As with other API fields, - types are classified into three conformance levels: - - - Core: Filter types and their corresponding configuration defined by - "Support: Core" in this package, e.g. "RequestHeaderModifier". All - implementations supporting GRPCRoute MUST support core filters. - - - Extended: Filter types and their corresponding configuration defined by - "Support: Extended" in this package, e.g. "RequestMirror". Implementers - are encouraged to support extended filters. - - - Implementation-specific: Filters that are defined and supported by specific vendors. - In the future, filters showing convergence in behavior across multiple - implementations will be considered for inclusion in extended or core - conformance levels. Filter-specific configuration for such filters - is specified using the ExtensionRef field. `Type` MUST be set to - "ExtensionRef" for custom filters. - - Implementers are encouraged to define custom implementation types to - extend the core API with implementation-specific behavior. - - If a reference to a custom filter type cannot be resolved, the filter - MUST NOT be skipped. Instead, requests that would have been processed by - that filter MUST receive a HTTP error response. - enum: - - ResponseHeaderModifier - - RequestHeaderModifier - - RequestMirror - - ExtensionRef - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: filter.requestHeaderModifier must be nil if the - filter.type is not RequestHeaderModifier - rule: '!(has(self.requestHeaderModifier) && self.type != - ''RequestHeaderModifier'')' - - message: filter.requestHeaderModifier must be specified - for RequestHeaderModifier filter.type - rule: '!(!has(self.requestHeaderModifier) && self.type == - ''RequestHeaderModifier'')' - - message: filter.responseHeaderModifier must be nil if the - filter.type is not ResponseHeaderModifier - rule: '!(has(self.responseHeaderModifier) && self.type != - ''ResponseHeaderModifier'')' - - message: filter.responseHeaderModifier must be specified - for ResponseHeaderModifier filter.type - rule: '!(!has(self.responseHeaderModifier) && self.type - == ''ResponseHeaderModifier'')' - - message: filter.requestMirror must be nil if the filter.type - is not RequestMirror - rule: '!(has(self.requestMirror) && self.type != ''RequestMirror'')' - - message: filter.requestMirror must be specified for RequestMirror - filter.type - rule: '!(!has(self.requestMirror) && self.type == ''RequestMirror'')' - - message: filter.extensionRef must be nil if the filter.type - is not ExtensionRef - rule: '!(has(self.extensionRef) && self.type != ''ExtensionRef'')' - - message: filter.extensionRef must be specified for ExtensionRef - filter.type - rule: '!(!has(self.extensionRef) && self.type == ''ExtensionRef'')' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: RequestHeaderModifier filter cannot be repeated - rule: self.filter(f, f.type == 'RequestHeaderModifier').size() - <= 1 - - message: ResponseHeaderModifier filter cannot be repeated - rule: self.filter(f, f.type == 'ResponseHeaderModifier').size() - <= 1 - matches: - description: |- - Matches define conditions used for matching the rule against incoming - gRPC requests. Each match is independent, i.e. this rule will be matched - if **any** one of the matches is satisfied. - - For example, take the following matches configuration: - - ``` - matches: - - method: - service: foo.bar - headers: - values: - version: 2 - - method: - service: foo.bar.v2 - ``` - - For a request to match against this rule, it MUST satisfy - EITHER of the two conditions: - - - service of foo.bar AND contains the header `version: 2` - - service of foo.bar.v2 - - See the documentation for GRPCRouteMatch on how to specify multiple - match conditions to be ANDed together. - - If no matches are specified, the implementation MUST match every gRPC request. - - Proxy or Load Balancer routing configuration generated from GRPCRoutes - MUST prioritize rules based on the following criteria, continuing on - ties. Merging MUST not be done between GRPCRoutes and HTTPRoutes. - Precedence MUST be given to the rule with the largest number of: - - * Characters in a matching non-wildcard hostname. - * Characters in a matching hostname. - * Characters in a matching service. - * Characters in a matching method. - * Header matches. - - If ties still exist across multiple Routes, matching precedence MUST be - determined in order of the following criteria, continuing on ties: - - * The oldest Route based on creation timestamp. - * The Route appearing first in alphabetical order by - "{namespace}/{name}". - - If ties still exist within the Route that has been given precedence, - matching precedence MUST be granted to the first matching rule meeting - the above criteria. - items: - description: |- - GRPCRouteMatch defines the predicate used to match requests to a given - action. Multiple match types are ANDed together, i.e. the match will - evaluate to true only if all conditions are satisfied. - - For example, the match below will match a gRPC request only if its service - is `foo` AND it contains the `version: v1` header: - - ``` - matches: - - method: - type: Exact - service: "foo" - - headers: - name: "version" - value "v1" - - ``` - properties: - headers: - description: |- - Headers specifies gRPC request header matchers. Multiple match values are - ANDed together, meaning, a request MUST match all the specified headers - to select the route. - items: - description: |- - GRPCHeaderMatch describes how to select a gRPC route by matching gRPC request - headers. - properties: - name: - description: |- - Name is the name of the gRPC Header to be matched. - - If multiple entries specify equivalent header names, only the first - entry with an equivalent name MUST be considered for a match. Subsequent - entries with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - type: - default: Exact - description: Type specifies how to match against - the value of the header. - enum: - - Exact - - RegularExpression - type: string - value: - description: Value is the value of the gRPC Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - method: - description: |- - Method specifies a gRPC request service/method matcher. If this field is - not specified, all services and methods will match. - properties: - method: - description: |- - Value of the method to match against. If left empty or omitted, will - match all services. - - At least one of Service and Method MUST be a non-empty string. - maxLength: 1024 - type: string - service: - description: |- - Value of the service to match against. If left empty or omitted, will - match any service. - - At least one of Service and Method MUST be a non-empty string. - maxLength: 1024 - type: string - type: - default: Exact - description: |- - Type specifies how to match against the service and/or method. - Support: Core (Exact with service and method specified) - - Support: Implementation-specific (Exact with method specified but no service specified) - - Support: Implementation-specific (RegularExpression) - enum: - - Exact - - RegularExpression - type: string - type: object - x-kubernetes-validations: - - message: One or both of 'service' or 'method' must be - specified - rule: 'has(self.type) ? has(self.service) || has(self.method) - : true' - - message: service must only contain valid characters - (matching ^(?i)\.?[a-z_][a-z_0-9]*(\.[a-z_][a-z_0-9]*)*$) - rule: '(!has(self.type) || self.type == ''Exact'') && - has(self.service) ? self.service.matches(r"""^(?i)\.?[a-z_][a-z_0-9]*(\.[a-z_][a-z_0-9]*)*$"""): - true' - - message: method must only contain valid characters (matching - ^[A-Za-z_][A-Za-z_0-9]*$) - rule: '(!has(self.type) || self.type == ''Exact'') && - has(self.method) ? self.method.matches(r"""^[A-Za-z_][A-Za-z_0-9]*$"""): - true' - type: object - maxItems: 64 - type: array - x-kubernetes-list-type: atomic - name: - description: |- - Name is the name of the route rule. This name MUST be unique within a Route if it is set. - - Support: Extended - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - type: object - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: While 16 rules and 64 matches per rule are allowed, the - total number of matches across all rules in a route must be less - than 128 - rule: '(self.size() > 0 ? (has(self[0].matches) ? self[0].matches.size() - : 0) : 0) + (self.size() > 1 ? (has(self[1].matches) ? self[1].matches.size() - : 0) : 0) + (self.size() > 2 ? (has(self[2].matches) ? self[2].matches.size() - : 0) : 0) + (self.size() > 3 ? (has(self[3].matches) ? self[3].matches.size() - : 0) : 0) + (self.size() > 4 ? (has(self[4].matches) ? self[4].matches.size() - : 0) : 0) + (self.size() > 5 ? (has(self[5].matches) ? self[5].matches.size() - : 0) : 0) + (self.size() > 6 ? (has(self[6].matches) ? self[6].matches.size() - : 0) : 0) + (self.size() > 7 ? (has(self[7].matches) ? self[7].matches.size() - : 0) : 0) + (self.size() > 8 ? (has(self[8].matches) ? self[8].matches.size() - : 0) : 0) + (self.size() > 9 ? (has(self[9].matches) ? self[9].matches.size() - : 0) : 0) + (self.size() > 10 ? (has(self[10].matches) ? self[10].matches.size() - : 0) : 0) + (self.size() > 11 ? (has(self[11].matches) ? self[11].matches.size() - : 0) : 0) + (self.size() > 12 ? (has(self[12].matches) ? self[12].matches.size() - : 0) : 0) + (self.size() > 13 ? (has(self[13].matches) ? self[13].matches.size() - : 0) : 0) + (self.size() > 14 ? (has(self[14].matches) ? self[14].matches.size() - : 0) : 0) + (self.size() > 15 ? (has(self[15].matches) ? self[15].matches.size() - : 0) : 0) <= 128' - type: object - status: - description: Status defines the current state of GRPCRoute. - properties: - parents: - description: |- - Parents is a list of parent resources (usually Gateways) that are - associated with the route, and the status of the route with respect to - each parent. When this route attaches to a parent, the controller that - manages the parent must add an entry to this list when the controller - first sees the route and should update the entry as appropriate when the - route or gateway is modified. - - Note that parent references that cannot be resolved by an implementation - of this API will not be added to this list. Implementations of this API - can only populate Route status for the Gateways/parent resources they are - responsible for. - - A maximum of 32 Gateways will be represented in this list. An empty list - means the route has not been attached to any Gateway. - items: - description: |- - RouteParentStatus describes the status of a route with respect to an - associated Parent. - properties: - conditions: - description: |- - Conditions describes the status of the route with respect to the Gateway. - Note that the route's availability is also subject to the Gateway's own - status conditions and listener status. - - If the Route's ParentRef specifies an existing Gateway that supports - Routes of this kind AND that Gateway's controller has sufficient access, - then that Gateway's controller MUST set the "Accepted" condition on the - Route, to indicate whether the route has been accepted or rejected by the - Gateway, and why. - - A Route MUST be considered "Accepted" if at least one of the Route's - rules is implemented by the Gateway. - - There are a number of cases where the "Accepted" condition may not be set - due to lack of controller visibility, that includes when: - - * The Route refers to a nonexistent parent. - * The Route is of a type that the controller does not support. - * The Route is in a namespace to which the controller does not have access. - items: - description: Condition contains details for one aspect of - the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, - Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - controllerName: - description: |- - ControllerName is a domain/path string that indicates the name of the - controller that wrote this status. This corresponds with the - controllerName field on GatewayClass. - - Example: "example.net/gateway-controller". - - The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are - valid Kubernetes names - (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). - - Controllers MUST populate this field when writing status. Controllers should ensure that - entries to status populated with their ControllerName are cleaned up when they are no - longer necessary. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - parentRef: - description: |- - ParentRef corresponds with a ParentRef in the spec that this - RouteParentStatus struct describes the status of. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - required: - - conditions - - controllerName - - parentRef - type: object - maxItems: 32 - type: array - x-kubernetes-list-type: atomic - required: - - parents - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: null - storedVersions: null ---- -# -# config/crd/standard/gateway.networking.k8s.io_httproutes.yaml -# -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - api-approved.kubernetes.io: https://github.com/kubernetes-sigs/gateway-api/pull/4530 - gateway.networking.k8s.io/bundle-version: v1.5.1 - gateway.networking.k8s.io/channel: standard - name: httproutes.gateway.networking.k8s.io -spec: - group: gateway.networking.k8s.io - names: - categories: - - gateway-api - kind: HTTPRoute - listKind: HTTPRouteList - plural: httproutes - singular: httproute - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .spec.hostnames - name: Hostnames - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1 - schema: - openAPIV3Schema: - description: |- - HTTPRoute provides a way to route HTTP requests. This includes the capability - to match requests by hostname, path, header, or query param. Filters can be - used to specify additional processing steps. Backends specify where matching - requests should be routed. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of HTTPRoute. - properties: - hostnames: - description: |- - Hostnames defines a set of hostnames that should match against the HTTP Host - header to select a HTTPRoute used to process the request. Implementations - MUST ignore any port value specified in the HTTP Host header while - performing a match and (absent of any applicable header modification - configuration) MUST forward this header unmodified to the backend. - - Valid values for Hostnames are determined by RFC 1123 definition of a - hostname with 2 notable exceptions: - - 1. IPs are not allowed. - 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard - label must appear by itself as the first label. - - If a hostname is specified by both the Listener and HTTPRoute, there - must be at least one intersecting hostname for the HTTPRoute to be - attached to the Listener. For example: - - * A Listener with `test.example.com` as the hostname matches HTTPRoutes - that have either not specified any hostnames, or have specified at - least one of `test.example.com` or `*.example.com`. - * A Listener with `*.example.com` as the hostname matches HTTPRoutes - that have either not specified any hostnames or have specified at least - one hostname that matches the Listener hostname. For example, - `*.example.com`, `test.example.com`, and `foo.test.example.com` would - all match. On the other hand, `example.com` and `test.example.net` would - not match. - - Hostnames that are prefixed with a wildcard label (`*.`) are interpreted - as a suffix match. That means that a match for `*.example.com` would match - both `test.example.com`, and `foo.test.example.com`, but not `example.com`. - - If both the Listener and HTTPRoute have specified hostnames, any - HTTPRoute hostnames that do not match the Listener hostname MUST be - ignored. For example, if a Listener specified `*.example.com`, and the - HTTPRoute specified `test.example.com` and `test.example.net`, - `test.example.net` must not be considered for a match. - - If both the Listener and HTTPRoute have specified hostnames, and none - match with the criteria above, then the HTTPRoute is not accepted. The - implementation must raise an 'Accepted' Condition with a status of - `False` in the corresponding RouteParentStatus. - - In the event that multiple HTTPRoutes specify intersecting hostnames (e.g. - overlapping wildcard matching and exact matching hostnames), precedence must - be given to rules from the HTTPRoute with the largest number of: - - * Characters in a matching non-wildcard hostname. - * Characters in a matching hostname. - - If ties exist across multiple Routes, the matching precedence rules for - HTTPRouteMatches takes over. - - Support: Core - items: - description: |- - Hostname is the fully qualified domain name of a network host. This matches - the RFC 1123 definition of a hostname with 2 notable exceptions: - - 1. IPs are not allowed. - 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard - label must appear by itself as the first label. - - Hostname can be "precise" which is a domain name without the terminating - dot of a network host (e.g. "foo.example.com") or "wildcard", which is a - domain name prefixed with a single wildcard label (e.g. `*.example.com`). - - Note that as per RFC1035 and RFC1123, a *label* must consist of lower case - alphanumeric characters or '-', and must start and end with an alphanumeric - character. No other punctuation is allowed. - maxLength: 253 - minLength: 1 - pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - parentRefs: - description: |- - ParentRefs references the resources (usually Gateways) that a Route wants - to be attached to. Note that the referenced parent resource needs to - allow this for the attachment to be complete. For Gateways, that means - the Gateway needs to allow attachment from Routes of this kind and - namespace. For Services, that means the Service must either be in the same - namespace for a "producer" route, or the mesh implementation must support - and allow "consumer" routes for the referenced Service. ReferenceGrant is - not applicable for governing ParentRefs to Services - it is not possible to - create a "producer" route for a Service in a different namespace from the - Route. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - This API may be extended in the future to support additional kinds of parent - resources. - - ParentRefs must be _distinct_. This means either that: - - * They select different objects. If this is the case, then parentRef - entries are distinct. In terms of fields, this means that the - multi-part key defined by `group`, `kind`, `namespace`, and `name` must - be unique across all parentRef entries in the Route. - * They do not select different objects, but for each optional field used, - each ParentRef that selects the same object must set the same set of - optional fields to different values. If one ParentRef sets a - combination of optional fields, all must set the same combination. - - Some examples: - - * If one ParentRef sets `sectionName`, all ParentRefs referencing the - same object must also set `sectionName`. - * If one ParentRef sets `port`, all ParentRefs referencing the same - object must also set `port`. - * If one ParentRef sets `sectionName` and `port`, all ParentRefs - referencing the same object must also set `sectionName` and `port`. - - It is possible to separately reference multiple distinct objects that may - be collapsed by an implementation. For example, some implementations may - choose to merge compatible Gateway Listeners together. If that is the - case, the list of routes attached to those resources should also be - merged. - - Note that for ParentRefs that cross namespace boundaries, there are specific - rules. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example, - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable other kinds of cross-namespace reference. - items: - description: |- - ParentReference identifies an API object (usually a Gateway) that can be considered - a parent of this resource (usually a route). There are two kinds of parent resources - with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - This API may be extended in the future to support additional kinds of parent - resources. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - maxItems: 32 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: sectionName must be specified when parentRefs includes - 2 or more references to the same parent - rule: 'self.all(p1, self.all(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.__namespace__) - || p1.__namespace__ == '''') && (!has(p2.__namespace__) || p2.__namespace__ - == '''')) || (has(p1.__namespace__) && has(p2.__namespace__) && - p1.__namespace__ == p2.__namespace__ )) ? ((!has(p1.sectionName) - || p1.sectionName == '''') == (!has(p2.sectionName) || p2.sectionName - == '''')) : true))' - - message: sectionName must be unique when parentRefs includes 2 or - more references to the same parent - rule: self.all(p1, self.exists_one(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.__namespace__) - || p1.__namespace__ == '') && (!has(p2.__namespace__) || p2.__namespace__ - == '')) || (has(p1.__namespace__) && has(p2.__namespace__) && - p1.__namespace__ == p2.__namespace__ )) && (((!has(p1.sectionName) - || p1.sectionName == '') && (!has(p2.sectionName) || p2.sectionName - == '')) || (has(p1.sectionName) && has(p2.sectionName) && p1.sectionName - == p2.sectionName)))) - rules: - default: - - matches: - - path: - type: PathPrefix - value: / - description: Rules are a list of HTTP matchers, filters and actions. - items: - description: |- - HTTPRouteRule defines semantics for matching an HTTP request based on - conditions (matches), processing it (filters), and forwarding the request to - an API object (backendRefs). - properties: - backendRefs: - description: |- - BackendRefs defines the backend(s) where matching requests should be - sent. - - Failure behavior here depends on how many BackendRefs are specified and - how many are invalid. - - If *all* entries in BackendRefs are invalid, and there are also no filters - specified in this route rule, *all* traffic which matches this rule MUST - receive a 500 status code. - - See the HTTPBackendRef definition for the rules about what makes a single - HTTPBackendRef invalid. - - When a HTTPBackendRef is invalid, 500 status codes MUST be returned for - requests that would have otherwise been routed to an invalid backend. If - multiple backends are specified, and some are invalid, the proportion of - requests that would otherwise have been routed to an invalid backend - MUST receive a 500 status code. - - For example, if two backends are specified with equal weights, and one is - invalid, 50 percent of traffic must receive a 500. Implementations may - choose how that 50 percent is determined. - - When a HTTPBackendRef refers to a Service that has no ready endpoints, - implementations SHOULD return a 503 for requests to that backend instead. - If an implementation chooses to do this, all of the above rules for 500 responses - MUST also apply for responses that return a 503. - - Support: Core for Kubernetes Service - - Support: Extended for Kubernetes ServiceImport - - Support: Implementation-specific for any other resource - - Support for weight: Core - items: - description: |- - HTTPBackendRef defines how a HTTPRoute forwards a HTTP request. - - Note that when a namespace different than the local namespace is specified, a - ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - properties: - filters: - description: |- - Filters defined at this level should be executed if and only if the - request is being forwarded to the backend defined here. - - Support: Implementation-specific (For broader support of filters, use the - Filters field in HTTPRouteRule.) - items: - description: |- - HTTPRouteFilter defines processing steps that must be completed during the - request or response lifecycle. HTTPRouteFilters are meant as an extension - point to express processing that may be done in Gateway implementations. Some - examples include request or response modification, implementing - authentication strategies, rate-limiting, and traffic shaping. API - guarantee/conformance is defined based on the type of the filter. - properties: - cors: - description: |- - CORS defines a schema for a filter that responds to the - cross-origin request based on HTTP response header. - - Support: Extended - properties: - allowCredentials: - description: |- - AllowCredentials indicates whether the actual cross-origin request allows - to include credentials. - - When set to true, the gateway will include the `Access-Control-Allow-Credentials` - response header with value true (case-sensitive). - - When set to false or omitted the gateway will omit the header - `Access-Control-Allow-Credentials` entirely (this is the standard CORS - behavior). - - Support: Extended - type: boolean - allowHeaders: - description: |- - AllowHeaders indicates which HTTP request headers are supported for - accessing the requested resource. - - Header names are not case-sensitive. - - Multiple header names in the value of the `Access-Control-Allow-Headers` - response header are separated by a comma (","). - - When the `AllowHeaders` field is configured with one or more headers, the - gateway must return the `Access-Control-Allow-Headers` response header - which value is present in the `AllowHeaders` field. - - If any header name in the `Access-Control-Request-Headers` request header - is not included in the list of header names specified by the response - header `Access-Control-Allow-Headers`, it will present an error on the - client side. - - If any header name in the `Access-Control-Allow-Headers` response header - does not recognize by the client, it will also occur an error on the - client side. - - A wildcard indicates that the requests with all HTTP headers are allowed. - If config contains the wildcard "*" in allowHeaders and the request is - not credentialed, the `Access-Control-Allow-Headers` response header - can either use the `*` wildcard or the value of - Access-Control-Request-Headers from the request. - - When the request is credentialed, the gateway must not specify the `*` - wildcard in the `Access-Control-Allow-Headers` response header. When - also the `AllowCredentials` field is true and `AllowHeaders` field - is specified with the `*` wildcard, the gateway must specify one or more - HTTP headers in the value of the `Access-Control-Allow-Headers` response - header. The value of the header `Access-Control-Allow-Headers` is same as - the `Access-Control-Request-Headers` header provided by the client. If - the header `Access-Control-Request-Headers` is not included in the - request, the gateway will omit the `Access-Control-Allow-Headers` - response header, instead of specifying the `*` wildcard. - - Support: Extended - items: - description: |- - HTTPHeaderName is the name of an HTTP header. - - Valid values include: - - * "Authorization" - * "Set-Cookie" - - Invalid values include: - - - ":method" - ":" is an invalid character. This means that HTTP/2 pseudo - headers are not currently supported by this type. - - "/invalid" - "/ " is an invalid character - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - maxItems: 64 - type: array - x-kubernetes-list-type: set - x-kubernetes-validations: - - message: AllowHeaders cannot contain '*' alongside - other methods - rule: '!(''*'' in self && self.size() > 1)' - allowMethods: - description: |- - AllowMethods indicates which HTTP methods are supported for accessing the - requested resource. - - Valid values are any method defined by RFC9110, along with the special - value `*`, which represents all HTTP methods are allowed. - - Method names are case-sensitive, so these values are also case-sensitive. - (See https://www.rfc-editor.org/rfc/rfc2616#section-5.1.1) - - Multiple method names in the value of the `Access-Control-Allow-Methods` - response header are separated by a comma (","). - - A CORS-safelisted method is a method that is `GET`, `HEAD`, or `POST`. - (See https://fetch.spec.whatwg.org/#cors-safelisted-method) The - CORS-safelisted methods are always allowed, regardless of whether they - are specified in the `AllowMethods` field. - - When the `AllowMethods` field is configured with one or more methods, the - gateway must return the `Access-Control-Allow-Methods` response header - which value is present in the `AllowMethods` field. - - If the HTTP method of the `Access-Control-Request-Method` request header - is not included in the list of methods specified by the response header - `Access-Control-Allow-Methods`, it will present an error on the client - side. - - If config contains the wildcard "*" in allowMethods and the request is - not credentialed, the `Access-Control-Allow-Methods` response header - can either use the `*` wildcard or the value of - Access-Control-Request-Method from the request. - - When the request is credentialed, the gateway must not specify the `*` - wildcard in the `Access-Control-Allow-Methods` response header. When - also the `AllowCredentials` field is true and `AllowMethods` field - specified with the `*` wildcard, the gateway must specify one HTTP method - in the value of the Access-Control-Allow-Methods response header. The - value of the header `Access-Control-Allow-Methods` is same as the - `Access-Control-Request-Method` header provided by the client. If the - header `Access-Control-Request-Method` is not included in the request, - the gateway will omit the `Access-Control-Allow-Methods` response header, - instead of specifying the `*` wildcard. - - Support: Extended - items: - enum: - - GET - - HEAD - - POST - - PUT - - DELETE - - CONNECT - - OPTIONS - - TRACE - - PATCH - - '*' - type: string - maxItems: 9 - type: array - x-kubernetes-list-type: set - x-kubernetes-validations: - - message: AllowMethods cannot contain '*' alongside - other methods - rule: '!(''*'' in self && self.size() > 1)' - allowOrigins: - description: |- - AllowOrigins indicates whether the response can be shared with requested - resource from the given `Origin`. - - The `Origin` consists of a scheme and a host, with an optional port, and - takes the form `://(:)`. - - Valid values for scheme are: `http` and `https`. - - Valid values for port are any integer between 1 and 65535 (the list of - available TCP/UDP ports). Note that, if not included, port `80` is - assumed for `http` scheme origins, and port `443` is assumed for `https` - origins. This may affect origin matching. - - The host part of the origin may contain the wildcard character `*`. These - wildcard characters behave as follows: - - * `*` is a greedy match to the _left_, including any number of - DNS labels to the left of its position. This also means that - `*` will include any number of period `.` characters to the - left of its position. - * A wildcard by itself matches all hosts. - - An origin value that includes _only_ the `*` character indicates requests - from all `Origin`s are allowed. - - When the `AllowOrigins` field is configured with multiple origins, it - means the server supports clients from multiple origins. If the request - `Origin` matches the configured allowed origins, the gateway must return - the given `Origin` and sets value of the header - `Access-Control-Allow-Origin` same as the `Origin` header provided by the - client. - - The status code of a successful response to a "preflight" request is - always an OK status (i.e., 204 or 200). - - If the request `Origin` does not match the configured allowed origins, - the gateway returns 204/200 response but doesn't set the relevant - cross-origin response headers. Alternatively, the gateway responds with - 403 status to the "preflight" request is denied, coupled with omitting - the CORS headers. The cross-origin request fails on the client side. - Therefore, the client doesn't attempt the actual cross-origin request. - - Conversely, if the request `Origin` matches one of the configured - allowed origins, the gateway sets the response header - `Access-Control-Allow-Origin` to the same value as the `Origin` - header provided by the client. - - When config has the wildcard ("*") in allowOrigins, and the request - is not credentialed (e.g., it is a preflight request), the - `Access-Control-Allow-Origin` response header either contains the - wildcard as well or the Origin from the request. - - When the request is credentialed, the gateway must not specify the `*` - wildcard in the `Access-Control-Allow-Origin` response header. When - also the `AllowCredentials` field is true and `AllowOrigins` field - specified with the `*` wildcard, the gateway must return a single origin - in the value of the `Access-Control-Allow-Origin` response header, - instead of specifying the `*` wildcard. The value of the header - `Access-Control-Allow-Origin` is same as the `Origin` header provided by - the client. - - Support: Extended - items: - description: |- - The CORSOrigin MUST NOT be a relative URI, and it MUST follow the URI syntax and - encoding rules specified in RFC3986. The CORSOrigin MUST include both a - scheme ("http" or "https") and a scheme-specific-part, or it should be a single '*' character. - URIs that include an authority MUST include a fully qualified domain name or - IP address as the host. - maxLength: 253 - minLength: 1 - pattern: (^\*$)|(^(http(s)?):\/\/(((\*\.)?([a-zA-Z0-9\-]+\.)*[a-zA-Z0-9-]+|\*)(:([0-9]{1,5}))?)$) - type: string - maxItems: 64 - type: array - x-kubernetes-list-type: set - x-kubernetes-validations: - - message: AllowOrigins cannot contain '*' alongside - other origins - rule: '!(''*'' in self && self.size() > 1)' - exposeHeaders: - description: |- - ExposeHeaders indicates which HTTP response headers can be exposed - to client-side scripts in response to a cross-origin request. - - A CORS-safelisted response header is an HTTP header in a CORS response - that it is considered safe to expose to the client scripts. - The CORS-safelisted response headers include the following headers: - `Cache-Control` - `Content-Language` - `Content-Length` - `Content-Type` - `Expires` - `Last-Modified` - `Pragma` - (See https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name) - The CORS-safelisted response headers are exposed to client by default. - - When an HTTP header name is specified using the `ExposeHeaders` field, - this additional header will be exposed as part of the response to the - client. - - Header names are not case-sensitive. - - Multiple header names in the value of the `Access-Control-Expose-Headers` - response header are separated by a comma (","). - - A wildcard indicates that the responses with all HTTP headers are exposed - to clients. The `Access-Control-Expose-Headers` response header can only - use `*` wildcard as value when the request is not credentialed. - - When the `exposeHeaders` config field contains the "*" wildcard and - the request is credentialed, the gateway cannot use the `*` wildcard in - the `Access-Control-Expose-Headers` response header. - - Support: Extended - items: - description: |- - HTTPHeaderName is the name of an HTTP header. - - Valid values include: - - * "Authorization" - * "Set-Cookie" - - Invalid values include: - - - ":method" - ":" is an invalid character. This means that HTTP/2 pseudo - headers are not currently supported by this type. - - "/invalid" - "/ " is an invalid character - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - maxItems: 64 - type: array - x-kubernetes-list-type: set - maxAge: - default: 5 - description: |- - MaxAge indicates the duration (in seconds) for the client to cache the - results of a "preflight" request. - - The information provided by the `Access-Control-Allow-Methods` and - `Access-Control-Allow-Headers` response headers can be cached by the - client until the time specified by `Access-Control-Max-Age` elapses. - - The default value of `Access-Control-Max-Age` response header is 5 - (seconds). - - When the `MaxAge` field is unspecified, the gateway sets the response - header "Access-Control-Max-Age: 5" by default. - format: int32 - minimum: 1 - type: integer - type: object - extensionRef: - description: |- - ExtensionRef is an optional, implementation-specific extension to the - "filter" behavior. For example, resource "myroutefilter" in group - "networking.example.net"). ExtensionRef MUST NOT be used for core and - extended filters. - - This filter can be used multiple times within the same rule. - - Support: Implementation-specific - properties: - group: - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. For - example "HTTPRoute" or "Service". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - required: - - group - - kind - - name - type: object - requestHeaderModifier: - description: |- - RequestHeaderModifier defines a schema for a filter that modifies request - headers. - - Support: Core - properties: - add: - description: |- - Add adds the given header(s) (name, value) to the request - before the action. It appends to any existing values associated - with the header name. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - add: - - name: "my-header" - value: "bar,baz" - - Output: - GET /foo HTTP/1.1 - my-header: foo,bar,baz - items: - description: HTTPHeader represents an HTTP - Header name and value as defined by RFC - 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP - Header to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - remove: - description: |- - Remove the given header(s) from the HTTP request before the action. The - value of Remove is a list of HTTP header names. Note that the header - names are case-insensitive (see - https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). - - Input: - GET /foo HTTP/1.1 - my-header1: foo - my-header2: bar - my-header3: baz - - Config: - remove: ["my-header1", "my-header3"] - - Output: - GET /foo HTTP/1.1 - my-header2: bar - items: - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: set - set: - description: |- - Set overwrites the request with the given header (name, value) - before the action. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - set: - - name: "my-header" - value: "bar" - - Output: - GET /foo HTTP/1.1 - my-header: bar - items: - description: HTTPHeader represents an HTTP - Header name and value as defined by RFC - 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP - Header to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - requestMirror: - description: |- - RequestMirror defines a schema for a filter that mirrors requests. - Requests are sent to the specified destination, but responses from - that destination are ignored. - - This filter can be used multiple times within the same rule. Note that - not all implementations will be able to support mirroring to multiple - backends. - - Support: Extended - properties: - backendRef: - description: |- - BackendRef references a resource where mirrored requests are sent. - - Mirrored requests must be sent only to a single destination endpoint - within this BackendRef, irrespective of how many endpoints are present - within this BackendRef. - - If the referent cannot be found, this BackendRef is invalid and must be - dropped from the Gateway. The controller must ensure the "ResolvedRefs" - condition on the Route status is set to `status: False` and not configure - this backend in the underlying implementation. - - If there is a cross-namespace reference to an *existing* object - that is not allowed by a ReferenceGrant, the controller must ensure the - "ResolvedRefs" condition on the Route is set to `status: False`, - with the "RefNotPermitted" reason and not configure this backend in the - underlying implementation. - - In either error case, the Message of the `ResolvedRefs` Condition - should be used to provide more detail about the problem. - - Support: Extended for Kubernetes Service - - Support: Implementation-specific for any other resource - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations SHOULD NOT - support ExternalName Services. - - Support: Core (Services with a type other than ExternalName) - - Support: Implementation-specific (Services with type ExternalName) - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the backend. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port specifies the destination port number to use for this resource. - Port is required when the referent is a Kubernetes Service. In this - case, the port number is the service port number, not the target port. - For other resources, destination port might be derived from the referent - resource or this field. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - required: - - name - type: object - x-kubernetes-validations: - - message: Must have port for Service reference - rule: '(size(self.group) == 0 && self.kind - == ''Service'') ? has(self.port) : true' - fraction: - description: |- - Fraction represents the fraction of requests that should be - mirrored to BackendRef. - - Only one of Fraction or Percent may be specified. If neither field - is specified, 100% of requests will be mirrored. - properties: - denominator: - default: 100 - format: int32 - minimum: 1 - type: integer - numerator: - format: int32 - minimum: 0 - type: integer - required: - - numerator - type: object - x-kubernetes-validations: - - message: numerator must be less than or equal - to denominator - rule: self.numerator <= self.denominator - percent: - description: |- - Percent represents the percentage of requests that should be - mirrored to BackendRef. Its minimum value is 0 (indicating 0% of - requests) and its maximum value is 100 (indicating 100% of requests). - - Only one of Fraction or Percent may be specified. If neither field - is specified, 100% of requests will be mirrored. - format: int32 - maximum: 100 - minimum: 0 - type: integer - required: - - backendRef - type: object - x-kubernetes-validations: - - message: Only one of percent or fraction may be - specified in HTTPRequestMirrorFilter - rule: '!(has(self.percent) && has(self.fraction))' - requestRedirect: - description: |- - RequestRedirect defines a schema for a filter that responds to the - request with an HTTP redirection. - - Support: Core - properties: - hostname: - description: |- - Hostname is the hostname to be used in the value of the `Location` - header in the response. - When empty, the hostname in the `Host` header of the request is used. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - path: - description: |- - Path defines parameters used to modify the path of the incoming request. - The modified path is then used to construct the `Location` header. When - empty, the request path is used as-is. - - Support: Extended - properties: - replaceFullPath: - description: |- - ReplaceFullPath specifies the value with which to replace the full path - of a request during a rewrite or redirect. - maxLength: 1024 - type: string - replacePrefixMatch: - description: |- - ReplacePrefixMatch specifies the value with which to replace the prefix - match of a request during a rewrite or redirect. For example, a request - to "/foo/bar" with a prefix match of "/foo" and a ReplacePrefixMatch - of "/xyz" would be modified to "/xyz/bar". - - Note that this matches the behavior of the PathPrefix match type. This - matches full path elements. A path element refers to the list of labels - in the path split by the `/` separator. When specified, a trailing `/` is - ignored. For example, the paths `/abc`, `/abc/`, and `/abc/def` would all - match the prefix `/abc`, but the path `/abcd` would not. - - ReplacePrefixMatch is only compatible with a `PathPrefix` HTTPRouteMatch. - Using any other HTTPRouteMatch type on the same HTTPRouteRule will result in - the implementation setting the Accepted Condition for the Route to `status: False`. - - Request Path | Prefix Match | Replace Prefix | Modified Path - maxLength: 1024 - type: string - type: - description: |- - Type defines the type of path modifier. Additional types may be - added in a future release of the API. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - enum: - - ReplaceFullPath - - ReplacePrefixMatch - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: replaceFullPath must be specified - when type is set to 'ReplaceFullPath' - rule: 'self.type == ''ReplaceFullPath'' ? - has(self.replaceFullPath) : true' - - message: type must be 'ReplaceFullPath' when - replaceFullPath is set - rule: 'has(self.replaceFullPath) ? self.type - == ''ReplaceFullPath'' : true' - - message: replacePrefixMatch must be specified - when type is set to 'ReplacePrefixMatch' - rule: 'self.type == ''ReplacePrefixMatch'' - ? has(self.replacePrefixMatch) : true' - - message: type must be 'ReplacePrefixMatch' - when replacePrefixMatch is set - rule: 'has(self.replacePrefixMatch) ? self.type - == ''ReplacePrefixMatch'' : true' - port: - description: |- - Port is the port to be used in the value of the `Location` - header in the response. - - If no port is specified, the redirect port MUST be derived using the - following rules: - - * If redirect scheme is not-empty, the redirect port MUST be the well-known - port associated with the redirect scheme. Specifically "http" to port 80 - and "https" to port 443. If the redirect scheme does not have a - well-known port, the listener port of the Gateway SHOULD be used. - * If redirect scheme is empty, the redirect port MUST be the Gateway - Listener port. - - Implementations SHOULD NOT add the port number in the 'Location' - header in the following cases: - - * A Location header that will use HTTP (whether that is determined via - the Listener protocol or the Scheme field) _and_ use port 80. - * A Location header that will use HTTPS (whether that is determined via - the Listener protocol or the Scheme field) _and_ use port 443. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - scheme: - description: |- - Scheme is the scheme to be used in the value of the `Location` header in - the response. When empty, the scheme of the request is used. - - Scheme redirects can affect the port of the redirect, for more information, - refer to the documentation for the port field of this filter. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - - Support: Extended - enum: - - http - - https - type: string - statusCode: - default: 302 - description: |- - StatusCode is the HTTP status code to be used in response. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - - Support: Core - enum: - - 301 - - 302 - - 303 - - 307 - - 308 - type: integer - type: object - responseHeaderModifier: - description: |- - ResponseHeaderModifier defines a schema for a filter that modifies response - headers. - - Support: Extended - properties: - add: - description: |- - Add adds the given header(s) (name, value) to the request - before the action. It appends to any existing values associated - with the header name. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - add: - - name: "my-header" - value: "bar,baz" - - Output: - GET /foo HTTP/1.1 - my-header: foo,bar,baz - items: - description: HTTPHeader represents an HTTP - Header name and value as defined by RFC - 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP - Header to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - remove: - description: |- - Remove the given header(s) from the HTTP request before the action. The - value of Remove is a list of HTTP header names. Note that the header - names are case-insensitive (see - https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). - - Input: - GET /foo HTTP/1.1 - my-header1: foo - my-header2: bar - my-header3: baz - - Config: - remove: ["my-header1", "my-header3"] - - Output: - GET /foo HTTP/1.1 - my-header2: bar - items: - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: set - set: - description: |- - Set overwrites the request with the given header (name, value) - before the action. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - set: - - name: "my-header" - value: "bar" - - Output: - GET /foo HTTP/1.1 - my-header: bar - items: - description: HTTPHeader represents an HTTP - Header name and value as defined by RFC - 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP - Header to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - type: - description: |- - Type identifies the type of filter to apply. As with other API fields, - types are classified into three conformance levels: - - - Core: Filter types and their corresponding configuration defined by - "Support: Core" in this package, e.g. "RequestHeaderModifier". All - implementations must support core filters. - - - Extended: Filter types and their corresponding configuration defined by - "Support: Extended" in this package, e.g. "RequestMirror". Implementers - are encouraged to support extended filters. - - - Implementation-specific: Filters that are defined and supported by - specific vendors. - In the future, filters showing convergence in behavior across multiple - implementations will be considered for inclusion in extended or core - conformance levels. Filter-specific configuration for such filters - is specified using the ExtensionRef field. `Type` should be set to - "ExtensionRef" for custom filters. - - Implementers are encouraged to define custom implementation types to - extend the core API with implementation-specific behavior. - - If a reference to a custom filter type cannot be resolved, the filter - MUST NOT be skipped. Instead, requests that would have been processed by - that filter MUST receive a HTTP error response. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - enum: - - RequestHeaderModifier - - ResponseHeaderModifier - - RequestMirror - - RequestRedirect - - URLRewrite - - ExtensionRef - - CORS - type: string - urlRewrite: - description: |- - URLRewrite defines a schema for a filter that modifies a request during forwarding. - - Support: Extended - properties: - hostname: - description: |- - Hostname is the value to be used to replace the Host header value during - forwarding. - - Support: Extended - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - path: - description: |- - Path defines a path rewrite. - - Support: Extended - properties: - replaceFullPath: - description: |- - ReplaceFullPath specifies the value with which to replace the full path - of a request during a rewrite or redirect. - maxLength: 1024 - type: string - replacePrefixMatch: - description: |- - ReplacePrefixMatch specifies the value with which to replace the prefix - match of a request during a rewrite or redirect. For example, a request - to "/foo/bar" with a prefix match of "/foo" and a ReplacePrefixMatch - of "/xyz" would be modified to "/xyz/bar". - - Note that this matches the behavior of the PathPrefix match type. This - matches full path elements. A path element refers to the list of labels - in the path split by the `/` separator. When specified, a trailing `/` is - ignored. For example, the paths `/abc`, `/abc/`, and `/abc/def` would all - match the prefix `/abc`, but the path `/abcd` would not. - - ReplacePrefixMatch is only compatible with a `PathPrefix` HTTPRouteMatch. - Using any other HTTPRouteMatch type on the same HTTPRouteRule will result in - the implementation setting the Accepted Condition for the Route to `status: False`. - - Request Path | Prefix Match | Replace Prefix | Modified Path - maxLength: 1024 - type: string - type: - description: |- - Type defines the type of path modifier. Additional types may be - added in a future release of the API. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - enum: - - ReplaceFullPath - - ReplacePrefixMatch - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: replaceFullPath must be specified - when type is set to 'ReplaceFullPath' - rule: 'self.type == ''ReplaceFullPath'' ? - has(self.replaceFullPath) : true' - - message: type must be 'ReplaceFullPath' when - replaceFullPath is set - rule: 'has(self.replaceFullPath) ? self.type - == ''ReplaceFullPath'' : true' - - message: replacePrefixMatch must be specified - when type is set to 'ReplacePrefixMatch' - rule: 'self.type == ''ReplacePrefixMatch'' - ? has(self.replacePrefixMatch) : true' - - message: type must be 'ReplacePrefixMatch' - when replacePrefixMatch is set - rule: 'has(self.replacePrefixMatch) ? self.type - == ''ReplacePrefixMatch'' : true' - type: object - required: - - type - type: object - x-kubernetes-validations: - - message: filter.cors must be nil if the filter.type - is not CORS - rule: '!(has(self.cors) && self.type != ''CORS'')' - - message: filter.cors must be specified for CORS filter.type - rule: '!(!has(self.cors) && self.type == ''CORS'')' - - message: filter.requestHeaderModifier must be nil - if the filter.type is not RequestHeaderModifier - rule: '!(has(self.requestHeaderModifier) && self.type - != ''RequestHeaderModifier'')' - - message: filter.requestHeaderModifier must be specified - for RequestHeaderModifier filter.type - rule: '!(!has(self.requestHeaderModifier) && self.type - == ''RequestHeaderModifier'')' - - message: filter.responseHeaderModifier must be nil - if the filter.type is not ResponseHeaderModifier - rule: '!(has(self.responseHeaderModifier) && self.type - != ''ResponseHeaderModifier'')' - - message: filter.responseHeaderModifier must be specified - for ResponseHeaderModifier filter.type - rule: '!(!has(self.responseHeaderModifier) && self.type - == ''ResponseHeaderModifier'')' - - message: filter.requestMirror must be nil if the filter.type - is not RequestMirror - rule: '!(has(self.requestMirror) && self.type != ''RequestMirror'')' - - message: filter.requestMirror must be specified for - RequestMirror filter.type - rule: '!(!has(self.requestMirror) && self.type == - ''RequestMirror'')' - - message: filter.requestRedirect must be nil if the - filter.type is not RequestRedirect - rule: '!(has(self.requestRedirect) && self.type != - ''RequestRedirect'')' - - message: filter.requestRedirect must be specified - for RequestRedirect filter.type - rule: '!(!has(self.requestRedirect) && self.type == - ''RequestRedirect'')' - - message: filter.urlRewrite must be nil if the filter.type - is not URLRewrite - rule: '!(has(self.urlRewrite) && self.type != ''URLRewrite'')' - - message: filter.urlRewrite must be specified for URLRewrite - filter.type - rule: '!(!has(self.urlRewrite) && self.type == ''URLRewrite'')' - - message: filter.extensionRef must be nil if the filter.type - is not ExtensionRef - rule: '!(has(self.extensionRef) && self.type != ''ExtensionRef'')' - - message: filter.extensionRef must be specified for - ExtensionRef filter.type - rule: '!(!has(self.extensionRef) && self.type == ''ExtensionRef'')' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: May specify either httpRouteFilterRequestRedirect - or httpRouteFilterRequestRewrite, but not both - rule: '!(self.exists(f, f.type == ''RequestRedirect'') - && self.exists(f, f.type == ''URLRewrite''))' - - message: CORS filter cannot be repeated - rule: self.filter(f, f.type == 'CORS').size() <= 1 - - message: RequestHeaderModifier filter cannot be repeated - rule: self.filter(f, f.type == 'RequestHeaderModifier').size() - <= 1 - - message: ResponseHeaderModifier filter cannot be repeated - rule: self.filter(f, f.type == 'ResponseHeaderModifier').size() - <= 1 - - message: RequestRedirect filter cannot be repeated - rule: self.filter(f, f.type == 'RequestRedirect').size() - <= 1 - - message: URLRewrite filter cannot be repeated - rule: self.filter(f, f.type == 'URLRewrite').size() - <= 1 - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations SHOULD NOT - support ExternalName Services. - - Support: Core (Services with a type other than ExternalName) - - Support: Implementation-specific (Services with type ExternalName) - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the backend. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port specifies the destination port number to use for this resource. - Port is required when the referent is a Kubernetes Service. In this - case, the port number is the service port number, not the target port. - For other resources, destination port might be derived from the referent - resource or this field. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - weight: - default: 1 - description: |- - Weight specifies the proportion of requests forwarded to the referenced - backend. This is computed as weight/(sum of all weights in this - BackendRefs list). For non-zero values, there may be some epsilon from - the exact proportion defined here depending on the precision an - implementation supports. Weight is not a percentage and the sum of - weights does not need to equal 100. - - If only one backend is specified and it has a weight greater than 0, 100% - of the traffic is forwarded to that backend. If weight is set to 0, no - traffic should be forwarded for this entry. If unspecified, weight - defaults to 1. - - Support for this field varies based on the context where used. - format: int32 - maximum: 1000000 - minimum: 0 - type: integer - required: - - name - type: object - x-kubernetes-validations: - - message: Must have port for Service reference - rule: '(size(self.group) == 0 && self.kind == ''Service'') - ? has(self.port) : true' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - filters: - description: |- - Filters define the filters that are applied to requests that match - this rule. - - Wherever possible, implementations SHOULD implement filters in the order - they are specified. - - Implementations MAY choose to implement this ordering strictly, rejecting - any combination or order of filters that cannot be supported. If implementations - choose a strict interpretation of filter ordering, they MUST clearly document - that behavior. - - To reject an invalid combination or order of filters, implementations SHOULD - consider the Route Rules with this configuration invalid. If all Route Rules - in a Route are invalid, the entire Route would be considered invalid. If only - a portion of Route Rules are invalid, implementations MUST set the - "PartiallyInvalid" condition for the Route. - - Conformance-levels at this level are defined based on the type of filter: - - - ALL core filters MUST be supported by all implementations. - - Implementers are encouraged to support extended filters. - - Implementation-specific custom filters have no API guarantees across - implementations. - - Specifying the same filter multiple times is not supported unless explicitly - indicated in the filter. - - All filters are expected to be compatible with each other except for the - URLRewrite and RequestRedirect filters, which may not be combined. If an - implementation cannot support other combinations of filters, they must clearly - document that limitation. In cases where incompatible or unsupported - filters are specified and cause the `Accepted` condition to be set to status - `False`, implementations may use the `IncompatibleFilters` reason to specify - this configuration error. - - Support: Core - items: - description: |- - HTTPRouteFilter defines processing steps that must be completed during the - request or response lifecycle. HTTPRouteFilters are meant as an extension - point to express processing that may be done in Gateway implementations. Some - examples include request or response modification, implementing - authentication strategies, rate-limiting, and traffic shaping. API - guarantee/conformance is defined based on the type of the filter. - properties: - cors: - description: |- - CORS defines a schema for a filter that responds to the - cross-origin request based on HTTP response header. - - Support: Extended - properties: - allowCredentials: - description: |- - AllowCredentials indicates whether the actual cross-origin request allows - to include credentials. - - When set to true, the gateway will include the `Access-Control-Allow-Credentials` - response header with value true (case-sensitive). - - When set to false or omitted the gateway will omit the header - `Access-Control-Allow-Credentials` entirely (this is the standard CORS - behavior). - - Support: Extended - type: boolean - allowHeaders: - description: |- - AllowHeaders indicates which HTTP request headers are supported for - accessing the requested resource. - - Header names are not case-sensitive. - - Multiple header names in the value of the `Access-Control-Allow-Headers` - response header are separated by a comma (","). - - When the `AllowHeaders` field is configured with one or more headers, the - gateway must return the `Access-Control-Allow-Headers` response header - which value is present in the `AllowHeaders` field. - - If any header name in the `Access-Control-Request-Headers` request header - is not included in the list of header names specified by the response - header `Access-Control-Allow-Headers`, it will present an error on the - client side. - - If any header name in the `Access-Control-Allow-Headers` response header - does not recognize by the client, it will also occur an error on the - client side. - - A wildcard indicates that the requests with all HTTP headers are allowed. - If config contains the wildcard "*" in allowHeaders and the request is - not credentialed, the `Access-Control-Allow-Headers` response header - can either use the `*` wildcard or the value of - Access-Control-Request-Headers from the request. - - When the request is credentialed, the gateway must not specify the `*` - wildcard in the `Access-Control-Allow-Headers` response header. When - also the `AllowCredentials` field is true and `AllowHeaders` field - is specified with the `*` wildcard, the gateway must specify one or more - HTTP headers in the value of the `Access-Control-Allow-Headers` response - header. The value of the header `Access-Control-Allow-Headers` is same as - the `Access-Control-Request-Headers` header provided by the client. If - the header `Access-Control-Request-Headers` is not included in the - request, the gateway will omit the `Access-Control-Allow-Headers` - response header, instead of specifying the `*` wildcard. - - Support: Extended - items: - description: |- - HTTPHeaderName is the name of an HTTP header. - - Valid values include: - - * "Authorization" - * "Set-Cookie" - - Invalid values include: - - - ":method" - ":" is an invalid character. This means that HTTP/2 pseudo - headers are not currently supported by this type. - - "/invalid" - "/ " is an invalid character - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - maxItems: 64 - type: array - x-kubernetes-list-type: set - x-kubernetes-validations: - - message: AllowHeaders cannot contain '*' alongside - other methods - rule: '!(''*'' in self && self.size() > 1)' - allowMethods: - description: |- - AllowMethods indicates which HTTP methods are supported for accessing the - requested resource. - - Valid values are any method defined by RFC9110, along with the special - value `*`, which represents all HTTP methods are allowed. - - Method names are case-sensitive, so these values are also case-sensitive. - (See https://www.rfc-editor.org/rfc/rfc2616#section-5.1.1) - - Multiple method names in the value of the `Access-Control-Allow-Methods` - response header are separated by a comma (","). - - A CORS-safelisted method is a method that is `GET`, `HEAD`, or `POST`. - (See https://fetch.spec.whatwg.org/#cors-safelisted-method) The - CORS-safelisted methods are always allowed, regardless of whether they - are specified in the `AllowMethods` field. - - When the `AllowMethods` field is configured with one or more methods, the - gateway must return the `Access-Control-Allow-Methods` response header - which value is present in the `AllowMethods` field. - - If the HTTP method of the `Access-Control-Request-Method` request header - is not included in the list of methods specified by the response header - `Access-Control-Allow-Methods`, it will present an error on the client - side. - - If config contains the wildcard "*" in allowMethods and the request is - not credentialed, the `Access-Control-Allow-Methods` response header - can either use the `*` wildcard or the value of - Access-Control-Request-Method from the request. - - When the request is credentialed, the gateway must not specify the `*` - wildcard in the `Access-Control-Allow-Methods` response header. When - also the `AllowCredentials` field is true and `AllowMethods` field - specified with the `*` wildcard, the gateway must specify one HTTP method - in the value of the Access-Control-Allow-Methods response header. The - value of the header `Access-Control-Allow-Methods` is same as the - `Access-Control-Request-Method` header provided by the client. If the - header `Access-Control-Request-Method` is not included in the request, - the gateway will omit the `Access-Control-Allow-Methods` response header, - instead of specifying the `*` wildcard. - - Support: Extended - items: - enum: - - GET - - HEAD - - POST - - PUT - - DELETE - - CONNECT - - OPTIONS - - TRACE - - PATCH - - '*' - type: string - maxItems: 9 - type: array - x-kubernetes-list-type: set - x-kubernetes-validations: - - message: AllowMethods cannot contain '*' alongside - other methods - rule: '!(''*'' in self && self.size() > 1)' - allowOrigins: - description: |- - AllowOrigins indicates whether the response can be shared with requested - resource from the given `Origin`. - - The `Origin` consists of a scheme and a host, with an optional port, and - takes the form `://(:)`. - - Valid values for scheme are: `http` and `https`. - - Valid values for port are any integer between 1 and 65535 (the list of - available TCP/UDP ports). Note that, if not included, port `80` is - assumed for `http` scheme origins, and port `443` is assumed for `https` - origins. This may affect origin matching. - - The host part of the origin may contain the wildcard character `*`. These - wildcard characters behave as follows: - - * `*` is a greedy match to the _left_, including any number of - DNS labels to the left of its position. This also means that - `*` will include any number of period `.` characters to the - left of its position. - * A wildcard by itself matches all hosts. - - An origin value that includes _only_ the `*` character indicates requests - from all `Origin`s are allowed. - - When the `AllowOrigins` field is configured with multiple origins, it - means the server supports clients from multiple origins. If the request - `Origin` matches the configured allowed origins, the gateway must return - the given `Origin` and sets value of the header - `Access-Control-Allow-Origin` same as the `Origin` header provided by the - client. - - The status code of a successful response to a "preflight" request is - always an OK status (i.e., 204 or 200). - - If the request `Origin` does not match the configured allowed origins, - the gateway returns 204/200 response but doesn't set the relevant - cross-origin response headers. Alternatively, the gateway responds with - 403 status to the "preflight" request is denied, coupled with omitting - the CORS headers. The cross-origin request fails on the client side. - Therefore, the client doesn't attempt the actual cross-origin request. - - Conversely, if the request `Origin` matches one of the configured - allowed origins, the gateway sets the response header - `Access-Control-Allow-Origin` to the same value as the `Origin` - header provided by the client. - - When config has the wildcard ("*") in allowOrigins, and the request - is not credentialed (e.g., it is a preflight request), the - `Access-Control-Allow-Origin` response header either contains the - wildcard as well or the Origin from the request. - - When the request is credentialed, the gateway must not specify the `*` - wildcard in the `Access-Control-Allow-Origin` response header. When - also the `AllowCredentials` field is true and `AllowOrigins` field - specified with the `*` wildcard, the gateway must return a single origin - in the value of the `Access-Control-Allow-Origin` response header, - instead of specifying the `*` wildcard. The value of the header - `Access-Control-Allow-Origin` is same as the `Origin` header provided by - the client. - - Support: Extended - items: - description: |- - The CORSOrigin MUST NOT be a relative URI, and it MUST follow the URI syntax and - encoding rules specified in RFC3986. The CORSOrigin MUST include both a - scheme ("http" or "https") and a scheme-specific-part, or it should be a single '*' character. - URIs that include an authority MUST include a fully qualified domain name or - IP address as the host. - maxLength: 253 - minLength: 1 - pattern: (^\*$)|(^(http(s)?):\/\/(((\*\.)?([a-zA-Z0-9\-]+\.)*[a-zA-Z0-9-]+|\*)(:([0-9]{1,5}))?)$) - type: string - maxItems: 64 - type: array - x-kubernetes-list-type: set - x-kubernetes-validations: - - message: AllowOrigins cannot contain '*' alongside - other origins - rule: '!(''*'' in self && self.size() > 1)' - exposeHeaders: - description: |- - ExposeHeaders indicates which HTTP response headers can be exposed - to client-side scripts in response to a cross-origin request. - - A CORS-safelisted response header is an HTTP header in a CORS response - that it is considered safe to expose to the client scripts. - The CORS-safelisted response headers include the following headers: - `Cache-Control` - `Content-Language` - `Content-Length` - `Content-Type` - `Expires` - `Last-Modified` - `Pragma` - (See https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name) - The CORS-safelisted response headers are exposed to client by default. - - When an HTTP header name is specified using the `ExposeHeaders` field, - this additional header will be exposed as part of the response to the - client. - - Header names are not case-sensitive. - - Multiple header names in the value of the `Access-Control-Expose-Headers` - response header are separated by a comma (","). - - A wildcard indicates that the responses with all HTTP headers are exposed - to clients. The `Access-Control-Expose-Headers` response header can only - use `*` wildcard as value when the request is not credentialed. - - When the `exposeHeaders` config field contains the "*" wildcard and - the request is credentialed, the gateway cannot use the `*` wildcard in - the `Access-Control-Expose-Headers` response header. - - Support: Extended - items: - description: |- - HTTPHeaderName is the name of an HTTP header. - - Valid values include: - - * "Authorization" - * "Set-Cookie" - - Invalid values include: - - - ":method" - ":" is an invalid character. This means that HTTP/2 pseudo - headers are not currently supported by this type. - - "/invalid" - "/ " is an invalid character - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - maxItems: 64 - type: array - x-kubernetes-list-type: set - maxAge: - default: 5 - description: |- - MaxAge indicates the duration (in seconds) for the client to cache the - results of a "preflight" request. - - The information provided by the `Access-Control-Allow-Methods` and - `Access-Control-Allow-Headers` response headers can be cached by the - client until the time specified by `Access-Control-Max-Age` elapses. - - The default value of `Access-Control-Max-Age` response header is 5 - (seconds). - - When the `MaxAge` field is unspecified, the gateway sets the response - header "Access-Control-Max-Age: 5" by default. - format: int32 - minimum: 1 - type: integer - type: object - extensionRef: - description: |- - ExtensionRef is an optional, implementation-specific extension to the - "filter" behavior. For example, resource "myroutefilter" in group - "networking.example.net"). ExtensionRef MUST NOT be used for core and - extended filters. - - This filter can be used multiple times within the same rule. - - Support: Implementation-specific - properties: - group: - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. For example - "HTTPRoute" or "Service". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - required: - - group - - kind - - name - type: object - requestHeaderModifier: - description: |- - RequestHeaderModifier defines a schema for a filter that modifies request - headers. - - Support: Core - properties: - add: - description: |- - Add adds the given header(s) (name, value) to the request - before the action. It appends to any existing values associated - with the header name. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - add: - - name: "my-header" - value: "bar,baz" - - Output: - GET /foo HTTP/1.1 - my-header: foo,bar,baz - items: - description: HTTPHeader represents an HTTP Header - name and value as defined by RFC 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - remove: - description: |- - Remove the given header(s) from the HTTP request before the action. The - value of Remove is a list of HTTP header names. Note that the header - names are case-insensitive (see - https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). - - Input: - GET /foo HTTP/1.1 - my-header1: foo - my-header2: bar - my-header3: baz - - Config: - remove: ["my-header1", "my-header3"] - - Output: - GET /foo HTTP/1.1 - my-header2: bar - items: - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: set - set: - description: |- - Set overwrites the request with the given header (name, value) - before the action. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - set: - - name: "my-header" - value: "bar" - - Output: - GET /foo HTTP/1.1 - my-header: bar - items: - description: HTTPHeader represents an HTTP Header - name and value as defined by RFC 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - requestMirror: - description: |- - RequestMirror defines a schema for a filter that mirrors requests. - Requests are sent to the specified destination, but responses from - that destination are ignored. - - This filter can be used multiple times within the same rule. Note that - not all implementations will be able to support mirroring to multiple - backends. - - Support: Extended - properties: - backendRef: - description: |- - BackendRef references a resource where mirrored requests are sent. - - Mirrored requests must be sent only to a single destination endpoint - within this BackendRef, irrespective of how many endpoints are present - within this BackendRef. - - If the referent cannot be found, this BackendRef is invalid and must be - dropped from the Gateway. The controller must ensure the "ResolvedRefs" - condition on the Route status is set to `status: False` and not configure - this backend in the underlying implementation. - - If there is a cross-namespace reference to an *existing* object - that is not allowed by a ReferenceGrant, the controller must ensure the - "ResolvedRefs" condition on the Route is set to `status: False`, - with the "RefNotPermitted" reason and not configure this backend in the - underlying implementation. - - In either error case, the Message of the `ResolvedRefs` Condition - should be used to provide more detail about the problem. - - Support: Extended for Kubernetes Service - - Support: Implementation-specific for any other resource - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations SHOULD NOT - support ExternalName Services. - - Support: Core (Services with a type other than ExternalName) - - Support: Implementation-specific (Services with type ExternalName) - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the backend. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port specifies the destination port number to use for this resource. - Port is required when the referent is a Kubernetes Service. In this - case, the port number is the service port number, not the target port. - For other resources, destination port might be derived from the referent - resource or this field. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - required: - - name - type: object - x-kubernetes-validations: - - message: Must have port for Service reference - rule: '(size(self.group) == 0 && self.kind == ''Service'') - ? has(self.port) : true' - fraction: - description: |- - Fraction represents the fraction of requests that should be - mirrored to BackendRef. - - Only one of Fraction or Percent may be specified. If neither field - is specified, 100% of requests will be mirrored. - properties: - denominator: - default: 100 - format: int32 - minimum: 1 - type: integer - numerator: - format: int32 - minimum: 0 - type: integer - required: - - numerator - type: object - x-kubernetes-validations: - - message: numerator must be less than or equal to - denominator - rule: self.numerator <= self.denominator - percent: - description: |- - Percent represents the percentage of requests that should be - mirrored to BackendRef. Its minimum value is 0 (indicating 0% of - requests) and its maximum value is 100 (indicating 100% of requests). - - Only one of Fraction or Percent may be specified. If neither field - is specified, 100% of requests will be mirrored. - format: int32 - maximum: 100 - minimum: 0 - type: integer - required: - - backendRef - type: object - x-kubernetes-validations: - - message: Only one of percent or fraction may be specified - in HTTPRequestMirrorFilter - rule: '!(has(self.percent) && has(self.fraction))' - requestRedirect: - description: |- - RequestRedirect defines a schema for a filter that responds to the - request with an HTTP redirection. - - Support: Core - properties: - hostname: - description: |- - Hostname is the hostname to be used in the value of the `Location` - header in the response. - When empty, the hostname in the `Host` header of the request is used. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - path: - description: |- - Path defines parameters used to modify the path of the incoming request. - The modified path is then used to construct the `Location` header. When - empty, the request path is used as-is. - - Support: Extended - properties: - replaceFullPath: - description: |- - ReplaceFullPath specifies the value with which to replace the full path - of a request during a rewrite or redirect. - maxLength: 1024 - type: string - replacePrefixMatch: - description: |- - ReplacePrefixMatch specifies the value with which to replace the prefix - match of a request during a rewrite or redirect. For example, a request - to "/foo/bar" with a prefix match of "/foo" and a ReplacePrefixMatch - of "/xyz" would be modified to "/xyz/bar". - - Note that this matches the behavior of the PathPrefix match type. This - matches full path elements. A path element refers to the list of labels - in the path split by the `/` separator. When specified, a trailing `/` is - ignored. For example, the paths `/abc`, `/abc/`, and `/abc/def` would all - match the prefix `/abc`, but the path `/abcd` would not. - - ReplacePrefixMatch is only compatible with a `PathPrefix` HTTPRouteMatch. - Using any other HTTPRouteMatch type on the same HTTPRouteRule will result in - the implementation setting the Accepted Condition for the Route to `status: False`. - - Request Path | Prefix Match | Replace Prefix | Modified Path - maxLength: 1024 - type: string - type: - description: |- - Type defines the type of path modifier. Additional types may be - added in a future release of the API. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - enum: - - ReplaceFullPath - - ReplacePrefixMatch - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: replaceFullPath must be specified when - type is set to 'ReplaceFullPath' - rule: 'self.type == ''ReplaceFullPath'' ? has(self.replaceFullPath) - : true' - - message: type must be 'ReplaceFullPath' when replaceFullPath - is set - rule: 'has(self.replaceFullPath) ? self.type == - ''ReplaceFullPath'' : true' - - message: replacePrefixMatch must be specified when - type is set to 'ReplacePrefixMatch' - rule: 'self.type == ''ReplacePrefixMatch'' ? has(self.replacePrefixMatch) - : true' - - message: type must be 'ReplacePrefixMatch' when - replacePrefixMatch is set - rule: 'has(self.replacePrefixMatch) ? self.type - == ''ReplacePrefixMatch'' : true' - port: - description: |- - Port is the port to be used in the value of the `Location` - header in the response. - - If no port is specified, the redirect port MUST be derived using the - following rules: - - * If redirect scheme is not-empty, the redirect port MUST be the well-known - port associated with the redirect scheme. Specifically "http" to port 80 - and "https" to port 443. If the redirect scheme does not have a - well-known port, the listener port of the Gateway SHOULD be used. - * If redirect scheme is empty, the redirect port MUST be the Gateway - Listener port. - - Implementations SHOULD NOT add the port number in the 'Location' - header in the following cases: - - * A Location header that will use HTTP (whether that is determined via - the Listener protocol or the Scheme field) _and_ use port 80. - * A Location header that will use HTTPS (whether that is determined via - the Listener protocol or the Scheme field) _and_ use port 443. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - scheme: - description: |- - Scheme is the scheme to be used in the value of the `Location` header in - the response. When empty, the scheme of the request is used. - - Scheme redirects can affect the port of the redirect, for more information, - refer to the documentation for the port field of this filter. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - - Support: Extended - enum: - - http - - https - type: string - statusCode: - default: 302 - description: |- - StatusCode is the HTTP status code to be used in response. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - - Support: Core - enum: - - 301 - - 302 - - 303 - - 307 - - 308 - type: integer - type: object - responseHeaderModifier: - description: |- - ResponseHeaderModifier defines a schema for a filter that modifies response - headers. - - Support: Extended - properties: - add: - description: |- - Add adds the given header(s) (name, value) to the request - before the action. It appends to any existing values associated - with the header name. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - add: - - name: "my-header" - value: "bar,baz" - - Output: - GET /foo HTTP/1.1 - my-header: foo,bar,baz - items: - description: HTTPHeader represents an HTTP Header - name and value as defined by RFC 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - remove: - description: |- - Remove the given header(s) from the HTTP request before the action. The - value of Remove is a list of HTTP header names. Note that the header - names are case-insensitive (see - https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). - - Input: - GET /foo HTTP/1.1 - my-header1: foo - my-header2: bar - my-header3: baz - - Config: - remove: ["my-header1", "my-header3"] - - Output: - GET /foo HTTP/1.1 - my-header2: bar - items: - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: set - set: - description: |- - Set overwrites the request with the given header (name, value) - before the action. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - set: - - name: "my-header" - value: "bar" - - Output: - GET /foo HTTP/1.1 - my-header: bar - items: - description: HTTPHeader represents an HTTP Header - name and value as defined by RFC 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - type: - description: |- - Type identifies the type of filter to apply. As with other API fields, - types are classified into three conformance levels: - - - Core: Filter types and their corresponding configuration defined by - "Support: Core" in this package, e.g. "RequestHeaderModifier". All - implementations must support core filters. - - - Extended: Filter types and their corresponding configuration defined by - "Support: Extended" in this package, e.g. "RequestMirror". Implementers - are encouraged to support extended filters. - - - Implementation-specific: Filters that are defined and supported by - specific vendors. - In the future, filters showing convergence in behavior across multiple - implementations will be considered for inclusion in extended or core - conformance levels. Filter-specific configuration for such filters - is specified using the ExtensionRef field. `Type` should be set to - "ExtensionRef" for custom filters. - - Implementers are encouraged to define custom implementation types to - extend the core API with implementation-specific behavior. - - If a reference to a custom filter type cannot be resolved, the filter - MUST NOT be skipped. Instead, requests that would have been processed by - that filter MUST receive a HTTP error response. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - enum: - - RequestHeaderModifier - - ResponseHeaderModifier - - RequestMirror - - RequestRedirect - - URLRewrite - - ExtensionRef - - CORS - type: string - urlRewrite: - description: |- - URLRewrite defines a schema for a filter that modifies a request during forwarding. - - Support: Extended - properties: - hostname: - description: |- - Hostname is the value to be used to replace the Host header value during - forwarding. - - Support: Extended - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - path: - description: |- - Path defines a path rewrite. - - Support: Extended - properties: - replaceFullPath: - description: |- - ReplaceFullPath specifies the value with which to replace the full path - of a request during a rewrite or redirect. - maxLength: 1024 - type: string - replacePrefixMatch: - description: |- - ReplacePrefixMatch specifies the value with which to replace the prefix - match of a request during a rewrite or redirect. For example, a request - to "/foo/bar" with a prefix match of "/foo" and a ReplacePrefixMatch - of "/xyz" would be modified to "/xyz/bar". - - Note that this matches the behavior of the PathPrefix match type. This - matches full path elements. A path element refers to the list of labels - in the path split by the `/` separator. When specified, a trailing `/` is - ignored. For example, the paths `/abc`, `/abc/`, and `/abc/def` would all - match the prefix `/abc`, but the path `/abcd` would not. - - ReplacePrefixMatch is only compatible with a `PathPrefix` HTTPRouteMatch. - Using any other HTTPRouteMatch type on the same HTTPRouteRule will result in - the implementation setting the Accepted Condition for the Route to `status: False`. - - Request Path | Prefix Match | Replace Prefix | Modified Path - maxLength: 1024 - type: string - type: - description: |- - Type defines the type of path modifier. Additional types may be - added in a future release of the API. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - enum: - - ReplaceFullPath - - ReplacePrefixMatch - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: replaceFullPath must be specified when - type is set to 'ReplaceFullPath' - rule: 'self.type == ''ReplaceFullPath'' ? has(self.replaceFullPath) - : true' - - message: type must be 'ReplaceFullPath' when replaceFullPath - is set - rule: 'has(self.replaceFullPath) ? self.type == - ''ReplaceFullPath'' : true' - - message: replacePrefixMatch must be specified when - type is set to 'ReplacePrefixMatch' - rule: 'self.type == ''ReplacePrefixMatch'' ? has(self.replacePrefixMatch) - : true' - - message: type must be 'ReplacePrefixMatch' when - replacePrefixMatch is set - rule: 'has(self.replacePrefixMatch) ? self.type - == ''ReplacePrefixMatch'' : true' - type: object - required: - - type - type: object - x-kubernetes-validations: - - message: filter.cors must be nil if the filter.type is not - CORS - rule: '!(has(self.cors) && self.type != ''CORS'')' - - message: filter.cors must be specified for CORS filter.type - rule: '!(!has(self.cors) && self.type == ''CORS'')' - - message: filter.requestHeaderModifier must be nil if the - filter.type is not RequestHeaderModifier - rule: '!(has(self.requestHeaderModifier) && self.type != - ''RequestHeaderModifier'')' - - message: filter.requestHeaderModifier must be specified - for RequestHeaderModifier filter.type - rule: '!(!has(self.requestHeaderModifier) && self.type == - ''RequestHeaderModifier'')' - - message: filter.responseHeaderModifier must be nil if the - filter.type is not ResponseHeaderModifier - rule: '!(has(self.responseHeaderModifier) && self.type != - ''ResponseHeaderModifier'')' - - message: filter.responseHeaderModifier must be specified - for ResponseHeaderModifier filter.type - rule: '!(!has(self.responseHeaderModifier) && self.type - == ''ResponseHeaderModifier'')' - - message: filter.requestMirror must be nil if the filter.type - is not RequestMirror - rule: '!(has(self.requestMirror) && self.type != ''RequestMirror'')' - - message: filter.requestMirror must be specified for RequestMirror - filter.type - rule: '!(!has(self.requestMirror) && self.type == ''RequestMirror'')' - - message: filter.requestRedirect must be nil if the filter.type - is not RequestRedirect - rule: '!(has(self.requestRedirect) && self.type != ''RequestRedirect'')' - - message: filter.requestRedirect must be specified for RequestRedirect - filter.type - rule: '!(!has(self.requestRedirect) && self.type == ''RequestRedirect'')' - - message: filter.urlRewrite must be nil if the filter.type - is not URLRewrite - rule: '!(has(self.urlRewrite) && self.type != ''URLRewrite'')' - - message: filter.urlRewrite must be specified for URLRewrite - filter.type - rule: '!(!has(self.urlRewrite) && self.type == ''URLRewrite'')' - - message: filter.extensionRef must be nil if the filter.type - is not ExtensionRef - rule: '!(has(self.extensionRef) && self.type != ''ExtensionRef'')' - - message: filter.extensionRef must be specified for ExtensionRef - filter.type - rule: '!(!has(self.extensionRef) && self.type == ''ExtensionRef'')' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: May specify either httpRouteFilterRequestRedirect - or httpRouteFilterRequestRewrite, but not both - rule: '!(self.exists(f, f.type == ''RequestRedirect'') && - self.exists(f, f.type == ''URLRewrite''))' - - message: CORS filter cannot be repeated - rule: self.filter(f, f.type == 'CORS').size() <= 1 - - message: RequestHeaderModifier filter cannot be repeated - rule: self.filter(f, f.type == 'RequestHeaderModifier').size() - <= 1 - - message: ResponseHeaderModifier filter cannot be repeated - rule: self.filter(f, f.type == 'ResponseHeaderModifier').size() - <= 1 - - message: RequestRedirect filter cannot be repeated - rule: self.filter(f, f.type == 'RequestRedirect').size() <= - 1 - - message: URLRewrite filter cannot be repeated - rule: self.filter(f, f.type == 'URLRewrite').size() <= 1 - matches: - default: - - path: - type: PathPrefix - value: / - description: |- - Matches define conditions used for matching the rule against incoming - HTTP requests. Each match is independent, i.e. this rule will be matched - if **any** one of the matches is satisfied. - - For example, take the following matches configuration: - - ``` - matches: - - path: - value: "/foo" - headers: - - name: "version" - value: "v2" - - path: - value: "/v2/foo" - ``` - - For a request to match against this rule, a request must satisfy - EITHER of the two conditions: - - - path prefixed with `/foo` AND contains the header `version: v2` - - path prefix of `/v2/foo` - - See the documentation for HTTPRouteMatch on how to specify multiple - match conditions that should be ANDed together. - - If no matches are specified, the default is a prefix - path match on "/", which has the effect of matching every - HTTP request. - - Proxy or Load Balancer routing configuration generated from HTTPRoutes - MUST prioritize matches based on the following criteria, continuing on - ties. Across all rules specified on applicable Routes, precedence must be - given to the match having: - - * "Exact" path match. - * "Prefix" path match with largest number of characters. - * Method match. - * Largest number of header matches. - * Largest number of query param matches. - - Note: The precedence of RegularExpression path matches are implementation-specific. - - If ties still exist across multiple Routes, matching precedence MUST be - determined in order of the following criteria, continuing on ties: - - * The oldest Route based on creation timestamp. - * The Route appearing first in alphabetical order by - "{namespace}/{name}". - - If ties still exist within an HTTPRoute, matching precedence MUST be granted - to the FIRST matching rule (in list order) with a match meeting the above - criteria. - - When no rules matching a request have been successfully attached to the - parent a request is coming from, a HTTP 404 status code MUST be returned. - items: - description: "HTTPRouteMatch defines the predicate used to - match requests to a given\naction. Multiple match types - are ANDed together, i.e. the match will\nevaluate to true - only if all conditions are satisfied.\n\nFor example, the - match below will match a HTTP request only if its path\nstarts - with `/foo` AND it contains the `version: v1` header:\n\n```\nmatch:\n\n\tpath:\n\t - \ value: \"/foo\"\n\theaders:\n\t- name: \"version\"\n\t - \ value \"v1\"\n\n```" - properties: - headers: - description: |- - Headers specifies HTTP request header matchers. Multiple match values are - ANDed together, meaning, a request must match all the specified headers - to select the route. - items: - description: |- - HTTPHeaderMatch describes how to select a HTTP route by matching HTTP request - headers. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, only the first - entry with an equivalent name MUST be considered for a match. Subsequent - entries with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - - When a header is repeated in an HTTP request, it is - implementation-specific behavior as to how this is represented. - Generally, proxies should follow the guidance from the RFC: - https://www.rfc-editor.org/rfc/rfc7230.html#section-3.2.2 regarding - processing a repeated header, with special handling for "Set-Cookie". - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - type: - default: Exact - description: |- - Type specifies how to match against the value of the header. - - Support: Core (Exact) - - Support: Implementation-specific (RegularExpression) - - Since RegularExpression HeaderMatchType has implementation-specific - conformance, implementations can support POSIX, PCRE or any other dialects - of regular expressions. Please read the implementation's documentation to - determine the supported dialect. - enum: - - Exact - - RegularExpression - type: string - value: - description: Value is the value of HTTP Header to - be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - method: - description: |- - Method specifies HTTP method matcher. - When specified, this route will be matched only if the request has the - specified method. - - Support: Extended - enum: - - GET - - HEAD - - POST - - PUT - - DELETE - - CONNECT - - OPTIONS - - TRACE - - PATCH - type: string - path: - default: - type: PathPrefix - value: / - description: |- - Path specifies a HTTP request path matcher. If this field is not - specified, a default prefix match on the "/" path is provided. - properties: - type: - default: PathPrefix - description: |- - Type specifies how to match against the path Value. - - Support: Core (Exact, PathPrefix) - - Support: Implementation-specific (RegularExpression) - enum: - - Exact - - PathPrefix - - RegularExpression - type: string - value: - default: / - description: Value of the HTTP path to match against. - maxLength: 1024 - type: string - type: object - x-kubernetes-validations: - - message: value must be an absolute path and start with - '/' when type one of ['Exact', 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? self.value.startsWith(''/'') - : true' - - message: must not contain '//' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.contains(''//'') - : true' - - message: must not contain '/./' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.contains(''/./'') - : true' - - message: must not contain '/../' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.contains(''/../'') - : true' - - message: must not contain '%2f' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.contains(''%2f'') - : true' - - message: must not contain '%2F' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.contains(''%2F'') - : true' - - message: must not contain '#' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.contains(''#'') - : true' - - message: must not end with '/..' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.endsWith(''/..'') - : true' - - message: must not end with '/.' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.endsWith(''/.'') - : true' - - message: type must be one of ['Exact', 'PathPrefix', - 'RegularExpression'] - rule: self.type in ['Exact','PathPrefix'] || self.type - == 'RegularExpression' - - message: must only contain valid characters (matching - ^(?:[-A-Za-z0-9/._~!$&'()*+,;=:@]|[%][0-9a-fA-F]{2})+$) - for types ['Exact', 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? self.value.matches(r"""^(?:[-A-Za-z0-9/._~!$&''()*+,;=:@]|[%][0-9a-fA-F]{2})+$""") - : true' - queryParams: - description: |- - QueryParams specifies HTTP query parameter matchers. Multiple match - values are ANDed together, meaning, a request must match all the - specified query parameters to select the route. - - Support: Extended - items: - description: |- - HTTPQueryParamMatch describes how to select a HTTP route by matching HTTP - query parameters. - properties: - name: - description: |- - Name is the name of the HTTP query param to be matched. This must be an - exact string match. (See - https://tools.ietf.org/html/rfc7230#section-2.7.3). - - If multiple entries specify equivalent query param names, only the first - entry with an equivalent name MUST be considered for a match. Subsequent - entries with an equivalent query param name MUST be ignored. - - If a query param is repeated in an HTTP request, the behavior is - purposely left undefined, since different data planes have different - capabilities. However, it is *recommended* that implementations should - match against the first value of the param if the data plane supports it, - as this behavior is expected in other load balancing contexts outside of - the Gateway API. - - Users SHOULD NOT route traffic based on repeated query params to guard - themselves against potential differences in the implementations. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - type: - default: Exact - description: |- - Type specifies how to match against the value of the query parameter. - - Support: Extended (Exact) - - Support: Implementation-specific (RegularExpression) - - Since RegularExpression QueryParamMatchType has Implementation-specific - conformance, implementations can support POSIX, PCRE or any other - dialects of regular expressions. Please read the implementation's - documentation to determine the supported dialect. - enum: - - Exact - - RegularExpression - type: string - value: - description: Value is the value of HTTP query param - to be matched. - maxLength: 1024 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - maxItems: 64 - type: array - x-kubernetes-list-type: atomic - name: - description: |- - Name is the name of the route rule. This name MUST be unique within a Route if it is set. - - Support: Extended - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - timeouts: - description: |- - Timeouts defines the timeouts that can be configured for an HTTP request. - - Support: Extended - properties: - backendRequest: - description: |- - BackendRequest specifies a timeout for an individual request from the gateway - to a backend. This covers the time from when the request first starts being - sent from the gateway to when the full response has been received from the backend. - - Setting a timeout to the zero duration (e.g. "0s") SHOULD disable the timeout - completely. Implementations that cannot completely disable the timeout MUST - instead interpret the zero duration as the longest possible value to which - the timeout can be set. - - An entire client HTTP transaction with a gateway, covered by the Request timeout, - may result in more than one call from the gateway to the destination backend, - for example, if automatic retries are supported. - - The value of BackendRequest must be a Gateway API Duration string as defined by - GEP-2257. When this field is unspecified, its behavior is implementation-specific; - when specified, the value of BackendRequest must be no more than the value of the - Request timeout (since the Request timeout encompasses the BackendRequest timeout). - - Support: Extended - pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ - type: string - request: - description: |- - Request specifies the maximum duration for a gateway to respond to an HTTP request. - If the gateway has not been able to respond before this deadline is met, the gateway - MUST return a timeout error. - - For example, setting the `rules.timeouts.request` field to the value `10s` in an - `HTTPRoute` will cause a timeout if a client request is taking longer than 10 seconds - to complete. - - Setting a timeout to the zero duration (e.g. "0s") SHOULD disable the timeout - completely. Implementations that cannot completely disable the timeout MUST - instead interpret the zero duration as the longest possible value to which - the timeout can be set. - - This timeout is intended to cover as close to the whole request-response transaction - as possible although an implementation MAY choose to start the timeout after the entire - request stream has been received instead of immediately after the transaction is - initiated by the client. - - The value of Request is a Gateway API Duration string as defined by GEP-2257. When this - field is unspecified, request timeout behavior is implementation-specific. - - Support: Extended - pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ - type: string - type: object - x-kubernetes-validations: - - message: backendRequest timeout cannot be longer than request - timeout - rule: '!(has(self.request) && has(self.backendRequest) && - duration(self.request) != duration(''0s'') && duration(self.backendRequest) - > duration(self.request))' - type: object - x-kubernetes-validations: - - message: RequestRedirect filter must not be used together with - backendRefs - rule: '(has(self.backendRefs) && size(self.backendRefs) > 0) ? - (!has(self.filters) || self.filters.all(f, !has(f.requestRedirect))): - true' - - message: When using RequestRedirect filter with path.replacePrefixMatch, - exactly one PathPrefix match must be specified - rule: '(has(self.filters) && self.filters.exists_one(f, has(f.requestRedirect) - && has(f.requestRedirect.path) && f.requestRedirect.path.type - == ''ReplacePrefixMatch'' && has(f.requestRedirect.path.replacePrefixMatch))) - ? ((size(self.matches) != 1 || !has(self.matches[0].path) || - self.matches[0].path.type != ''PathPrefix'') ? false : true) - : true' - - message: When using URLRewrite filter with path.replacePrefixMatch, - exactly one PathPrefix match must be specified - rule: '(has(self.filters) && self.filters.exists_one(f, has(f.urlRewrite) - && has(f.urlRewrite.path) && f.urlRewrite.path.type == ''ReplacePrefixMatch'' - && has(f.urlRewrite.path.replacePrefixMatch))) ? ((size(self.matches) - != 1 || !has(self.matches[0].path) || self.matches[0].path.type - != ''PathPrefix'') ? false : true) : true' - - message: Within backendRefs, when using RequestRedirect filter - with path.replacePrefixMatch, exactly one PathPrefix match must - be specified - rule: '(has(self.backendRefs) && self.backendRefs.exists_one(b, - (has(b.filters) && b.filters.exists_one(f, has(f.requestRedirect) - && has(f.requestRedirect.path) && f.requestRedirect.path.type - == ''ReplacePrefixMatch'' && has(f.requestRedirect.path.replacePrefixMatch))) - )) ? ((size(self.matches) != 1 || !has(self.matches[0].path) - || self.matches[0].path.type != ''PathPrefix'') ? false : true) - : true' - - message: Within backendRefs, When using URLRewrite filter with - path.replacePrefixMatch, exactly one PathPrefix match must be - specified - rule: '(has(self.backendRefs) && self.backendRefs.exists_one(b, - (has(b.filters) && b.filters.exists_one(f, has(f.urlRewrite) - && has(f.urlRewrite.path) && f.urlRewrite.path.type == ''ReplacePrefixMatch'' - && has(f.urlRewrite.path.replacePrefixMatch))) )) ? ((size(self.matches) - != 1 || !has(self.matches[0].path) || self.matches[0].path.type - != ''PathPrefix'') ? false : true) : true' - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: While 16 rules and 64 matches per rule are allowed, the - total number of matches across all rules in a route must be less - than 128 - rule: '(self.size() > 0 ? self[0].matches.size() : 0) + (self.size() - > 1 ? self[1].matches.size() : 0) + (self.size() > 2 ? self[2].matches.size() - : 0) + (self.size() > 3 ? self[3].matches.size() : 0) + (self.size() - > 4 ? self[4].matches.size() : 0) + (self.size() > 5 ? self[5].matches.size() - : 0) + (self.size() > 6 ? self[6].matches.size() : 0) + (self.size() - > 7 ? self[7].matches.size() : 0) + (self.size() > 8 ? self[8].matches.size() - : 0) + (self.size() > 9 ? self[9].matches.size() : 0) + (self.size() - > 10 ? self[10].matches.size() : 0) + (self.size() > 11 ? self[11].matches.size() - : 0) + (self.size() > 12 ? self[12].matches.size() : 0) + (self.size() - > 13 ? self[13].matches.size() : 0) + (self.size() > 14 ? self[14].matches.size() - : 0) + (self.size() > 15 ? self[15].matches.size() : 0) <= 128' - type: object - status: - description: Status defines the current state of HTTPRoute. - properties: - parents: - description: |- - Parents is a list of parent resources (usually Gateways) that are - associated with the route, and the status of the route with respect to - each parent. When this route attaches to a parent, the controller that - manages the parent must add an entry to this list when the controller - first sees the route and should update the entry as appropriate when the - route or gateway is modified. - - Note that parent references that cannot be resolved by an implementation - of this API will not be added to this list. Implementations of this API - can only populate Route status for the Gateways/parent resources they are - responsible for. - - A maximum of 32 Gateways will be represented in this list. An empty list - means the route has not been attached to any Gateway. - items: - description: |- - RouteParentStatus describes the status of a route with respect to an - associated Parent. - properties: - conditions: - description: |- - Conditions describes the status of the route with respect to the Gateway. - Note that the route's availability is also subject to the Gateway's own - status conditions and listener status. - - If the Route's ParentRef specifies an existing Gateway that supports - Routes of this kind AND that Gateway's controller has sufficient access, - then that Gateway's controller MUST set the "Accepted" condition on the - Route, to indicate whether the route has been accepted or rejected by the - Gateway, and why. - - A Route MUST be considered "Accepted" if at least one of the Route's - rules is implemented by the Gateway. - - There are a number of cases where the "Accepted" condition may not be set - due to lack of controller visibility, that includes when: - - * The Route refers to a nonexistent parent. - * The Route is of a type that the controller does not support. - * The Route is in a namespace to which the controller does not have access. - items: - description: Condition contains details for one aspect of - the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, - Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - controllerName: - description: |- - ControllerName is a domain/path string that indicates the name of the - controller that wrote this status. This corresponds with the - controllerName field on GatewayClass. - - Example: "example.net/gateway-controller". - - The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are - valid Kubernetes names - (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). - - Controllers MUST populate this field when writing status. Controllers should ensure that - entries to status populated with their ControllerName are cleaned up when they are no - longer necessary. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - parentRef: - description: |- - ParentRef corresponds with a ParentRef in the spec that this - RouteParentStatus struct describes the status of. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - required: - - conditions - - controllerName - - parentRef - type: object - maxItems: 32 - type: array - x-kubernetes-list-type: atomic - required: - - parents - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} - - additionalPrinterColumns: - - jsonPath: .spec.hostnames - name: Hostnames - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1beta1 - schema: - openAPIV3Schema: - description: |- - HTTPRoute provides a way to route HTTP requests. This includes the capability - to match requests by hostname, path, header, or query param. Filters can be - used to specify additional processing steps. Backends specify where matching - requests should be routed. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of HTTPRoute. - properties: - hostnames: - description: |- - Hostnames defines a set of hostnames that should match against the HTTP Host - header to select a HTTPRoute used to process the request. Implementations - MUST ignore any port value specified in the HTTP Host header while - performing a match and (absent of any applicable header modification - configuration) MUST forward this header unmodified to the backend. - - Valid values for Hostnames are determined by RFC 1123 definition of a - hostname with 2 notable exceptions: - - 1. IPs are not allowed. - 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard - label must appear by itself as the first label. - - If a hostname is specified by both the Listener and HTTPRoute, there - must be at least one intersecting hostname for the HTTPRoute to be - attached to the Listener. For example: - - * A Listener with `test.example.com` as the hostname matches HTTPRoutes - that have either not specified any hostnames, or have specified at - least one of `test.example.com` or `*.example.com`. - * A Listener with `*.example.com` as the hostname matches HTTPRoutes - that have either not specified any hostnames or have specified at least - one hostname that matches the Listener hostname. For example, - `*.example.com`, `test.example.com`, and `foo.test.example.com` would - all match. On the other hand, `example.com` and `test.example.net` would - not match. - - Hostnames that are prefixed with a wildcard label (`*.`) are interpreted - as a suffix match. That means that a match for `*.example.com` would match - both `test.example.com`, and `foo.test.example.com`, but not `example.com`. - - If both the Listener and HTTPRoute have specified hostnames, any - HTTPRoute hostnames that do not match the Listener hostname MUST be - ignored. For example, if a Listener specified `*.example.com`, and the - HTTPRoute specified `test.example.com` and `test.example.net`, - `test.example.net` must not be considered for a match. - - If both the Listener and HTTPRoute have specified hostnames, and none - match with the criteria above, then the HTTPRoute is not accepted. The - implementation must raise an 'Accepted' Condition with a status of - `False` in the corresponding RouteParentStatus. - - In the event that multiple HTTPRoutes specify intersecting hostnames (e.g. - overlapping wildcard matching and exact matching hostnames), precedence must - be given to rules from the HTTPRoute with the largest number of: - - * Characters in a matching non-wildcard hostname. - * Characters in a matching hostname. - - If ties exist across multiple Routes, the matching precedence rules for - HTTPRouteMatches takes over. - - Support: Core - items: - description: |- - Hostname is the fully qualified domain name of a network host. This matches - the RFC 1123 definition of a hostname with 2 notable exceptions: - - 1. IPs are not allowed. - 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard - label must appear by itself as the first label. - - Hostname can be "precise" which is a domain name without the terminating - dot of a network host (e.g. "foo.example.com") or "wildcard", which is a - domain name prefixed with a single wildcard label (e.g. `*.example.com`). - - Note that as per RFC1035 and RFC1123, a *label* must consist of lower case - alphanumeric characters or '-', and must start and end with an alphanumeric - character. No other punctuation is allowed. - maxLength: 253 - minLength: 1 - pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - parentRefs: - description: |- - ParentRefs references the resources (usually Gateways) that a Route wants - to be attached to. Note that the referenced parent resource needs to - allow this for the attachment to be complete. For Gateways, that means - the Gateway needs to allow attachment from Routes of this kind and - namespace. For Services, that means the Service must either be in the same - namespace for a "producer" route, or the mesh implementation must support - and allow "consumer" routes for the referenced Service. ReferenceGrant is - not applicable for governing ParentRefs to Services - it is not possible to - create a "producer" route for a Service in a different namespace from the - Route. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - This API may be extended in the future to support additional kinds of parent - resources. - - ParentRefs must be _distinct_. This means either that: - - * They select different objects. If this is the case, then parentRef - entries are distinct. In terms of fields, this means that the - multi-part key defined by `group`, `kind`, `namespace`, and `name` must - be unique across all parentRef entries in the Route. - * They do not select different objects, but for each optional field used, - each ParentRef that selects the same object must set the same set of - optional fields to different values. If one ParentRef sets a - combination of optional fields, all must set the same combination. - - Some examples: - - * If one ParentRef sets `sectionName`, all ParentRefs referencing the - same object must also set `sectionName`. - * If one ParentRef sets `port`, all ParentRefs referencing the same - object must also set `port`. - * If one ParentRef sets `sectionName` and `port`, all ParentRefs - referencing the same object must also set `sectionName` and `port`. - - It is possible to separately reference multiple distinct objects that may - be collapsed by an implementation. For example, some implementations may - choose to merge compatible Gateway Listeners together. If that is the - case, the list of routes attached to those resources should also be - merged. - - Note that for ParentRefs that cross namespace boundaries, there are specific - rules. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example, - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable other kinds of cross-namespace reference. - items: - description: |- - ParentReference identifies an API object (usually a Gateway) that can be considered - a parent of this resource (usually a route). There are two kinds of parent resources - with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - This API may be extended in the future to support additional kinds of parent - resources. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - maxItems: 32 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: sectionName must be specified when parentRefs includes - 2 or more references to the same parent - rule: 'self.all(p1, self.all(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.__namespace__) - || p1.__namespace__ == '''') && (!has(p2.__namespace__) || p2.__namespace__ - == '''')) || (has(p1.__namespace__) && has(p2.__namespace__) && - p1.__namespace__ == p2.__namespace__ )) ? ((!has(p1.sectionName) - || p1.sectionName == '''') == (!has(p2.sectionName) || p2.sectionName - == '''')) : true))' - - message: sectionName must be unique when parentRefs includes 2 or - more references to the same parent - rule: self.all(p1, self.exists_one(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.__namespace__) - || p1.__namespace__ == '') && (!has(p2.__namespace__) || p2.__namespace__ - == '')) || (has(p1.__namespace__) && has(p2.__namespace__) && - p1.__namespace__ == p2.__namespace__ )) && (((!has(p1.sectionName) - || p1.sectionName == '') && (!has(p2.sectionName) || p2.sectionName - == '')) || (has(p1.sectionName) && has(p2.sectionName) && p1.sectionName - == p2.sectionName)))) - rules: - default: - - matches: - - path: - type: PathPrefix - value: / - description: Rules are a list of HTTP matchers, filters and actions. - items: - description: |- - HTTPRouteRule defines semantics for matching an HTTP request based on - conditions (matches), processing it (filters), and forwarding the request to - an API object (backendRefs). - properties: - backendRefs: - description: |- - BackendRefs defines the backend(s) where matching requests should be - sent. - - Failure behavior here depends on how many BackendRefs are specified and - how many are invalid. - - If *all* entries in BackendRefs are invalid, and there are also no filters - specified in this route rule, *all* traffic which matches this rule MUST - receive a 500 status code. - - See the HTTPBackendRef definition for the rules about what makes a single - HTTPBackendRef invalid. - - When a HTTPBackendRef is invalid, 500 status codes MUST be returned for - requests that would have otherwise been routed to an invalid backend. If - multiple backends are specified, and some are invalid, the proportion of - requests that would otherwise have been routed to an invalid backend - MUST receive a 500 status code. - - For example, if two backends are specified with equal weights, and one is - invalid, 50 percent of traffic must receive a 500. Implementations may - choose how that 50 percent is determined. - - When a HTTPBackendRef refers to a Service that has no ready endpoints, - implementations SHOULD return a 503 for requests to that backend instead. - If an implementation chooses to do this, all of the above rules for 500 responses - MUST also apply for responses that return a 503. - - Support: Core for Kubernetes Service - - Support: Extended for Kubernetes ServiceImport - - Support: Implementation-specific for any other resource - - Support for weight: Core - items: - description: |- - HTTPBackendRef defines how a HTTPRoute forwards a HTTP request. - - Note that when a namespace different than the local namespace is specified, a - ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - properties: - filters: - description: |- - Filters defined at this level should be executed if and only if the - request is being forwarded to the backend defined here. - - Support: Implementation-specific (For broader support of filters, use the - Filters field in HTTPRouteRule.) - items: - description: |- - HTTPRouteFilter defines processing steps that must be completed during the - request or response lifecycle. HTTPRouteFilters are meant as an extension - point to express processing that may be done in Gateway implementations. Some - examples include request or response modification, implementing - authentication strategies, rate-limiting, and traffic shaping. API - guarantee/conformance is defined based on the type of the filter. - properties: - cors: - description: |- - CORS defines a schema for a filter that responds to the - cross-origin request based on HTTP response header. - - Support: Extended - properties: - allowCredentials: - description: |- - AllowCredentials indicates whether the actual cross-origin request allows - to include credentials. - - When set to true, the gateway will include the `Access-Control-Allow-Credentials` - response header with value true (case-sensitive). - - When set to false or omitted the gateway will omit the header - `Access-Control-Allow-Credentials` entirely (this is the standard CORS - behavior). - - Support: Extended - type: boolean - allowHeaders: - description: |- - AllowHeaders indicates which HTTP request headers are supported for - accessing the requested resource. - - Header names are not case-sensitive. - - Multiple header names in the value of the `Access-Control-Allow-Headers` - response header are separated by a comma (","). - - When the `AllowHeaders` field is configured with one or more headers, the - gateway must return the `Access-Control-Allow-Headers` response header - which value is present in the `AllowHeaders` field. - - If any header name in the `Access-Control-Request-Headers` request header - is not included in the list of header names specified by the response - header `Access-Control-Allow-Headers`, it will present an error on the - client side. - - If any header name in the `Access-Control-Allow-Headers` response header - does not recognize by the client, it will also occur an error on the - client side. - - A wildcard indicates that the requests with all HTTP headers are allowed. - If config contains the wildcard "*" in allowHeaders and the request is - not credentialed, the `Access-Control-Allow-Headers` response header - can either use the `*` wildcard or the value of - Access-Control-Request-Headers from the request. - - When the request is credentialed, the gateway must not specify the `*` - wildcard in the `Access-Control-Allow-Headers` response header. When - also the `AllowCredentials` field is true and `AllowHeaders` field - is specified with the `*` wildcard, the gateway must specify one or more - HTTP headers in the value of the `Access-Control-Allow-Headers` response - header. The value of the header `Access-Control-Allow-Headers` is same as - the `Access-Control-Request-Headers` header provided by the client. If - the header `Access-Control-Request-Headers` is not included in the - request, the gateway will omit the `Access-Control-Allow-Headers` - response header, instead of specifying the `*` wildcard. - - Support: Extended - items: - description: |- - HTTPHeaderName is the name of an HTTP header. - - Valid values include: - - * "Authorization" - * "Set-Cookie" - - Invalid values include: - - - ":method" - ":" is an invalid character. This means that HTTP/2 pseudo - headers are not currently supported by this type. - - "/invalid" - "/ " is an invalid character - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - maxItems: 64 - type: array - x-kubernetes-list-type: set - x-kubernetes-validations: - - message: AllowHeaders cannot contain '*' alongside - other methods - rule: '!(''*'' in self && self.size() > 1)' - allowMethods: - description: |- - AllowMethods indicates which HTTP methods are supported for accessing the - requested resource. - - Valid values are any method defined by RFC9110, along with the special - value `*`, which represents all HTTP methods are allowed. - - Method names are case-sensitive, so these values are also case-sensitive. - (See https://www.rfc-editor.org/rfc/rfc2616#section-5.1.1) - - Multiple method names in the value of the `Access-Control-Allow-Methods` - response header are separated by a comma (","). - - A CORS-safelisted method is a method that is `GET`, `HEAD`, or `POST`. - (See https://fetch.spec.whatwg.org/#cors-safelisted-method) The - CORS-safelisted methods are always allowed, regardless of whether they - are specified in the `AllowMethods` field. - - When the `AllowMethods` field is configured with one or more methods, the - gateway must return the `Access-Control-Allow-Methods` response header - which value is present in the `AllowMethods` field. - - If the HTTP method of the `Access-Control-Request-Method` request header - is not included in the list of methods specified by the response header - `Access-Control-Allow-Methods`, it will present an error on the client - side. - - If config contains the wildcard "*" in allowMethods and the request is - not credentialed, the `Access-Control-Allow-Methods` response header - can either use the `*` wildcard or the value of - Access-Control-Request-Method from the request. - - When the request is credentialed, the gateway must not specify the `*` - wildcard in the `Access-Control-Allow-Methods` response header. When - also the `AllowCredentials` field is true and `AllowMethods` field - specified with the `*` wildcard, the gateway must specify one HTTP method - in the value of the Access-Control-Allow-Methods response header. The - value of the header `Access-Control-Allow-Methods` is same as the - `Access-Control-Request-Method` header provided by the client. If the - header `Access-Control-Request-Method` is not included in the request, - the gateway will omit the `Access-Control-Allow-Methods` response header, - instead of specifying the `*` wildcard. - - Support: Extended - items: - enum: - - GET - - HEAD - - POST - - PUT - - DELETE - - CONNECT - - OPTIONS - - TRACE - - PATCH - - '*' - type: string - maxItems: 9 - type: array - x-kubernetes-list-type: set - x-kubernetes-validations: - - message: AllowMethods cannot contain '*' alongside - other methods - rule: '!(''*'' in self && self.size() > 1)' - allowOrigins: - description: |- - AllowOrigins indicates whether the response can be shared with requested - resource from the given `Origin`. - - The `Origin` consists of a scheme and a host, with an optional port, and - takes the form `://(:)`. - - Valid values for scheme are: `http` and `https`. - - Valid values for port are any integer between 1 and 65535 (the list of - available TCP/UDP ports). Note that, if not included, port `80` is - assumed for `http` scheme origins, and port `443` is assumed for `https` - origins. This may affect origin matching. - - The host part of the origin may contain the wildcard character `*`. These - wildcard characters behave as follows: - - * `*` is a greedy match to the _left_, including any number of - DNS labels to the left of its position. This also means that - `*` will include any number of period `.` characters to the - left of its position. - * A wildcard by itself matches all hosts. - - An origin value that includes _only_ the `*` character indicates requests - from all `Origin`s are allowed. - - When the `AllowOrigins` field is configured with multiple origins, it - means the server supports clients from multiple origins. If the request - `Origin` matches the configured allowed origins, the gateway must return - the given `Origin` and sets value of the header - `Access-Control-Allow-Origin` same as the `Origin` header provided by the - client. - - The status code of a successful response to a "preflight" request is - always an OK status (i.e., 204 or 200). - - If the request `Origin` does not match the configured allowed origins, - the gateway returns 204/200 response but doesn't set the relevant - cross-origin response headers. Alternatively, the gateway responds with - 403 status to the "preflight" request is denied, coupled with omitting - the CORS headers. The cross-origin request fails on the client side. - Therefore, the client doesn't attempt the actual cross-origin request. - - Conversely, if the request `Origin` matches one of the configured - allowed origins, the gateway sets the response header - `Access-Control-Allow-Origin` to the same value as the `Origin` - header provided by the client. - - When config has the wildcard ("*") in allowOrigins, and the request - is not credentialed (e.g., it is a preflight request), the - `Access-Control-Allow-Origin` response header either contains the - wildcard as well or the Origin from the request. - - When the request is credentialed, the gateway must not specify the `*` - wildcard in the `Access-Control-Allow-Origin` response header. When - also the `AllowCredentials` field is true and `AllowOrigins` field - specified with the `*` wildcard, the gateway must return a single origin - in the value of the `Access-Control-Allow-Origin` response header, - instead of specifying the `*` wildcard. The value of the header - `Access-Control-Allow-Origin` is same as the `Origin` header provided by - the client. - - Support: Extended - items: - description: |- - The CORSOrigin MUST NOT be a relative URI, and it MUST follow the URI syntax and - encoding rules specified in RFC3986. The CORSOrigin MUST include both a - scheme ("http" or "https") and a scheme-specific-part, or it should be a single '*' character. - URIs that include an authority MUST include a fully qualified domain name or - IP address as the host. - maxLength: 253 - minLength: 1 - pattern: (^\*$)|(^(http(s)?):\/\/(((\*\.)?([a-zA-Z0-9\-]+\.)*[a-zA-Z0-9-]+|\*)(:([0-9]{1,5}))?)$) - type: string - maxItems: 64 - type: array - x-kubernetes-list-type: set - x-kubernetes-validations: - - message: AllowOrigins cannot contain '*' alongside - other origins - rule: '!(''*'' in self && self.size() > 1)' - exposeHeaders: - description: |- - ExposeHeaders indicates which HTTP response headers can be exposed - to client-side scripts in response to a cross-origin request. - - A CORS-safelisted response header is an HTTP header in a CORS response - that it is considered safe to expose to the client scripts. - The CORS-safelisted response headers include the following headers: - `Cache-Control` - `Content-Language` - `Content-Length` - `Content-Type` - `Expires` - `Last-Modified` - `Pragma` - (See https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name) - The CORS-safelisted response headers are exposed to client by default. - - When an HTTP header name is specified using the `ExposeHeaders` field, - this additional header will be exposed as part of the response to the - client. - - Header names are not case-sensitive. - - Multiple header names in the value of the `Access-Control-Expose-Headers` - response header are separated by a comma (","). - - A wildcard indicates that the responses with all HTTP headers are exposed - to clients. The `Access-Control-Expose-Headers` response header can only - use `*` wildcard as value when the request is not credentialed. - - When the `exposeHeaders` config field contains the "*" wildcard and - the request is credentialed, the gateway cannot use the `*` wildcard in - the `Access-Control-Expose-Headers` response header. - - Support: Extended - items: - description: |- - HTTPHeaderName is the name of an HTTP header. - - Valid values include: - - * "Authorization" - * "Set-Cookie" - - Invalid values include: - - - ":method" - ":" is an invalid character. This means that HTTP/2 pseudo - headers are not currently supported by this type. - - "/invalid" - "/ " is an invalid character - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - maxItems: 64 - type: array - x-kubernetes-list-type: set - maxAge: - default: 5 - description: |- - MaxAge indicates the duration (in seconds) for the client to cache the - results of a "preflight" request. - - The information provided by the `Access-Control-Allow-Methods` and - `Access-Control-Allow-Headers` response headers can be cached by the - client until the time specified by `Access-Control-Max-Age` elapses. - - The default value of `Access-Control-Max-Age` response header is 5 - (seconds). - - When the `MaxAge` field is unspecified, the gateway sets the response - header "Access-Control-Max-Age: 5" by default. - format: int32 - minimum: 1 - type: integer - type: object - extensionRef: - description: |- - ExtensionRef is an optional, implementation-specific extension to the - "filter" behavior. For example, resource "myroutefilter" in group - "networking.example.net"). ExtensionRef MUST NOT be used for core and - extended filters. - - This filter can be used multiple times within the same rule. - - Support: Implementation-specific - properties: - group: - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. For - example "HTTPRoute" or "Service". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - required: - - group - - kind - - name - type: object - requestHeaderModifier: - description: |- - RequestHeaderModifier defines a schema for a filter that modifies request - headers. - - Support: Core - properties: - add: - description: |- - Add adds the given header(s) (name, value) to the request - before the action. It appends to any existing values associated - with the header name. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - add: - - name: "my-header" - value: "bar,baz" - - Output: - GET /foo HTTP/1.1 - my-header: foo,bar,baz - items: - description: HTTPHeader represents an HTTP - Header name and value as defined by RFC - 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP - Header to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - remove: - description: |- - Remove the given header(s) from the HTTP request before the action. The - value of Remove is a list of HTTP header names. Note that the header - names are case-insensitive (see - https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). - - Input: - GET /foo HTTP/1.1 - my-header1: foo - my-header2: bar - my-header3: baz - - Config: - remove: ["my-header1", "my-header3"] - - Output: - GET /foo HTTP/1.1 - my-header2: bar - items: - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: set - set: - description: |- - Set overwrites the request with the given header (name, value) - before the action. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - set: - - name: "my-header" - value: "bar" - - Output: - GET /foo HTTP/1.1 - my-header: bar - items: - description: HTTPHeader represents an HTTP - Header name and value as defined by RFC - 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP - Header to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - requestMirror: - description: |- - RequestMirror defines a schema for a filter that mirrors requests. - Requests are sent to the specified destination, but responses from - that destination are ignored. - - This filter can be used multiple times within the same rule. Note that - not all implementations will be able to support mirroring to multiple - backends. - - Support: Extended - properties: - backendRef: - description: |- - BackendRef references a resource where mirrored requests are sent. - - Mirrored requests must be sent only to a single destination endpoint - within this BackendRef, irrespective of how many endpoints are present - within this BackendRef. - - If the referent cannot be found, this BackendRef is invalid and must be - dropped from the Gateway. The controller must ensure the "ResolvedRefs" - condition on the Route status is set to `status: False` and not configure - this backend in the underlying implementation. - - If there is a cross-namespace reference to an *existing* object - that is not allowed by a ReferenceGrant, the controller must ensure the - "ResolvedRefs" condition on the Route is set to `status: False`, - with the "RefNotPermitted" reason and not configure this backend in the - underlying implementation. - - In either error case, the Message of the `ResolvedRefs` Condition - should be used to provide more detail about the problem. - - Support: Extended for Kubernetes Service - - Support: Implementation-specific for any other resource - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations SHOULD NOT - support ExternalName Services. - - Support: Core (Services with a type other than ExternalName) - - Support: Implementation-specific (Services with type ExternalName) - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the backend. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port specifies the destination port number to use for this resource. - Port is required when the referent is a Kubernetes Service. In this - case, the port number is the service port number, not the target port. - For other resources, destination port might be derived from the referent - resource or this field. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - required: - - name - type: object - x-kubernetes-validations: - - message: Must have port for Service reference - rule: '(size(self.group) == 0 && self.kind - == ''Service'') ? has(self.port) : true' - fraction: - description: |- - Fraction represents the fraction of requests that should be - mirrored to BackendRef. - - Only one of Fraction or Percent may be specified. If neither field - is specified, 100% of requests will be mirrored. - properties: - denominator: - default: 100 - format: int32 - minimum: 1 - type: integer - numerator: - format: int32 - minimum: 0 - type: integer - required: - - numerator - type: object - x-kubernetes-validations: - - message: numerator must be less than or equal - to denominator - rule: self.numerator <= self.denominator - percent: - description: |- - Percent represents the percentage of requests that should be - mirrored to BackendRef. Its minimum value is 0 (indicating 0% of - requests) and its maximum value is 100 (indicating 100% of requests). - - Only one of Fraction or Percent may be specified. If neither field - is specified, 100% of requests will be mirrored. - format: int32 - maximum: 100 - minimum: 0 - type: integer - required: - - backendRef - type: object - x-kubernetes-validations: - - message: Only one of percent or fraction may be - specified in HTTPRequestMirrorFilter - rule: '!(has(self.percent) && has(self.fraction))' - requestRedirect: - description: |- - RequestRedirect defines a schema for a filter that responds to the - request with an HTTP redirection. - - Support: Core - properties: - hostname: - description: |- - Hostname is the hostname to be used in the value of the `Location` - header in the response. - When empty, the hostname in the `Host` header of the request is used. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - path: - description: |- - Path defines parameters used to modify the path of the incoming request. - The modified path is then used to construct the `Location` header. When - empty, the request path is used as-is. - - Support: Extended - properties: - replaceFullPath: - description: |- - ReplaceFullPath specifies the value with which to replace the full path - of a request during a rewrite or redirect. - maxLength: 1024 - type: string - replacePrefixMatch: - description: |- - ReplacePrefixMatch specifies the value with which to replace the prefix - match of a request during a rewrite or redirect. For example, a request - to "/foo/bar" with a prefix match of "/foo" and a ReplacePrefixMatch - of "/xyz" would be modified to "/xyz/bar". - - Note that this matches the behavior of the PathPrefix match type. This - matches full path elements. A path element refers to the list of labels - in the path split by the `/` separator. When specified, a trailing `/` is - ignored. For example, the paths `/abc`, `/abc/`, and `/abc/def` would all - match the prefix `/abc`, but the path `/abcd` would not. - - ReplacePrefixMatch is only compatible with a `PathPrefix` HTTPRouteMatch. - Using any other HTTPRouteMatch type on the same HTTPRouteRule will result in - the implementation setting the Accepted Condition for the Route to `status: False`. - - Request Path | Prefix Match | Replace Prefix | Modified Path - maxLength: 1024 - type: string - type: - description: |- - Type defines the type of path modifier. Additional types may be - added in a future release of the API. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - enum: - - ReplaceFullPath - - ReplacePrefixMatch - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: replaceFullPath must be specified - when type is set to 'ReplaceFullPath' - rule: 'self.type == ''ReplaceFullPath'' ? - has(self.replaceFullPath) : true' - - message: type must be 'ReplaceFullPath' when - replaceFullPath is set - rule: 'has(self.replaceFullPath) ? self.type - == ''ReplaceFullPath'' : true' - - message: replacePrefixMatch must be specified - when type is set to 'ReplacePrefixMatch' - rule: 'self.type == ''ReplacePrefixMatch'' - ? has(self.replacePrefixMatch) : true' - - message: type must be 'ReplacePrefixMatch' - when replacePrefixMatch is set - rule: 'has(self.replacePrefixMatch) ? self.type - == ''ReplacePrefixMatch'' : true' - port: - description: |- - Port is the port to be used in the value of the `Location` - header in the response. - - If no port is specified, the redirect port MUST be derived using the - following rules: - - * If redirect scheme is not-empty, the redirect port MUST be the well-known - port associated with the redirect scheme. Specifically "http" to port 80 - and "https" to port 443. If the redirect scheme does not have a - well-known port, the listener port of the Gateway SHOULD be used. - * If redirect scheme is empty, the redirect port MUST be the Gateway - Listener port. - - Implementations SHOULD NOT add the port number in the 'Location' - header in the following cases: - - * A Location header that will use HTTP (whether that is determined via - the Listener protocol or the Scheme field) _and_ use port 80. - * A Location header that will use HTTPS (whether that is determined via - the Listener protocol or the Scheme field) _and_ use port 443. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - scheme: - description: |- - Scheme is the scheme to be used in the value of the `Location` header in - the response. When empty, the scheme of the request is used. - - Scheme redirects can affect the port of the redirect, for more information, - refer to the documentation for the port field of this filter. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - - Support: Extended - enum: - - http - - https - type: string - statusCode: - default: 302 - description: |- - StatusCode is the HTTP status code to be used in response. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - - Support: Core - enum: - - 301 - - 302 - - 303 - - 307 - - 308 - type: integer - type: object - responseHeaderModifier: - description: |- - ResponseHeaderModifier defines a schema for a filter that modifies response - headers. - - Support: Extended - properties: - add: - description: |- - Add adds the given header(s) (name, value) to the request - before the action. It appends to any existing values associated - with the header name. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - add: - - name: "my-header" - value: "bar,baz" - - Output: - GET /foo HTTP/1.1 - my-header: foo,bar,baz - items: - description: HTTPHeader represents an HTTP - Header name and value as defined by RFC - 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP - Header to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - remove: - description: |- - Remove the given header(s) from the HTTP request before the action. The - value of Remove is a list of HTTP header names. Note that the header - names are case-insensitive (see - https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). - - Input: - GET /foo HTTP/1.1 - my-header1: foo - my-header2: bar - my-header3: baz - - Config: - remove: ["my-header1", "my-header3"] - - Output: - GET /foo HTTP/1.1 - my-header2: bar - items: - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: set - set: - description: |- - Set overwrites the request with the given header (name, value) - before the action. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - set: - - name: "my-header" - value: "bar" - - Output: - GET /foo HTTP/1.1 - my-header: bar - items: - description: HTTPHeader represents an HTTP - Header name and value as defined by RFC - 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP - Header to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - type: - description: |- - Type identifies the type of filter to apply. As with other API fields, - types are classified into three conformance levels: - - - Core: Filter types and their corresponding configuration defined by - "Support: Core" in this package, e.g. "RequestHeaderModifier". All - implementations must support core filters. - - - Extended: Filter types and their corresponding configuration defined by - "Support: Extended" in this package, e.g. "RequestMirror". Implementers - are encouraged to support extended filters. - - - Implementation-specific: Filters that are defined and supported by - specific vendors. - In the future, filters showing convergence in behavior across multiple - implementations will be considered for inclusion in extended or core - conformance levels. Filter-specific configuration for such filters - is specified using the ExtensionRef field. `Type` should be set to - "ExtensionRef" for custom filters. - - Implementers are encouraged to define custom implementation types to - extend the core API with implementation-specific behavior. - - If a reference to a custom filter type cannot be resolved, the filter - MUST NOT be skipped. Instead, requests that would have been processed by - that filter MUST receive a HTTP error response. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - enum: - - RequestHeaderModifier - - ResponseHeaderModifier - - RequestMirror - - RequestRedirect - - URLRewrite - - ExtensionRef - - CORS - type: string - urlRewrite: - description: |- - URLRewrite defines a schema for a filter that modifies a request during forwarding. - - Support: Extended - properties: - hostname: - description: |- - Hostname is the value to be used to replace the Host header value during - forwarding. - - Support: Extended - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - path: - description: |- - Path defines a path rewrite. - - Support: Extended - properties: - replaceFullPath: - description: |- - ReplaceFullPath specifies the value with which to replace the full path - of a request during a rewrite or redirect. - maxLength: 1024 - type: string - replacePrefixMatch: - description: |- - ReplacePrefixMatch specifies the value with which to replace the prefix - match of a request during a rewrite or redirect. For example, a request - to "/foo/bar" with a prefix match of "/foo" and a ReplacePrefixMatch - of "/xyz" would be modified to "/xyz/bar". - - Note that this matches the behavior of the PathPrefix match type. This - matches full path elements. A path element refers to the list of labels - in the path split by the `/` separator. When specified, a trailing `/` is - ignored. For example, the paths `/abc`, `/abc/`, and `/abc/def` would all - match the prefix `/abc`, but the path `/abcd` would not. - - ReplacePrefixMatch is only compatible with a `PathPrefix` HTTPRouteMatch. - Using any other HTTPRouteMatch type on the same HTTPRouteRule will result in - the implementation setting the Accepted Condition for the Route to `status: False`. - - Request Path | Prefix Match | Replace Prefix | Modified Path - maxLength: 1024 - type: string - type: - description: |- - Type defines the type of path modifier. Additional types may be - added in a future release of the API. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - enum: - - ReplaceFullPath - - ReplacePrefixMatch - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: replaceFullPath must be specified - when type is set to 'ReplaceFullPath' - rule: 'self.type == ''ReplaceFullPath'' ? - has(self.replaceFullPath) : true' - - message: type must be 'ReplaceFullPath' when - replaceFullPath is set - rule: 'has(self.replaceFullPath) ? self.type - == ''ReplaceFullPath'' : true' - - message: replacePrefixMatch must be specified - when type is set to 'ReplacePrefixMatch' - rule: 'self.type == ''ReplacePrefixMatch'' - ? has(self.replacePrefixMatch) : true' - - message: type must be 'ReplacePrefixMatch' - when replacePrefixMatch is set - rule: 'has(self.replacePrefixMatch) ? self.type - == ''ReplacePrefixMatch'' : true' - type: object - required: - - type - type: object - x-kubernetes-validations: - - message: filter.cors must be nil if the filter.type - is not CORS - rule: '!(has(self.cors) && self.type != ''CORS'')' - - message: filter.cors must be specified for CORS filter.type - rule: '!(!has(self.cors) && self.type == ''CORS'')' - - message: filter.requestHeaderModifier must be nil - if the filter.type is not RequestHeaderModifier - rule: '!(has(self.requestHeaderModifier) && self.type - != ''RequestHeaderModifier'')' - - message: filter.requestHeaderModifier must be specified - for RequestHeaderModifier filter.type - rule: '!(!has(self.requestHeaderModifier) && self.type - == ''RequestHeaderModifier'')' - - message: filter.responseHeaderModifier must be nil - if the filter.type is not ResponseHeaderModifier - rule: '!(has(self.responseHeaderModifier) && self.type - != ''ResponseHeaderModifier'')' - - message: filter.responseHeaderModifier must be specified - for ResponseHeaderModifier filter.type - rule: '!(!has(self.responseHeaderModifier) && self.type - == ''ResponseHeaderModifier'')' - - message: filter.requestMirror must be nil if the filter.type - is not RequestMirror - rule: '!(has(self.requestMirror) && self.type != ''RequestMirror'')' - - message: filter.requestMirror must be specified for - RequestMirror filter.type - rule: '!(!has(self.requestMirror) && self.type == - ''RequestMirror'')' - - message: filter.requestRedirect must be nil if the - filter.type is not RequestRedirect - rule: '!(has(self.requestRedirect) && self.type != - ''RequestRedirect'')' - - message: filter.requestRedirect must be specified - for RequestRedirect filter.type - rule: '!(!has(self.requestRedirect) && self.type == - ''RequestRedirect'')' - - message: filter.urlRewrite must be nil if the filter.type - is not URLRewrite - rule: '!(has(self.urlRewrite) && self.type != ''URLRewrite'')' - - message: filter.urlRewrite must be specified for URLRewrite - filter.type - rule: '!(!has(self.urlRewrite) && self.type == ''URLRewrite'')' - - message: filter.extensionRef must be nil if the filter.type - is not ExtensionRef - rule: '!(has(self.extensionRef) && self.type != ''ExtensionRef'')' - - message: filter.extensionRef must be specified for - ExtensionRef filter.type - rule: '!(!has(self.extensionRef) && self.type == ''ExtensionRef'')' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: May specify either httpRouteFilterRequestRedirect - or httpRouteFilterRequestRewrite, but not both - rule: '!(self.exists(f, f.type == ''RequestRedirect'') - && self.exists(f, f.type == ''URLRewrite''))' - - message: CORS filter cannot be repeated - rule: self.filter(f, f.type == 'CORS').size() <= 1 - - message: RequestHeaderModifier filter cannot be repeated - rule: self.filter(f, f.type == 'RequestHeaderModifier').size() - <= 1 - - message: ResponseHeaderModifier filter cannot be repeated - rule: self.filter(f, f.type == 'ResponseHeaderModifier').size() - <= 1 - - message: RequestRedirect filter cannot be repeated - rule: self.filter(f, f.type == 'RequestRedirect').size() - <= 1 - - message: URLRewrite filter cannot be repeated - rule: self.filter(f, f.type == 'URLRewrite').size() - <= 1 - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations SHOULD NOT - support ExternalName Services. - - Support: Core (Services with a type other than ExternalName) - - Support: Implementation-specific (Services with type ExternalName) - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the backend. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port specifies the destination port number to use for this resource. - Port is required when the referent is a Kubernetes Service. In this - case, the port number is the service port number, not the target port. - For other resources, destination port might be derived from the referent - resource or this field. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - weight: - default: 1 - description: |- - Weight specifies the proportion of requests forwarded to the referenced - backend. This is computed as weight/(sum of all weights in this - BackendRefs list). For non-zero values, there may be some epsilon from - the exact proportion defined here depending on the precision an - implementation supports. Weight is not a percentage and the sum of - weights does not need to equal 100. - - If only one backend is specified and it has a weight greater than 0, 100% - of the traffic is forwarded to that backend. If weight is set to 0, no - traffic should be forwarded for this entry. If unspecified, weight - defaults to 1. - - Support for this field varies based on the context where used. - format: int32 - maximum: 1000000 - minimum: 0 - type: integer - required: - - name - type: object - x-kubernetes-validations: - - message: Must have port for Service reference - rule: '(size(self.group) == 0 && self.kind == ''Service'') - ? has(self.port) : true' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - filters: - description: |- - Filters define the filters that are applied to requests that match - this rule. - - Wherever possible, implementations SHOULD implement filters in the order - they are specified. - - Implementations MAY choose to implement this ordering strictly, rejecting - any combination or order of filters that cannot be supported. If implementations - choose a strict interpretation of filter ordering, they MUST clearly document - that behavior. - - To reject an invalid combination or order of filters, implementations SHOULD - consider the Route Rules with this configuration invalid. If all Route Rules - in a Route are invalid, the entire Route would be considered invalid. If only - a portion of Route Rules are invalid, implementations MUST set the - "PartiallyInvalid" condition for the Route. - - Conformance-levels at this level are defined based on the type of filter: - - - ALL core filters MUST be supported by all implementations. - - Implementers are encouraged to support extended filters. - - Implementation-specific custom filters have no API guarantees across - implementations. - - Specifying the same filter multiple times is not supported unless explicitly - indicated in the filter. - - All filters are expected to be compatible with each other except for the - URLRewrite and RequestRedirect filters, which may not be combined. If an - implementation cannot support other combinations of filters, they must clearly - document that limitation. In cases where incompatible or unsupported - filters are specified and cause the `Accepted` condition to be set to status - `False`, implementations may use the `IncompatibleFilters` reason to specify - this configuration error. - - Support: Core - items: - description: |- - HTTPRouteFilter defines processing steps that must be completed during the - request or response lifecycle. HTTPRouteFilters are meant as an extension - point to express processing that may be done in Gateway implementations. Some - examples include request or response modification, implementing - authentication strategies, rate-limiting, and traffic shaping. API - guarantee/conformance is defined based on the type of the filter. - properties: - cors: - description: |- - CORS defines a schema for a filter that responds to the - cross-origin request based on HTTP response header. - - Support: Extended - properties: - allowCredentials: - description: |- - AllowCredentials indicates whether the actual cross-origin request allows - to include credentials. - - When set to true, the gateway will include the `Access-Control-Allow-Credentials` - response header with value true (case-sensitive). - - When set to false or omitted the gateway will omit the header - `Access-Control-Allow-Credentials` entirely (this is the standard CORS - behavior). - - Support: Extended - type: boolean - allowHeaders: - description: |- - AllowHeaders indicates which HTTP request headers are supported for - accessing the requested resource. - - Header names are not case-sensitive. - - Multiple header names in the value of the `Access-Control-Allow-Headers` - response header are separated by a comma (","). - - When the `AllowHeaders` field is configured with one or more headers, the - gateway must return the `Access-Control-Allow-Headers` response header - which value is present in the `AllowHeaders` field. - - If any header name in the `Access-Control-Request-Headers` request header - is not included in the list of header names specified by the response - header `Access-Control-Allow-Headers`, it will present an error on the - client side. - - If any header name in the `Access-Control-Allow-Headers` response header - does not recognize by the client, it will also occur an error on the - client side. - - A wildcard indicates that the requests with all HTTP headers are allowed. - If config contains the wildcard "*" in allowHeaders and the request is - not credentialed, the `Access-Control-Allow-Headers` response header - can either use the `*` wildcard or the value of - Access-Control-Request-Headers from the request. - - When the request is credentialed, the gateway must not specify the `*` - wildcard in the `Access-Control-Allow-Headers` response header. When - also the `AllowCredentials` field is true and `AllowHeaders` field - is specified with the `*` wildcard, the gateway must specify one or more - HTTP headers in the value of the `Access-Control-Allow-Headers` response - header. The value of the header `Access-Control-Allow-Headers` is same as - the `Access-Control-Request-Headers` header provided by the client. If - the header `Access-Control-Request-Headers` is not included in the - request, the gateway will omit the `Access-Control-Allow-Headers` - response header, instead of specifying the `*` wildcard. - - Support: Extended - items: - description: |- - HTTPHeaderName is the name of an HTTP header. - - Valid values include: - - * "Authorization" - * "Set-Cookie" - - Invalid values include: - - - ":method" - ":" is an invalid character. This means that HTTP/2 pseudo - headers are not currently supported by this type. - - "/invalid" - "/ " is an invalid character - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - maxItems: 64 - type: array - x-kubernetes-list-type: set - x-kubernetes-validations: - - message: AllowHeaders cannot contain '*' alongside - other methods - rule: '!(''*'' in self && self.size() > 1)' - allowMethods: - description: |- - AllowMethods indicates which HTTP methods are supported for accessing the - requested resource. - - Valid values are any method defined by RFC9110, along with the special - value `*`, which represents all HTTP methods are allowed. - - Method names are case-sensitive, so these values are also case-sensitive. - (See https://www.rfc-editor.org/rfc/rfc2616#section-5.1.1) - - Multiple method names in the value of the `Access-Control-Allow-Methods` - response header are separated by a comma (","). - - A CORS-safelisted method is a method that is `GET`, `HEAD`, or `POST`. - (See https://fetch.spec.whatwg.org/#cors-safelisted-method) The - CORS-safelisted methods are always allowed, regardless of whether they - are specified in the `AllowMethods` field. - - When the `AllowMethods` field is configured with one or more methods, the - gateway must return the `Access-Control-Allow-Methods` response header - which value is present in the `AllowMethods` field. - - If the HTTP method of the `Access-Control-Request-Method` request header - is not included in the list of methods specified by the response header - `Access-Control-Allow-Methods`, it will present an error on the client - side. - - If config contains the wildcard "*" in allowMethods and the request is - not credentialed, the `Access-Control-Allow-Methods` response header - can either use the `*` wildcard or the value of - Access-Control-Request-Method from the request. - - When the request is credentialed, the gateway must not specify the `*` - wildcard in the `Access-Control-Allow-Methods` response header. When - also the `AllowCredentials` field is true and `AllowMethods` field - specified with the `*` wildcard, the gateway must specify one HTTP method - in the value of the Access-Control-Allow-Methods response header. The - value of the header `Access-Control-Allow-Methods` is same as the - `Access-Control-Request-Method` header provided by the client. If the - header `Access-Control-Request-Method` is not included in the request, - the gateway will omit the `Access-Control-Allow-Methods` response header, - instead of specifying the `*` wildcard. - - Support: Extended - items: - enum: - - GET - - HEAD - - POST - - PUT - - DELETE - - CONNECT - - OPTIONS - - TRACE - - PATCH - - '*' - type: string - maxItems: 9 - type: array - x-kubernetes-list-type: set - x-kubernetes-validations: - - message: AllowMethods cannot contain '*' alongside - other methods - rule: '!(''*'' in self && self.size() > 1)' - allowOrigins: - description: |- - AllowOrigins indicates whether the response can be shared with requested - resource from the given `Origin`. - - The `Origin` consists of a scheme and a host, with an optional port, and - takes the form `://(:)`. - - Valid values for scheme are: `http` and `https`. - - Valid values for port are any integer between 1 and 65535 (the list of - available TCP/UDP ports). Note that, if not included, port `80` is - assumed for `http` scheme origins, and port `443` is assumed for `https` - origins. This may affect origin matching. - - The host part of the origin may contain the wildcard character `*`. These - wildcard characters behave as follows: - - * `*` is a greedy match to the _left_, including any number of - DNS labels to the left of its position. This also means that - `*` will include any number of period `.` characters to the - left of its position. - * A wildcard by itself matches all hosts. - - An origin value that includes _only_ the `*` character indicates requests - from all `Origin`s are allowed. - - When the `AllowOrigins` field is configured with multiple origins, it - means the server supports clients from multiple origins. If the request - `Origin` matches the configured allowed origins, the gateway must return - the given `Origin` and sets value of the header - `Access-Control-Allow-Origin` same as the `Origin` header provided by the - client. - - The status code of a successful response to a "preflight" request is - always an OK status (i.e., 204 or 200). - - If the request `Origin` does not match the configured allowed origins, - the gateway returns 204/200 response but doesn't set the relevant - cross-origin response headers. Alternatively, the gateway responds with - 403 status to the "preflight" request is denied, coupled with omitting - the CORS headers. The cross-origin request fails on the client side. - Therefore, the client doesn't attempt the actual cross-origin request. - - Conversely, if the request `Origin` matches one of the configured - allowed origins, the gateway sets the response header - `Access-Control-Allow-Origin` to the same value as the `Origin` - header provided by the client. - - When config has the wildcard ("*") in allowOrigins, and the request - is not credentialed (e.g., it is a preflight request), the - `Access-Control-Allow-Origin` response header either contains the - wildcard as well or the Origin from the request. - - When the request is credentialed, the gateway must not specify the `*` - wildcard in the `Access-Control-Allow-Origin` response header. When - also the `AllowCredentials` field is true and `AllowOrigins` field - specified with the `*` wildcard, the gateway must return a single origin - in the value of the `Access-Control-Allow-Origin` response header, - instead of specifying the `*` wildcard. The value of the header - `Access-Control-Allow-Origin` is same as the `Origin` header provided by - the client. - - Support: Extended - items: - description: |- - The CORSOrigin MUST NOT be a relative URI, and it MUST follow the URI syntax and - encoding rules specified in RFC3986. The CORSOrigin MUST include both a - scheme ("http" or "https") and a scheme-specific-part, or it should be a single '*' character. - URIs that include an authority MUST include a fully qualified domain name or - IP address as the host. - maxLength: 253 - minLength: 1 - pattern: (^\*$)|(^(http(s)?):\/\/(((\*\.)?([a-zA-Z0-9\-]+\.)*[a-zA-Z0-9-]+|\*)(:([0-9]{1,5}))?)$) - type: string - maxItems: 64 - type: array - x-kubernetes-list-type: set - x-kubernetes-validations: - - message: AllowOrigins cannot contain '*' alongside - other origins - rule: '!(''*'' in self && self.size() > 1)' - exposeHeaders: - description: |- - ExposeHeaders indicates which HTTP response headers can be exposed - to client-side scripts in response to a cross-origin request. - - A CORS-safelisted response header is an HTTP header in a CORS response - that it is considered safe to expose to the client scripts. - The CORS-safelisted response headers include the following headers: - `Cache-Control` - `Content-Language` - `Content-Length` - `Content-Type` - `Expires` - `Last-Modified` - `Pragma` - (See https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name) - The CORS-safelisted response headers are exposed to client by default. - - When an HTTP header name is specified using the `ExposeHeaders` field, - this additional header will be exposed as part of the response to the - client. - - Header names are not case-sensitive. - - Multiple header names in the value of the `Access-Control-Expose-Headers` - response header are separated by a comma (","). - - A wildcard indicates that the responses with all HTTP headers are exposed - to clients. The `Access-Control-Expose-Headers` response header can only - use `*` wildcard as value when the request is not credentialed. - - When the `exposeHeaders` config field contains the "*" wildcard and - the request is credentialed, the gateway cannot use the `*` wildcard in - the `Access-Control-Expose-Headers` response header. - - Support: Extended - items: - description: |- - HTTPHeaderName is the name of an HTTP header. - - Valid values include: - - * "Authorization" - * "Set-Cookie" - - Invalid values include: - - - ":method" - ":" is an invalid character. This means that HTTP/2 pseudo - headers are not currently supported by this type. - - "/invalid" - "/ " is an invalid character - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - maxItems: 64 - type: array - x-kubernetes-list-type: set - maxAge: - default: 5 - description: |- - MaxAge indicates the duration (in seconds) for the client to cache the - results of a "preflight" request. - - The information provided by the `Access-Control-Allow-Methods` and - `Access-Control-Allow-Headers` response headers can be cached by the - client until the time specified by `Access-Control-Max-Age` elapses. - - The default value of `Access-Control-Max-Age` response header is 5 - (seconds). - - When the `MaxAge` field is unspecified, the gateway sets the response - header "Access-Control-Max-Age: 5" by default. - format: int32 - minimum: 1 - type: integer - type: object - extensionRef: - description: |- - ExtensionRef is an optional, implementation-specific extension to the - "filter" behavior. For example, resource "myroutefilter" in group - "networking.example.net"). ExtensionRef MUST NOT be used for core and - extended filters. - - This filter can be used multiple times within the same rule. - - Support: Implementation-specific - properties: - group: - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is kind of the referent. For example - "HTTPRoute" or "Service". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - required: - - group - - kind - - name - type: object - requestHeaderModifier: - description: |- - RequestHeaderModifier defines a schema for a filter that modifies request - headers. - - Support: Core - properties: - add: - description: |- - Add adds the given header(s) (name, value) to the request - before the action. It appends to any existing values associated - with the header name. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - add: - - name: "my-header" - value: "bar,baz" - - Output: - GET /foo HTTP/1.1 - my-header: foo,bar,baz - items: - description: HTTPHeader represents an HTTP Header - name and value as defined by RFC 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - remove: - description: |- - Remove the given header(s) from the HTTP request before the action. The - value of Remove is a list of HTTP header names. Note that the header - names are case-insensitive (see - https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). - - Input: - GET /foo HTTP/1.1 - my-header1: foo - my-header2: bar - my-header3: baz - - Config: - remove: ["my-header1", "my-header3"] - - Output: - GET /foo HTTP/1.1 - my-header2: bar - items: - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: set - set: - description: |- - Set overwrites the request with the given header (name, value) - before the action. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - set: - - name: "my-header" - value: "bar" - - Output: - GET /foo HTTP/1.1 - my-header: bar - items: - description: HTTPHeader represents an HTTP Header - name and value as defined by RFC 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - requestMirror: - description: |- - RequestMirror defines a schema for a filter that mirrors requests. - Requests are sent to the specified destination, but responses from - that destination are ignored. - - This filter can be used multiple times within the same rule. Note that - not all implementations will be able to support mirroring to multiple - backends. - - Support: Extended - properties: - backendRef: - description: |- - BackendRef references a resource where mirrored requests are sent. - - Mirrored requests must be sent only to a single destination endpoint - within this BackendRef, irrespective of how many endpoints are present - within this BackendRef. - - If the referent cannot be found, this BackendRef is invalid and must be - dropped from the Gateway. The controller must ensure the "ResolvedRefs" - condition on the Route status is set to `status: False` and not configure - this backend in the underlying implementation. - - If there is a cross-namespace reference to an *existing* object - that is not allowed by a ReferenceGrant, the controller must ensure the - "ResolvedRefs" condition on the Route is set to `status: False`, - with the "RefNotPermitted" reason and not configure this backend in the - underlying implementation. - - In either error case, the Message of the `ResolvedRefs` Condition - should be used to provide more detail about the problem. - - Support: Extended for Kubernetes Service - - Support: Implementation-specific for any other resource - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations SHOULD NOT - support ExternalName Services. - - Support: Core (Services with a type other than ExternalName) - - Support: Implementation-specific (Services with type ExternalName) - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the backend. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port specifies the destination port number to use for this resource. - Port is required when the referent is a Kubernetes Service. In this - case, the port number is the service port number, not the target port. - For other resources, destination port might be derived from the referent - resource or this field. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - required: - - name - type: object - x-kubernetes-validations: - - message: Must have port for Service reference - rule: '(size(self.group) == 0 && self.kind == ''Service'') - ? has(self.port) : true' - fraction: - description: |- - Fraction represents the fraction of requests that should be - mirrored to BackendRef. - - Only one of Fraction or Percent may be specified. If neither field - is specified, 100% of requests will be mirrored. - properties: - denominator: - default: 100 - format: int32 - minimum: 1 - type: integer - numerator: - format: int32 - minimum: 0 - type: integer - required: - - numerator - type: object - x-kubernetes-validations: - - message: numerator must be less than or equal to - denominator - rule: self.numerator <= self.denominator - percent: - description: |- - Percent represents the percentage of requests that should be - mirrored to BackendRef. Its minimum value is 0 (indicating 0% of - requests) and its maximum value is 100 (indicating 100% of requests). - - Only one of Fraction or Percent may be specified. If neither field - is specified, 100% of requests will be mirrored. - format: int32 - maximum: 100 - minimum: 0 - type: integer - required: - - backendRef - type: object - x-kubernetes-validations: - - message: Only one of percent or fraction may be specified - in HTTPRequestMirrorFilter - rule: '!(has(self.percent) && has(self.fraction))' - requestRedirect: - description: |- - RequestRedirect defines a schema for a filter that responds to the - request with an HTTP redirection. - - Support: Core - properties: - hostname: - description: |- - Hostname is the hostname to be used in the value of the `Location` - header in the response. - When empty, the hostname in the `Host` header of the request is used. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - path: - description: |- - Path defines parameters used to modify the path of the incoming request. - The modified path is then used to construct the `Location` header. When - empty, the request path is used as-is. - - Support: Extended - properties: - replaceFullPath: - description: |- - ReplaceFullPath specifies the value with which to replace the full path - of a request during a rewrite or redirect. - maxLength: 1024 - type: string - replacePrefixMatch: - description: |- - ReplacePrefixMatch specifies the value with which to replace the prefix - match of a request during a rewrite or redirect. For example, a request - to "/foo/bar" with a prefix match of "/foo" and a ReplacePrefixMatch - of "/xyz" would be modified to "/xyz/bar". - - Note that this matches the behavior of the PathPrefix match type. This - matches full path elements. A path element refers to the list of labels - in the path split by the `/` separator. When specified, a trailing `/` is - ignored. For example, the paths `/abc`, `/abc/`, and `/abc/def` would all - match the prefix `/abc`, but the path `/abcd` would not. - - ReplacePrefixMatch is only compatible with a `PathPrefix` HTTPRouteMatch. - Using any other HTTPRouteMatch type on the same HTTPRouteRule will result in - the implementation setting the Accepted Condition for the Route to `status: False`. - - Request Path | Prefix Match | Replace Prefix | Modified Path - maxLength: 1024 - type: string - type: - description: |- - Type defines the type of path modifier. Additional types may be - added in a future release of the API. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - enum: - - ReplaceFullPath - - ReplacePrefixMatch - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: replaceFullPath must be specified when - type is set to 'ReplaceFullPath' - rule: 'self.type == ''ReplaceFullPath'' ? has(self.replaceFullPath) - : true' - - message: type must be 'ReplaceFullPath' when replaceFullPath - is set - rule: 'has(self.replaceFullPath) ? self.type == - ''ReplaceFullPath'' : true' - - message: replacePrefixMatch must be specified when - type is set to 'ReplacePrefixMatch' - rule: 'self.type == ''ReplacePrefixMatch'' ? has(self.replacePrefixMatch) - : true' - - message: type must be 'ReplacePrefixMatch' when - replacePrefixMatch is set - rule: 'has(self.replacePrefixMatch) ? self.type - == ''ReplacePrefixMatch'' : true' - port: - description: |- - Port is the port to be used in the value of the `Location` - header in the response. - - If no port is specified, the redirect port MUST be derived using the - following rules: - - * If redirect scheme is not-empty, the redirect port MUST be the well-known - port associated with the redirect scheme. Specifically "http" to port 80 - and "https" to port 443. If the redirect scheme does not have a - well-known port, the listener port of the Gateway SHOULD be used. - * If redirect scheme is empty, the redirect port MUST be the Gateway - Listener port. - - Implementations SHOULD NOT add the port number in the 'Location' - header in the following cases: - - * A Location header that will use HTTP (whether that is determined via - the Listener protocol or the Scheme field) _and_ use port 80. - * A Location header that will use HTTPS (whether that is determined via - the Listener protocol or the Scheme field) _and_ use port 443. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - scheme: - description: |- - Scheme is the scheme to be used in the value of the `Location` header in - the response. When empty, the scheme of the request is used. - - Scheme redirects can affect the port of the redirect, for more information, - refer to the documentation for the port field of this filter. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - - Support: Extended - enum: - - http - - https - type: string - statusCode: - default: 302 - description: |- - StatusCode is the HTTP status code to be used in response. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - - Support: Core - enum: - - 301 - - 302 - - 303 - - 307 - - 308 - type: integer - type: object - responseHeaderModifier: - description: |- - ResponseHeaderModifier defines a schema for a filter that modifies response - headers. - - Support: Extended - properties: - add: - description: |- - Add adds the given header(s) (name, value) to the request - before the action. It appends to any existing values associated - with the header name. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - add: - - name: "my-header" - value: "bar,baz" - - Output: - GET /foo HTTP/1.1 - my-header: foo,bar,baz - items: - description: HTTPHeader represents an HTTP Header - name and value as defined by RFC 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - remove: - description: |- - Remove the given header(s) from the HTTP request before the action. The - value of Remove is a list of HTTP header names. Note that the header - names are case-insensitive (see - https://datatracker.ietf.org/doc/html/rfc2616#section-4.2). - - Input: - GET /foo HTTP/1.1 - my-header1: foo - my-header2: bar - my-header3: baz - - Config: - remove: ["my-header1", "my-header3"] - - Output: - GET /foo HTTP/1.1 - my-header2: bar - items: - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: set - set: - description: |- - Set overwrites the request with the given header (name, value) - before the action. - - Input: - GET /foo HTTP/1.1 - my-header: foo - - Config: - set: - - name: "my-header" - value: "bar" - - Output: - GET /foo HTTP/1.1 - my-header: bar - items: - description: HTTPHeader represents an HTTP Header - name and value as defined by RFC 7230. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, the first entry with - an equivalent name MUST be considered for a match. Subsequent entries - with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - value: - description: Value is the value of HTTP Header - to be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - type: - description: |- - Type identifies the type of filter to apply. As with other API fields, - types are classified into three conformance levels: - - - Core: Filter types and their corresponding configuration defined by - "Support: Core" in this package, e.g. "RequestHeaderModifier". All - implementations must support core filters. - - - Extended: Filter types and their corresponding configuration defined by - "Support: Extended" in this package, e.g. "RequestMirror". Implementers - are encouraged to support extended filters. - - - Implementation-specific: Filters that are defined and supported by - specific vendors. - In the future, filters showing convergence in behavior across multiple - implementations will be considered for inclusion in extended or core - conformance levels. Filter-specific configuration for such filters - is specified using the ExtensionRef field. `Type` should be set to - "ExtensionRef" for custom filters. - - Implementers are encouraged to define custom implementation types to - extend the core API with implementation-specific behavior. - - If a reference to a custom filter type cannot be resolved, the filter - MUST NOT be skipped. Instead, requests that would have been processed by - that filter MUST receive a HTTP error response. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - enum: - - RequestHeaderModifier - - ResponseHeaderModifier - - RequestMirror - - RequestRedirect - - URLRewrite - - ExtensionRef - - CORS - type: string - urlRewrite: - description: |- - URLRewrite defines a schema for a filter that modifies a request during forwarding. - - Support: Extended - properties: - hostname: - description: |- - Hostname is the value to be used to replace the Host header value during - forwarding. - - Support: Extended - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - path: - description: |- - Path defines a path rewrite. - - Support: Extended - properties: - replaceFullPath: - description: |- - ReplaceFullPath specifies the value with which to replace the full path - of a request during a rewrite or redirect. - maxLength: 1024 - type: string - replacePrefixMatch: - description: |- - ReplacePrefixMatch specifies the value with which to replace the prefix - match of a request during a rewrite or redirect. For example, a request - to "/foo/bar" with a prefix match of "/foo" and a ReplacePrefixMatch - of "/xyz" would be modified to "/xyz/bar". - - Note that this matches the behavior of the PathPrefix match type. This - matches full path elements. A path element refers to the list of labels - in the path split by the `/` separator. When specified, a trailing `/` is - ignored. For example, the paths `/abc`, `/abc/`, and `/abc/def` would all - match the prefix `/abc`, but the path `/abcd` would not. - - ReplacePrefixMatch is only compatible with a `PathPrefix` HTTPRouteMatch. - Using any other HTTPRouteMatch type on the same HTTPRouteRule will result in - the implementation setting the Accepted Condition for the Route to `status: False`. - - Request Path | Prefix Match | Replace Prefix | Modified Path - maxLength: 1024 - type: string - type: - description: |- - Type defines the type of path modifier. Additional types may be - added in a future release of the API. - - Note that values may be added to this enum, implementations - must ensure that unknown values will not cause a crash. - - Unknown values here must result in the implementation setting the - Accepted Condition for the Route to `status: False`, with a - Reason of `UnsupportedValue`. - enum: - - ReplaceFullPath - - ReplacePrefixMatch - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: replaceFullPath must be specified when - type is set to 'ReplaceFullPath' - rule: 'self.type == ''ReplaceFullPath'' ? has(self.replaceFullPath) - : true' - - message: type must be 'ReplaceFullPath' when replaceFullPath - is set - rule: 'has(self.replaceFullPath) ? self.type == - ''ReplaceFullPath'' : true' - - message: replacePrefixMatch must be specified when - type is set to 'ReplacePrefixMatch' - rule: 'self.type == ''ReplacePrefixMatch'' ? has(self.replacePrefixMatch) - : true' - - message: type must be 'ReplacePrefixMatch' when - replacePrefixMatch is set - rule: 'has(self.replacePrefixMatch) ? self.type - == ''ReplacePrefixMatch'' : true' - type: object - required: - - type - type: object - x-kubernetes-validations: - - message: filter.cors must be nil if the filter.type is not - CORS - rule: '!(has(self.cors) && self.type != ''CORS'')' - - message: filter.cors must be specified for CORS filter.type - rule: '!(!has(self.cors) && self.type == ''CORS'')' - - message: filter.requestHeaderModifier must be nil if the - filter.type is not RequestHeaderModifier - rule: '!(has(self.requestHeaderModifier) && self.type != - ''RequestHeaderModifier'')' - - message: filter.requestHeaderModifier must be specified - for RequestHeaderModifier filter.type - rule: '!(!has(self.requestHeaderModifier) && self.type == - ''RequestHeaderModifier'')' - - message: filter.responseHeaderModifier must be nil if the - filter.type is not ResponseHeaderModifier - rule: '!(has(self.responseHeaderModifier) && self.type != - ''ResponseHeaderModifier'')' - - message: filter.responseHeaderModifier must be specified - for ResponseHeaderModifier filter.type - rule: '!(!has(self.responseHeaderModifier) && self.type - == ''ResponseHeaderModifier'')' - - message: filter.requestMirror must be nil if the filter.type - is not RequestMirror - rule: '!(has(self.requestMirror) && self.type != ''RequestMirror'')' - - message: filter.requestMirror must be specified for RequestMirror - filter.type - rule: '!(!has(self.requestMirror) && self.type == ''RequestMirror'')' - - message: filter.requestRedirect must be nil if the filter.type - is not RequestRedirect - rule: '!(has(self.requestRedirect) && self.type != ''RequestRedirect'')' - - message: filter.requestRedirect must be specified for RequestRedirect - filter.type - rule: '!(!has(self.requestRedirect) && self.type == ''RequestRedirect'')' - - message: filter.urlRewrite must be nil if the filter.type - is not URLRewrite - rule: '!(has(self.urlRewrite) && self.type != ''URLRewrite'')' - - message: filter.urlRewrite must be specified for URLRewrite - filter.type - rule: '!(!has(self.urlRewrite) && self.type == ''URLRewrite'')' - - message: filter.extensionRef must be nil if the filter.type - is not ExtensionRef - rule: '!(has(self.extensionRef) && self.type != ''ExtensionRef'')' - - message: filter.extensionRef must be specified for ExtensionRef - filter.type - rule: '!(!has(self.extensionRef) && self.type == ''ExtensionRef'')' - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: May specify either httpRouteFilterRequestRedirect - or httpRouteFilterRequestRewrite, but not both - rule: '!(self.exists(f, f.type == ''RequestRedirect'') && - self.exists(f, f.type == ''URLRewrite''))' - - message: CORS filter cannot be repeated - rule: self.filter(f, f.type == 'CORS').size() <= 1 - - message: RequestHeaderModifier filter cannot be repeated - rule: self.filter(f, f.type == 'RequestHeaderModifier').size() - <= 1 - - message: ResponseHeaderModifier filter cannot be repeated - rule: self.filter(f, f.type == 'ResponseHeaderModifier').size() - <= 1 - - message: RequestRedirect filter cannot be repeated - rule: self.filter(f, f.type == 'RequestRedirect').size() <= - 1 - - message: URLRewrite filter cannot be repeated - rule: self.filter(f, f.type == 'URLRewrite').size() <= 1 - matches: - default: - - path: - type: PathPrefix - value: / - description: |- - Matches define conditions used for matching the rule against incoming - HTTP requests. Each match is independent, i.e. this rule will be matched - if **any** one of the matches is satisfied. - - For example, take the following matches configuration: - - ``` - matches: - - path: - value: "/foo" - headers: - - name: "version" - value: "v2" - - path: - value: "/v2/foo" - ``` - - For a request to match against this rule, a request must satisfy - EITHER of the two conditions: - - - path prefixed with `/foo` AND contains the header `version: v2` - - path prefix of `/v2/foo` - - See the documentation for HTTPRouteMatch on how to specify multiple - match conditions that should be ANDed together. - - If no matches are specified, the default is a prefix - path match on "/", which has the effect of matching every - HTTP request. - - Proxy or Load Balancer routing configuration generated from HTTPRoutes - MUST prioritize matches based on the following criteria, continuing on - ties. Across all rules specified on applicable Routes, precedence must be - given to the match having: - - * "Exact" path match. - * "Prefix" path match with largest number of characters. - * Method match. - * Largest number of header matches. - * Largest number of query param matches. - - Note: The precedence of RegularExpression path matches are implementation-specific. - - If ties still exist across multiple Routes, matching precedence MUST be - determined in order of the following criteria, continuing on ties: - - * The oldest Route based on creation timestamp. - * The Route appearing first in alphabetical order by - "{namespace}/{name}". - - If ties still exist within an HTTPRoute, matching precedence MUST be granted - to the FIRST matching rule (in list order) with a match meeting the above - criteria. - - When no rules matching a request have been successfully attached to the - parent a request is coming from, a HTTP 404 status code MUST be returned. - items: - description: "HTTPRouteMatch defines the predicate used to - match requests to a given\naction. Multiple match types - are ANDed together, i.e. the match will\nevaluate to true - only if all conditions are satisfied.\n\nFor example, the - match below will match a HTTP request only if its path\nstarts - with `/foo` AND it contains the `version: v1` header:\n\n```\nmatch:\n\n\tpath:\n\t - \ value: \"/foo\"\n\theaders:\n\t- name: \"version\"\n\t - \ value \"v1\"\n\n```" - properties: - headers: - description: |- - Headers specifies HTTP request header matchers. Multiple match values are - ANDed together, meaning, a request must match all the specified headers - to select the route. - items: - description: |- - HTTPHeaderMatch describes how to select a HTTP route by matching HTTP request - headers. - properties: - name: - description: |- - Name is the name of the HTTP Header to be matched. Name matching MUST be - case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2). - - If multiple entries specify equivalent header names, only the first - entry with an equivalent name MUST be considered for a match. Subsequent - entries with an equivalent header name MUST be ignored. Due to the - case-insensitivity of header names, "foo" and "Foo" are considered - equivalent. - - When a header is repeated in an HTTP request, it is - implementation-specific behavior as to how this is represented. - Generally, proxies should follow the guidance from the RFC: - https://www.rfc-editor.org/rfc/rfc7230.html#section-3.2.2 regarding - processing a repeated header, with special handling for "Set-Cookie". - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - type: - default: Exact - description: |- - Type specifies how to match against the value of the header. - - Support: Core (Exact) - - Support: Implementation-specific (RegularExpression) - - Since RegularExpression HeaderMatchType has implementation-specific - conformance, implementations can support POSIX, PCRE or any other dialects - of regular expressions. Please read the implementation's documentation to - determine the supported dialect. - enum: - - Exact - - RegularExpression - type: string - value: - description: Value is the value of HTTP Header to - be matched. - maxLength: 4096 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - method: - description: |- - Method specifies HTTP method matcher. - When specified, this route will be matched only if the request has the - specified method. - - Support: Extended - enum: - - GET - - HEAD - - POST - - PUT - - DELETE - - CONNECT - - OPTIONS - - TRACE - - PATCH - type: string - path: - default: - type: PathPrefix - value: / - description: |- - Path specifies a HTTP request path matcher. If this field is not - specified, a default prefix match on the "/" path is provided. - properties: - type: - default: PathPrefix - description: |- - Type specifies how to match against the path Value. - - Support: Core (Exact, PathPrefix) - - Support: Implementation-specific (RegularExpression) - enum: - - Exact - - PathPrefix - - RegularExpression - type: string - value: - default: / - description: Value of the HTTP path to match against. - maxLength: 1024 - type: string - type: object - x-kubernetes-validations: - - message: value must be an absolute path and start with - '/' when type one of ['Exact', 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? self.value.startsWith(''/'') - : true' - - message: must not contain '//' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.contains(''//'') - : true' - - message: must not contain '/./' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.contains(''/./'') - : true' - - message: must not contain '/../' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.contains(''/../'') - : true' - - message: must not contain '%2f' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.contains(''%2f'') - : true' - - message: must not contain '%2F' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.contains(''%2F'') - : true' - - message: must not contain '#' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.contains(''#'') - : true' - - message: must not end with '/..' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.endsWith(''/..'') - : true' - - message: must not end with '/.' when type one of ['Exact', - 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? !self.value.endsWith(''/.'') - : true' - - message: type must be one of ['Exact', 'PathPrefix', - 'RegularExpression'] - rule: self.type in ['Exact','PathPrefix'] || self.type - == 'RegularExpression' - - message: must only contain valid characters (matching - ^(?:[-A-Za-z0-9/._~!$&'()*+,;=:@]|[%][0-9a-fA-F]{2})+$) - for types ['Exact', 'PathPrefix'] - rule: '(self.type in [''Exact'',''PathPrefix'']) ? self.value.matches(r"""^(?:[-A-Za-z0-9/._~!$&''()*+,;=:@]|[%][0-9a-fA-F]{2})+$""") - : true' - queryParams: - description: |- - QueryParams specifies HTTP query parameter matchers. Multiple match - values are ANDed together, meaning, a request must match all the - specified query parameters to select the route. - - Support: Extended - items: - description: |- - HTTPQueryParamMatch describes how to select a HTTP route by matching HTTP - query parameters. - properties: - name: - description: |- - Name is the name of the HTTP query param to be matched. This must be an - exact string match. (See - https://tools.ietf.org/html/rfc7230#section-2.7.3). - - If multiple entries specify equivalent query param names, only the first - entry with an equivalent name MUST be considered for a match. Subsequent - entries with an equivalent query param name MUST be ignored. - - If a query param is repeated in an HTTP request, the behavior is - purposely left undefined, since different data planes have different - capabilities. However, it is *recommended* that implementations should - match against the first value of the param if the data plane supports it, - as this behavior is expected in other load balancing contexts outside of - the Gateway API. - - Users SHOULD NOT route traffic based on repeated query params to guard - themselves against potential differences in the implementations. - maxLength: 256 - minLength: 1 - pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ - type: string - type: - default: Exact - description: |- - Type specifies how to match against the value of the query parameter. - - Support: Extended (Exact) - - Support: Implementation-specific (RegularExpression) - - Since RegularExpression QueryParamMatchType has Implementation-specific - conformance, implementations can support POSIX, PCRE or any other - dialects of regular expressions. Please read the implementation's - documentation to determine the supported dialect. - enum: - - Exact - - RegularExpression - type: string - value: - description: Value is the value of HTTP query param - to be matched. - maxLength: 1024 - minLength: 1 - type: string - required: - - name - - value - type: object - maxItems: 16 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - maxItems: 64 - type: array - x-kubernetes-list-type: atomic - name: - description: |- - Name is the name of the route rule. This name MUST be unique within a Route if it is set. - - Support: Extended - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - timeouts: - description: |- - Timeouts defines the timeouts that can be configured for an HTTP request. - - Support: Extended - properties: - backendRequest: - description: |- - BackendRequest specifies a timeout for an individual request from the gateway - to a backend. This covers the time from when the request first starts being - sent from the gateway to when the full response has been received from the backend. - - Setting a timeout to the zero duration (e.g. "0s") SHOULD disable the timeout - completely. Implementations that cannot completely disable the timeout MUST - instead interpret the zero duration as the longest possible value to which - the timeout can be set. - - An entire client HTTP transaction with a gateway, covered by the Request timeout, - may result in more than one call from the gateway to the destination backend, - for example, if automatic retries are supported. - - The value of BackendRequest must be a Gateway API Duration string as defined by - GEP-2257. When this field is unspecified, its behavior is implementation-specific; - when specified, the value of BackendRequest must be no more than the value of the - Request timeout (since the Request timeout encompasses the BackendRequest timeout). - - Support: Extended - pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ - type: string - request: - description: |- - Request specifies the maximum duration for a gateway to respond to an HTTP request. - If the gateway has not been able to respond before this deadline is met, the gateway - MUST return a timeout error. - - For example, setting the `rules.timeouts.request` field to the value `10s` in an - `HTTPRoute` will cause a timeout if a client request is taking longer than 10 seconds - to complete. - - Setting a timeout to the zero duration (e.g. "0s") SHOULD disable the timeout - completely. Implementations that cannot completely disable the timeout MUST - instead interpret the zero duration as the longest possible value to which - the timeout can be set. - - This timeout is intended to cover as close to the whole request-response transaction - as possible although an implementation MAY choose to start the timeout after the entire - request stream has been received instead of immediately after the transaction is - initiated by the client. - - The value of Request is a Gateway API Duration string as defined by GEP-2257. When this - field is unspecified, request timeout behavior is implementation-specific. - - Support: Extended - pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ - type: string - type: object - x-kubernetes-validations: - - message: backendRequest timeout cannot be longer than request - timeout - rule: '!(has(self.request) && has(self.backendRequest) && - duration(self.request) != duration(''0s'') && duration(self.backendRequest) - > duration(self.request))' - type: object - x-kubernetes-validations: - - message: RequestRedirect filter must not be used together with - backendRefs - rule: '(has(self.backendRefs) && size(self.backendRefs) > 0) ? - (!has(self.filters) || self.filters.all(f, !has(f.requestRedirect))): - true' - - message: When using RequestRedirect filter with path.replacePrefixMatch, - exactly one PathPrefix match must be specified - rule: '(has(self.filters) && self.filters.exists_one(f, has(f.requestRedirect) - && has(f.requestRedirect.path) && f.requestRedirect.path.type - == ''ReplacePrefixMatch'' && has(f.requestRedirect.path.replacePrefixMatch))) - ? ((size(self.matches) != 1 || !has(self.matches[0].path) || - self.matches[0].path.type != ''PathPrefix'') ? false : true) - : true' - - message: When using URLRewrite filter with path.replacePrefixMatch, - exactly one PathPrefix match must be specified - rule: '(has(self.filters) && self.filters.exists_one(f, has(f.urlRewrite) - && has(f.urlRewrite.path) && f.urlRewrite.path.type == ''ReplacePrefixMatch'' - && has(f.urlRewrite.path.replacePrefixMatch))) ? ((size(self.matches) - != 1 || !has(self.matches[0].path) || self.matches[0].path.type - != ''PathPrefix'') ? false : true) : true' - - message: Within backendRefs, when using RequestRedirect filter - with path.replacePrefixMatch, exactly one PathPrefix match must - be specified - rule: '(has(self.backendRefs) && self.backendRefs.exists_one(b, - (has(b.filters) && b.filters.exists_one(f, has(f.requestRedirect) - && has(f.requestRedirect.path) && f.requestRedirect.path.type - == ''ReplacePrefixMatch'' && has(f.requestRedirect.path.replacePrefixMatch))) - )) ? ((size(self.matches) != 1 || !has(self.matches[0].path) - || self.matches[0].path.type != ''PathPrefix'') ? false : true) - : true' - - message: Within backendRefs, When using URLRewrite filter with - path.replacePrefixMatch, exactly one PathPrefix match must be - specified - rule: '(has(self.backendRefs) && self.backendRefs.exists_one(b, - (has(b.filters) && b.filters.exists_one(f, has(f.urlRewrite) - && has(f.urlRewrite.path) && f.urlRewrite.path.type == ''ReplacePrefixMatch'' - && has(f.urlRewrite.path.replacePrefixMatch))) )) ? ((size(self.matches) - != 1 || !has(self.matches[0].path) || self.matches[0].path.type - != ''PathPrefix'') ? false : true) : true' - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: While 16 rules and 64 matches per rule are allowed, the - total number of matches across all rules in a route must be less - than 128 - rule: '(self.size() > 0 ? self[0].matches.size() : 0) + (self.size() - > 1 ? self[1].matches.size() : 0) + (self.size() > 2 ? self[2].matches.size() - : 0) + (self.size() > 3 ? self[3].matches.size() : 0) + (self.size() - > 4 ? self[4].matches.size() : 0) + (self.size() > 5 ? self[5].matches.size() - : 0) + (self.size() > 6 ? self[6].matches.size() : 0) + (self.size() - > 7 ? self[7].matches.size() : 0) + (self.size() > 8 ? self[8].matches.size() - : 0) + (self.size() > 9 ? self[9].matches.size() : 0) + (self.size() - > 10 ? self[10].matches.size() : 0) + (self.size() > 11 ? self[11].matches.size() - : 0) + (self.size() > 12 ? self[12].matches.size() : 0) + (self.size() - > 13 ? self[13].matches.size() : 0) + (self.size() > 14 ? self[14].matches.size() - : 0) + (self.size() > 15 ? self[15].matches.size() : 0) <= 128' - type: object - status: - description: Status defines the current state of HTTPRoute. - properties: - parents: - description: |- - Parents is a list of parent resources (usually Gateways) that are - associated with the route, and the status of the route with respect to - each parent. When this route attaches to a parent, the controller that - manages the parent must add an entry to this list when the controller - first sees the route and should update the entry as appropriate when the - route or gateway is modified. - - Note that parent references that cannot be resolved by an implementation - of this API will not be added to this list. Implementations of this API - can only populate Route status for the Gateways/parent resources they are - responsible for. - - A maximum of 32 Gateways will be represented in this list. An empty list - means the route has not been attached to any Gateway. - items: - description: |- - RouteParentStatus describes the status of a route with respect to an - associated Parent. - properties: - conditions: - description: |- - Conditions describes the status of the route with respect to the Gateway. - Note that the route's availability is also subject to the Gateway's own - status conditions and listener status. - - If the Route's ParentRef specifies an existing Gateway that supports - Routes of this kind AND that Gateway's controller has sufficient access, - then that Gateway's controller MUST set the "Accepted" condition on the - Route, to indicate whether the route has been accepted or rejected by the - Gateway, and why. - - A Route MUST be considered "Accepted" if at least one of the Route's - rules is implemented by the Gateway. - - There are a number of cases where the "Accepted" condition may not be set - due to lack of controller visibility, that includes when: - - * The Route refers to a nonexistent parent. - * The Route is of a type that the controller does not support. - * The Route is in a namespace to which the controller does not have access. - items: - description: Condition contains details for one aspect of - the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, - Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - controllerName: - description: |- - ControllerName is a domain/path string that indicates the name of the - controller that wrote this status. This corresponds with the - controllerName field on GatewayClass. - - Example: "example.net/gateway-controller". - - The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are - valid Kubernetes names - (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). - - Controllers MUST populate this field when writing status. Controllers should ensure that - entries to status populated with their ControllerName are cleaned up when they are no - longer necessary. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - parentRef: - description: |- - ParentRef corresponds with a ParentRef in the spec that this - RouteParentStatus struct describes the status of. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - required: - - conditions - - controllerName - - parentRef - type: object - maxItems: 32 - type: array - x-kubernetes-list-type: atomic - required: - - parents - type: object - required: - - spec - type: object - served: true - storage: false - subresources: - status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: null - storedVersions: null ---- -# -# config/crd/standard/gateway.networking.k8s.io_listenersets.yaml -# -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - api-approved.kubernetes.io: https://github.com/kubernetes-sigs/gateway-api/pull/4530 - gateway.networking.k8s.io/bundle-version: v1.5.1 - gateway.networking.k8s.io/channel: standard - name: listenersets.gateway.networking.k8s.io -spec: - group: gateway.networking.k8s.io - names: - categories: - - gateway-api - kind: ListenerSet - listKind: ListenerSetList - plural: listenersets - shortNames: - - lset - singular: listenerset - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .status.conditions[?(@.type=="Accepted")].status - name: Accepted - type: string - - jsonPath: .status.conditions[?(@.type=="Programmed")].status - name: Programmed - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1 - schema: - openAPIV3Schema: - description: |- - ListenerSet defines a set of additional listeners to attach to an existing Gateway. - This resource provides a mechanism to merge multiple listeners into a single Gateway. - - The parent Gateway must explicitly allow ListenerSet attachment through its - AllowedListeners configuration. By default, Gateways do not allow ListenerSet - attachment. - - Routes can attach to a ListenerSet by specifying it as a parentRef, and can - optionally target specific listeners using the sectionName field. - - Policy Attachment: - - Policies that attach to a ListenerSet apply to all listeners defined in that resource - - Policies do not impact listeners in the parent Gateway - - Different ListenerSets attached to the same Gateway can have different policies - - If an implementation cannot apply a policy to specific listeners, it should reject the policy - - ReferenceGrant Semantics: - - ReferenceGrants applied to a Gateway are not inherited by child ListenerSets - - ReferenceGrants applied to a ListenerSet do not grant permission to the parent Gateway's listeners - - A ListenerSet can reference secrets/backends in its own namespace without a ReferenceGrant - - Gateway Integration: - - The parent Gateway's status will include "AttachedListenerSets" - which is the count of ListenerSets that have successfully attached to a Gateway - A ListenerSet is successfully attached to a Gateway when all the following conditions are met: - - The ListenerSet is selected by the Gateway's AllowedListeners field - - The ListenerSet has a valid ParentRef selecting the Gateway - - The ListenerSet's status has the condition "Accepted: true" - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of ListenerSet. - properties: - listeners: - description: |- - Listeners associated with this ListenerSet. Listeners define - logical endpoints that are bound on this referenced parent Gateway's addresses. - - Listeners in a `Gateway` and their attached `ListenerSets` are concatenated - as a list when programming the underlying infrastructure. Each listener - name does not need to be unique across the Gateway and ListenerSets. - See ListenerEntry.Name for more details. - - Implementations MUST treat the parent Gateway as having the merged - list of all listeners from itself and attached ListenerSets using - the following precedence: - - 1. "parent" Gateway - 2. ListenerSet ordered by creation time (oldest first) - 3. ListenerSet ordered alphabetically by "{namespace}/{name}". - - An implementation MAY reject listeners by setting the ListenerEntryStatus - `Accepted` condition to False with the Reason `TooManyListeners` - - If a listener has a conflict, this will be reported in the - Status.ListenerEntryStatus setting the `Conflicted` condition to True. - - Implementations SHOULD be cautious about what information from the - parent or siblings are reported to avoid accidentally leaking - sensitive information that the child would not otherwise have access - to. This can include contents of secrets etc. - items: - properties: - allowedRoutes: - default: - namespaces: - from: Same - description: |- - AllowedRoutes defines the types of routes that MAY be attached to a - Listener and the trusted namespaces where those Route resources MAY be - present. - - Although a client request may match multiple route rules, only one rule - may ultimately receive the request. Matching precedence MUST be - determined in order of the following criteria: - - * The most specific match as defined by the Route type. - * The oldest Route based on creation timestamp. For example, a Route with - a creation timestamp of "2020-09-08 01:02:03" is given precedence over - a Route with a creation timestamp of "2020-09-08 01:02:04". - * If everything else is equivalent, the Route appearing first in - alphabetical order (namespace/name) should be given precedence. For - example, foo/bar is given precedence over foo/baz. - - All valid rules within a Route attached to this Listener should be - implemented. Invalid Route rules can be ignored (sometimes that will mean - the full Route). If a Route rule transitions from valid to invalid, - support for that Route rule should be dropped to ensure consistency. For - example, even if a filter specified by a Route rule is invalid, the rest - of the rules within that Route should still be supported. - properties: - kinds: - description: |- - Kinds specifies the groups and kinds of Routes that are allowed to bind - to this Gateway Listener. When unspecified or empty, the kinds of Routes - selected are determined using the Listener protocol. - - A RouteGroupKind MUST correspond to kinds of Routes that are compatible - with the application protocol specified in the Listener's Protocol field. - If an implementation does not support or recognize this resource type, it - MUST set the "ResolvedRefs" condition to False for this Listener with the - "InvalidRouteKinds" reason. - - Support: Core - items: - description: RouteGroupKind indicates the group and kind - of a Route resource. - properties: - group: - default: gateway.networking.k8s.io - description: Group is the group of the Route. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is the kind of the Route. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - required: - - kind - type: object - maxItems: 8 - type: array - x-kubernetes-list-type: atomic - namespaces: - default: - from: Same - description: |- - Namespaces indicates namespaces from which Routes may be attached to this - Listener. This is restricted to the namespace of this Gateway by default. - - Support: Core - properties: - from: - default: Same - description: |- - From indicates where Routes will be selected for this Gateway. Possible - values are: - - * All: Routes in all namespaces may be used by this Gateway. - * Selector: Routes in namespaces selected by the selector may be used by - this Gateway. - * Same: Only Routes in the same namespace may be used by this Gateway. - - Support: Core - enum: - - All - - Selector - - Same - type: string - selector: - description: |- - Selector must be specified when From is set to "Selector". In that case, - only Routes in Namespaces matching this Selector will be selected by this - Gateway. This field is ignored for other values of "From". - - Support: Core - properties: - matchExpressions: - description: matchExpressions is a list of label - selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the - selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - type: object - type: object - hostname: - description: |- - Hostname specifies the virtual hostname to match for protocol types that - define this concept. When unspecified, all hostnames are matched. This - field is ignored for protocols that don't require hostname based - matching. - - Implementations MUST apply Hostname matching appropriately for each of - the following protocols: - - * TLS: The Listener Hostname MUST match the SNI. - * HTTP: The Listener Hostname MUST match the Host header of the request. - * HTTPS: The Listener Hostname SHOULD match at both the TLS and HTTP - protocol layers as described above. If an implementation does not - ensure that both the SNI and Host header match the Listener hostname, - it MUST clearly document that. - - For HTTPRoute and TLSRoute resources, there is an interaction with the - `spec.hostnames` array. When both listener and route specify hostnames, - there MUST be an intersection between the values for a Route to be - accepted. For more information, refer to the Route specific Hostnames - documentation. - - Hostnames that are prefixed with a wildcard label (`*.`) are interpreted - as a suffix match. That means that a match for `*.example.com` would match - both `test.example.com`, and `foo.test.example.com`, but not `example.com`. - maxLength: 253 - minLength: 1 - pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - name: - description: |- - Name is the name of the Listener. This name MUST be unique within a - ListenerSet. - - Name is not required to be unique across a Gateway and ListenerSets. - Routes can attach to a Listener by having a ListenerSet as a parentRef - and setting the SectionName - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - port: - description: |- - Port is the network port. Multiple listeners may use the - same port, subject to the Listener compatibility rules. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - protocol: - description: Protocol specifies the network protocol this listener - expects to receive. - maxLength: 255 - minLength: 1 - pattern: ^[a-zA-Z0-9]([-a-zA-Z0-9]*[a-zA-Z0-9])?$|[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9]+$ - type: string - tls: - description: |- - TLS is the TLS configuration for the Listener. This field is required if - the Protocol field is "HTTPS" or "TLS". It is invalid to set this field - if the Protocol field is "HTTP", "TCP", or "UDP". - - The association of SNIs to Certificate defined in ListenerTLSConfig is - defined based on the Hostname field for this listener. - - The GatewayClass MUST use the longest matching SNI out of all - available certificates for any TLS handshake. - properties: - certificateRefs: - description: |- - CertificateRefs contains a series of references to Kubernetes objects that - contains TLS certificates and private keys. These certificates are used to - establish a TLS handshake for requests that match the hostname of the - associated listener. - - A single CertificateRef to a Kubernetes Secret has "Core" support. - Implementations MAY choose to support attaching multiple certificates to - a Listener, but this behavior is implementation-specific. - - References to a resource in different namespace are invalid UNLESS there - is a ReferenceGrant in the target namespace that allows the certificate - to be attached. If a ReferenceGrant does not allow this reference, the - "ResolvedRefs" condition MUST be set to False for this listener with the - "RefNotPermitted" reason. - - This field is required to have at least one element when the mode is set - to "Terminate" (default) and is optional otherwise. - - CertificateRefs can reference to standard Kubernetes resources, i.e. - Secret, or implementation-specific custom resources. - - Support: Core - A single reference to a Kubernetes Secret of type kubernetes.io/tls - - Support: Implementation-specific (More than one reference or other resource types) - items: - description: |- - SecretObjectReference identifies an API object including its namespace, - defaulting to Secret. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - - References to objects with invalid Group and Kind are not valid, and must - be rejected by the implementation, with appropriate Conditions set - on the containing object. - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Secret - description: Kind is kind of the referent. For example - "Secret". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referenced object. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - name - type: object - maxItems: 64 - type: array - x-kubernetes-list-type: atomic - mode: - default: Terminate - description: |- - Mode defines the TLS behavior for the TLS session initiated by the client. - There are two possible modes: - - - Terminate: The TLS session between the downstream client and the - Gateway is terminated at the Gateway. This mode requires certificates - to be specified in some way, such as populating the certificateRefs - field. - - Passthrough: The TLS session is NOT terminated by the Gateway. This - implies that the Gateway can't decipher the TLS stream except for - the ClientHello message of the TLS protocol. The certificateRefs field - is ignored in this mode. - - Support: Core - enum: - - Terminate - - Passthrough - type: string - options: - additionalProperties: - description: |- - AnnotationValue is the value of an annotation in Gateway API. This is used - for validation of maps such as TLS options. This roughly matches Kubernetes - annotation validation, although the length validation in that case is based - on the entire size of the annotations struct. - maxLength: 4096 - minLength: 0 - type: string - description: |- - Options are a list of key/value pairs to enable extended TLS - configuration for each implementation. For example, configuring the - minimum TLS version or supported cipher suites. - - A set of common keys MAY be defined by the API in the future. To avoid - any ambiguity, implementation-specific definitions MUST use - domain-prefixed names, such as `example.com/my-custom-option`. - Un-prefixed names are reserved for key names defined by Gateway API. - - Support: Implementation-specific - maxProperties: 16 - type: object - type: object - x-kubernetes-validations: - - message: certificateRefs or options must be specified when - mode is Terminate - rule: 'self.mode == ''Terminate'' ? size(self.certificateRefs) - > 0 || size(self.options) > 0 : true' - required: - - name - - port - - protocol - type: object - maxItems: 64 - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - x-kubernetes-validations: - - message: tls must not be specified for protocols ['HTTP', 'TCP', - 'UDP'] - rule: 'self.all(l, l.protocol in [''HTTP'', ''TCP'', ''UDP''] ? - !has(l.tls) : true)' - - message: tls mode must be Terminate for protocol HTTPS - rule: 'self.all(l, (l.protocol == ''HTTPS'' && has(l.tls)) ? (l.tls.mode - == '''' || l.tls.mode == ''Terminate'') : true)' - - message: tls mode must be set for protocol TLS - rule: 'self.all(l, (l.protocol == ''TLS'' ? has(l.tls) && has(l.tls.mode) - && l.tls.mode != '''' : true))' - - message: hostname must not be specified for protocols ['TCP', 'UDP'] - rule: 'self.all(l, l.protocol in [''TCP'', ''UDP''] ? (!has(l.hostname) - || l.hostname == '''') : true)' - - message: Listener name must be unique within the Gateway - rule: self.all(l1, self.exists_one(l2, l1.name == l2.name)) - - message: Combination of port, protocol and hostname must be unique - for each listener - rule: 'self.all(l1, !has(l1.port) || self.exists_one(l2, has(l2.port) - && l1.port == l2.port && l1.protocol == l2.protocol && (has(l1.hostname) - && has(l2.hostname) ? l1.hostname == l2.hostname : !has(l1.hostname) - && !has(l2.hostname))))' - parentRef: - description: ParentRef references the Gateway that the listeners are - attached to. - properties: - group: - default: gateway.networking.k8s.io - description: Group is the group of the referent. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: Kind is kind of the referent. For example "Gateway". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. If not present, - the namespace of the referent is assumed to be the same as - the namespace of the referring object. - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - name - type: object - required: - - listeners - - parentRef - type: object - status: - default: - conditions: - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Accepted - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Programmed - description: Status defines the current state of ListenerSet. - properties: - conditions: - default: - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Accepted - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Programmed - description: |- - Conditions describe the current conditions of the ListenerSet. - - Implementations MUST express ListenerSet conditions using the - `ListenerSetConditionType` and `ListenerSetConditionReason` - constants so that operators and tools can converge on a common - vocabulary to describe ListenerSet state. - - Known condition types are: - - * "Accepted" - * "Programmed" - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - listeners: - description: Listeners provide status for each unique listener port - defined in the Spec. - items: - description: ListenerStatus is the status associated with a Listener. - properties: - attachedRoutes: - description: |- - AttachedRoutes represents the total number of Routes that have been - successfully attached to this Listener. - - Successful attachment of a Route to a Listener is based solely on the - combination of the AllowedRoutes field on the corresponding Listener - and the Route's ParentRefs field. A Route is successfully attached to - a Listener when it is selected by the Listener's AllowedRoutes field - AND the Route has a valid ParentRef selecting the whole Gateway - resource or a specific Listener as a parent resource (more detail on - attachment semantics can be found in the documentation on the various - Route kinds ParentRefs fields). Listener status does not impact - successful attachment, i.e. the AttachedRoutes field count MUST be set - for Listeners, even if the Accepted condition of an individual Listener is set - to "False". The AttachedRoutes number represents the number of Routes with - the Accepted condition set to "True" that have been attached to this Listener. - Routes with any other value for the Accepted condition MUST NOT be included - in this count. - - Uses for this field include troubleshooting Route attachment and - measuring blast radius/impact of changes to a Listener. - format: int32 - type: integer - conditions: - description: Conditions describe the current condition of this - listener. - items: - description: Condition contains details for one aspect of - the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, - Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - name: - description: Name is the name of the Listener that this status - corresponds to. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - supportedKinds: - description: |- - SupportedKinds is the list indicating the Kinds supported by this - listener. This MUST represent the kinds supported by an implementation for - that Listener configuration. - - If kinds are specified in Spec that are not supported, they MUST NOT - appear in this list and an implementation MUST set the "ResolvedRefs" - condition to "False" with the "InvalidRouteKinds" reason. If both valid - and invalid Route kinds are specified, the implementation MUST - reference the valid Route kinds that have been specified. - items: - description: RouteGroupKind indicates the group and kind of - a Route resource. - properties: - group: - default: gateway.networking.k8s.io - description: Group is the group of the Route. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: Kind is the kind of the Route. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - required: - - kind - type: object - maxItems: 8 - type: array - x-kubernetes-list-type: atomic - required: - - attachedRoutes - - conditions - - name - type: object - maxItems: 64 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: null - storedVersions: null ---- -# -# config/crd/standard/gateway.networking.k8s.io_referencegrants.yaml -# -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - api-approved.kubernetes.io: https://github.com/kubernetes-sigs/gateway-api/pull/4530 - gateway.networking.k8s.io/bundle-version: v1.5.1 - gateway.networking.k8s.io/channel: standard - name: referencegrants.gateway.networking.k8s.io -spec: - group: gateway.networking.k8s.io - names: - categories: - - gateway-api - kind: ReferenceGrant - listKind: ReferenceGrantList - plural: referencegrants - shortNames: - - refgrant - singular: referencegrant - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1 - schema: - openAPIV3Schema: - description: |- - ReferenceGrant identifies kinds of resources in other namespaces that are - trusted to reference the specified kinds of resources in the same namespace - as the policy. - - Each ReferenceGrant can be used to represent a unique trust relationship. - Additional Reference Grants can be used to add to the set of trusted - sources of inbound references for the namespace they are defined within. - - All cross-namespace references in Gateway API (with the exception of cross-namespace - Gateway-route attachment) require a ReferenceGrant. - - ReferenceGrant is a form of runtime verification allowing users to assert - which cross-namespace object references are permitted. Implementations that - support ReferenceGrant MUST NOT permit cross-namespace references which have - no grant, and MUST respond to the removal of a grant by revoking the access - that the grant allowed. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of ReferenceGrant. - properties: - from: - description: |- - From describes the trusted namespaces and kinds that can reference the - resources described in "To". Each entry in this list MUST be considered - to be an additional place that references can be valid from, or to put - this another way, entries MUST be combined using OR. - - Support: Core - items: - description: ReferenceGrantFrom describes trusted namespaces and - kinds. - properties: - group: - description: |- - Group is the group of the referent. - When empty, the Kubernetes core API group is inferred. - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: |- - Kind is the kind of the referent. Although implementations may support - additional resources, the following types are part of the "Core" - support level for this field. - - When used to permit a SecretObjectReference: - - * Gateway - - When used to permit a BackendObjectReference: - - * GRPCRoute - * HTTPRoute - * TCPRoute - * TLSRoute - * UDPRoute - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - namespace: - description: |- - Namespace is the namespace of the referent. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - group - - kind - - namespace - type: object - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - to: - description: |- - To describes the resources that may be referenced by the resources - described in "From". Each entry in this list MUST be considered to be an - additional place that references can be valid to, or to put this another - way, entries MUST be combined using OR. - - Support: Core - items: - description: |- - ReferenceGrantTo describes what Kinds are allowed as targets of the - references. - properties: - group: - description: |- - Group is the group of the referent. - When empty, the Kubernetes core API group is inferred. - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: |- - Kind is the kind of the referent. Although implementations may support - additional resources, the following types are part of the "Core" - support level for this field: - - * Secret when used to permit a SecretObjectReference - * Service when used to permit a BackendObjectReference - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. When unspecified, this policy - refers to all resources of the specified Group and Kind in the local - namespace. - maxLength: 253 - minLength: 1 - type: string - required: - - group - - kind - type: object - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - required: - - from - - to - type: object - type: object - served: true - storage: false - subresources: {} - - additionalPrinterColumns: - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1beta1 - schema: - openAPIV3Schema: - description: |- - ReferenceGrant identifies kinds of resources in other namespaces that are - trusted to reference the specified kinds of resources in the same namespace - as the policy. - - Each ReferenceGrant can be used to represent a unique trust relationship. - Additional Reference Grants can be used to add to the set of trusted - sources of inbound references for the namespace they are defined within. - - All cross-namespace references in Gateway API (with the exception of cross-namespace - Gateway-route attachment) require a ReferenceGrant. - - ReferenceGrant is a form of runtime verification allowing users to assert - which cross-namespace object references are permitted. Implementations that - support ReferenceGrant MUST NOT permit cross-namespace references which have - no grant, and MUST respond to the removal of a grant by revoking the access - that the grant allowed. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of ReferenceGrant. - properties: - from: - description: |- - From describes the trusted namespaces and kinds that can reference the - resources described in "To". Each entry in this list MUST be considered - to be an additional place that references can be valid from, or to put - this another way, entries MUST be combined using OR. - - Support: Core - items: - description: ReferenceGrantFrom describes trusted namespaces and - kinds. - properties: - group: - description: |- - Group is the group of the referent. - When empty, the Kubernetes core API group is inferred. - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: |- - Kind is the kind of the referent. Although implementations may support - additional resources, the following types are part of the "Core" - support level for this field. - - When used to permit a SecretObjectReference: - - * Gateway - - When used to permit a BackendObjectReference: - - * GRPCRoute - * HTTPRoute - * TCPRoute - * TLSRoute - * UDPRoute - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - namespace: - description: |- - Namespace is the namespace of the referent. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - required: - - group - - kind - - namespace - type: object - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - to: - description: |- - To describes the resources that may be referenced by the resources - described in "From". Each entry in this list MUST be considered to be an - additional place that references can be valid to, or to put this another - way, entries MUST be combined using OR. - - Support: Core - items: - description: |- - ReferenceGrantTo describes what Kinds are allowed as targets of the - references. - properties: - group: - description: |- - Group is the group of the referent. - When empty, the Kubernetes core API group is inferred. - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - description: |- - Kind is the kind of the referent. Although implementations may support - additional resources, the following types are part of the "Core" - support level for this field: - - * Secret when used to permit a SecretObjectReference - * Service when used to permit a BackendObjectReference - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. When unspecified, this policy - refers to all resources of the specified Group and Kind in the local - namespace. - maxLength: 253 - minLength: 1 - type: string - required: - - group - - kind - type: object - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - required: - - from - - to - type: object - type: object - served: true - storage: true - subresources: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: null - storedVersions: null ---- -# -# config/crd/standard/gateway.networking.k8s.io_tlsroutes.yaml -# -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - api-approved.kubernetes.io: https://github.com/kubernetes-sigs/gateway-api/pull/4530 - gateway.networking.k8s.io/bundle-version: v1.5.1 - gateway.networking.k8s.io/channel: standard - name: tlsroutes.gateway.networking.k8s.io -spec: - group: gateway.networking.k8s.io - names: - categories: - - gateway-api - kind: TLSRoute - listKind: TLSRouteList - plural: tlsroutes - singular: tlsroute - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1 - schema: - openAPIV3Schema: - description: |- - The TLSRoute resource is similar to TCPRoute, but can be configured - to match against TLS-specific metadata. This allows more flexibility - in matching streams for a given TLS listener. - - If you need to forward traffic to a single target for a TLS listener, you - could choose to use a TCPRoute with a TLS listener. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of TLSRoute. - properties: - hostnames: - description: |- - Hostnames defines a set of SNI hostnames that should match against the - SNI attribute of TLS ClientHello message in TLS handshake. This matches - the RFC 1123 definition of a hostname with 2 notable exceptions: - - 1. IPs are not allowed in SNI hostnames per RFC 6066. - 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard - label must appear by itself as the first label. - items: - description: |- - Hostname is the fully qualified domain name of a network host. This matches - the RFC 1123 definition of a hostname with 2 notable exceptions: - - 1. IPs are not allowed. - 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard - label must appear by itself as the first label. - - Hostname can be "precise" which is a domain name without the terminating - dot of a network host (e.g. "foo.example.com") or "wildcard", which is a - domain name prefixed with a single wildcard label (e.g. `*.example.com`). - - Note that as per RFC1035 and RFC1123, a *label* must consist of lower case - alphanumeric characters or '-', and must start and end with an alphanumeric - character. No other punctuation is allowed. - maxLength: 253 - minLength: 1 - pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: Hostnames cannot contain an IP - rule: self.all(h, !isIP(h)) - - message: Hostnames must be valid based on RFC-1123 - rule: 'self.all(h, !h.contains(''*'') ? h.matches(''^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*)$'') - : true)' - - message: Wildcards on hostnames must be the first label, and the - rest of hostname must be valid based on RFC-1123 - rule: 'self.all(h, h.contains(''*'') ? (h.startsWith(''*.'') && - h.substring(2).matches(''^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*)$'')) - : true)' - parentRefs: - description: |- - ParentRefs references the resources (usually Gateways) that a Route wants - to be attached to. Note that the referenced parent resource needs to - allow this for the attachment to be complete. For Gateways, that means - the Gateway needs to allow attachment from Routes of this kind and - namespace. For Services, that means the Service must either be in the same - namespace for a "producer" route, or the mesh implementation must support - and allow "consumer" routes for the referenced Service. ReferenceGrant is - not applicable for governing ParentRefs to Services - it is not possible to - create a "producer" route for a Service in a different namespace from the - Route. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - This API may be extended in the future to support additional kinds of parent - resources. - - ParentRefs must be _distinct_. This means either that: - - * They select different objects. If this is the case, then parentRef - entries are distinct. In terms of fields, this means that the - multi-part key defined by `group`, `kind`, `namespace`, and `name` must - be unique across all parentRef entries in the Route. - * They do not select different objects, but for each optional field used, - each ParentRef that selects the same object must set the same set of - optional fields to different values. If one ParentRef sets a - combination of optional fields, all must set the same combination. - - Some examples: - - * If one ParentRef sets `sectionName`, all ParentRefs referencing the - same object must also set `sectionName`. - * If one ParentRef sets `port`, all ParentRefs referencing the same - object must also set `port`. - * If one ParentRef sets `sectionName` and `port`, all ParentRefs - referencing the same object must also set `sectionName` and `port`. - - It is possible to separately reference multiple distinct objects that may - be collapsed by an implementation. For example, some implementations may - choose to merge compatible Gateway Listeners together. If that is the - case, the list of routes attached to those resources should also be - merged. - - Note that for ParentRefs that cross namespace boundaries, there are specific - rules. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example, - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable other kinds of cross-namespace reference. - items: - description: |- - ParentReference identifies an API object (usually a Gateway) that can be considered - a parent of this resource (usually a route). There are two kinds of parent resources - with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - This API may be extended in the future to support additional kinds of parent - resources. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - maxItems: 32 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: sectionName must be specified when parentRefs includes - 2 or more references to the same parent - rule: 'self.all(p1, self.all(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.__namespace__) - || p1.__namespace__ == '''') && (!has(p2.__namespace__) || p2.__namespace__ - == '''')) || (has(p1.__namespace__) && has(p2.__namespace__) && - p1.__namespace__ == p2.__namespace__ )) ? ((!has(p1.sectionName) - || p1.sectionName == '''') == (!has(p2.sectionName) || p2.sectionName - == '''')) : true))' - - message: sectionName must be unique when parentRefs includes 2 or - more references to the same parent - rule: self.all(p1, self.exists_one(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.__namespace__) - || p1.__namespace__ == '') && (!has(p2.__namespace__) || p2.__namespace__ - == '')) || (has(p1.__namespace__) && has(p2.__namespace__) && - p1.__namespace__ == p2.__namespace__ )) && (((!has(p1.sectionName) - || p1.sectionName == '') && (!has(p2.sectionName) || p2.sectionName - == '')) || (has(p1.sectionName) && has(p2.sectionName) && p1.sectionName - == p2.sectionName)))) - rules: - description: Rules are a list of actions. - items: - description: TLSRouteRule is the configuration for a given rule. - properties: - backendRefs: - description: |- - BackendRefs defines the backend(s) where matching requests should be - sent. If unspecified or invalid (refers to a nonexistent resource or - a Service with no endpoints), the rule performs no forwarding; if no - filters are specified that would result in a response being sent, the - underlying implementation must actively reject request attempts to this - backend, by rejecting the connection. Request rejections must respect - weight; if an invalid backend is requested to have 80% of requests, then - 80% of requests must be rejected instead. - - Support: Core for Kubernetes Service - - Support: Extended for Kubernetes ServiceImport - - Support: Implementation-specific for any other resource - - Support for weight: Extended - items: - description: |- - BackendRef defines how a Route should forward a request to a Kubernetes - resource. - - Note that when a namespace different than the local namespace is specified, a - ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Note that when the BackendTLSPolicy object is enabled by the implementation, - there are some extra rules about validity to consider here. See the fields - where this struct is used for more information about the exact behavior. - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations SHOULD NOT - support ExternalName Services. - - Support: Core (Services with a type other than ExternalName) - - Support: Implementation-specific (Services with type ExternalName) - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the backend. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port specifies the destination port number to use for this resource. - Port is required when the referent is a Kubernetes Service. In this - case, the port number is the service port number, not the target port. - For other resources, destination port might be derived from the referent - resource or this field. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - weight: - default: 1 - description: |- - Weight specifies the proportion of requests forwarded to the referenced - backend. This is computed as weight/(sum of all weights in this - BackendRefs list). For non-zero values, there may be some epsilon from - the exact proportion defined here depending on the precision an - implementation supports. Weight is not a percentage and the sum of - weights does not need to equal 100. - - If only one backend is specified and it has a weight greater than 0, 100% - of the traffic is forwarded to that backend. If weight is set to 0, no - traffic should be forwarded for this entry. If unspecified, weight - defaults to 1. - - Support for this field varies based on the context where used. - format: int32 - maximum: 1000000 - minimum: 0 - type: integer - required: - - name - type: object - x-kubernetes-validations: - - message: Must have port for Service reference - rule: '(size(self.group) == 0 && self.kind == ''Service'') - ? has(self.port) : true' - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - name: - description: Name is the name of the route rule. This name MUST - be unique within a Route if it is set. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - backendRefs - type: object - maxItems: 1 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - required: - - hostnames - - rules - type: object - status: - description: Status defines the current state of TLSRoute. - properties: - parents: - description: |- - Parents is a list of parent resources (usually Gateways) that are - associated with the route, and the status of the route with respect to - each parent. When this route attaches to a parent, the controller that - manages the parent must add an entry to this list when the controller - first sees the route and should update the entry as appropriate when the - route or gateway is modified. - - Note that parent references that cannot be resolved by an implementation - of this API will not be added to this list. Implementations of this API - can only populate Route status for the Gateways/parent resources they are - responsible for. - - A maximum of 32 Gateways will be represented in this list. An empty list - means the route has not been attached to any Gateway. - items: - description: |- - RouteParentStatus describes the status of a route with respect to an - associated Parent. - properties: - conditions: - description: |- - Conditions describes the status of the route with respect to the Gateway. - Note that the route's availability is also subject to the Gateway's own - status conditions and listener status. - - If the Route's ParentRef specifies an existing Gateway that supports - Routes of this kind AND that Gateway's controller has sufficient access, - then that Gateway's controller MUST set the "Accepted" condition on the - Route, to indicate whether the route has been accepted or rejected by the - Gateway, and why. - - A Route MUST be considered "Accepted" if at least one of the Route's - rules is implemented by the Gateway. - - There are a number of cases where the "Accepted" condition may not be set - due to lack of controller visibility, that includes when: - - * The Route refers to a nonexistent parent. - * The Route is of a type that the controller does not support. - * The Route is in a namespace to which the controller does not have access. - items: - description: Condition contains details for one aspect of - the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, - Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - controllerName: - description: |- - ControllerName is a domain/path string that indicates the name of the - controller that wrote this status. This corresponds with the - controllerName field on GatewayClass. - - Example: "example.net/gateway-controller". - - The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are - valid Kubernetes names - (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). - - Controllers MUST populate this field when writing status. Controllers should ensure that - entries to status populated with their ControllerName are cleaned up when they are no - longer necessary. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - parentRef: - description: |- - ParentRef corresponds with a ParentRef in the spec that this - RouteParentStatus struct describes the status of. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - required: - - conditions - - controllerName - - parentRef - type: object - maxItems: 32 - type: array - x-kubernetes-list-type: atomic - required: - - parents - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} - - additionalPrinterColumns: - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - deprecated: true - deprecationWarning: The v1alpha2 version of TLSRoute has been deprecated and will - be removed in a future release of the API. Please upgrade to v1. - name: v1alpha2 - schema: - openAPIV3Schema: - description: |- - The TLSRoute resource is similar to TCPRoute, but can be configured - to match against TLS-specific metadata. This allows more flexibility - in matching streams for a given TLS listener. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of TLSRoute. - properties: - hostnames: - description: |- - Hostnames defines a set of SNI names that should match against the - SNI attribute of TLS ClientHello message in TLS handshake. This matches - the RFC 1123 definition of a hostname with 2 notable exceptions: - - 1. IPs are not allowed in SNI names per RFC 6066. - 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard - label must appear by itself as the first label. - - If a hostname is specified by both the Listener and TLSRoute, there - must be at least one intersecting hostname for the TLSRoute to be - attached to the Listener. For example: - - * A Listener with `test.example.com` as the hostname matches TLSRoutes - that have either not specified any hostnames, or have specified at - least one of `test.example.com` or `*.example.com`. - * A Listener with `*.example.com` as the hostname matches TLSRoutes - that have either not specified any hostnames or have specified at least - one hostname that matches the Listener hostname. For example, - `test.example.com` and `*.example.com` would both match. On the other - hand, `example.com` and `test.example.net` would not match. - - If both the Listener and TLSRoute have specified hostnames, any - TLSRoute hostnames that do not match the Listener hostname MUST be - ignored. For example, if a Listener specified `*.example.com`, and the - TLSRoute specified `test.example.com` and `test.example.net`, - `test.example.net` must not be considered for a match. - - If both the Listener and TLSRoute have specified hostnames, and none - match with the criteria above, then the TLSRoute is not accepted. The - implementation must raise an 'Accepted' Condition with a status of - `False` in the corresponding RouteParentStatus. - - Support: Core - items: - description: |- - Hostname is the fully qualified domain name of a network host. This matches - the RFC 1123 definition of a hostname with 2 notable exceptions: - - 1. IPs are not allowed. - 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard - label must appear by itself as the first label. - - Hostname can be "precise" which is a domain name without the terminating - dot of a network host (e.g. "foo.example.com") or "wildcard", which is a - domain name prefixed with a single wildcard label (e.g. `*.example.com`). - - Note that as per RFC1035 and RFC1123, a *label* must consist of lower case - alphanumeric characters or '-', and must start and end with an alphanumeric - character. No other punctuation is allowed. - maxLength: 253 - minLength: 1 - pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - maxItems: 16 - type: array - x-kubernetes-list-type: atomic - parentRefs: - description: |- - ParentRefs references the resources (usually Gateways) that a Route wants - to be attached to. Note that the referenced parent resource needs to - allow this for the attachment to be complete. For Gateways, that means - the Gateway needs to allow attachment from Routes of this kind and - namespace. For Services, that means the Service must either be in the same - namespace for a "producer" route, or the mesh implementation must support - and allow "consumer" routes for the referenced Service. ReferenceGrant is - not applicable for governing ParentRefs to Services - it is not possible to - create a "producer" route for a Service in a different namespace from the - Route. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - This API may be extended in the future to support additional kinds of parent - resources. - - ParentRefs must be _distinct_. This means either that: - - * They select different objects. If this is the case, then parentRef - entries are distinct. In terms of fields, this means that the - multi-part key defined by `group`, `kind`, `namespace`, and `name` must - be unique across all parentRef entries in the Route. - * They do not select different objects, but for each optional field used, - each ParentRef that selects the same object must set the same set of - optional fields to different values. If one ParentRef sets a - combination of optional fields, all must set the same combination. - - Some examples: - - * If one ParentRef sets `sectionName`, all ParentRefs referencing the - same object must also set `sectionName`. - * If one ParentRef sets `port`, all ParentRefs referencing the same - object must also set `port`. - * If one ParentRef sets `sectionName` and `port`, all ParentRefs - referencing the same object must also set `sectionName` and `port`. - - It is possible to separately reference multiple distinct objects that may - be collapsed by an implementation. For example, some implementations may - choose to merge compatible Gateway Listeners together. If that is the - case, the list of routes attached to those resources should also be - merged. - - Note that for ParentRefs that cross namespace boundaries, there are specific - rules. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example, - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable other kinds of cross-namespace reference. - items: - description: |- - ParentReference identifies an API object (usually a Gateway) that can be considered - a parent of this resource (usually a route). There are two kinds of parent resources - with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - This API may be extended in the future to support additional kinds of parent - resources. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - maxItems: 32 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: sectionName must be specified when parentRefs includes - 2 or more references to the same parent - rule: 'self.all(p1, self.all(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.__namespace__) - || p1.__namespace__ == '''') && (!has(p2.__namespace__) || p2.__namespace__ - == '''')) || (has(p1.__namespace__) && has(p2.__namespace__) && - p1.__namespace__ == p2.__namespace__ )) ? ((!has(p1.sectionName) - || p1.sectionName == '''') == (!has(p2.sectionName) || p2.sectionName - == '''')) : true))' - - message: sectionName must be unique when parentRefs includes 2 or - more references to the same parent - rule: self.all(p1, self.exists_one(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.__namespace__) - || p1.__namespace__ == '') && (!has(p2.__namespace__) || p2.__namespace__ - == '')) || (has(p1.__namespace__) && has(p2.__namespace__) && - p1.__namespace__ == p2.__namespace__ )) && (((!has(p1.sectionName) - || p1.sectionName == '') && (!has(p2.sectionName) || p2.sectionName - == '')) || (has(p1.sectionName) && has(p2.sectionName) && p1.sectionName - == p2.sectionName)))) - rules: - description: Rules are a list of TLS matchers and actions. - items: - description: TLSRouteRule is the configuration for a given rule. - properties: - backendRefs: - description: |- - BackendRefs defines the backend(s) where matching requests should be - sent. If unspecified or invalid (refers to a nonexistent resource or - a Service with no endpoints), the rule performs no forwarding; if no - filters are specified that would result in a response being sent, the - underlying implementation must actively reject request attempts to this - backend, by rejecting the connection. Request rejections must respect - weight; if an invalid backend is requested to have 80% of requests, then - 80% of requests must be rejected instead. - - Support: Core for Kubernetes Service - - Support: Extended for Kubernetes ServiceImport - - Support: Implementation-specific for any other resource - - Support for weight: Extended - items: - description: |- - BackendRef defines how a Route should forward a request to a Kubernetes - resource. - - Note that when a namespace different than the local namespace is specified, a - ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Note that when the BackendTLSPolicy object is enabled by the implementation, - there are some extra rules about validity to consider here. See the fields - where this struct is used for more information about the exact behavior. - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations SHOULD NOT - support ExternalName Services. - - Support: Core (Services with a type other than ExternalName) - - Support: Implementation-specific (Services with type ExternalName) - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the backend. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port specifies the destination port number to use for this resource. - Port is required when the referent is a Kubernetes Service. In this - case, the port number is the service port number, not the target port. - For other resources, destination port might be derived from the referent - resource or this field. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - weight: - default: 1 - description: |- - Weight specifies the proportion of requests forwarded to the referenced - backend. This is computed as weight/(sum of all weights in this - BackendRefs list). For non-zero values, there may be some epsilon from - the exact proportion defined here depending on the precision an - implementation supports. Weight is not a percentage and the sum of - weights does not need to equal 100. - - If only one backend is specified and it has a weight greater than 0, 100% - of the traffic is forwarded to that backend. If weight is set to 0, no - traffic should be forwarded for this entry. If unspecified, weight - defaults to 1. - - Support for this field varies based on the context where used. - format: int32 - maximum: 1000000 - minimum: 0 - type: integer - required: - - name - type: object - x-kubernetes-validations: - - message: Must have port for Service reference - rule: '(size(self.group) == 0 && self.kind == ''Service'') - ? has(self.port) : true' - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - name: - description: Name is the name of the route rule. This name MUST - be unique within a Route if it is set. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - backendRefs - type: object - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - required: - - rules - type: object - status: - description: Status defines the current state of TLSRoute. - properties: - parents: - description: |- - Parents is a list of parent resources (usually Gateways) that are - associated with the route, and the status of the route with respect to - each parent. When this route attaches to a parent, the controller that - manages the parent must add an entry to this list when the controller - first sees the route and should update the entry as appropriate when the - route or gateway is modified. - - Note that parent references that cannot be resolved by an implementation - of this API will not be added to this list. Implementations of this API - can only populate Route status for the Gateways/parent resources they are - responsible for. - - A maximum of 32 Gateways will be represented in this list. An empty list - means the route has not been attached to any Gateway. - items: - description: |- - RouteParentStatus describes the status of a route with respect to an - associated Parent. - properties: - conditions: - description: |- - Conditions describes the status of the route with respect to the Gateway. - Note that the route's availability is also subject to the Gateway's own - status conditions and listener status. - - If the Route's ParentRef specifies an existing Gateway that supports - Routes of this kind AND that Gateway's controller has sufficient access, - then that Gateway's controller MUST set the "Accepted" condition on the - Route, to indicate whether the route has been accepted or rejected by the - Gateway, and why. - - A Route MUST be considered "Accepted" if at least one of the Route's - rules is implemented by the Gateway. - - There are a number of cases where the "Accepted" condition may not be set - due to lack of controller visibility, that includes when: - - * The Route refers to a nonexistent parent. - * The Route is of a type that the controller does not support. - * The Route is in a namespace to which the controller does not have access. - items: - description: Condition contains details for one aspect of - the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, - Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - controllerName: - description: |- - ControllerName is a domain/path string that indicates the name of the - controller that wrote this status. This corresponds with the - controllerName field on GatewayClass. - - Example: "example.net/gateway-controller". - - The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are - valid Kubernetes names - (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). - - Controllers MUST populate this field when writing status. Controllers should ensure that - entries to status populated with their ControllerName are cleaned up when they are no - longer necessary. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - parentRef: - description: |- - ParentRef corresponds with a ParentRef in the spec that this - RouteParentStatus struct describes the status of. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - required: - - conditions - - controllerName - - parentRef - type: object - maxItems: 32 - type: array - x-kubernetes-list-type: atomic - required: - - parents - type: object - required: - - spec - type: object - served: false - storage: false - subresources: - status: {} - - additionalPrinterColumns: - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - deprecated: true - deprecationWarning: The v1alpha3 version of TLSRoute has been deprecated and will - be removed in a future release of the API. Please upgrade to v1. - name: v1alpha3 - schema: - openAPIV3Schema: - description: |- - The TLSRoute resource is similar to TCPRoute, but can be configured - to match against TLS-specific metadata. This allows more flexibility - in matching streams for a given TLS listener. - - If you need to forward traffic to a single target for a TLS listener, you - could choose to use a TCPRoute with a TLS listener. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of TLSRoute. - properties: - hostnames: - description: |- - Hostnames defines a set of SNI hostnames that should match against the - SNI attribute of TLS ClientHello message in TLS handshake. This matches - the RFC 1123 definition of a hostname with 2 notable exceptions: - - 1. IPs are not allowed in SNI hostnames per RFC 6066. - 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard - label must appear by itself as the first label. - items: - description: |- - Hostname is the fully qualified domain name of a network host. This matches - the RFC 1123 definition of a hostname with 2 notable exceptions: - - 1. IPs are not allowed. - 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard - label must appear by itself as the first label. - - Hostname can be "precise" which is a domain name without the terminating - dot of a network host (e.g. "foo.example.com") or "wildcard", which is a - domain name prefixed with a single wildcard label (e.g. `*.example.com`). - - Note that as per RFC1035 and RFC1123, a *label* must consist of lower case - alphanumeric characters or '-', and must start and end with an alphanumeric - character. No other punctuation is allowed. - maxLength: 253 - minLength: 1 - pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: Hostnames cannot contain an IP - rule: self.all(h, !isIP(h)) - - message: Hostnames must be valid based on RFC-1123 - rule: 'self.all(h, !h.contains(''*'') ? h.matches(''^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*)$'') - : true)' - - message: Wildcards on hostnames must be the first label, and the - rest of hostname must be valid based on RFC-1123 - rule: 'self.all(h, h.contains(''*'') ? (h.startsWith(''*.'') && - h.substring(2).matches(''^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*)$'')) - : true)' - parentRefs: - description: |- - ParentRefs references the resources (usually Gateways) that a Route wants - to be attached to. Note that the referenced parent resource needs to - allow this for the attachment to be complete. For Gateways, that means - the Gateway needs to allow attachment from Routes of this kind and - namespace. For Services, that means the Service must either be in the same - namespace for a "producer" route, or the mesh implementation must support - and allow "consumer" routes for the referenced Service. ReferenceGrant is - not applicable for governing ParentRefs to Services - it is not possible to - create a "producer" route for a Service in a different namespace from the - Route. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - This API may be extended in the future to support additional kinds of parent - resources. - - ParentRefs must be _distinct_. This means either that: - - * They select different objects. If this is the case, then parentRef - entries are distinct. In terms of fields, this means that the - multi-part key defined by `group`, `kind`, `namespace`, and `name` must - be unique across all parentRef entries in the Route. - * They do not select different objects, but for each optional field used, - each ParentRef that selects the same object must set the same set of - optional fields to different values. If one ParentRef sets a - combination of optional fields, all must set the same combination. - - Some examples: - - * If one ParentRef sets `sectionName`, all ParentRefs referencing the - same object must also set `sectionName`. - * If one ParentRef sets `port`, all ParentRefs referencing the same - object must also set `port`. - * If one ParentRef sets `sectionName` and `port`, all ParentRefs - referencing the same object must also set `sectionName` and `port`. - - It is possible to separately reference multiple distinct objects that may - be collapsed by an implementation. For example, some implementations may - choose to merge compatible Gateway Listeners together. If that is the - case, the list of routes attached to those resources should also be - merged. - - Note that for ParentRefs that cross namespace boundaries, there are specific - rules. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example, - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable other kinds of cross-namespace reference. - items: - description: |- - ParentReference identifies an API object (usually a Gateway) that can be considered - a parent of this resource (usually a route). There are two kinds of parent resources - with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - This API may be extended in the future to support additional kinds of parent - resources. - - The API object must be valid in the cluster; the Group and Kind must - be registered in the cluster for this reference to be valid. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - maxItems: 32 - type: array - x-kubernetes-list-type: atomic - x-kubernetes-validations: - - message: sectionName must be specified when parentRefs includes - 2 or more references to the same parent - rule: 'self.all(p1, self.all(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.__namespace__) - || p1.__namespace__ == '''') && (!has(p2.__namespace__) || p2.__namespace__ - == '''')) || (has(p1.__namespace__) && has(p2.__namespace__) && - p1.__namespace__ == p2.__namespace__ )) ? ((!has(p1.sectionName) - || p1.sectionName == '''') == (!has(p2.sectionName) || p2.sectionName - == '''')) : true))' - - message: sectionName must be unique when parentRefs includes 2 or - more references to the same parent - rule: self.all(p1, self.exists_one(p2, p1.group == p2.group && p1.kind - == p2.kind && p1.name == p2.name && (((!has(p1.__namespace__) - || p1.__namespace__ == '') && (!has(p2.__namespace__) || p2.__namespace__ - == '')) || (has(p1.__namespace__) && has(p2.__namespace__) && - p1.__namespace__ == p2.__namespace__ )) && (((!has(p1.sectionName) - || p1.sectionName == '') && (!has(p2.sectionName) || p2.sectionName - == '')) || (has(p1.sectionName) && has(p2.sectionName) && p1.sectionName - == p2.sectionName)))) - rules: - description: Rules are a list of actions. - items: - description: TLSRouteRule is the configuration for a given rule. - properties: - backendRefs: - description: |- - BackendRefs defines the backend(s) where matching requests should be - sent. If unspecified or invalid (refers to a nonexistent resource or - a Service with no endpoints), the rule performs no forwarding; if no - filters are specified that would result in a response being sent, the - underlying implementation must actively reject request attempts to this - backend, by rejecting the connection. Request rejections must respect - weight; if an invalid backend is requested to have 80% of requests, then - 80% of requests must be rejected instead. - - Support: Core for Kubernetes Service - - Support: Extended for Kubernetes ServiceImport - - Support: Implementation-specific for any other resource - - Support for weight: Extended - items: - description: |- - BackendRef defines how a Route should forward a request to a Kubernetes - resource. - - Note that when a namespace different than the local namespace is specified, a - ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Note that when the BackendTLSPolicy object is enabled by the implementation, - there are some extra rules about validity to consider here. See the fields - where this struct is used for more information about the exact behavior. - properties: - group: - default: "" - description: |- - Group is the group of the referent. For example, "gateway.networking.k8s.io". - When unspecified or empty string, core API group is inferred. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations SHOULD NOT - support ExternalName Services. - - Support: Core (Services with a type other than ExternalName) - - Support: Implementation-specific (Services with type ExternalName) - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the backend. When unspecified, the local - namespace is inferred. - - Note that when a namespace different than the local namespace is specified, - a ReferenceGrant object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port specifies the destination port number to use for this resource. - Port is required when the referent is a Kubernetes Service. In this - case, the port number is the service port number, not the target port. - For other resources, destination port might be derived from the referent - resource or this field. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - weight: - default: 1 - description: |- - Weight specifies the proportion of requests forwarded to the referenced - backend. This is computed as weight/(sum of all weights in this - BackendRefs list). For non-zero values, there may be some epsilon from - the exact proportion defined here depending on the precision an - implementation supports. Weight is not a percentage and the sum of - weights does not need to equal 100. - - If only one backend is specified and it has a weight greater than 0, 100% - of the traffic is forwarded to that backend. If weight is set to 0, no - traffic should be forwarded for this entry. If unspecified, weight - defaults to 1. - - Support for this field varies based on the context where used. - format: int32 - maximum: 1000000 - minimum: 0 - type: integer - required: - - name - type: object - x-kubernetes-validations: - - message: Must have port for Service reference - rule: '(size(self.group) == 0 && self.kind == ''Service'') - ? has(self.port) : true' - maxItems: 16 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - name: - description: Name is the name of the route rule. This name MUST - be unique within a Route if it is set. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - backendRefs - type: object - maxItems: 1 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - required: - - hostnames - - rules - type: object - status: - description: Status defines the current state of TLSRoute. - properties: - parents: - description: |- - Parents is a list of parent resources (usually Gateways) that are - associated with the route, and the status of the route with respect to - each parent. When this route attaches to a parent, the controller that - manages the parent must add an entry to this list when the controller - first sees the route and should update the entry as appropriate when the - route or gateway is modified. - - Note that parent references that cannot be resolved by an implementation - of this API will not be added to this list. Implementations of this API - can only populate Route status for the Gateways/parent resources they are - responsible for. - - A maximum of 32 Gateways will be represented in this list. An empty list - means the route has not been attached to any Gateway. - items: - description: |- - RouteParentStatus describes the status of a route with respect to an - associated Parent. - properties: - conditions: - description: |- - Conditions describes the status of the route with respect to the Gateway. - Note that the route's availability is also subject to the Gateway's own - status conditions and listener status. - - If the Route's ParentRef specifies an existing Gateway that supports - Routes of this kind AND that Gateway's controller has sufficient access, - then that Gateway's controller MUST set the "Accepted" condition on the - Route, to indicate whether the route has been accepted or rejected by the - Gateway, and why. - - A Route MUST be considered "Accepted" if at least one of the Route's - rules is implemented by the Gateway. - - There are a number of cases where the "Accepted" condition may not be set - due to lack of controller visibility, that includes when: - - * The Route refers to a nonexistent parent. - * The Route is of a type that the controller does not support. - * The Route is in a namespace to which the controller does not have access. - items: - description: Condition contains details for one aspect of - the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, - Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - controllerName: - description: |- - ControllerName is a domain/path string that indicates the name of the - controller that wrote this status. This corresponds with the - controllerName field on GatewayClass. - - Example: "example.net/gateway-controller". - - The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are - valid Kubernetes names - (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). - - Controllers MUST populate this field when writing status. Controllers should ensure that - entries to status populated with their ControllerName are cleaned up when they are no - longer necessary. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ - type: string - parentRef: - description: |- - ParentRef corresponds with a ParentRef in the spec that this - RouteParentStatus struct describes the status of. - properties: - group: - default: gateway.networking.k8s.io - description: |- - Group is the group of the referent. - When unspecified, "gateway.networking.k8s.io" is inferred. - To set the core API group (such as for a "Service" kind referent), - Group must be explicitly set to "" (empty string). - - Support: Core - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: Gateway - description: |- - Kind is kind of the referent. - - There are two kinds of parent resources with "Core" support: - - * Gateway (Gateway conformance profile) - * Service (Mesh conformance profile, ClusterIP Services only) - - Support for other resources is Implementation-Specific. - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: |- - Name is the name of the referent. - - Support: Core - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - Namespace is the namespace of the referent. When unspecified, this refers - to the local namespace of the Route. - - Note that there are specific rules for ParentRefs which cross namespace - boundaries. Cross-namespace references are only valid if they are explicitly - allowed by something in the namespace they are referring to. For example: - Gateway has the AllowedRoutes field, and ReferenceGrant provides a - generic way to enable any other kind of cross-namespace reference. - - Support: Core - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - port: - description: |- - Port is the network port this Route targets. It can be interpreted - differently based on the type of parent resource. - - When the parent resource is a Gateway, this targets all listeners - listening on the specified port that also support this kind of Route(and - select this Route). It's not recommended to set `Port` unless the - networking behaviors specified in a Route must apply to a specific port - as opposed to a listener(s) whose port(s) may be changed. When both Port - and SectionName are specified, the name and port of the selected listener - must match both specified values. - - Implementations MAY choose to support other parent resources. - Implementations supporting other types of parent resources MUST clearly - document how/if Port is interpreted. - - For the purpose of status, an attachment is considered successful as - long as the parent resource accepts it partially. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment - from the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, - the Route MUST be considered detached from the Gateway. - - Support: Extended - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sectionName: - description: |- - SectionName is the name of a section within the target resource. In the - following resources, SectionName is interpreted as the following: - - * Gateway: Listener name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - * Service: Port name. When both Port (experimental) and SectionName - are specified, the name and port of the selected listener must match - both specified values. - - Implementations MAY choose to support attaching Routes to other resources. - If that is the case, they MUST clearly document how SectionName is - interpreted. - - When unspecified (empty string), this will reference the entire resource. - For the purpose of status, an attachment is considered successful if at - least one section in the parent resource accepts it. For example, Gateway - listeners can restrict which Routes can attach to them by Route kind, - namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from - the referencing Route, the Route MUST be considered successfully - attached. If no Gateway listeners accept attachment from this Route, the - Route MUST be considered detached from the Gateway. - - Support: Core - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - required: - - name - type: object - required: - - conditions - - controllerName - - parentRef - type: object - maxItems: 32 - type: array - x-kubernetes-list-type: atomic - required: - - parents - type: object - required: - - spec - type: object - served: false - storage: false - subresources: - status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: null - storedVersions: null ---- -# -# config/crd/standard/gateway.networking.k8s.io_vap_safeupgrades.yaml -# -apiVersion: admissionregistration.k8s.io/v1 -kind: ValidatingAdmissionPolicy -metadata: - annotations: - gateway.networking.k8s.io/bundle-version: v1.5.0-dev - gateway.networking.k8s.io/channel: standard - name: "safe-upgrades.gateway.networking.k8s.io" -spec: - failurePolicy: Fail - matchConstraints: - resourceRules: - - apiGroups: ["apiextensions.k8s.io"] - apiVersions: ["v1"] - operations: ["CREATE", "UPDATE"] - resources: ["*"] - validations: - - expression: "object.spec.group != 'gateway.networking.k8s.io' || oldObject == null || ( - has(object.metadata.annotations) && object.metadata.annotations.exists(k, k == 'gateway.networking.k8s.io/channel') && - object.metadata.annotations['gateway.networking.k8s.io/channel'] == 'standard' ) || ( - oldObject != null && has(oldObject.metadata.annotations) && oldObject.metadata.annotations.exists(k, k == 'gateway.networking.k8s.io/channel') && - oldObject.metadata.annotations['gateway.networking.k8s.io/channel'] == 'experimental' )" - message: "Installing experimental CRDs on top of standard channel CRDs is prohibited by default. Uninstall ValidatingAdmissionPolicy safe-upgrades.gateway.networking.k8s.io to install experimental CRDs on top of standard channel CRDs." - reason: Invalid - - expression: "object.spec.group != 'gateway.networking.k8s.io' || - (has(object.metadata.annotations) && object.metadata.annotations.exists(k, k == 'gateway.networking.k8s.io/bundle-version') && - !matches(object.metadata.annotations['gateway.networking.k8s.io/bundle-version'], 'v1.[0-4].\\\\d+') && - !matches(object.metadata.annotations['gateway.networking.k8s.io/bundle-version'], 'v0'))" #TODO Kubernetes 1.37: Migrate to kubernetes semver library - message: "Installing CRDs with version before v1.5.0 is prohibited by default. Uninstall ValidatingAdmissionPolicy safe-upgrades.gateway.networking.k8s.io to install older versions." - reason: Invalid - ---- - -apiVersion: admissionregistration.k8s.io/v1 -kind: ValidatingAdmissionPolicyBinding -metadata: - annotations: - gateway.networking.k8s.io/bundle-version: v1.5.0-dev - gateway.networking.k8s.io/channel: standard - name: safe-upgrades.gateway.networking.k8s.io -spec: - policyName: safe-upgrades.gateway.networking.k8s.io - validationActions: [Deny] - matchResources: - resourceRules: - - apiGroups: ["apiextensions.k8s.io"] - apiVersions: ["v1"] - resources: ["customresourcedefinitions"] - operations: ["CREATE", "UPDATE"] diff --git a/functions/compose-inference-gateway/pyproject.toml b/functions/compose-inference-gateway/pyproject.toml index 087392c7b..53f6ac7cc 100644 --- a/functions/compose-inference-gateway/pyproject.toml +++ b/functions/compose-inference-gateway/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "uv_build" [project] name = "compose-inference-gateway" version = "0.0.0" -description = "Compose the control plane routing gateway with Envoy Gateway and MetalLB." +description = "Compose the fleet gateway: the front door for inference requests." requires-python = ">=3.11,<3.14" license = "Apache-2.0" dependencies = [ @@ -13,7 +13,6 @@ dependencies = [ "click>=8.1.0", "grpcio>=1.73.1", "crossplane-models", - "pyyaml>=6.0", ] [tool.uv.sources] diff --git a/functions/compose-inference-gateway/tests/test_fn.py b/functions/compose-inference-gateway/tests/test_fn.py index f54a427e1..587fbbe30 100644 --- a/functions/compose-inference-gateway/tests/test_fn.py +++ b/functions/compose-inference-gateway/tests/test_fn.py @@ -14,6 +14,7 @@ """Tests for the compose-inference-gateway function.""" +import base64 import dataclasses import unittest @@ -24,7 +25,10 @@ from google.protobuf import json_format from google.protobuf import struct_pb2 as structpb from models.ai.modelplane.inferencegateway import v1alpha1 -from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 + +_PC = "gw-eu-cluster-kubeconfig" +_CLUSTER = "gw-eu" +_ADDRESS = "34.56.129.3" @dataclasses.dataclass @@ -36,136 +40,187 @@ class Case: want: fnv1.RunFunctionResponse -def _crd_desired_resources(ready: bool) -> dict: - """Desired Gateway API CRD resources, built from the same vendored bundle - the function composes so the test stays in sync. When ready is True each - CRD is marked READY_TRUE, matching a pass where the CRDs are observed as - Established.""" - out = {} - for doc in fn._GATEWAY_API_CRDS: - key = fn._crd_key(doc) - res = fnv1.Resource(resource=resource.dict_to_struct(doc)) - if ready: - res.ready = fnv1.READY_TRUE - out[key] = res - return out - - -def _crd_observed_resources() -> dict: - """Observed Gateway API CRD resources, each reporting Established.""" - out = {} - for doc in fn._GATEWAY_API_CRDS: - key = fn._crd_key(doc) - observed = { - "apiVersion": doc["apiVersion"], - "kind": doc["kind"], - "status": {"conditions": [{"type": "Established", "status": "True"}]}, - } - out[key] = fnv1.Resource(resource=resource.dict_to_struct(observed)) - return out - - -def _gateway_usage_resources() -> dict: - """The Usages ordering the GatewayClass and Gateway ahead of the Traefik - release on teardown.""" - release_by = { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "Release", - "resourceSelector": { - "matchControllerRef": True, - "matchLabels": {"modelplane.ai/release": "traefik"}, +def _xr(*, name: str = "eu", **spec) -> dict: # noqa: ANN003 + """The InferenceGateway XR, built from the generated model so a field the + XRD doesn't define can't creep into a test.""" + xr = v1alpha1.InferenceGateway( + apiVersion="modelplane.ai/v1alpha1", + kind="InferenceGateway", + metadata={"name": name}, + spec=v1alpha1.Spec(clusterName=_CLUSTER, **spec), + ) + return xr.model_dump(exclude_none=True, mode="json", by_alias=True) + + +def _cluster(*, provider_config: str | None = _PC) -> dict: + """An observed InferenceCluster, optionally without a providerConfigRef. + + A registered cluster with no GPU pools, which is what a region with callers + but no accelerators looks like, and the least a gateway needs. + """ + status: dict = {} + if provider_config: + status["providerConfigRef"] = {"name": provider_config} + return { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "InferenceCluster", + "metadata": {"name": _CLUSTER}, + "spec": { + "cluster": { + "source": "Existing", + "existing": {"secretRef": {"name": f"{_CLUSTER}-kubeconfig", "key": "kubeconfig"}}, + } }, + "status": status, } + + +def _cluster_with_gateway(name: str, *, address: str, hostname: str) -> dict: + """An observed InferenceCluster whose gateway has published an address and + the internal name Modelplane derived for it.""" return { - "usage-gateway-class-by-traefik": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "protection.crossplane.io/v1beta1", - "kind": "ClusterUsage", - "spec": { - "of": { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "GatewayClass", - "resourceRef": {"name": "traefik"}, - }, - "by": release_by, - "replayDeletion": True, - }, - } - ), - ready=fnv1.READY_TRUE, - ), - "usage-gateway-by-traefik": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "protection.crossplane.io/v1beta1", - "kind": "Usage", - "metadata": {"namespace": "modelplane-system"}, - "spec": { - "of": { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "Gateway", - "resourceRef": {"name": "modelplane"}, - }, - "by": release_by, - "replayDeletion": True, - }, - } - ), - ready=fnv1.READY_TRUE, + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "InferenceCluster", + "metadata": {"name": name}, + "spec": { + "cluster": { + "source": "Existing", + "existing": {"secretRef": {"name": f"{name}-kubeconfig", "key": "kubeconfig"}}, + } + }, + "status": {"gateway": {"address": address, "hostname": hostname}}, + } + + +def _gateway_xr(name: str, cluster: str) -> dict: + """Another InferenceGateway, for the one-per-cluster contest.""" + return { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "InferenceGateway", + "metadata": {"name": name}, + "spec": {"clusterName": cluster}, + } + + +def _secret(name: str, data: dict[str, str]) -> dict: + """A control-plane Secret, with values base64 encoded as the API server + stores them, since the function copies data verbatim.""" + return { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": name, "namespace": fn.CONTROL_PLANE_NAMESPACE}, + "data": {k: base64.b64encode(v.encode()).decode() for k, v in data.items()}, + } + + +def _required(**resources) -> dict: # noqa: ANN003 + """Build the request's required_resources map.""" + return { + name: fnv1.Resources(items=[fnv1.Resource(resource=resource.dict_to_struct(r)) for r in items]) + for name, items in resources.items() + } + + +def _requirements(*, auth: bool = False, tls: int = 0) -> fnv1.Requirements: + """The requirements the function always emits, in the order it emits them.""" + reqs = { + "cluster": fnv1.ResourceSelector( + api_version="modelplane.ai/v1alpha1", kind="InferenceCluster", match_name=_CLUSTER ), + "gateways": fnv1.ResourceSelector(api_version="modelplane.ai/v1alpha1", kind="InferenceGateway"), + "clusters": fnv1.ResourceSelector(api_version="modelplane.ai/v1alpha1", kind="InferenceCluster"), } + if auth: + reqs["caller-secrets"] = fnv1.ResourceSelector( + api_version="v1", + kind="Secret", + namespace=fn.CONTROL_PLANE_NAMESPACE, + match_labels=fnv1.MatchLabels(labels={"modelplane.ai/inference-keys": "true"}), + ) + for i in range(tls): + reqs[f"tls-secret-{i}"] = fnv1.ResourceSelector( + api_version="v1", kind="Secret", namespace=fn.CONTROL_PLANE_NAMESPACE, match_name=f"eu-tls-{i}" + ) + return fnv1.Requirements(resources=reqs) + +def _observed_gateway(address: str | None, *, ready: bool) -> fnv1.Resource: + """The composed Gateway Object as observed, optionally with an address. -def _traefik_desired_release(ready: bool) -> fnv1.Resource: - """The desired Traefik Helm Release the function composes once the - ProviderConfig is observed and the Gateway API CRDs are Established.""" - res = fnv1.Resource( + lastTransitionTime is fixed so the input is deterministic. + """ + manifest: dict = { + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "Gateway", + "metadata": {"name": fn._GATEWAY_NAME, "namespace": fn.REMOTE_NAMESPACE}, + } + if address: + manifest["status"] = {"addresses": [{"type": "IPAddress", "value": address}]} + status: dict = {"atProvider": {"manifest": manifest}} + if ready: + status["conditions"] = [ + { + "type": "Ready", + "status": "True", + "reason": "Available", + "lastTransitionTime": "2026-06-08T00:00:00Z", + } + ] + return fnv1.Resource( resource=resource.dict_to_struct( { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "Release", - "metadata": { - "namespace": "modelplane-system", - "labels": {"modelplane.ai/release": "traefik"}, - }, - "spec": { - "providerConfigRef": { - "kind": "ProviderConfig", - "name": "modelplane-in-cluster", - }, - "forProvider": { - "chart": { - "name": "traefik", - "repository": "https://traefik.github.io/charts", - "version": "40.2.0", - }, - "namespace": "traefik-system", - "values": { - "providers": { - "kubernetesGateway": { - "enabled": True, - "statusAddress": { - "service": { - "namespace": "traefik-system", - "name": "traefik", - }, - }, - }, - "kubernetesIngress": {"enabled": False}, - }, - "service": {"nameOverride": "traefik"}, - "gateway": {"enabled": False}, - "gatewayClass": {"enabled": False}, - }, - }, + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "status": status, + } + ) + ) + + +def _observed_accepted() -> fnv1.Resource: + """A composed policy Object as observed once accepted. + + Its readiness comes from a CEL query on the policy's own Accepted condition, + so an Object that merely applied isn't enough. + """ + return fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "status": { + "conditions": [ + { + "type": "Ready", + "status": "True", + "reason": "Available", + "lastTransitionTime": "2026-06-08T00:00:00Z", + } + ] }, } - ), + ) + ) + + +def _not_ready(reason: str, message: str, requirements: fnv1.Requirements) -> fnv1.RunFunctionResponse: + """The whole response for a pass that composes nothing: no desired + resources, one GatewayReady=False condition, and the reason as a result.""" + return fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), + desired=fnv1.State(), + context=structpb.Struct(), + requirements=requirements, + conditions=[ + fnv1.Condition( + type=fn.CONDITION_TYPE_GATEWAY_READY, + status=fnv1.STATUS_CONDITION_FALSE, + reason=reason, + message=message, + ) + ], + results=[fnv1.Result(severity=fnv1.SEVERITY_NORMAL, message=message)], ) - if ready: - res.ready = fnv1.READY_TRUE - return res def setUpModule() -> None: @@ -173,343 +228,96 @@ def setUpModule() -> None: class TestFunctionRunner(unittest.IsolatedAsyncioTestCase): - """Tests for FunctionRunner.RunFunction.""" + maxDiff = None @classmethod def setUpClass(cls) -> None: cls.runner = fn.FunctionRunner() - async def test_compose(self) -> None: - """The function composes an InferenceGateway.""" + async def test_gates(self) -> None: + """Passes where the gateway can't be composed compose nothing, and say + why. Asserting the whole response proves nothing is composed against a + cluster we can't reach, rather than a subset being applied.""" cases = [ Case( - name="first pass composes provider config and gateway api crds; traefik and gateway are gated", + name="unresolved requirements compose nothing", req=fnv1.RunFunctionRequest( - observed=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct( - v1alpha1.InferenceGateway( - metadata=metav1.ObjectMeta( - name="test-gateway", - namespace="modelplane-system", - ), - spec=v1alpha1.Spec(traefik=v1alpha1.Traefik(version="40.2.0")), - ).model_dump(exclude_none=True, mode="json") - ), - ), - ), + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_xr()))), ), - want=fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {}}), - ), - resources={ - "provider-config-helm": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "ProviderConfig", - "metadata": { - "name": "modelplane-in-cluster", - "namespace": "modelplane-system", - }, - "spec": {"credentials": {"source": "InjectedIdentity"}}, - } - ), - ready=fnv1.READY_TRUE, - ), - # CRDs are composed on the first pass but not yet - # observed as Established, so they aren't ready and - # Traefik stays gated. - **_crd_desired_resources(ready=False), - }, - ), - conditions=[ - fnv1.Condition( - type="ControllerReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Installing", - ), - ], - context=structpb.Struct(), + want=_not_ready( + fn.CONDITION_REASON_WAITING_FOR_CLUSTER, + "Waiting for the gateway's cluster and the other gateways to resolve", + _requirements(), ), ), Case( - name="traefik is composed with its gateway usages in the same pass the crds become established", + name="a named cluster that does not exist", req=fnv1.RunFunctionRequest( - observed=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct( - v1alpha1.InferenceGateway( - metadata=metav1.ObjectMeta( - name="test-gateway", - namespace="modelplane-system", - ), - spec=v1alpha1.Spec(traefik=v1alpha1.Traefik(version="40.2.0")), - ).model_dump(exclude_none=True, mode="json") - ), - ), - # The ProviderConfig is observed and the CRDs report - # Established, so Traefik is composed this pass. It is - # not yet observed: its Usages must still be composed - # now so deletion-order protection is in place the - # moment the Release is first emitted as desired state. - resources={ - "provider-config-helm": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "ProviderConfig", - "metadata": {"name": "modelplane-in-cluster"}, - } - ), - ), - **_crd_observed_resources(), - }, + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_xr()))), + required_resources=_required(cluster=[], gateways=[_gateway_xr("eu", _CLUSTER)]), + ), + want=_not_ready( + fn.CONDITION_REASON_WAITING_FOR_CLUSTER, + f"InferenceCluster {_CLUSTER} does not exist", + _requirements(), + ), + ), + Case( + name="a cluster that already hosts a lower-named gateway", + req=fnv1.RunFunctionRequest( + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_xr()))), + required_resources=_required( + cluster=[_cluster()], + gateways=[_gateway_xr("eu", _CLUSTER), _gateway_xr("aaa", _CLUSTER)], ), ), - want=fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {}}), - ), - resources={ - "provider-config-helm": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "ProviderConfig", - "metadata": { - "name": "modelplane-in-cluster", - "namespace": "modelplane-system", - }, - "spec": {"credentials": {"source": "InjectedIdentity"}}, - } - ), - ready=fnv1.READY_TRUE, - ), - # Traefik is composed but not yet observed, so it - # isn't marked ready and the Gateway/GatewayClass - # stay gated. - "traefik": _traefik_desired_release(ready=False), - "usage-pc-by-traefik": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "protection.crossplane.io/v1beta1", - "kind": "Usage", - "metadata": {"namespace": "modelplane-system"}, - "spec": { - "of": { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "ProviderConfig", - "resourceRef": {"name": "modelplane-in-cluster"}, - }, - "by": { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "Release", - "resourceSelector": { - "matchControllerRef": True, - "matchLabels": {"modelplane.ai/release": "traefik"}, - }, - }, - "replayDeletion": True, - }, - } - ), - ready=fnv1.READY_TRUE, - ), - **_crd_desired_resources(ready=True), - # The gateway usages are composed alongside Traefik, - # before the Release is observed. - **_gateway_usage_resources(), - }, + want=_not_ready( + fn.CONDITION_REASON_CLUSTER_TAKEN, + f"InferenceCluster {_CLUSTER} already hosts InferenceGateway aaa", + _requirements(), + ), + ), + Case( + name="a cluster with no providerConfigRef yet", + req=fnv1.RunFunctionRequest( + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_xr()))), + required_resources=_required( + cluster=[_cluster(provider_config=None)], gateways=[_gateway_xr("eu", _CLUSTER)] ), - conditions=[ - fnv1.Condition( - type="ControllerReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Installing", - ), - ], - context=structpb.Struct(), + ), + want=_not_ready( + fn.CONDITION_REASON_WAITING_FOR_CLUSTER, + f"InferenceCluster {_CLUSTER} has not published a providerConfigRef", + _requirements(), ), ), Case( - name="second pass with observed crds and traefik ready composes gateway resources", + name="auth selecting no Secret would authenticate nobody", req=fnv1.RunFunctionRequest( observed=fnv1.State( composite=fnv1.Resource( resource=resource.dict_to_struct( - v1alpha1.InferenceGateway( - metadata=metav1.ObjectMeta( - name="test-gateway", - namespace="modelplane-system", - ), - spec=v1alpha1.Spec(traefik=v1alpha1.Traefik(version="40.2.0")), - ).model_dump(exclude_none=True, mode="json") - ), - ), - resources={ - "provider-config-helm": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "ProviderConfig", - "metadata": {"name": "modelplane-in-cluster"}, - } - ), - ), - "traefik": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "Release", - "status": { - "conditions": [{"type": "Ready", "status": "True"}], - }, - } - ), - ), - "gateway": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "Gateway", - "metadata": {"name": "modelplane"}, - "status": { - "addresses": [{"value": "10.0.0.42"}], - "conditions": [{"type": "Accepted", "status": "True"}], - }, - } - ), - ), - "gateway-class": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "GatewayClass", - "metadata": {"name": "traefik"}, - "status": { - "conditions": [{"type": "Accepted", "status": "True"}], - }, - } - ), - ), - # CRDs observed as Established ungate Traefik. - **_crd_observed_resources(), - }, + _xr( + auth=v1alpha1.Auth( + secretSelector=v1alpha1.SecretSelector( + matchLabels={"modelplane.ai/inference-keys": "true"} + ) + ) + ) + ) + ) ), - ), - want=fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct( - {"status": {"address": "10.0.0.42"}}, - ), - ), - resources={ - "provider-config-helm": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "ProviderConfig", - "metadata": { - "name": "modelplane-in-cluster", - "namespace": "modelplane-system", - }, - "spec": {"credentials": {"source": "InjectedIdentity"}}, - } - ), - ready=fnv1.READY_TRUE, - ), - "traefik": _traefik_desired_release(ready=True), - "usage-pc-by-traefik": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "protection.crossplane.io/v1beta1", - "kind": "Usage", - "metadata": {"namespace": "modelplane-system"}, - "spec": { - "of": { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "ProviderConfig", - "resourceRef": {"name": "modelplane-in-cluster"}, - }, - "by": { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "Release", - "resourceSelector": { - "matchControllerRef": True, - "matchLabels": {"modelplane.ai/release": "traefik"}, - }, - }, - "replayDeletion": True, - }, - } - ), - ready=fnv1.READY_TRUE, - ), - "gateway-class": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "GatewayClass", - "metadata": {"name": "traefik"}, - "spec": { - "controllerName": "traefik.io/gateway-controller", - }, - } - ), - ready=fnv1.READY_TRUE, - ), - "gateway": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "Gateway", - "metadata": { - "name": "modelplane", - "namespace": "modelplane-system", - }, - "spec": { - "gatewayClassName": "traefik", - "listeners": [ - { - "name": "web", - "protocol": "HTTP", - "port": 8000, - "allowedRoutes": {"namespaces": {"from": "All"}}, - }, - ], - }, - } - ), - ready=fnv1.READY_TRUE, - ), - # CRDs remain composed and are ready now that - # they're observed as Established. - **_crd_desired_resources(ready=True), - # Usages ordering GatewayClass/Gateway ahead of the - # Traefik release on teardown. - **_gateway_usage_resources(), - }, + required_resources=_required( + cluster=[_cluster()], gateways=[_gateway_xr("eu", _CLUSTER)], **{"caller-secrets": []} ), - conditions=[ - fnv1.Condition( - type="ControllerReady", - status=fnv1.STATUS_CONDITION_TRUE, - reason="ControllerHealthy", - ), - ], - context=structpb.Struct(), + ), + want=_not_ready( + fn.CONDITION_REASON_SECRETS_MISSING, + "spec.auth.secretSelector matches no Secret, so no caller could authenticate", + _requirements(auth=True), ), ), ] - for case in cases: with self.subTest(case.name): got = await self.runner.RunFunction(case.req, None) @@ -518,3 +326,662 @@ async def test_compose(self) -> None: json_format.MessageToDict(got), "-want, +got", ) + + async def test_minimal_gateway(self) -> None: + """A gateway with no hostname, TLS or auth: the getting-started shape. + + Composes the gateway objects and no auth policies, and reports no + endpoints until the Gateway has an address. + """ + req = fnv1.RunFunctionRequest( + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_xr()))), + required_resources=_required(cluster=[_cluster()], gateways=[_gateway_xr("eu", _CLUSTER)]), + ) + got = await self.runner.RunFunction(req, None) + + self.assertEqual( + sorted(got.desired.resources), + sorted( + [ + # The client certificate this gateway presents to a cluster + # gateway, which refuses a request that arrives without one. + "client-ca-certificate", + "client-ca-issuer", + "client-ca-bundle", + "client-ca-configmap", + "client-certificate", + "client-selfsigned-issuer", + "envoy-proxy", + "failover-policy", + "gateway", + "healthz-filter", + "healthz-route", + ] + ), + "composes the gateway objects and its client PKI, and no caller auth", + ) + for key, res in got.desired.resources.items(): + d = resource.struct_to_dict(res.resource) + self.assertEqual(d["kind"], "Object", f"{key} targets the gateway's cluster") + self.assertEqual( + d["spec"]["providerConfigRef"], + {"kind": "ClusterProviderConfig", "name": _PC}, + f"{key} uses the cluster's ClusterProviderConfig", + ) + # An InferenceGateway is cluster-scoped, and Crossplane only + # defaults a composed namespaced resource's namespace from a + # namespaced composite. Without this every reconcile fails with + # "an empty namespace may not be set when a resource name is + # provided" and nothing is composed at all. + self.assertEqual( + d["metadata"]["namespace"], + fn.CONTROL_PLANE_NAMESPACE, + f"{key} sets its own namespace, which a cluster-scoped XR must", + ) + manifest = d["spec"]["forProvider"]["manifest"] + if manifest["kind"] == "Bundle": + # A Bundle is cluster-scoped, so it has no namespace of its own. + # It picks the namespace it syncs its ConfigMap to by selector. + self.assertNotIn( + "namespace", + manifest["metadata"], + f"{key} is cluster-scoped, so it sets no namespace", + ) + self.assertEqual( + manifest["spec"]["target"]["namespaceSelector"], + {"matchLabels": {"kubernetes.io/metadata.name": fn.REMOTE_NAMESPACE}}, + f"{key} syncs only to the remote namespace", + ) + continue + self.assertEqual( + manifest["metadata"]["namespace"], + fn.REMOTE_NAMESPACE, + f"{key} lands in the remote namespace", + ) + + # numAttemptsPerPriority is what installs Envoy's previous_priorities + # retry predicate. Without it a ModelService's priorities are stamped on + # the endpoints and ignored, so every endpoint shares traffic and + # failover never happens. Nothing in status would show it. + failover = resource.struct_to_dict(got.desired.resources["failover-policy"].resource) + self.assertEqual( + failover["spec"]["forProvider"]["manifest"]["spec"]["retry"], + { + "numAttemptsPerPriority": 1, + "numRetries": 3, + "retryOn": { + # retriable-status-codes has to be present for the status + # code below to do anything: Envoy Gateway replaces retry_on + # wholesale with this list, and Envoy only consults + # retriable_status_codes when retry_on names it. Without it a + # provider answering 503 is never retried, which is the case + # failover exists for. + "triggers": [ + "connect-failure", + "refused-stream", + "reset", + "retriable-status-codes", + ], + "httpStatusCodes": [503], + }, + }, + ) + # Panic mode defaults to 50%, above which Envoy ignores health and + # spreads traffic over every endpoint including the ejected ones. Every + # endpoint of a ModelService shares one cluster, so ejecting a whole + # priority tier usually crosses it and failover stops working. + # + # Asserted on the whole healthCheck, because panicThreshold is a sibling + # of passive rather than a field inside it, and nested wrongly the API + # server prunes it while the policy still applies. Reaching for it at a + # path that doesn't exist is how the wrong nesting survived review. + self.assertEqual( + failover["spec"]["forProvider"]["manifest"]["spec"]["healthCheck"], + { + "passive": { + "baseEjectionTime": "30s", + "consecutive5XxErrors": 5, + "interval": "5s", + "maxEjectionPercent": 100, + }, + "panicThreshold": 0, + }, + ) + self.assertEqual( + failover["spec"]["forProvider"]["manifest"]["spec"]["targetRefs"], + [{"group": "gateway.networking.k8s.io", "kind": "Gateway", "name": fn._GATEWAY_NAME}], + "targets the Gateway, so it covers every ModelService's route", + ) + + # The token fields must read request metadata, not the response body or + # a header. The caller header is stripped before a third-party backend + # sees it, so a log reading the header loses the caller on exactly the + # records that attribute provider spend. + log = resource.struct_to_dict(got.desired.resources["envoy-proxy"].resource) + fields = log["spec"]["forProvider"]["manifest"]["spec"]["telemetry"]["accessLog"]["settings"][0]["format"][ + "json" + ] + # Without ndots:1 every backend hostname is resolved against each of the + # pod's search domains first, since they all have fewer than five dots. + # A cluster whose upstream resolver is slow then stalls resolution, and + # Envoy answers 503 with nothing but DNS timeouts to show for it. + self.assertEqual( + log["spec"]["forProvider"]["manifest"]["spec"]["provider"]["kubernetes"]["envoyDeployment"]["patch"], + {"type": "StrategicMerge", "value": fn._NDOTS_PATCH}, + ) + self.assertEqual( + fn._NDOTS_PATCH["spec"]["template"]["spec"]["dnsConfig"]["options"], + [{"name": "ndots", "value": "1"}], + ) + + self.assertEqual(fields["caller"], "%DYNAMIC_METADATA(io.envoy.ai_gateway:caller)%") + self.assertEqual(fields["input_tokens"], "%DYNAMIC_METADATA(io.envoy.ai_gateway:llm_input_token)%") + self.assertEqual(fields["output_tokens"], "%DYNAMIC_METADATA(io.envoy.ai_gateway:llm_output_token)%") + + gw = resource.struct_to_dict(got.desired.resources["gateway"].resource) + manifest = gw["spec"]["forProvider"]["manifest"] + self.assertEqual( + manifest["spec"]["listeners"], + [{"name": "http", "protocol": "HTTP", "port": 80, "allowedRoutes": {"namespaces": {"from": "Same"}}}], + "one HTTP listener, no hostname, accepting only this namespace's routes", + ) + self.assertEqual( + manifest["spec"]["infrastructure"]["parametersRef"], + {"group": "gateway.envoyproxy.io", "kind": "EnvoyProxy", "name": fn._GATEWAY_NAME}, + "its own EnvoyProxy, not the GatewayClass's", + ) + self.assertEqual( + resource.struct_to_dict(got.desired.composite.resource).get("status"), + {}, + "nothing to report until the Gateway has an address", + ) + + async def test_full_gateway(self) -> None: + """A gateway with a hostname, TLS and auth, whose Gateway has an address. + + Checks the things a caller depends on: the HTTPS listener, the Secrets + copied to the cluster, the caller policy naming them, /healthz exempted + from that policy, and the endpoints status reporting HTTPS URLs. + """ + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct( + _xr( + hostname="eu.example.com", + tls=v1alpha1.Tls(certificateRefs=[v1alpha1.CertificateRef(name="eu-tls-0")]), + auth=v1alpha1.Auth( + secretSelector=v1alpha1.SecretSelector( + matchLabels={"modelplane.ai/inference-keys": "true"} + ) + ), + ) + ) + ), + resources={ + "gateway": _observed_gateway(_ADDRESS, ready=True), + "caller-auth": _observed_accepted(), + }, + ), + required_resources=_required( + cluster=[_cluster()], + gateways=[_gateway_xr("eu", _CLUSTER)], + **{ + "caller-secrets": [_secret("ml-team-keys", {"ml-team-assistant": "sk-mp-a1b2c3"})], + "tls-secret-0": [_secret("eu-tls-0", {"tls.crt": "cert", "tls.key": "key"})], + }, + ), + ) + got = await self.runner.RunFunction(req, None) + + self.assertEqual( + sorted(got.desired.resources), + [ + "caller-auth", + "caller-secret-ml-team-keys", + "client-ca-bundle", + "client-ca-certificate", + "client-ca-configmap", + "client-ca-issuer", + "client-certificate", + "client-selfsigned-issuer", + "envoy-proxy", + "failover-policy", + "gateway", + "healthz-auth", + "healthz-filter", + "healthz-route", + "tls-secret-eu-tls-0", + ], + ) + + def manifest(key: str) -> dict: + return resource.struct_to_dict(got.desired.resources[key].resource)["spec"]["forProvider"]["manifest"] + + self.assertEqual( + manifest("gateway")["spec"]["listeners"][1], + { + "name": "https", + "protocol": "HTTPS", + "port": 443, + "hostname": "eu.example.com", + "tls": {"mode": "Terminate", "certificateRefs": [{"name": "eu-tls-0"}]}, + "allowedRoutes": {"namespaces": {"from": "Same"}}, + }, + ) + # The HTTP listener stays hostname-less even here. A listener hostname is + # matched against the request Host, so setting it 404s anything addressed + # by IP, which is what /healthz on status.address is. + self.assertNotIn("hostname", manifest("gateway")["spec"]["listeners"][0]) + self.assertEqual( + manifest("tls-secret-eu-tls-0"), + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": "eu-tls-0", "namespace": fn.REMOTE_NAMESPACE}, + "type": "kubernetes.io/tls", + "data": { + "tls.crt": base64.b64encode(b"cert").decode(), + "tls.key": base64.b64encode(b"key").decode(), + }, + }, + "the certificate is copied verbatim, keeping the name the Gateway refers to it by", + ) + self.assertEqual( + manifest("caller-auth")["spec"]["apiKeyAuth"], + { + "credentialRefs": [{"name": "callers-ml-team-keys"}], + "extractFrom": [{"headers": ["Authorization"]}], + "forwardClientIDHeader": fn._CALLER_HEADER, + "sanitize": True, + }, + ) + self.assertEqual( + manifest("healthz-auth")["spec"], + { + "targetRefs": [{"group": "gateway.networking.k8s.io", "kind": "HTTPRoute", "name": fn._HEALTHZ_NAME}], + "authorization": {"defaultAction": "Allow"}, + }, + "/healthz overrides the Gateway-level policy so a health check needs no credential", + ) + self.assertEqual( + resource.struct_to_dict(got.desired.composite.resource)["status"], + { + "address": _ADDRESS, + "endpoints": { + "openAI": "https://eu.example.com/v1", + "anthropic": "https://eu.example.com/anthropic/v1", + }, + }, + ) + self.assertEqual( + list(got.conditions), + [ + fnv1.Condition( + type=fn.CONDITION_TYPE_GATEWAY_READY, + status=fnv1.STATUS_CONDITION_TRUE, + reason=fn.CONDITION_REASON_GATEWAY_PROGRAMMED, + ) + ], + ) + + async def test_endpoints_fall_back_to_the_address(self) -> None: + """Without a hostname the endpoints use the address over plain HTTP, so + what status reports is always something a caller can actually use.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_xr())), + resources={"gateway": _observed_gateway(_ADDRESS, ready=False)}, + ), + required_resources=_required(cluster=[_cluster()], gateways=[_gateway_xr("eu", _CLUSTER)]), + ) + got = await self.runner.RunFunction(req, None) + self.assertEqual( + resource.struct_to_dict(got.desired.composite.resource)["status"], + { + "address": _ADDRESS, + "endpoints": { + "openAI": f"http://{_ADDRESS}/v1", + "anthropic": f"http://{_ADDRESS}/anthropic/v1", + }, + }, + ) + self.assertEqual( + next(iter(got.conditions)).reason, + fn.CONDITION_REASON_WAITING_FOR_GATEWAY, + "an address alone isn't readiness; the Gateway must be programmed", + ) + + async def test_resolves_each_cluster_gateway_name(self) -> None: + """A Service per cluster gateway, resolving its name to its address here. + + A ModelService's backends address a cluster gateway by the name + compose-inference-cluster derived, and this gateway's Envoy resolves it, + so its cluster needs a Service of that name. An IP is served by a + headless Service and an EndpointSlice; a hostname, which is how a cloud + load balancer names itself, by an ExternalName Service. A cluster that + hasn't published both an address and a name gets neither. + """ + ipv4 = "prod-ipv4-gateway-aaaaa.modelplane-system.svc.cluster.local" + ipv6 = "prod-ipv6-gateway-bbbbb.modelplane-system.svc.cluster.local" + dns = "prod-dns-gateway-ccccc.modelplane-system.svc.cluster.local" + req = fnv1.RunFunctionRequest( + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_xr()))), + required_resources=_required( + cluster=[_cluster()], + gateways=[_gateway_xr("eu", _CLUSTER)], + clusters=[ + _cluster(), # this gateway's own cluster, no gateway published yet + _cluster_with_gateway("prod-ipv4", address="203.0.113.7", hostname=ipv4), + _cluster_with_gateway("prod-ipv6", address="2001:db8::1", hostname=ipv6), + _cluster_with_gateway("prod-dns", address="lb-x.elb.amazonaws.com", hostname=dns), + ], + ), + ) + got = await self.runner.RunFunction(req, None) + + resolvers = { + key: resource.struct_to_dict(res.resource) + for key, res in got.desired.resources.items() + if key.startswith("cluster-name") + } + for key, obj in resolvers.items(): + self.assertEqual( + obj["spec"]["providerConfigRef"], + {"kind": "ClusterProviderConfig", "name": _PC}, + f"{key} is composed against this gateway's own cluster", + ) + manifests = {key: obj["spec"]["forProvider"]["manifest"] for key, obj in resolvers.items()} + self.assertEqual( + manifests, + { + "cluster-name-prod-ipv4-gateway-aaaaa": { + "apiVersion": "v1", + "kind": "Service", + "metadata": {"name": "prod-ipv4-gateway-aaaaa", "namespace": fn.REMOTE_NAMESPACE}, + "spec": {"clusterIP": "None", "ports": [{"name": "https", "port": 443}]}, + }, + "cluster-name-slice-prod-ipv4-gateway-aaaaa": { + "apiVersion": "discovery.k8s.io/v1", + "kind": "EndpointSlice", + "metadata": { + "name": "prod-ipv4-gateway-aaaaa", + "namespace": fn.REMOTE_NAMESPACE, + "labels": {"kubernetes.io/service-name": "prod-ipv4-gateway-aaaaa"}, + }, + "addressType": "IPv4", + "ports": [{"name": "https", "port": 443}], + "endpoints": [{"addresses": ["203.0.113.7"], "conditions": {"ready": True}}], + }, + "cluster-name-prod-ipv6-gateway-bbbbb": { + "apiVersion": "v1", + "kind": "Service", + "metadata": {"name": "prod-ipv6-gateway-bbbbb", "namespace": fn.REMOTE_NAMESPACE}, + "spec": {"clusterIP": "None", "ports": [{"name": "https", "port": 443}]}, + }, + "cluster-name-slice-prod-ipv6-gateway-bbbbb": { + "apiVersion": "discovery.k8s.io/v1", + "kind": "EndpointSlice", + "metadata": { + "name": "prod-ipv6-gateway-bbbbb", + "namespace": fn.REMOTE_NAMESPACE, + "labels": {"kubernetes.io/service-name": "prod-ipv6-gateway-bbbbb"}, + }, + "addressType": "IPv6", + "ports": [{"name": "https", "port": 443}], + "endpoints": [{"addresses": ["2001:db8::1"], "conditions": {"ready": True}}], + }, + "cluster-name-prod-dns-gateway-ccccc": { + "apiVersion": "v1", + "kind": "Service", + "metadata": {"name": "prod-dns-gateway-ccccc", "namespace": fn.REMOTE_NAMESPACE}, + "spec": {"type": "ExternalName", "externalName": "lb-x.elb.amazonaws.com"}, + }, + }, + "IP clusters get a headless Service + EndpointSlice, the hostname cluster an ExternalName, " + "and the own cluster with nothing published gets neither", + ) + + async def test_certificate_common_names_fit_the_x509_limit(self) -> None: + """A long gateway name must not push a certificate commonName past the + 64-byte X.509 limit, which cert-manager's webhook rejects. A gateway name + is a cluster-scoped resource name, so it can be up to 253 characters.""" + long_name = "g" + "a" * 62 + req = fnv1.RunFunctionRequest( + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_xr(name=long_name)))), + required_resources=_required(cluster=[_cluster()], gateways=[_gateway_xr(long_name, _CLUSTER)]), + ) + got = await self.runner.RunFunction(req, None) + for key in ("client-ca-certificate", "client-certificate"): + manifest = resource.struct_to_dict(got.desired.resources[key].resource)["spec"]["forProvider"]["manifest"] + cn = manifest["spec"]["commonName"] + self.assertLessEqual(len(cn.encode()), 64, f"{key} commonName exceeds the 64-byte X.509 limit") + + async def test_a_rejected_caller_policy_is_not_ready(self) -> None: + """A gateway whose caller policy was rejected refuses every request with + a 500 while its Gateway is perfectly healthy. Envoy Gateway rejects the + policy when two selected Secrets share a key value, so this is reachable + by writing two Secrets, and reporting Ready would say the front door + works when nothing can get through it.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct( + _xr( + auth=v1alpha1.Auth( + secretSelector=v1alpha1.SecretSelector( + matchLabels={"modelplane.ai/inference-keys": "true"} + ) + ) + ) + ) + ), + # The Gateway is programmed; the policy is not accepted. + resources={"gateway": _observed_gateway(_ADDRESS, ready=True)}, + ), + required_resources=_required( + cluster=[_cluster()], + gateways=[_gateway_xr("eu", _CLUSTER)], + **{"caller-secrets": [_secret("ml-team-keys", {"a": "sk-1"})]}, + ), + ) + got = await self.runner.RunFunction(req, None) + cond = next(iter(got.conditions)) + self.assertEqual(cond.status, fnv1.STATUS_CONDITION_FALSE) + self.assertEqual(cond.reason, fn.CONDITION_REASON_AUTH_NOT_ACCEPTED) + + async def test_the_incumbent_keeps_its_cluster(self) -> None: + """A gateway created later must not take a cluster off one already + serving traffic. Doing so would delete the incumbent's Gateway and bring + its load balancer back on a different address, which is the one thing a + gateway may never do to its callers, and lowest-name-wins would have.""" + # "aaa" sorts before "zzz" but "zzz" already has an address. + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "InferenceGateway", + "metadata": {"name": "aaa"}, + "spec": {"clusterName": _CLUSTER}, + } + ) + ) + ), + required_resources=_required( + cluster=[_cluster()], + gateways=[ + _gateway_xr("aaa", _CLUSTER), + {**_gateway_xr("zzz", _CLUSTER), "status": {"address": _ADDRESS}}, + ], + ), + ) + got = await self.runner.RunFunction(req, None) + self.assertEqual(len(got.desired.resources), 0, "the newcomer composes nothing") + cond = next(iter(got.conditions)) + self.assertEqual(cond.reason, fn.CONDITION_REASON_CLUSTER_TAKEN) + self.assertIn("zzz", cond.message) + + async def test_no_composed_object_observes_a_secret(self) -> None: + """No composed Object reads a Secret, which is what keeps this gateway's + client CA private key off the control plane. + + provider-kubernetes copies an observed object's whole manifest into the + Object's status, and its --sanitize-secrets flag defaults to false, so + observing a Secret publishes every key in it to anyone who can get + objects. This CA signs the certificate every cluster gateway in the fleet + accepts, so leaking its key means anyone can reach any engine. + + Asserted over everything composed rather than over the PKI, because the + cost of reintroducing this anywhere is the same. + + Observing is the case that matters here. The Secrets this function + *writes* also end up in status, because provider-kubernetes reports what + it observes of what it manages, so this alone doesn't keep their contents + off the control plane. Those hold caller keys and serving certificates + that came from control-plane Secrets to begin with, so the exposure is a + wider audience for data already present rather than data that would + otherwise never be there, and prerequisites.yaml runs + provider-kubernetes with --sanitize-secrets to redact it. A CA private + key is different in kind: it is generated on the workload cluster and + observing it is the only way it could ever reach the control plane. + """ + # Auth and TLS both on, so the Secret-copying path is exercised: without + # them this function composes no Secret at all and the assertion holds + # vacuously. + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct( + _xr( + hostname="gw.example.org", + tls={"certificateRefs": [{"name": "eu-tls-0"}]}, + auth={"secretSelector": {"matchLabels": {"team": "ml"}}}, + ) + ) + ), + ), + required_resources=_required( + cluster=[_cluster()], + gateways=[_gateway_xr("eu", _CLUSTER)], + **{ + "caller-secrets": [_secret("ml-team-keys", {"alice": "key"})], + "tls-secret-0": [_secret("eu-tls-0", {"tls.crt": "cert", "tls.key": "key"})], + }, + ), + ) + got = await self.runner.RunFunction(req, None) + + composed_secrets = [] + observed_secrets = [] + for key, res in got.desired.resources.items(): + d = resource.struct_to_dict(res.resource) + manifest = d["spec"]["forProvider"]["manifest"] + if manifest["kind"] != "Secret": + continue + composed_secrets.append(key) + if "Observe" in d["spec"].get("managementPolicies", []): + observed_secrets.append(key) + self.assertEqual(observed_secrets, [], "these observe a Secret, so its private keys reach the control plane") + self.assertNotEqual(composed_secrets, [], "no Secret composed, so the assertion above proves nothing") + + async def test_client_pki_publishes_the_ca_without_its_key(self) -> None: + """The client CA's certificate reaches the control plane through a + trust-manager Bundle, which copies one named key into a ConfigMap, rather + than through the Secret that also holds the private key.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_xr()))), + required_resources=_required(cluster=[_cluster()], gateways=[_gateway_xr("eu", _CLUSTER)]), + ) + got = await self.runner.RunFunction(req, None) + + def manifest(key: str) -> dict: + return resource.struct_to_dict(got.desired.resources[key].resource)["spec"]["forProvider"]["manifest"] + + self.assertEqual( + manifest("client-ca-bundle"), + { + "apiVersion": "trust.cert-manager.io/v1alpha1", + "kind": "Bundle", + "metadata": {"name": "fleet-gateway-ca"}, + "spec": { + "sources": [{"secret": {"name": "fleet-gateway-ca", "key": "ca.crt"}}], + "target": { + "configMap": {"key": "ca.crt"}, + "namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "modelplane-system"}}, + }, + }, + }, + ) + # Named after the Bundle, because that's the ConfigMap a Bundle syncs. + self.assertEqual( + manifest("client-ca-configmap"), + { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": "fleet-gateway-ca", "namespace": "modelplane-system"}, + }, + ) + self.assertEqual( + resource.struct_to_dict(got.desired.resources["client-ca-configmap"].resource)["spec"][ + "managementPolicies" + ], + ["Observe"], + "trust-manager owns this ConfigMap; Crossplane must not write it", + ) + + async def test_client_ca_published_from_the_observed_configmap(self) -> None: + """status.clientCACertificate comes from the ConfigMap trust-manager + syncs, as plain text rather than base64. A cluster only trusts this + gateway once it has it, so nothing reaches an engine before it appears. + """ + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_xr())), + resources={ + "gateway": _observed_gateway("gw.example.org", ready=True), + "client-ca-configmap": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "status": { + "atProvider": { + "manifest": { + "apiVersion": "v1", + "kind": "ConfigMap", + "data": {"ca.crt": "-----BEGIN CERTIFICATE-----\nclient\n"}, + } + } + }, + } + ), + ), + }, + ), + required_resources=_required(cluster=[_cluster()], gateways=[_gateway_xr("eu", _CLUSTER)]), + ) + got = await self.runner.RunFunction(req, None) + + self.assertEqual( + resource.struct_to_dict(got.desired.composite.resource)["status"]["clientCACertificate"], + "-----BEGIN CERTIFICATE-----\nclient\n", + ) + + async def test_no_client_ca_before_the_bundle_syncs(self) -> None: + """With no observed ConfigMap the gateway publishes no CA, so no cluster + trusts it yet and no cluster publishes a hostname on its account.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_xr())), + resources={"gateway": _observed_gateway("gw.example.org", ready=True)}, + ), + required_resources=_required(cluster=[_cluster()], gateways=[_gateway_xr("eu", _CLUSTER)]), + ) + got = await self.runner.RunFunction(req, None) + + self.assertNotIn( + "clientCACertificate", + resource.struct_to_dict(got.desired.composite.resource)["status"], + ) diff --git a/functions/compose-model-deployment/function/fn.py b/functions/compose-model-deployment/function/fn.py index 8771626ae..a07608636 100644 --- a/functions/compose-model-deployment/function/fn.py +++ b/functions/compose-model-deployment/function/fn.py @@ -65,9 +65,54 @@ _LABEL_INDEX = "modelplane.ai/replica-index" -# Scheme for gateway-facing URLs. Traffic between the control plane gateway -# and remote cluster gateways uses plain HTTP; TLS terminates at the edge. -_GATEWAY_SCHEME = "http" +# The hop from a fleet gateway to a cluster gateway crosses whatever network +# separates two clusters, so TLS is originated to it and the cluster gateway +# requires a client certificate in return. Neither is configurable: a composed +# endpoint is always https, and a cluster gateway always refuses a request that +# arrives without a fleet gateway's certificate. +_GATEWAY_SCHEME = "https" + +# Injected into every engine container so an engine can be started under the +# name Modelplane routes to, rather than Modelplane having to be told what the +# engine was started with. Reference it in the engine's args: +# +# args: +# - --model=Qwen/Qwen3-8B +# - --served-model-name=$(MODELPLANE_SERVED_MODEL_NAME) +# +# An engine only answers to the name it was started with, and a caller names a +# ModelService rather than a deployment, so something has to reconcile the two. +# Doing it this way means the name can't drift: the alternative, a field +# declaring what the engine was started with, is a second place to write the +# same string and a 404 when the two disagree. +SERVED_MODEL_NAME_ENV = "MODELPLANE_SERVED_MODEL_NAME" + + +def _inject_served_model_name(template: mrv1alpha1.Template, served: str) -> None: + """Put SERVED_MODEL_NAME_ENV ahead of a container's own env entries. + + Ahead, because env expansion is left to right, so an arg or a later env + entry referencing $(MODELPLANE_SERVED_MODEL_NAME) only resolves if this + comes first. A user entry of the same name is dropped rather than + duplicated: the whole point is that Modelplane decides this value, and + honouring an override would let the engine answer to a name nothing routes + to. + """ + if template.spec is None: + return + for container in template.spec.containers: + existing = [e for e in container.env or [] if e.name != SERVED_MODEL_NAME_ENV] + container.env = [mrv1alpha1.EnvItem(name=SERVED_MODEL_NAME_ENV, value=served), *existing] + + +def served_model_name(namespace: str, deployment: str) -> str: + """The name every replica of a deployment serves under. + + Namespaced, so two deployments in different namespaces can't collide on a + shared engine name, and so a ModelService can rewrite one model name for a + whole deployment rather than one per replica. + """ + return f"{namespace}/{deployment}" def _name(meta: metav1.ObjectMeta | None) -> str: @@ -353,12 +398,21 @@ def schedule(self) -> list[scheduling.Candidate]: def _child_labels(self, cluster_info: scheduling.Candidate) -> dict[str, str]: """Labels for a composed ModelReplica or ModelEndpoint. - The user's template labels first, then the labels Modelplane manages, so - a managed key always wins over a colliding one (the XRD also rejects - template labels under the modelplane.ai/ prefix). + The user's template labels first, then the cluster's placement labels, + then the labels Modelplane manages, so a managed key always wins over a + colliding one (the XRD also rejects template labels under the + modelplane.ai/ prefix, and placement labels likewise). + + The cluster's placement labels are what let a ModelService select + endpoints by where they are: the fact is declared once on the + InferenceCluster and inherited by everything composed there, rather than + repeated on each replica. A cluster's labels beat the deployment's own + template labels on a collision, because the cluster is the authority on + where it is. """ metadata = self.xr.spec.template.metadata labels = dict((metadata.labels if metadata else None) or {}) + labels.update(cluster_info.placement_labels) labels[_LABEL_DEPLOYMENT] = _name(self.xr.metadata) labels[_LABEL_CLUSTER] = cluster_info.name labels[_LABEL_INDEX] = str(cluster_info.index) @@ -413,12 +467,15 @@ def _replica_engine(self, engine: v1alpha1.Engine, placement: scheduling.EngineP pin; the scheduler guarantees at least one member of every engine carries requests. """ + served = served_model_name(_namespace(self.xr.metadata), _name(self.xr.metadata)) members = [] for member, mp in zip(engine.members, placement.members, strict=True): + template = mrv1alpha1.Template.model_validate(member.template.model_dump(exclude_unset=True)) + _inject_served_model_name(template, served) replica_member = mrv1alpha1.Member( role=member.role, nodePoolName=mp.pool, - template=mrv1alpha1.Template.model_validate(member.template.model_dump(exclude_unset=True)), + template=template, ) # A claimless member omits deviceRequests rather than carrying an # empty list; setting [] would serialize a literal empty array into @@ -453,32 +510,35 @@ def _replica_engine(self, engine: v1alpha1.Engine, placement: scheduling.EngineP def compose_endpoints(self, matched: list[scheduling.Candidate]) -> None: """Compose one ModelEndpoint per matched replica. - Endpoints are labeled with the deployment name so a ModelService - can select them. The URL points at the per-replica path on the - remote cluster's gateway. The rewritePath tells ModelService what - URL prefix to rewrite to on the remote cluster. The path is - per-replica โ€” /// โ€” matching the HTTPRoute - emitted by compose-model-replica's backends (named after the replica - so co-located replicas on one cluster don't collide). - - Replicas pinned to clusters that are currently unavailable (no - gateway address) get no endpoint. Routing must not direct - traffic at a dead backend. When the cluster recovers and its - gateway address is observed again the endpoint will be composed - on the next reconcile. - - For the same reason an endpoint is withheld until its ModelReplica - is Ready. The replica's Ready tracks both the engine workloads - serving and the remote Service and HTTPRoute that front them - the - whole traffic path the endpoint advertises. Composing the endpoint - any earlier routes traffic at pods still pulling images or loading - weights, returning 503s during deployment and scale-up (#102). The - endpoint is composed on the reconcile that first observes the - replica Ready, and withdrawn again if the replica later goes - not-Ready, pulling a dead backend out of rotation. + Endpoints carry the deployment name as a label so a ModelService can + select them. Each describes its replica the way an external provider + would be described by hand: an origin, the path its API is served under, + and the name the backend knows the model by. + + The origin is the cluster gateway's hostname, never its address. Envoy + AI Gateway applies per-backend model rewriting, credentials and priority + failover only when every backend in a route is addressed by hostname; + given an address it keeps passing traffic and silently stops applying + them. The path is per-replica, matching the HTTPRoute + compose-model-replica composes on the cluster gateway, so co-located + replicas don't collide. + + Every replica of a deployment serves under the deployment's own name, so + a ModelService fanning over them can rewrite one model name for the + whole set rather than one per replica. + + A replica whose cluster has no hostname gets no endpoint, and so does + one whose ModelReplica isn't Ready. The replica's Ready tracks the + engine workloads serving and the remote Service and HTTPRoute that front + them, which is the whole path this endpoint advertises. Composing it any + earlier routes traffic at pods still pulling images or loading weights, + returning 503s during deployment and scale-up (#102). The endpoint + appears on the reconcile that first observes the replica Ready, and is + withdrawn again if the replica stops being Ready, pulling a dead backend + out of rotation. """ for cluster_info in matched: - if not cluster_info.gateway_address: + if not cluster_info.gateway_hostname: continue replica_observed = self.req.observed.resources.get(name.replica_key(cluster_info)) @@ -489,21 +549,24 @@ def compose_endpoints(self, matched: list[scheduling.Candidate]) -> None: # resources) is the per-placement routing key. Must match the name # composed in compose_replicas so routing lands on this replica. replica_name = name.replica(_name(self.xr.metadata), cluster_info) - rewrite_path = f"/{_namespace(self.xr.metadata)}/{replica_name}/" + namespace = _namespace(self.xr.metadata) endpoint_key = name.endpoint_key(cluster_info) - url = f"{_GATEWAY_SCHEME}://{cluster_info.gateway_address}{rewrite_path}v1" resource.update( self.rsp.desired.resources[endpoint_key], mev1alpha1.ModelEndpoint( metadata=metav1.ObjectMeta( name=replica_name, - namespace=_namespace(self.xr.metadata), + namespace=namespace, labels=self._child_labels(cluster_info), ), spec=mev1alpha1.Spec( - url=url, - rewritePath=rewrite_path, + origin=f"{_GATEWAY_SCHEME}://{cluster_info.gateway_hostname}", + api=mev1alpha1.Api( + schema="OpenAI", + prefix=f"/{namespace}/{replica_name}/v1", + ), + model=served_model_name(namespace, _name(self.xr.metadata)), ), ), ) diff --git a/functions/compose-model-deployment/function/scheduling.py b/functions/compose-model-deployment/function/scheduling.py index c64215168..a384d3548 100644 --- a/functions/compose-model-deployment/function/scheduling.py +++ b/functions/compose-model-deployment/function/scheduling.py @@ -198,11 +198,15 @@ class Candidate: # same deployment on the same cluster. Stable across reconciles for a # retained replica. index: int - # The cluster's gateway address. Empty if the cluster is pinned but - # currently unavailable (no Ready condition or no gateway address). - # Callers should not compose a ModelEndpoint when this is empty - - # there is nothing to route traffic to. - gateway_address: str = "" + # The name this cluster's gateway is addressable by. Empty if the cluster is + # pinned but currently unavailable: no Ready condition, no gateway address, + # or no DNS published for it. Callers must not compose a ModelEndpoint when + # this is empty, because there is no name to route traffic to. + gateway_hostname: str = "" + # The cluster's spec.placement.metadata.labels, projected onto the + # ModelReplica and ModelEndpoint composed here. How a self-hosted endpoint + # gets its region, so a region-scoped ModelService can select it. + placement_labels: dict[str, str] = field(default_factory=dict) # Per-engine placement: the pool each member of the replica's engines was # assigned and that member's resolved device requests. One entry per engine # in deployment order. Always populated for a scheduled replica. @@ -306,14 +310,20 @@ def compile_engines(deployment: mdv1alpha1.ModelDeployment) -> list[_CompiledEng def _cluster_ready(cluster: icv1alpha1.InferenceCluster) -> bool: - """Check that the cluster is Ready and has a gateway address. - - A cluster without a Ready=True condition hasn't finished provisioning - or has become unavailable. A cluster without a gateway address can't - receive routed traffic. Both must be true for the cluster to be - schedulable for new placements. + """Check that the cluster is Ready and its gateway is addressable by name. + + A cluster without a Ready=True condition hasn't finished provisioning or has + become unavailable. A cluster whose gateway has no hostname can't receive + routed traffic: an InferenceGateway addresses a cluster by name, because + Envoy AI Gateway only applies per-backend model rewriting, credentials and + priority failover when every backend in a route is a hostname. + + The cluster decides when to publish that hostname, and withholds it until + traffic to it is mutually authenticated as well as addressable, so this is + also what keeps work off a cluster whose gateway isn't serving. See + compose-inference-cluster's write_status for the conditions. """ - if not cluster.status or not cluster.status.gateway or not cluster.status.gateway.address: + if not cluster.status or not cluster.status.gateway or not cluster.status.gateway.hostname: return False return any(c.type == "Ready" and c.status == "True" for c in cluster.status.conditions or []) @@ -622,7 +632,8 @@ def _retain( Candidate( name=cluster_name, index=identity[1], - gateway_address=_gateway_address(cluster), + gateway_hostname=_gateway_hostname(cluster), + placement_labels=_placement_labels(cluster), engines=placements, ) ) @@ -880,7 +891,8 @@ def _fill( Candidate( name=name, index=index, - gateway_address=_gateway_address(cluster), + gateway_hostname=_gateway_hostname(cluster), + placement_labels=_placement_labels(cluster), engines=placements, ) ) @@ -938,11 +950,25 @@ def _lowest_free_index(used: set[int]) -> int: return i -def _gateway_address(cluster: icv1alpha1.InferenceCluster) -> str: - """The cluster's gateway address, or empty when degraded/unset.""" +def _placement_labels(cluster: icv1alpha1.InferenceCluster) -> dict[str, str]: + """The cluster's placement labels, or {} when it declares none.""" + placement = cluster.spec.placement + if not placement or not placement.metadata or not placement.metadata.labels: + return {} + return dict(placement.metadata.labels) + + +def _gateway_hostname(cluster: icv1alpha1.InferenceCluster) -> str: + """The name the cluster's gateway is addressable by, or empty when unset. + + Modelplane derives the name and publishes it once the gateway is both + addressable and mutually authenticated, so an empty value means one of: no + address yet, no CA of its own, or no fleet gateway CA for it to demand a + client certificate against. + """ if not cluster.status or not cluster.status.gateway: return "" - return cluster.status.gateway.address or "" + return cluster.status.gateway.hostname or "" def _scale_down(retained: list[Candidate], desired: int) -> list[Candidate]: diff --git a/functions/compose-model-deployment/tests/test_fn.py b/functions/compose-model-deployment/tests/test_fn.py index 112284a3b..ec2e50716 100644 --- a/functions/compose-model-deployment/tests/test_fn.py +++ b/functions/compose-model-deployment/tests/test_fn.py @@ -97,8 +97,17 @@ def _replica_engines(*, args: bool = True) -> list: args toggles the engine container's --model arg, matching the fixture deployment a want is built from. + + Every container carries MODELPLANE_SERVED_MODEL_NAME, ahead of any env the + user wrote, so an arg can reference it. It's how an engine comes up under the + name Modelplane routes to instead of Modelplane having to be told what the + engine was started with. """ - container: dict[str, Any] = {"name": "engine", "image": "vllm/vllm-openai:latest"} + container: dict[str, Any] = { + "name": "engine", + "image": "vllm/vllm-openai:latest", + "env": [{"name": "MODELPLANE_SERVED_MODEL_NAME", "value": "ml-team/my-model"}], + } if args: container["args"] = ["--model=Qwen/Qwen3-0.6B"] return [ @@ -143,11 +152,18 @@ def _replica_engines(*, args: bool = True) -> list: ).model_dump(exclude_none=True, mode="json") -def _cluster(name: str, *, ready: bool = True, address: str | None = "10.0.0.1", nodes: int = 2) -> dict: +def _cluster( + name: str, + *, + ready: bool = True, + hostname: str | None = "cluster.clusters.example.com", + nodes: int = 2, + placement_labels: dict[str, str] | None = None, +) -> dict: """An InferenceCluster input fixture, dumped to a dict. - A ready cluster has a Ready=True condition and a gateway address. ready=False - flips the condition to Unavailable; address=None drops the gateway entirely + A ready cluster has a Ready=True condition and a gateway hostname. ready=False + flips the condition to Unavailable; hostname=None drops the gateway entirely (mirroring an offline cluster). nodes=0 yields a pool with no capacity. """ return icv1alpha1.InferenceCluster( @@ -157,6 +173,11 @@ def _cluster(name: str, *, ready: bool = True, address: str | None = "10.0.0.1", source="Existing", existing=icv1alpha1.Existing(secretRef=icv1alpha1.SecretRef(name="k")), ), + placement=( + icv1alpha1.Placement(metadata=icv1alpha1.Metadata(labels=placement_labels)) + if placement_labels + else None + ), ), status=icv1alpha1.Status( conditions=[ @@ -167,7 +188,7 @@ def _cluster(name: str, *, ready: bool = True, address: str | None = "10.0.0.1", lastTransitionTime=_TRANSITION_TIME, ) ], - gateway=icv1alpha1.Gateway(address=address) if address else None, + gateway=icv1alpha1.Gateway(address="10.0.0.1", hostname=hostname) if hostname else None, providerConfigRef=icv1alpha1.ProviderConfigRef(name=name), gpuPools=[ icv1alpha1.GpuPool( @@ -671,8 +692,12 @@ async def test_compose(self) -> None: }, }, "spec": { - "url": "http://10.0.0.1/ml-team/my-model-5ab63/v1", - "rewritePath": "/ml-team/my-model-5ab63/", + "origin": "https://cluster.clusters.example.com", + "api": { + "schema": "OpenAI", + "prefix": "/ml-team/my-model-5ab63/v1", + }, + "model": "ml-team/my-model", }, } ), @@ -701,7 +726,7 @@ async def test_compose(self) -> None: name="offline pinned cluster keeps replica but drops endpoint", req=_req( _XR, - clusters=[_cluster("cluster-a", ready=False, address=None)], + clusters=[_cluster("cluster-a", ready=False, hostname=None)], replicas=[_EXISTING_REPLICA], observed={"replica-cluster-a-0": _EXISTING_REPLICA}, ), @@ -758,7 +783,7 @@ async def test_compose(self) -> None: name="deleted pinned cluster triggers replica re-placement", req=_req( _XR, - clusters=[_cluster("cluster-b", address="10.0.0.2")], + clusters=[_cluster("cluster-b", hostname="cluster-b.clusters.example.com")], replicas=[_EXISTING_REPLICA], observed={"replica-cluster-a-0": _replica_status(_EXISTING_REPLICA, ready=True)}, ), @@ -1058,8 +1083,12 @@ async def test_compose(self) -> None: }, }, "spec": { - "url": "http://10.0.0.1/ml-team/my-model-5ab63/v1", - "rewritePath": "/ml-team/my-model-5ab63/", + "origin": "https://cluster.clusters.example.com", + "api": { + "schema": "OpenAI", + "prefix": "/ml-team/my-model-5ab63/v1", + }, + "model": "ml-team/my-model", }, } ), @@ -1333,3 +1362,108 @@ def test_resolve_required(self) -> None: # UNRESOLVED: Crossplane has not fetched the requirement (key absent). req = fnv1.RunFunctionRequest() self.assertEqual((fn.Resolution.UNRESOLVED, None), fn.resolve_required(req, "cache")) + + +class TestServedModelName(unittest.TestCase): + """The name an engine is started under, and how it gets there.""" + + def test_it_goes_ahead_of_the_users_env(self) -> None: + """Env expansion is left to right, so an arg or a later entry + referencing $(MODELPLANE_SERVED_MODEL_NAME) only resolves if it's + first.""" + template = mrv1alpha1.Template( + spec=mrv1alpha1.Spec( + containers=[ + mrv1alpha1.Container( + name="engine", + image="vllm/vllm-openai:latest", + env=[mrv1alpha1.EnvItem(name="HF_TOKEN", value="x")], + ) + ] + ) + ) + fn._inject_served_model_name(template, "ml-team/kimi-k2") + assert template.spec is not None + self.assertEqual( + [(e.name, e.value) for e in template.spec.containers[0].env or []], + [("MODELPLANE_SERVED_MODEL_NAME", "ml-team/kimi-k2"), ("HF_TOKEN", "x")], + ) + + def test_a_user_override_is_dropped(self) -> None: + """Modelplane decides this value. Honouring an override would let the + engine answer to a name nothing routes to, which surfaces as a 404 from + the engine rather than anything visible in status.""" + template = mrv1alpha1.Template( + spec=mrv1alpha1.Spec( + containers=[ + mrv1alpha1.Container( + name="engine", + image="vllm/vllm-openai:latest", + env=[mrv1alpha1.EnvItem(name="MODELPLANE_SERVED_MODEL_NAME", value="mine")], + ) + ] + ) + ) + fn._inject_served_model_name(template, "ml-team/kimi-k2") + assert template.spec is not None + self.assertEqual( + [(e.name, e.value) for e in template.spec.containers[0].env or []], + [("MODELPLANE_SERVED_MODEL_NAME", "ml-team/kimi-k2")], + ) + + def test_it_is_namespaced(self) -> None: + """So two deployments in different namespaces can't collide, and a + ModelService can rewrite one name for a whole deployment.""" + self.assertEqual(fn.served_model_name("ml-team", "kimi-k2"), "ml-team/kimi-k2") + + +class TestPlacementLabels(unittest.IsolatedAsyncioTestCase): + """A cluster's spec.placement.metadata.labels land on the ModelReplicas and + ModelEndpoints composed there. + + This is the endpoint half of residency: a ModelService selects endpoints by + label, so without it a region-scoped service can't select its own replicas, + and nobody can label them by hand because Modelplane owns them. The gateway + half is an InferenceGateway's serviceSelector. + """ + + async def test_stamped_on_replica_and_endpoint(self) -> None: + xr = v1alpha1.ModelDeployment( + metadata=metav1.ObjectMeta(name="my-model", namespace="ml-team"), + spec=v1alpha1.SpecModel1( + replicas=1, + template=v1alpha1.TemplateModel(spec=v1alpha1.SpecModel(engines=[_ENGINE])), + ), + ).model_dump(exclude_none=True, mode="json") + req = _req( + xr, + clusters=[_cluster("cluster-a", placement_labels={"example.org/region": "eu"})], + replicas=[_EXISTING_REPLICA], + observed={"replica-cluster-a-0": _replica_status(_EXISTING_REPLICA, ready=True)}, + ) + got = await fn.FunctionRunner().RunFunction(req, None) + + composed = _composed(got, "ModelReplica") + _composed(got, "ModelEndpoint") + self.assertEqual(len(composed), 2, "expected one ModelReplica and one ModelEndpoint") + for obj in composed: + self.assertEqual(obj["metadata"]["labels"].get("example.org/region"), "eu") + + async def test_a_cluster_label_beats_a_template_label(self) -> None: + """The cluster is the authority on where it is, so its placement labels + are stamped after the deployment's own template labels.""" + xr = v1alpha1.ModelDeployment( + metadata=metav1.ObjectMeta(name="my-model", namespace="ml-team"), + spec=v1alpha1.SpecModel1( + replicas=1, + template=v1alpha1.TemplateModel( + metadata=v1alpha1.Metadata(labels={"example.org/region": "wrong"}), + spec=v1alpha1.SpecModel(engines=[_ENGINE]), + ), + ), + ).model_dump(exclude_none=True, mode="json") + got = await fn.FunctionRunner().RunFunction( + _req(xr, clusters=[_cluster("cluster-a", placement_labels={"example.org/region": "eu"})]), + None, + ) + replica = _composed(got, "ModelReplica")[0] + self.assertEqual(replica["metadata"]["labels"]["example.org/region"], "eu") diff --git a/functions/compose-model-deployment/tests/test_scheduling.py b/functions/compose-model-deployment/tests/test_scheduling.py index 0ce66fc17..07fdb2316 100644 --- a/functions/compose-model-deployment/tests/test_scheduling.py +++ b/functions/compose-model-deployment/tests/test_scheduling.py @@ -182,15 +182,17 @@ def _pool(name: str, *, nodes: int = 2, devices: list[dict] | None = None) -> di def _cluster( name: str, *, + placement_labels: dict[str, str] | None = None, ready: bool = True, - gateway_address: str = "10.0.0.1", + gateway_hostname: str = "cluster-a.clusters.example.com", pools: list[dict] | None = None, taints: list[icv1alpha1.Taint] | None = None, ) -> icv1alpha1.InferenceCluster: """Construct an InferenceCluster with the given readiness and pools. - A "ready" cluster has a Ready=True condition and a gateway address. - Setting ready=False or gateway_address="" produces a degraded cluster + A "ready" cluster has a Ready=True condition and a gateway hostname. An + address alone is not enough: a fleet gateway addresses a cluster by name. + Setting ready=False or gateway_hostname="" produces a degraded cluster the scheduler will retain but not pick anew. """ if pools is None: @@ -215,10 +217,19 @@ def _cluster( existing=icv1alpha1.Existing(secretRef=icv1alpha1.SecretRef(name="k")), ), taints=taints, + placement=( + icv1alpha1.Placement(metadata=icv1alpha1.Metadata(labels=placement_labels)) + if placement_labels + else None + ), ), status=icv1alpha1.Status( conditions=conditions, - gateway=icv1alpha1.Gateway(address=gateway_address) if gateway_address else icv1alpha1.Gateway(), + gateway=( + icv1alpha1.Gateway(address="10.0.0.1", hostname=gateway_hostname) + if gateway_hostname + else icv1alpha1.Gateway(address="10.0.0.1") + ), providerConfigRef=icv1alpha1.ProviderConfigRef(name=name), gpuPools=[icv1alpha1.GpuPool(**p) for p in pools], ), @@ -380,11 +391,18 @@ def _cand( device_requests: list[scheduling.DeviceRequest] | None = None, pipeline: int = 1, engines: list[scheduling.EnginePlacement] | None = None, - **kwargs: str, + gateway_hostname: str = "", + placement_labels: dict[str, str] | None = None, ) -> scheduling.Candidate: if engines is None: engines = [_placement(pool=pool, device_requests=device_requests, pipeline=pipeline)] - return scheduling.Candidate(name=name, index=index, engines=engines, **kwargs) + return scheduling.Candidate( + name=name, + index=index, + engines=engines, + gateway_hostname=gateway_hostname, + placement_labels=placement_labels or {}, + ) class TestSchedule(unittest.TestCase): @@ -411,7 +429,7 @@ def test_schedule(self) -> None: deployment=_deployment(), clusters=[_cluster("cluster-a")], all_replicas=[], - want=[_cand(name="cluster-a", gateway_address="10.0.0.1", pool="default")], + want=[_cand(name="cluster-a", gateway_hostname="cluster-a.clusters.example.com", pool="default")], ), Case( name="not-ready cluster is not picked for a new replica", @@ -423,7 +441,7 @@ def test_schedule(self) -> None: Case( name="cluster without gateway address is not picked", deployment=_deployment(), - clusters=[_cluster("cluster-a", gateway_address="")], + clusters=[_cluster("cluster-a", gateway_hostname="")], all_replicas=[], want=[], ), @@ -437,34 +455,40 @@ def test_schedule(self) -> None: Case( name="existing replica is retained on its pinned cluster", deployment=_deployment(), - clusters=[_cluster("cluster-a"), _cluster("cluster-b", gateway_address="10.0.0.2")], + clusters=[ + _cluster("cluster-a"), + _cluster("cluster-b", gateway_hostname="cluster-b.clusters.example.com"), + ], all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default")], # cluster-a wins even though cluster-b is also viable. The pin # still matches, so it's retained with its resolved pool/requests. - want=[_cand(name="cluster-a", gateway_address="10.0.0.1")], + want=[_cand(name="cluster-a", gateway_hostname="cluster-a.clusters.example.com")], ), Case( name="degraded pinned cluster is retained with empty gateway", deployment=_deployment(), - clusters=[_cluster("cluster-a", ready=False, gateway_address="")], + clusters=[_cluster("cluster-a", ready=False, gateway_hostname="")], all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default")], - want=[_cand(name="cluster-a", gateway_address="")], + want=[_cand(name="cluster-a", gateway_hostname="")], ), Case( name="deleted pinned cluster triggers re-placement", deployment=_deployment(), - clusters=[_cluster("cluster-b", gateway_address="10.0.0.2")], + clusters=[_cluster("cluster-b", gateway_hostname="cluster-b.clusters.example.com")], all_replicas=[_replica("my-model", "cluster-a")], - want=[_cand(name="cluster-b", gateway_address="10.0.0.2", pool="default")], + want=[_cand(name="cluster-b", gateway_hostname="cluster-b.clusters.example.com", pool="default")], ), Case( name="scale up places new replicas on additional clusters", deployment=_deployment(replicas=2), - clusters=[_cluster("cluster-a"), _cluster("cluster-b", gateway_address="10.0.0.2")], + clusters=[ + _cluster("cluster-a"), + _cluster("cluster-b", gateway_hostname="cluster-b.clusters.example.com"), + ], all_replicas=[_replica("my-model", "cluster-a")], want=[ - _cand(name="cluster-a", gateway_address="10.0.0.1"), - _cand(name="cluster-b", gateway_address="10.0.0.2", pool="default"), + _cand(name="cluster-a", gateway_hostname="cluster-a.clusters.example.com"), + _cand(name="cluster-b", gateway_hostname="cluster-b.clusters.example.com", pool="default"), ], ), Case( @@ -474,7 +498,7 @@ def test_schedule(self) -> None: # second replica can be placed - not even on the same cluster. clusters=[_cluster("cluster-a", pools=[_pool("default", nodes=1)])], all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default")], - want=[_cand(name="cluster-a", gateway_address="10.0.0.1", pool="default")], + want=[_cand(name="cluster-a", gateway_hostname="cluster-a.clusters.example.com", pool="default")], ), Case( name="two replicas pack onto one cluster when it is the only option", @@ -484,8 +508,8 @@ def test_schedule(self) -> None: clusters=[_cluster("cluster-a", pools=[_pool("default", nodes=2)])], all_replicas=[], want=[ - _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-a", index=1, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-a", index=0, gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-a", index=1, gateway_hostname="cluster-a.clusters.example.com", pool="default"), ], ), Case( @@ -494,12 +518,16 @@ def test_schedule(self) -> None: # Both clusters can hold two replicas, but we prefer one each. clusters=[ _cluster("cluster-a", pools=[_pool("default", nodes=2)]), - _cluster("cluster-b", gateway_address="10.0.0.2", pools=[_pool("default", nodes=2)]), + _cluster( + "cluster-b", + gateway_hostname="cluster-b.clusters.example.com", + pools=[_pool("default", nodes=2)], + ), ], all_replicas=[], want=[ - _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-b", index=0, gateway_address="10.0.0.2", pool="default"), + _cand(name="cluster-a", index=0, gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-b", index=0, gateway_hostname="cluster-b.clusters.example.com", pool="default"), ], ), Case( @@ -509,13 +537,17 @@ def test_schedule(self) -> None: # the third lands back on cluster-a (lowest load, name tiebreak). clusters=[ _cluster("cluster-a", pools=[_pool("default", nodes=4)]), - _cluster("cluster-b", gateway_address="10.0.0.2", pools=[_pool("default", nodes=4)]), + _cluster( + "cluster-b", + gateway_hostname="cluster-b.clusters.example.com", + pools=[_pool("default", nodes=4)], + ), ], all_replicas=[], want=[ - _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-a", index=1, gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-b", index=0, gateway_address="10.0.0.2", pool="default"), + _cand(name="cluster-a", index=0, gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-a", index=1, gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-b", index=0, gateway_hostname="cluster-b.clusters.example.com", pool="default"), ], ), Case( @@ -526,13 +558,17 @@ def test_schedule(self) -> None: # so it packs onto a. clusters=[ _cluster("cluster-a", pools=[_pool("default", nodes=4)]), - _cluster("cluster-b", gateway_address="10.0.0.2", pools=[_pool("default", nodes=1)]), + _cluster( + "cluster-b", + gateway_hostname="cluster-b.clusters.example.com", + pools=[_pool("default", nodes=1)], + ), ], all_replicas=[], want=[ - _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-a", index=1, gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-b", index=0, gateway_address="10.0.0.2", pool="default"), + _cand(name="cluster-a", index=0, gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-a", index=1, gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-b", index=0, gateway_hostname="cluster-b.clusters.example.com", pool="default"), ], ), Case( @@ -542,12 +578,16 @@ def test_schedule(self) -> None: # replica prefers empty cluster-b over packing onto a. clusters=[ _cluster("cluster-a", pools=[_pool("default", nodes=4)]), - _cluster("cluster-b", gateway_address="10.0.0.2", pools=[_pool("default", nodes=4)]), + _cluster( + "cluster-b", + gateway_hostname="cluster-b.clusters.example.com", + pools=[_pool("default", nodes=4)], + ), ], all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default")], want=[ - _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-b", index=0, gateway_address="10.0.0.2", pool="default"), + _cand(name="cluster-a", index=0, gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-b", index=0, gateway_hostname="cluster-b.clusters.example.com", pool="default"), ], ), Case( @@ -561,9 +601,9 @@ def test_schedule(self) -> None: _replica_with_pool("my-model", "cluster-a", pool="default", index=2), ], want=[ - _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-a", index=1, gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-a", index=2, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-a", index=0, gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-a", index=1, gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-a", index=2, gateway_hostname="cluster-a.clusters.example.com", pool="default"), ], ), Case( @@ -574,7 +614,11 @@ def test_schedule(self) -> None: # spread across a/0 and b/0. clusters=[ _cluster("cluster-a", pools=[_pool("default", nodes=4)]), - _cluster("cluster-b", gateway_address="10.0.0.2", pools=[_pool("default", nodes=4)]), + _cluster( + "cluster-b", + gateway_hostname="cluster-b.clusters.example.com", + pools=[_pool("default", nodes=4)], + ), ], all_replicas=[ _replica_with_pool("my-model", "cluster-a", pool="default", index=0), @@ -582,8 +626,8 @@ def test_schedule(self) -> None: _replica_with_pool("my-model", "cluster-b", pool="default", index=0), ], want=[ - _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-b", index=0, gateway_address="10.0.0.2", pool="default"), + _cand(name="cluster-a", index=0, gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-b", index=0, gateway_hostname="cluster-b.clusters.example.com", pool="default"), ], ), Case( @@ -602,8 +646,8 @@ def test_schedule(self) -> None: # pipeline=4 shape but still charged its observed 2 nodes in the # ledger. want=[ - _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pipeline=4), - _cand(name="cluster-a", index=1, gateway_address="10.0.0.1", pipeline=4), + _cand(name="cluster-a", index=0, gateway_hostname="cluster-a.clusters.example.com", pipeline=4), + _cand(name="cluster-a", index=1, gateway_hostname="cluster-a.clusters.example.com", pipeline=4), ], ), Case( @@ -616,7 +660,11 @@ def test_schedule(self) -> None: # a global index comparison. clusters=[ _cluster("cluster-a", pools=[_pool("default", nodes=4)]), - _cluster("cluster-b", gateway_address="10.0.0.2", pools=[_pool("default", nodes=4)]), + _cluster( + "cluster-b", + gateway_hostname="cluster-b.clusters.example.com", + pools=[_pool("default", nodes=4)], + ), ], all_replicas=[ _replica_with_pool("my-model", "cluster-a", pool="default", index=0), @@ -624,8 +672,8 @@ def test_schedule(self) -> None: _replica_with_pool("my-model", "cluster-b", pool="default", index=3), ], want=[ - _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-b", index=3, gateway_address="10.0.0.2", pool="default"), + _cand(name="cluster-a", index=0, gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-b", index=3, gateway_hostname="cluster-b.clusters.example.com", pool="default"), ], ), Case( @@ -637,33 +685,36 @@ def test_schedule(self) -> None: _replica_with_pool("my-model", "cluster-a", pool="default", index=1), ], want=[ - _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-a", index=1, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-a", index=0, gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-a", index=1, gateway_hostname="cluster-a.clusters.example.com", pool="default"), ], ), Case( name="scale down across clusters drops higher cluster name at equal index", deployment=_deployment(replicas=1), - clusters=[_cluster("cluster-a"), _cluster("cluster-b", gateway_address="10.0.0.2")], + clusters=[ + _cluster("cluster-a"), + _cluster("cluster-b", gateway_hostname="cluster-b.clusters.example.com"), + ], all_replicas=[ _replica("my-model", "cluster-b"), _replica("my-model", "cluster-a"), ], # Both at index 0, so the (index, name) tiebreak keeps cluster-a. - want=[_cand(name="cluster-a", gateway_address="10.0.0.1")], + want=[_cand(name="cluster-a", gateway_hostname="cluster-a.clusters.example.com")], ), Case( name="new placement is alphabetical for determinism", deployment=_deployment(replicas=2), clusters=[ - _cluster("cluster-c", gateway_address="10.0.0.3"), + _cluster("cluster-c", gateway_hostname="cluster-c.clusters.example.com"), _cluster("cluster-a"), - _cluster("cluster-b", gateway_address="10.0.0.2"), + _cluster("cluster-b", gateway_hostname="cluster-b.clusters.example.com"), ], all_replicas=[], want=[ - _cand(name="cluster-a", gateway_address="10.0.0.1", pool="default"), - _cand(name="cluster-b", gateway_address="10.0.0.2", pool="default"), + _cand(name="cluster-a", gateway_hostname="cluster-a.clusters.example.com", pool="default"), + _cand(name="cluster-b", gateway_hostname="cluster-b.clusters.example.com", pool="default"), ], ), Case( @@ -681,14 +732,14 @@ def test_schedule(self) -> None: all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default")], # Retained on its pin: the single node it already occupies isn't # charged against itself, so it stays rather than being evicted. - want=[_cand(name="cluster-a", gateway_address="10.0.0.1")], + want=[_cand(name="cluster-a", gateway_hostname="cluster-a.clusters.example.com")], ), Case( name="replica labeled for our deployment but pinned to unknown cluster is ignored", deployment=_deployment(), - clusters=[_cluster("cluster-b", gateway_address="10.0.0.2")], + clusters=[_cluster("cluster-b", gateway_hostname="cluster-b.clusters.example.com")], all_replicas=[_replica("my-model", "cluster-a")], - want=[_cand(name="cluster-b", gateway_address="10.0.0.2", pool="default")], + want=[_cand(name="cluster-b", gateway_hostname="cluster-b.clusters.example.com", pool="default")], ), Case( name="another deployment pinned to a deleted pool consumes no capacity", @@ -704,7 +755,7 @@ def test_schedule(self) -> None: want=[ _cand( name="cluster-a", - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="frontier", device_requests=[_resolved()], ) @@ -735,7 +786,7 @@ def test_schedule(self) -> None: want=[ _cand( name="cluster-a", - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="a", device_requests=[_resolved()], ) @@ -768,14 +819,17 @@ def test_fill_false_is_retain_only(self) -> None: deployment=_deployment(), clusters=[_cluster("cluster-a")], all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default")], - want=[_cand(name="cluster-a", gateway_address="10.0.0.1")], + want=[_cand(name="cluster-a", gateway_hostname="cluster-a.clusters.example.com")], ), Case( name="scale-up shortfall is not filled, only the retained replica remains", deployment=_deployment(replicas=3), - clusters=[_cluster("cluster-a"), _cluster("cluster-b", gateway_address="10.0.0.2")], + clusters=[ + _cluster("cluster-a"), + _cluster("cluster-b", gateway_hostname="cluster-b.clusters.example.com"), + ], all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default")], - want=[_cand(name="cluster-a", gateway_address="10.0.0.1")], + want=[_cand(name="cluster-a", gateway_hostname="cluster-a.clusters.example.com")], ), ] @@ -798,7 +852,7 @@ def test_node_selector(self) -> None: want=[ _cand( name="cluster-a", - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="frontier", device_requests=[_resolved()], ) @@ -860,7 +914,7 @@ def test_node_selector(self) -> None: want=[ _cand( name="cluster-a", - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="frontier", device_requests=[_resolved(name="gpu")], ) @@ -923,7 +977,7 @@ def test_node_selector(self) -> None: want=[ _cand( name="cluster-a", - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="frontier", device_requests=[ _resolved(name="gpu-a", count=4), @@ -956,7 +1010,7 @@ def test_node_selector(self) -> None: want=[ _cand( name="cluster-a", - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="frontier", device_requests=[_resolved(name="gpu")], ) @@ -985,7 +1039,7 @@ def test_node_selector(self) -> None: want=[ _cand( name="cluster-a", - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="frontier", device_requests=[_resolved()], ) @@ -1014,7 +1068,7 @@ def test_node_selector(self) -> None: want=[ _cand( name="cluster-a", - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="b", device_requests=[_resolved(name="gpu")], ) @@ -1041,7 +1095,7 @@ def test_node_selector(self) -> None: want=[ _cand( name="cluster-a", - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="a", device_requests=[_resolved(name="gpu")], ) @@ -1082,7 +1136,7 @@ def test_node_selector(self) -> None: want=[ _cand( name="cluster-a", - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="frontier", device_requests=[_resolved(name="gpu")], ) @@ -1106,14 +1160,14 @@ def test_node_selector(self) -> None: _cand( name="cluster-a", index=0, - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="frontier", device_requests=[_resolved()], ), _cand( name="cluster-a", index=1, - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="frontier", device_requests=[_resolved()], ), @@ -1137,7 +1191,7 @@ def test_node_selector(self) -> None: want=[ _cand( name="cluster-a", - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pool="b", device_requests=[_resolved(count=8)], ) @@ -1210,7 +1264,7 @@ def test_members(self) -> None: scheduling.Candidate( name="cluster-a", index=0, - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", engines=[ scheduling.EnginePlacement( name=_ENGINE, @@ -1299,7 +1353,7 @@ def test_members(self) -> None: clusters=[ _cluster( "cluster-a", - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", pools=[ _pool("small", nodes=8, devices=[_gpu_device(memory="40Gi")]), _pool("big", nodes=1, devices=[_gpu_device(memory="141Gi")]), @@ -1307,7 +1361,7 @@ def test_members(self) -> None: ), _cluster( "cluster-b", - gateway_address="10.0.0.2", + gateway_hostname="cluster-b.clusters.example.com", pools=[_pool("big", nodes=2, devices=[_gpu_device(memory="141Gi")])], ), ], @@ -1316,7 +1370,7 @@ def test_members(self) -> None: scheduling.Candidate( name="cluster-b", index=0, - gateway_address="10.0.0.2", + gateway_hostname="cluster-b.clusters.example.com", engines=[ scheduling.EnginePlacement( name=_ENGINE, @@ -1368,7 +1422,7 @@ def test_members(self) -> None: scheduling.Candidate( name="cluster-a", index=0, - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", engines=[ scheduling.EnginePlacement( name=_ENGINE, @@ -1413,7 +1467,7 @@ def test_members(self) -> None: scheduling.Candidate( name="cluster-a", index=0, - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", engines=[ scheduling.EnginePlacement( name=_ENGINE, @@ -1454,7 +1508,7 @@ def test_members(self) -> None: scheduling.Candidate( name="cluster-a", index=0, - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", engines=[ scheduling.EnginePlacement( name=_ENGINE, @@ -1514,7 +1568,7 @@ def test_members(self) -> None: scheduling.Candidate( name="cluster-a", index=0, - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", engines=[ scheduling.EnginePlacement( name=_ENGINE, @@ -1574,7 +1628,7 @@ def test_members(self) -> None: ], ) ], - want=[_cand(name="cluster-a", gateway_address="10.0.0.1")], + want=[_cand(name="cluster-a", gateway_hostname="cluster-a.clusters.example.com")], ), Case( name="a member shape change re-places the replica", @@ -1588,7 +1642,7 @@ def test_members(self) -> None: scheduling.Candidate( name="cluster-a", index=0, - gateway_address="10.0.0.1", + gateway_hostname="cluster-a.clusters.example.com", engines=[ scheduling.EnginePlacement( name=_ENGINE, @@ -1626,7 +1680,10 @@ def _names(self, got: list[scheduling.Candidate]) -> list[tuple[str, int]]: return [(c.name, c.index) for c in got] def test_noschedule_keeps_new_replicas_off(self) -> None: - clusters = [_cluster("cluster-a", taints=[self._MAINT]), _cluster("cluster-b", gateway_address="10.0.0.2")] + clusters = [ + _cluster("cluster-a", taints=[self._MAINT]), + _cluster("cluster-b", gateway_hostname="cluster-b.clusters.example.com"), + ] got = scheduling.schedule(_deployment(replicas=1), clusters, []) self.assertEqual(self._names(got), [("cluster-b", 0)]) @@ -1644,7 +1701,10 @@ def test_toleration_allows_placement_on_tainted(self) -> None: def test_noexecute_drains_and_reschedules(self) -> None: existing = _replica("my-model", "cluster-a") - clusters = [_cluster("cluster-a", taints=[self._DECOMM]), _cluster("cluster-b", gateway_address="10.0.0.2")] + clusters = [ + _cluster("cluster-a", taints=[self._DECOMM]), + _cluster("cluster-b", gateway_hostname="cluster-b.clusters.example.com"), + ] got = scheduling.schedule(_deployment(replicas=1), clusters, [existing]) self.assertEqual(self._names(got), [("cluster-b", 0)]) @@ -1668,7 +1728,10 @@ def test_noschedule_toleration_does_not_cover_a_noexecute_taint(self) -> None: tolerates only NoSchedule is still drained by a NoExecute taint.""" tol = mdv1alpha1.Toleration(key="modelplane.ai/decommission", operator="Exists", effect="NoSchedule") existing = _replica("my-model", "cluster-a") - clusters = [_cluster("cluster-a", taints=[self._DECOMM]), _cluster("cluster-b", gateway_address="10.0.0.2")] + clusters = [ + _cluster("cluster-a", taints=[self._DECOMM]), + _cluster("cluster-b", gateway_hostname="cluster-b.clusters.example.com"), + ] got = scheduling.schedule(_deployment(replicas=1, tolerations=[tol]), clusters, [existing]) self.assertEqual(self._names(got), [("cluster-b", 0)]) @@ -1679,7 +1742,7 @@ def test_untolerated_second_taint_still_repels(self) -> None: tol = mdv1alpha1.Toleration(key="modelplane.ai/maintenance", operator="Exists") clusters = [ _cluster("cluster-a", taints=[self._MAINT, other]), - _cluster("cluster-b", gateway_address="10.0.0.2"), + _cluster("cluster-b", gateway_hostname="cluster-b.clusters.example.com"), ] got = scheduling.schedule(_deployment(replicas=1, tolerations=[tol]), clusters, []) self.assertEqual(self._names(got), [("cluster-b", 0)]) @@ -1708,3 +1771,25 @@ def test_keyless_exists_tolerates_every_taint(self) -> None: if __name__ == "__main__": unittest.main() + + +class TestPlacementLabels(unittest.TestCase): + """A cluster's placement labels reach the Candidate, and so the ModelReplica + and ModelEndpoint composed from it. + + This is how a self-hosted endpoint gets its region: a ModelService selects + endpoints by label, so without it a region-scoped service can't select its + own replicas, and it can't label them by hand because Modelplane owns them. + """ + + def test_labels_reach_the_candidate(self) -> None: + got = scheduling.schedule( + _deployment(replicas=1), + [_cluster("cluster-a", placement_labels={"example.org/region": "eu"})], + [], + ) + self.assertEqual([c.placement_labels for c in got], [{"example.org/region": "eu"}]) + + def test_a_cluster_declaring_none_yields_none(self) -> None: + got = scheduling.schedule(_deployment(replicas=1), [_cluster("cluster-a")], []) + self.assertEqual([c.placement_labels for c in got], [{}]) diff --git a/functions/compose-model-endpoint/function/fn.py b/functions/compose-model-endpoint/function/fn.py index 30864054c..ef6c19e36 100644 --- a/functions/compose-model-endpoint/function/fn.py +++ b/functions/compose-model-endpoint/function/fn.py @@ -12,56 +12,49 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Compose a Kubernetes Service and EndpointSlice from a ModelEndpoint. - -ModelEndpoint is a reachable inference endpoint. This function parses -spec.url and composes a selectorless Service plus a manually-managed -EndpointSlice on the control plane pointing at the URL's host:port. -ModelService reads the resulting service name from -status.routing.backendName to build its HTTPRoute. - -For IPv4 URLs (e.g. workload cluster gateways) the EndpointSlice uses -addressType IPv4; for IPv6 URLs, IPv6; for FQDN URLs (e.g. external -SaaS providers like Together or Groq), FQDN. +"""Report whether a ModelEndpoint can carry traffic. + +A ModelEndpoint composes nothing. It is a description of a backend, and the +objects a gateway needs in order to reach it are per-gateway, so +compose-model-service composes them once per gateway that serves a +ModelService selecting this endpoint. An endpoint can't know that set without +reading the services that select it, and having two XRs compose the same object +would put them in a fight over it. + +What's left is worth doing here rather than there: deciding whether this +endpoint is usable at all, once, where the answer belongs. An endpoint naming a +credential Secret that doesn't exist would otherwise be composed into every +gateway's route and fail there, N times, with the reason visible only in Envoy's +logs. Reporting it on the endpoint puts it where someone looking at the endpoint +will find it, and lets compose-model-service leave a broken endpoint out of the +route rather than sending traffic to something that will reject it. """ -import ipaddress -import urllib.parse - import grpc -from crossplane.function import logging, resource, response +from crossplane.function import logging, request, resource, response from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 from models.ai.modelplane.modelendpoint import v1alpha1 from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 -SERVICE_RESOURCE_KEY = "service" -ENDPOINTSLICE_RESOURCE_KEY = "endpointslice" +# EndpointReady says whether a gateway could serve a request from this endpoint. +# compose-model-service reads it, and leaves an endpoint out of a route until +# it's True, so a broken endpoint carries no traffic rather than failing +# requests that reach it. +CONDITION_TYPE_ENDPOINT_READY = "EndpointReady" -# Condition type shared with compose-model-service. Both functions write -# RoutingReady to signal whether traffic can reach the endpoint. -CONDITION_TYPE_ROUTING_READY = "RoutingReady" -CONDITION_REASON_BACKEND_CONFIGURED = "BackendConfigured" -CONDITION_REASON_WAITING_FOR_BACKEND = "WaitingForBackend" -CONDITION_REASON_INVALID_URL = "InvalidURL" +CONDITION_REASON_ENDPOINT_USABLE = "EndpointUsable" +CONDITION_REASON_CREDENTIAL_MISSING = "CredentialMissing" +CONDITION_REASON_WAITING_FOR_CREDENTIAL = "WaitingForCredential" def _namespace(meta: metav1.ObjectMeta | None) -> str: - """The object's namespace, always set on namespaced resources read from the API server.""" + """The endpoint's namespace, always set on a namespaced resource.""" if meta is None or meta.namespace is None: raise ValueError("metadata.namespace is unexpectedly absent") return meta.namespace -def _address_type(host: str) -> str: - """Return the EndpointSlice addressType for a host: IPv4, IPv6, or FQDN.""" - try: - addr = ipaddress.ip_address(host) - except ValueError: - return "FQDN" - return "IPv6" if isinstance(addr, ipaddress.IPv6Address) else "IPv4" - - class FunctionRunner(grpcv1.FunctionRunnerServiceServicer): """A FunctionRunner handles gRPC RunFunctionRequests.""" @@ -77,8 +70,7 @@ async def RunFunction( log.info("Running function") rsp = response.to(req) - c = Composer(req, rsp) - c.compose() + Composer(req, rsp).compose() return rsp @@ -89,130 +81,74 @@ def __init__(self, req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse) self.xr = v1alpha1.ModelEndpoint(**resource.struct_to_dict(req.observed.composite.resource)) def compose(self) -> None: - parsed = self.parse_url() - if parsed is None: - return - host, port = parsed - - self.compose_backend(host, port, _address_type(host)) - self.write_status() self.derive_conditions() - def parse_url(self) -> tuple[str, int] | None: - """Parse spec.url into (host, port), or None (marking the XR not-ready) - if the URL is invalid.""" - parsed = urllib.parse.urlparse(self.xr.spec.url) - try: - # .port parses lazily and raises on a non-integer port, e.g. - # https://host:abc. - port = parsed.port or (443 if parsed.scheme == "https" else 80) - except ValueError: - port = None - - if not parsed.hostname or port is None: - response.set_conditions( - self.rsp, - resource.Condition( - typ=CONDITION_TYPE_ROUTING_READY, - status="False", - reason=CONDITION_REASON_INVALID_URL, - message=f"Invalid spec.url: {self.xr.spec.url}", - ), - ) - response.warning(self.rsp, f"Invalid spec.url: {self.xr.spec.url}") - return None - - return parsed.hostname, port - - def compose_backend(self, host: str, port: int, address_type: str) -> None: - """Compose a selectorless Service and EndpointSlice for the endpoint. - - The Service has no selector (Kubernetes will not auto-populate - EndpointSlices for it) so we compose the EndpointSlice ourselves. - The kubernetes.io/service-name label associates the slice with - the Service. The slice is gated on the Service being observed - because Crossplane generates the Service's name. + def derive_conditions(self) -> None: + """Set EndpointReady, having resolved the credential Secret if any. - ExternalName Services aren't an option for FQDN endpoints: - Traefik's Gateway API provider explicitly rejects them. See - https://github.com/traefik/traefik/blob/fa49e2bcad7ffd8a80accdf1fae1ae480913d93d/pkg/provider/kubernetes/gateway/kubernetes.go#L890. + An endpoint with no credentialRef is usable as soon as it exists: the + XRD's validation has already established that its origin is a scheme and + a host, and whether the backend actually answers is a question only a + request can settle, which the gateway's outlier detection then acts on. """ - ns = _namespace(self.xr.metadata) - - resource.update( - self.rsp.desired.resources[SERVICE_RESOURCE_KEY], - { - "apiVersion": "v1", - "kind": "Service", - "metadata": {"namespace": ns}, - "spec": { - "ports": [{"port": port, "protocol": "TCP"}], - }, - }, - ) + ref = self.xr.spec.credentialRef + if ref is None: + self.ready() + return - svc_observed = self.req.observed.resources.get(SERVICE_RESOURCE_KEY) - svc_name = ( - resource.struct_to_dict(svc_observed.resource).get("metadata", {}).get("name") if svc_observed else None + response.require_resources( + self.rsp, + name="credential", + api_version="v1", + kind="Secret", + match_name=ref.name, + namespace=_namespace(self.xr.metadata), ) - if svc_name: - resource.update( - self.rsp.desired.resources[ENDPOINTSLICE_RESOURCE_KEY], - { - "apiVersion": "discovery.k8s.io/v1", - "kind": "EndpointSlice", - "metadata": { - "namespace": ns, - "labels": {"kubernetes.io/service-name": svc_name}, - }, - "addressType": address_type, - "ports": [{"name": "", "port": port, "protocol": "TCP"}], - # Traefik's Gateway API provider skips endpoints - # whose ready condition is nil, contradicting the - # Kubernetes spec which says nil should be - # interpreted as true. See - # https://github.com/traefik/traefik/blob/fa49e2bcad7ffd8a80accdf1fae1ae480913d93d/pkg/provider/kubernetes/gateway/kubernetes.go#L948. - "endpoints": [ - { - "addresses": [host], - "conditions": {"ready": True}, - } - ], - }, + # A requirement key is absent until it resolves, which is how the SDK + # distinguishes unresolved from resolved-empty. + if "credential" not in self.req.required_resources: + self.not_ready( + CONDITION_REASON_WAITING_FOR_CREDENTIAL, + f"Waiting for Secret {ref.name} to resolve", ) + return - def write_status(self) -> None: - """Surface the composed Service's name in status, but only once the - EndpointSlice is observed too. ModelService treats backendName as - routable, so we must not advertise it until the backing endpoint - exists or Traefik will report ResolvedRefs=False until the next - reconcile catches up.""" - status = v1alpha1.Status() + secrets = request.get_required_resources(self.req, "credential") + if not secrets: + self.not_ready( + CONDITION_REASON_CREDENTIAL_MISSING, + f"Secret {ref.name} does not exist", + ) + return - svc_observed = self.req.observed.resources.get(SERVICE_RESOURCE_KEY) - slice_observed = ENDPOINTSLICE_RESOURCE_KEY in self.req.observed.resources - if svc_observed and slice_observed: - svc_name = resource.struct_to_dict(svc_observed.resource).get("metadata", {}).get("name") - if svc_name: - status.routing = v1alpha1.Routing(backendName=svc_name) + key = ref.key or "apiKey" + if key not in secrets[0].get("data", {}): + self.not_ready( + CONDITION_REASON_CREDENTIAL_MISSING, + f"Secret {ref.name} has no key {key}", + ) + return - resource.update_status(self.rsp.desired.composite, status) + self.ready() - def derive_conditions(self) -> None: - """RoutingReady: both the Service and the EndpointSlice have been - observed on the control plane.""" - svc_exists = SERVICE_RESOURCE_KEY in self.req.observed.resources - slice_exists = ENDPOINTSLICE_RESOURCE_KEY in self.req.observed.resources - ready = svc_exists and slice_exists + def ready(self) -> None: + response.set_conditions( + self.rsp, + resource.Condition( + typ=CONDITION_TYPE_ENDPOINT_READY, + status="True", + reason=CONDITION_REASON_ENDPOINT_USABLE, + ), + ) + + def not_ready(self, reason: str, message: str) -> None: response.set_conditions( self.rsp, resource.Condition( - typ=CONDITION_TYPE_ROUTING_READY, - status="True" if ready else "False", - reason=CONDITION_REASON_BACKEND_CONFIGURED if ready else CONDITION_REASON_WAITING_FOR_BACKEND, + typ=CONDITION_TYPE_ENDPOINT_READY, + status="False", + reason=reason, + message=message, ), ) - if svc_exists: - self.rsp.desired.resources[SERVICE_RESOURCE_KEY].ready = fnv1.READY_TRUE - if ready: - self.rsp.desired.resources[ENDPOINTSLICE_RESOURCE_KEY].ready = fnv1.READY_TRUE + response.normal(self.rsp, message) diff --git a/functions/compose-model-endpoint/pyproject.toml b/functions/compose-model-endpoint/pyproject.toml index 9495319e6..7802974ee 100644 --- a/functions/compose-model-endpoint/pyproject.toml +++ b/functions/compose-model-endpoint/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "uv_build" [project] name = "compose-model-endpoint" version = "0.0.0" -description = "Compose an Envoy Gateway Backend from a ModelEndpoint." +description = "Validate a ModelEndpoint and report whether it can carry traffic." requires-python = ">=3.11,<3.14" license = "Apache-2.0" dependencies = [ diff --git a/functions/compose-model-endpoint/tests/test_fn.py b/functions/compose-model-endpoint/tests/test_fn.py index cf9bb2492..b06e2b65d 100644 --- a/functions/compose-model-endpoint/tests/test_fn.py +++ b/functions/compose-model-endpoint/tests/test_fn.py @@ -14,6 +14,7 @@ """Tests for the compose-model-endpoint function.""" +import base64 import dataclasses import unittest @@ -24,7 +25,9 @@ from google.protobuf import json_format from google.protobuf import struct_pb2 as structpb from models.ai.modelplane.modelendpoint import v1alpha1 -from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 + +_NS = "ml-team" +_NAME = "together-kimi-k2" @dataclasses.dataclass @@ -36,400 +39,207 @@ class Case: want: fnv1.RunFunctionResponse -def setUpModule() -> None: - logging.configure(level=logging.Level.DISABLED) +def _xr(**spec) -> dict: # noqa: ANN003 + """The ModelEndpoint XR, built from the generated model so a field the XRD + doesn't define can't creep into a test.""" + xr = v1alpha1.ModelEndpoint( + apiVersion="modelplane.ai/v1alpha1", + kind="ModelEndpoint", + metadata={"name": _NAME, "namespace": _NS}, + spec=v1alpha1.Spec(origin="https://api.together.xyz", **spec), + ) + return xr.model_dump(exclude_none=True, mode="json", by_alias=True) + + +def _secret(name: str, data: dict[str, str]) -> dict: + """A Secret as the API server stores it, values base64 encoded.""" + return { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": name, "namespace": _NS}, + "data": {k: base64.b64encode(v.encode()).decode() for k, v in data.items()}, + } -def _service_only(name: str, namespace: str = "ml-team") -> fnv1.Resource: - """Build an observed Service resource with just metadata.name set.""" - return fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "v1", - "kind": "Service", - "metadata": {"name": name, "namespace": namespace}, - } - ), +def _credential_requirement(name: str) -> fnv1.Requirements: + return fnv1.Requirements( + resources={"credential": fnv1.ResourceSelector(api_version="v1", kind="Secret", match_name=name, namespace=_NS)} ) -def _endpointslice_only(name: str, namespace: str = "ml-team") -> fnv1.Resource: - """Build an observed EndpointSlice resource with just metadata.name set.""" - return fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "discovery.k8s.io/v1", - "kind": "EndpointSlice", - "metadata": {"name": name, "namespace": namespace}, - } - ), +def _response( + *, + reason: str, + status: fnv1.Status, + message: str | None = None, + requirements: fnv1.Requirements | None = None, +) -> fnv1.RunFunctionResponse: + """The whole response. This function composes no resources, so desired + carries only the composite, and asserting the whole thing proves it stays + that way.""" + rsp = fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), + desired=fnv1.State(), + context=structpb.Struct(), + conditions=[ + fnv1.Condition(type=fn.CONDITION_TYPE_ENDPOINT_READY, status=status, reason=reason, message=message) + ], ) + if requirements is not None: + rsp.requirements.CopyFrom(requirements) + if message is not None: + rsp.results.append(fnv1.Result(severity=fnv1.SEVERITY_NORMAL, message=message)) + return rsp + + +def setUpModule() -> None: + logging.configure(level=logging.Level.DISABLED) class TestFunctionRunner(unittest.IsolatedAsyncioTestCase): - """Tests for FunctionRunner.RunFunction.""" + maxDiff = None @classmethod def setUpClass(cls) -> None: cls.runner = fn.FunctionRunner() async def test_compose(self) -> None: - """The function composes a Service and EndpointSlice from a ModelEndpoint.""" - - ip_xr = v1alpha1.ModelEndpoint( - metadata=metav1.ObjectMeta(name="test-endpoint", namespace="ml-team"), - spec=v1alpha1.Spec(url="http://34.55.100.10/v1"), - ).model_dump(exclude_none=True, mode="json") - - ip_service = { - "apiVersion": "v1", - "kind": "Service", - "metadata": {"namespace": "ml-team"}, - "spec": { - "ports": [{"port": 80, "protocol": "TCP"}], - }, - } - ip_endpointslice = { - "apiVersion": "discovery.k8s.io/v1", - "kind": "EndpointSlice", - "metadata": { - "namespace": "ml-team", - "labels": {"kubernetes.io/service-name": "my-service"}, - }, - "addressType": "IPv4", - "ports": [{"name": "", "port": 80, "protocol": "TCP"}], - "endpoints": [ - { - "addresses": ["34.55.100.10"], - "conditions": {"ready": True}, - } - ], - } - - fqdn_xr = v1alpha1.ModelEndpoint( - metadata=metav1.ObjectMeta(name="test-endpoint", namespace="ml-team"), - spec=v1alpha1.Spec(url="https://api.together.xyz/v1"), - ).model_dump(exclude_none=True, mode="json") - - fqdn_service = { - "apiVersion": "v1", - "kind": "Service", - "metadata": {"namespace": "ml-team"}, - "spec": { - "ports": [{"port": 443, "protocol": "TCP"}], - }, - } - fqdn_endpointslice = { - "apiVersion": "discovery.k8s.io/v1", - "kind": "EndpointSlice", - "metadata": { - "namespace": "ml-team", - "labels": {"kubernetes.io/service-name": "together-svc"}, - }, - "addressType": "FQDN", - "ports": [{"name": "", "port": 443, "protocol": "TCP"}], - "endpoints": [ - { - "addresses": ["api.together.xyz"], - "conditions": {"ready": True}, - } - ], - } - cases = [ Case( - name="IP URL first pass composes Service only; EndpointSlice gated on Service name", + name="no credential: usable as soon as it exists", req=fnv1.RunFunctionRequest( - observed=fnv1.State( - composite=fnv1.Resource(resource=resource.dict_to_struct(ip_xr)), - ), + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_xr()))), ), - want=fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {}}), - ), - resources={ - "service": fnv1.Resource( - resource=resource.dict_to_struct(ip_service), - ), - }, - ), - conditions=[ - fnv1.Condition( - type="RoutingReady", status=fnv1.STATUS_CONDITION_FALSE, reason="WaitingForBackend" - ), - ], - context=structpb.Struct(), + want=_response( + reason=fn.CONDITION_REASON_ENDPOINT_USABLE, + status=fnv1.STATUS_CONDITION_TRUE, ), ), Case( - name="IP URL second pass composes EndpointSlice; backendName not yet set", + name="a credential that resolves", req=fnv1.RunFunctionRequest( observed=fnv1.State( - composite=fnv1.Resource(resource=resource.dict_to_struct(ip_xr)), - resources={ - "service": _service_only("my-service"), - }, - ), - ), - want=fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {}}), - ), - resources={ - "service": fnv1.Resource( - resource=resource.dict_to_struct(ip_service), - ready=fnv1.READY_TRUE, - ), - "endpointslice": fnv1.Resource( - resource=resource.dict_to_struct(ip_endpointslice), - ), - }, - ), - conditions=[ - fnv1.Condition( - type="RoutingReady", status=fnv1.STATUS_CONDITION_FALSE, reason="WaitingForBackend" - ), - ], - context=structpb.Struct(), - ), - ), - Case( - name="IP URL third pass with EndpointSlice observed sets backendName and RoutingReady", - req=fnv1.RunFunctionRequest( - observed=fnv1.State( - composite=fnv1.Resource(resource=resource.dict_to_struct(ip_xr)), - resources={ - "service": _service_only("my-service"), - "endpointslice": _endpointslice_only("my-slice"), - }, - ), - ), - want=fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {"routing": {"backendName": "my-service"}}}), - ), - resources={ - "service": fnv1.Resource( - resource=resource.dict_to_struct(ip_service), - ready=fnv1.READY_TRUE, - ), - "endpointslice": fnv1.Resource( - resource=resource.dict_to_struct(ip_endpointslice), - ready=fnv1.READY_TRUE, - ), - }, + resource=resource.dict_to_struct( + _xr(credentialRef=v1alpha1.CredentialRef(name="together-api-key")) + ) + ) ), - conditions=[ - fnv1.Condition( - type="RoutingReady", status=fnv1.STATUS_CONDITION_TRUE, reason="BackendConfigured" - ), - ], - context=structpb.Struct(), + required_resources={ + "credential": fnv1.Resources( + items=[ + fnv1.Resource( + resource=resource.dict_to_struct(_secret("together-api-key", {"apiKey": "sk-abc"})) + ) + ] + ) + }, + ), + want=_response( + reason=fn.CONDITION_REASON_ENDPOINT_USABLE, + status=fnv1.STATUS_CONDITION_TRUE, + requirements=_credential_requirement("together-api-key"), ), ), Case( - name="FQDN URL first pass composes Service only", + name="a credential Secret that does not exist", req=fnv1.RunFunctionRequest( observed=fnv1.State( - composite=fnv1.Resource(resource=resource.dict_to_struct(fqdn_xr)), - ), - ), - want=fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {}}), - ), - resources={ - "service": fnv1.Resource( - resource=resource.dict_to_struct(fqdn_service), - ), - }, - ), - conditions=[ - fnv1.Condition( - type="RoutingReady", status=fnv1.STATUS_CONDITION_FALSE, reason="WaitingForBackend" - ), - ], - context=structpb.Struct(), - ), - ), - Case( - name="FQDN URL with EndpointSlice observed sets backendName and RoutingReady", - req=fnv1.RunFunctionRequest( - observed=fnv1.State( - composite=fnv1.Resource(resource=resource.dict_to_struct(fqdn_xr)), - resources={ - "service": _service_only("together-svc"), - "endpointslice": _endpointslice_only("together-slice"), - }, + resource=resource.dict_to_struct( + _xr(credentialRef=v1alpha1.CredentialRef(name="together-api-key")) + ) + ) ), + required_resources={"credential": fnv1.Resources(items=[])}, ), - want=fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {"routing": {"backendName": "together-svc"}}}), - ), - resources={ - "service": fnv1.Resource( - resource=resource.dict_to_struct(fqdn_service), - ready=fnv1.READY_TRUE, - ), - "endpointslice": fnv1.Resource( - resource=resource.dict_to_struct(fqdn_endpointslice), - ready=fnv1.READY_TRUE, - ), - }, - ), - conditions=[ - fnv1.Condition( - type="RoutingReady", status=fnv1.STATUS_CONDITION_TRUE, reason="BackendConfigured" - ), - ], - context=structpb.Struct(), + want=_response( + reason=fn.CONDITION_REASON_CREDENTIAL_MISSING, + status=fnv1.STATUS_CONDITION_FALSE, + message="Secret together-api-key does not exist", + requirements=_credential_requirement("together-api-key"), ), ), Case( - name="IPv6 URL composes EndpointSlice with addressType IPv6", + # A Secret that exists but lacks the key is the likelier mistake, + # and would otherwise surface as a 401 from the provider. + name="a credential Secret missing the key", req=fnv1.RunFunctionRequest( observed=fnv1.State( composite=fnv1.Resource( resource=resource.dict_to_struct( - v1alpha1.ModelEndpoint( - metadata=metav1.ObjectMeta(name="test-endpoint", namespace="ml-team"), - spec=v1alpha1.Spec(url="http://[2001:db8::1]/v1"), - ).model_dump(exclude_none=True, mode="json") - ), - ), - resources={ - "service": _service_only("v6-svc"), - "endpointslice": _endpointslice_only("v6-slice"), - }, + _xr(credentialRef=v1alpha1.CredentialRef(name="together-api-key")) + ) + ) ), - ), - want=fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {"routing": {"backendName": "v6-svc"}}}), - ), - resources={ - "service": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "v1", - "kind": "Service", - "metadata": {"namespace": "ml-team"}, - "spec": { - "ports": [{"port": 80, "protocol": "TCP"}], - }, - } - ), - ready=fnv1.READY_TRUE, - ), - "endpointslice": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "discovery.k8s.io/v1", - "kind": "EndpointSlice", - "metadata": { - "namespace": "ml-team", - "labels": {"kubernetes.io/service-name": "v6-svc"}, - }, - "addressType": "IPv6", - "ports": [{"name": "", "port": 80, "protocol": "TCP"}], - "endpoints": [ - { - "addresses": ["2001:db8::1"], - "conditions": {"ready": True}, - } - ], - } - ), - ready=fnv1.READY_TRUE, - ), - }, - ), - conditions=[ - fnv1.Condition( - type="RoutingReady", status=fnv1.STATUS_CONDITION_TRUE, reason="BackendConfigured" - ), - ], - context=structpb.Struct(), + required_resources={ + "credential": fnv1.Resources( + items=[ + fnv1.Resource( + resource=resource.dict_to_struct(_secret("together-api-key", {"token": "sk-abc"})) + ) + ] + ) + }, + ), + want=_response( + reason=fn.CONDITION_REASON_CREDENTIAL_MISSING, + status=fnv1.STATUS_CONDITION_FALSE, + message="Secret together-api-key has no key apiKey", + requirements=_credential_requirement("together-api-key"), ), ), Case( - name="invalid URL produces a warning and no service", + name="a credential under a non-default key", req=fnv1.RunFunctionRequest( observed=fnv1.State( composite=fnv1.Resource( resource=resource.dict_to_struct( - v1alpha1.ModelEndpoint( - metadata=metav1.ObjectMeta(name="test-endpoint", namespace="ml-team"), - spec=v1alpha1.Spec(url="not-a-url"), - ).model_dump(exclude_none=True, mode="json") - ), - ), + _xr( + credentialRef=v1alpha1.CredentialRef( + name="together-api-key", key="TOGETHER_API_KEY" + ) + ) + ) + ) ), - ), - want=fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State(), - conditions=[ - fnv1.Condition( - type="RoutingReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="InvalidURL", - message="Invalid spec.url: not-a-url", - ), - ], - results=[ - fnv1.Result(severity=fnv1.SEVERITY_WARNING, message="Invalid spec.url: not-a-url"), - ], - context=structpb.Struct(), + required_resources={ + "credential": fnv1.Resources( + items=[ + fnv1.Resource( + resource=resource.dict_to_struct( + _secret("together-api-key", {"TOGETHER_API_KEY": "sk-abc"}) + ) + ) + ] + ) + }, + ), + want=_response( + reason=fn.CONDITION_REASON_ENDPOINT_USABLE, + status=fnv1.STATUS_CONDITION_TRUE, + requirements=_credential_requirement("together-api-key"), ), ), Case( - name="URL with a non-integer port produces a warning and no service", + name="an unresolved credential requirement", req=fnv1.RunFunctionRequest( observed=fnv1.State( composite=fnv1.Resource( resource=resource.dict_to_struct( - v1alpha1.ModelEndpoint( - metadata=metav1.ObjectMeta(name="test-endpoint", namespace="ml-team"), - spec=v1alpha1.Spec(url="https://host:abc"), - ).model_dump(exclude_none=True, mode="json") - ), - ), + _xr(credentialRef=v1alpha1.CredentialRef(name="together-api-key")) + ) + ) ), ), - want=fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State(), - conditions=[ - fnv1.Condition( - type="RoutingReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="InvalidURL", - message="Invalid spec.url: https://host:abc", - ), - ], - results=[ - fnv1.Result(severity=fnv1.SEVERITY_WARNING, message="Invalid spec.url: https://host:abc"), - ], - context=structpb.Struct(), + want=_response( + reason=fn.CONDITION_REASON_WAITING_FOR_CREDENTIAL, + status=fnv1.STATUS_CONDITION_FALSE, + message="Waiting for Secret together-api-key to resolve", + requirements=_credential_requirement("together-api-key"), ), ), ] - for case in cases: with self.subTest(case.name): got = await self.runner.RunFunction(case.req, None) diff --git a/functions/compose-model-service/function/fn.py b/functions/compose-model-service/function/fn.py index 149403936..410006d09 100644 --- a/functions/compose-model-service/function/fn.py +++ b/functions/compose-model-service/function/fn.py @@ -12,162 +12,228 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Compose a Gateway-API HTTPRoute from a ModelService. - -ModelService selects ModelEndpoints by label and load-balances across -them. This function fetches the InferenceGateway (for the public -address and parentRef) and the matching ModelEndpoints (for their -backend service names and rewrite paths), then composes a single -HTTPRoute on the control plane. - -The match prefix is `///`. Each endpoint's -rewritePath is attached as a per-backendRef URLRewrite filter so that -endpoints with different path conventions (e.g. composed replicas at -/v1/ alongside external providers at /openai/v1/) each get the correct -path rewrite. This is a Gateway API Extended feature (per-backendRef -filters) supported by Traefik Proxy. +"""Compose a ModelService's route and backends on every gateway serving it. + +A ModelService is one model as a caller sees it: a name that resolves to +whichever of its ModelEndpoints should serve the next request. This function +turns that into an AIGatewayRoute per gateway, plus, per endpoint, the objects +that gateway needs in order to reach it and translate the request for it. + +Which gateways serve a service is the gateway's choice, not the service's: an +InferenceGateway's serviceSelector matches the service's labels, and an absent +selector matches every service. So this function reads every InferenceGateway +and works out which of them select it, rather than the service naming gateways. +That is what makes residency fall out of labels instead of needing a feature: +label a service for a region and only that region's gateways serve it. + +The objects are composed per service rather than per endpoint. Two ModelServices +selecting one endpoint each compose their own copies, which costs some +duplicated config and avoids two composites owning one object. It also keeps a +credential from being propagated to a gateway that serves neither service. """ import math -import urllib.parse import grpc from crossplane.function import logging, request, resource, response from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 -from models.ai.modelplane.inferencegateway import v1alpha1 as igwv1alpha1 +from models.ai.modelplane.inferencecluster import v1alpha1 as icv1alpha1 +from models.ai.modelplane.inferencegateway import v1alpha1 as igv1alpha1 from models.ai.modelplane.modelendpoint import v1alpha1 as mev1alpha1 from models.ai.modelplane.modelservice import v1alpha1 +from models.io.crossplane.m.kubernetes.object import v1alpha1 as k8sobjv1alpha1 from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 -CONDITION_TYPE_ENDPOINTS_RESOLVED = "EndpointsResolved" -CONDITION_REASON_RESOLVED = "Resolved" -CONDITION_REASON_NO_ENDPOINTS = "NoEndpoints" -CONDITION_REASON_WAITING_FOR_GATEWAY = "WaitingForGateway" -CONDITION_REASON_ROUTE_CONFIGURED = "RouteConfigured" -CONDITION_REASON_CONFIGURING = "Configuring" +from function import names + +# Condition types this function sets on the ModelService. CONDITION_TYPE_ROUTING_READY = "RoutingReady" -# The control plane gateway name and namespace. ModelService composes -# HTTPRoutes that reference this gateway as a parentRef. -_GATEWAY_NAME = "modelplane" -_NAMESPACE_SYSTEM = "modelplane-system" +CONDITION_REASON_ROUTES_ACCEPTED = "RoutesAccepted" +CONDITION_REASON_WAITING_FOR_RESOURCES = "WaitingForResources" +CONDITION_REASON_NO_GATEWAY = "NoGatewayServesThisService" +CONDITION_REASON_NO_ENDPOINTS = "NoReadyEndpoints" +CONDITION_REASON_WAITING_FOR_ROUTES = "WaitingForRoutes" + +# The namespace on a gateway's cluster that every composed object lands in. The +# ServingStack already creates it there. +REMOTE_NAMESPACE = "modelplane-system" + +# The Gateway compose-inference-gateway composes on each gateway's cluster. +_GATEWAY_NAME = "fleet-gateway" + +# The header the AI Gateway's ext-proc puts the request body's model into, +# before the routing decision, so a route can match on it. +_MODEL_HEADER = "x-ai-eg-model" -# Scheme for user-facing service URLs. -_GATEWAY_SCHEME = "http" +# The header the fleet gateway stamps the caller's identity onto. Removed again +# for a backend Modelplane doesn't operate, so a third-party provider isn't told +# which tenant is calling. The usage record reads the caller from request +# metadata, which this doesn't disturb. +_CALLER_HEADER = "x-modelplane-caller" -# Gateway API caps a backendRef's weight at 1,000,000 (an int32 limit in the -# HTTPRoute CRD). We keep composed weights at or below it so the API server -# accepts the HTTPRoute. +# The Secret compose-inference-gateway has cert-manager issue for the fleet +# gateway's client certificate, in the same namespace on the same cluster. +_CLIENT_CERT_SECRET = "fleet-gateway-client" + +# Envoy AI Gateway's per-backendRef weight limit, inherited from Gateway API. _MAX_WEIGHT = 1000000 +# An AIGatewayRoute reports acceptance as a top-level condition. +_ROUTE_ACCEPTED_CEL = ( + "has(object.status) && has(object.status.conditions) && " + "object.status.conditions.exists(c, c.type == 'Accepted' && c.status == 'True')" +) + +# How long the gateway waits for a whole response, and for the first byte of +# one. streamIdleTimeout is what lets a backend that hangs before the first +# token reset and fall over to the next priority; past the first byte the +# tokens are already sent, so it truncates instead. +_REQUEST_TIMEOUT = "300s" +_STREAM_IDLE_TIMEOUT = "60s" + +# Token counts to capture per request. Declaring them is also what makes the +# gateway ask a backend for usage on a streamed response, which otherwise +# reports none at all. +_LLM_REQUEST_COSTS = [ + {"metadataKey": "llm_input_token", "type": "InputToken"}, + {"metadataKey": "llm_output_token", "type": "OutputToken"}, + {"metadataKey": "llm_total_token", "type": "TotalToken"}, +] + def _name(meta: metav1.ObjectMeta | None) -> str: - """The object's name, always set on resources read from the API server.""" if meta is None or meta.name is None: raise ValueError("metadata.name is unexpectedly absent") return meta.name def _namespace(meta: metav1.ObjectMeta | None) -> str: - """The object's namespace, always set on namespaced resources read from the API server.""" if meta is None or meta.namespace is None: raise ValueError("metadata.namespace is unexpectedly absent") return meta.namespace -def _port_from_url(url: str) -> int: - """Parse the backend port from a ModelEndpoint URL. +def _labels(meta: metav1.ObjectMeta | None) -> dict[str, str]: + return dict(meta.labels) if meta and meta.labels else {} + + +# Set by compose-model-deployment on every ModelEndpoint it composes, naming +# the cluster the replica landed on. Its presence is what marks an endpoint as +# one Modelplane operates. +_LABEL_CLUSTER = "modelplane.ai/cluster" - Defaults to 443 for https and 80 for http when not explicit, matching - what compose-model-endpoint uses when creating the backend Service. + +def _composed_by_modelplane(ep: mev1alpha1.ModelEndpoint) -> bool: + """Whether Modelplane composed this endpoint, and so operates it. + + Decided by the cluster label compose-model-deployment stamps on a composed + endpoint. A hand-written endpoint carrying it is claiming to be ours, and + will be treated as ours. """ - parsed = urllib.parse.urlparse(url) - if parsed.port: - return parsed.port - return 443 if parsed.scheme == "https" else 80 + return _LABEL_CLUSTER in _labels(ep.metadata) + + +def _endpoint_ready(d: dict) -> bool: + """Whether a ModelEndpoint reports EndpointReady=True. + + An endpoint that doesn't is left out of the route, so a missing credential + keeps traffic away rather than failing the requests that reach it. + """ + for c in d.get("status", {}).get("conditions", []): + if c.get("type") == "EndpointReady": + return c.get("status") == "True" + return False def _distribute_weights( - groups: list[tuple[int, list[mev1alpha1.ModelEndpoint]]], + entries: list[tuple[int, list[mev1alpha1.ModelEndpoint]]], ) -> list[tuple[mev1alpha1.ModelEndpoint, int]]: - """Turn group weights into per-endpoint backendRef weights. - - Gateway API only supports per-backendRef weights, so each group's weight - is spread across its endpoints. The result preserves the ratio between - groups: a group weighted 80 next to one weighted 20 gets 80% of the total - weight. - - Every group's weight is first scaled up by a common factor so it is at - least its endpoint count, so no endpoint rounds down to weight 0 (which - Gateway API treats as "no traffic") - e.g. weight 1 across 5 endpoints - scales to 5, spread as [1, 1, 1, 1, 1]. The scaled weights are then reduced - by their greatest common divisor to the smallest equivalent integers, and - clamped to Gateway API's per-backendRef maximum so even extreme ratios - yield an HTTPRoute the API server accepts. - - Groups with no ready endpoints are dropped; they can't receive traffic and - so don't count towards the split. + """Turn per-entry weights into per-backendRef weights within one priority. + + A weight is written per selector entry but applied per backend, so each + entry's weight is spread across the endpoints it matched, preserving the + ratio between entries: an entry weighted 90 next to one weighted 10 keeps + 90% of the traffic however many endpoints each matched. + + Every entry's weight is first scaled by a common factor so it is at least + its endpoint count, because a backend weighted 0 is not merely + deprioritised, it is dropped from the load assignment entirely. The scaled + weights are then reduced by their greatest common divisor, and clamped to + the per-backendRef maximum so even an extreme ratio yields a route the API + server accepts. + + Called once per priority, because weights only compete within a tier. """ - # A group with no ready endpoints can't receive traffic, so drop it. Its - # share is effectively redistributed across the groups that can serve. - live = [(weight, eps) for weight, eps in groups if eps] + live = [(weight, eps) for weight, eps in entries if eps] if not live: return [] - # We give each endpoint (group weight * scale) // (endpoint count), then - # hand out the remainder. Without scaling, a group whose weight is smaller - # than its endpoint count would floor some endpoints to 0 (which Gateway - # API reads as "no traffic"). To keep every endpoint at 1 or more, the - # scaled group weight must be at least its endpoint count, so each group - # needs scale >= ceil(endpoint count / group weight). Take the largest - # requirement across all groups: multiplying every group by one common - # factor leaves the ratios between groups unchanged. + # Each endpoint gets (entry weight * scale) // (endpoint count) plus a share + # of the remainder. Unscaled, an entry whose weight is below its endpoint + # count would floor some endpoints to 0, so scale must be at least + # ceil(endpoint count / entry weight) for every entry. One common factor + # leaves the ratios between entries unchanged. scale = 1 for weight, eps in live: scale = max(scale, math.ceil(len(eps) / weight)) - # Spread each group's scaled weight across its endpoints as evenly as the - # integers allow, handing the leftover one unit at a time to the first few. - # For example weight 80 over 3 endpoints with scale 1 gives 27, 27, 26. weighted: list[tuple[mev1alpha1.ModelEndpoint, int]] = [] for weight, eps in live: base, remainder = divmod(weight * scale, len(eps)) for idx, ep in enumerate(eps): weighted.append((ep, base + (1 if idx < remainder else 0))) - # Scaling can inflate the weights well past what's needed to express the - # ratio (e.g. all groups landing on even numbers), so divide them back down - # by their greatest common divisor to the smallest equivalent integers. weights = [w for _, w in weighted] divisor = math.gcd(*weights) highest = max(weights) // divisor if highest <= _MAX_WEIGHT: return [(ep, w // divisor) for ep, w in weighted] - # Even reduced, an extreme ratio (say 1,000,000 to 1) can push a weight past - # Gateway API's per-backendRef limit, which would make the API server - # reject the HTTPRoute. Rescale everything so the largest weight lands on - # the limit, keeping every endpoint at 1 or more. This trades a little ratio - # precision for a valid route in a case no realistic config reaches. + # An extreme ratio can still exceed the limit once reduced. Rescale so the + # largest weight lands on it, keeping every endpoint at 1 or more. Trades a + # little precision for a valid route, in a case no realistic config reaches. return [(ep, max(1, round(w / divisor / highest * _MAX_WEIGHT))) for ep, w in weighted] -def _has_parent_condition(req: fnv1.RunFunctionRequest, name: str, cond: str) -> bool: - """Check a Gateway API condition nested under status.parents[].conditions. +def _wrap(provider_config: str, manifest: dict, *, cel_query: str | None = None) -> k8sobjv1alpha1.Object: + """Wrap a manifest in a provider-kubernetes Object for a gateway's cluster.""" + readiness = ( + k8sobjv1alpha1.Readiness(policy="DeriveFromCelQuery", celQuery=cel_query) + if cel_query is not None + else k8sobjv1alpha1.Readiness(policy="SuccessfulCreate") + ) + return k8sobjv1alpha1.Object( + spec=k8sobjv1alpha1.Spec( + providerConfigRef=k8sobjv1alpha1.ProviderConfigRef( + kind="ClusterProviderConfig", + name=provider_config, + ), + readiness=readiness, + forProvider=k8sobjv1alpha1.ForProvider(manifest=manifest), + ), + ) + - Gateway API resources (HTTPRoute, etc.) nest route status under - status.parents[].conditions instead of top-level status.conditions. - """ - observed = req.observed.resources.get(name) - if observed is None: - return False - d = resource.struct_to_dict(observed.resource) - for p in d.get("status", {}).get("parents", []): - for c in p.get("conditions", []): - if c.get("type") == cond and c.get("status") == "True": - return True - return False +class ServingGateway: + """An InferenceGateway serving this service, and how to reach its cluster.""" + + def __init__(self, xr: igv1alpha1.InferenceGateway, provider_config: str) -> None: + self.xr = xr + self.name = _name(xr.metadata) + self.provider_config = provider_config + + def serves(self, labels: dict[str, str]) -> bool: + """Whether this gateway's serviceSelector matches a service's labels. + + An absent selector serves every service, which is the default and what + a single-gateway Modelplane wants. + """ + sel = self.xr.spec.serviceSelector + if sel is None: + return True + return all(labels.get(k) == v for k, v in sel.matchLabels.items()) class FunctionRunner(grpcv1.FunctionRunnerServiceServicer): @@ -185,8 +251,7 @@ async def RunFunction( log.info("Running function") rsp = response.to(req) - c = Composer(req, rsp) - c.compose() + Composer(req, rsp).compose() return rsp @@ -195,193 +260,566 @@ def __init__(self, req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse) self.req = req self.rsp = rsp self.xr = v1alpha1.ModelService(**resource.struct_to_dict(req.observed.composite.resource)) - self.gateway = None - self.endpoints: list[mev1alpha1.ModelEndpoint] = [] - # Matched endpoints grouped by spec.endpoints[] entry, paired with - # that entry's weight. Traffic is split across groups in proportion - # to their weights; compose_httproute spreads each group's weight - # across its endpoints. - self.groups: list[tuple[int, list[mev1alpha1.ModelEndpoint]]] = [] + self.ns = _namespace(self.xr.metadata) + self.svc = _name(self.xr.metadata) + self.gateways: list[ServingGateway] = [] + # Endpoints per priority, as (entry weight, endpoints) so weights can be + # distributed within a tier. + self.tiers: dict[int, list[tuple[int, list[mev1alpha1.ModelEndpoint]]]] = {} + self.credentials: dict[str, dict] = {} + # CA certificate per InferenceCluster, for validating its gateway. + self.cluster_cas: dict[str, str] = {} + self.total = 0 + self.ready_count = 0 def compose(self) -> None: if not self.resolve_inputs(): + self.write_status() return - self.compose_httproute() + self.compose_routes() self.write_status() + self.mark_ready() self.derive_conditions() + def mark_ready(self) -> None: + """Mark each composed resource ready once its observed counterpart is. + + Nothing else does this. The composition pipeline has no auto-ready + function, so a desired resource's readiness is whatever the function + says, and a function that says nothing leaves the XR permanently + not-Ready however healthy everything under it is. + """ + for key, res in self.rsp.desired.resources.items(): + if resource.get_condition(self.req.observed.resources.get(key), "Ready").status == "True": + res.ready = fnv1.READY_TRUE + def resolve_inputs(self) -> bool: - """Fetch the InferenceGateway and matching ModelEndpoints.""" + """Resolve the gateways serving this service and the endpoints behind it. + + Returns False, having set conditions, when there's nothing to compose. + """ response.require_resources( self.rsp, - name="inference-gateway", + name="gateways", api_version="modelplane.ai/v1alpha1", kind="InferenceGateway", - match_name="default", ) - - # One required-resources request per spec.endpoints[i] entry. + response.require_resources( + self.rsp, + name="clusters", + api_version="modelplane.ai/v1alpha1", + kind="InferenceCluster", + ) for i, entry in enumerate(self.xr.spec.endpoints): response.require_resources( self.rsp, name=f"endpoints-{i}", api_version="modelplane.ai/v1alpha1", kind="ModelEndpoint", - match_labels=entry.selector.matchLabels, + namespace=self.ns, + match_labels=dict(entry.selector.matchLabels), ) - gw_dict = request.get_required_resource(self.req, "inference-gateway") - self.gateway = igwv1alpha1.InferenceGateway.model_validate(gw_dict) if gw_dict else None + keys = ["gateways", "clusters"] + [f"endpoints-{i}" for i in range(len(self.xr.spec.endpoints))] + if any(k not in self.req.required_resources for k in keys): + self.not_ready(CONDITION_REASON_WAITING_FOR_RESOURCES, "Waiting for gateways and endpoints to resolve") + return False + + self.resolve_gateways() + self.resolve_endpoints() + + if not self.gateways: + self.not_ready( + CONDITION_REASON_NO_GATEWAY, + "No InferenceGateway's serviceSelector matches this service's labels, so no caller can reach it", + ) + return False + if not self.resolve_credentials(): + return False + + # After resolving, not inside it: an endpoint with no credentialRef needs + # no Secret but may still be missing its cluster's CA, and resolving + # returns early when nothing has a credential at all. + self.drop_unusable_endpoints() + # One check, after dropping rather than also before it, because dropping + # only ever removes endpoints. A route with no backendRefs is worse than + # no route: a caller gets a reply that isn't an error. + if not any(eps for entries in self.tiers.values() for _, eps in entries): + self.not_ready( + CONDITION_REASON_NO_ENDPOINTS, + f"None of the {self.total} selected ModelEndpoints is ready to carry traffic", + ) + return False + return True - # Gather matched endpoints per selector entry, preserving the group - # structure so each group's weight can be applied. An endpoint matched - # by more than one entry is assigned to the first entry that matches - # it, so its weight is unambiguous. - seen_names: set[str] = set() + def resolve_gateways(self) -> None: + """The gateways whose serviceSelector matches this service's labels, and + which have a cluster we can compose onto.""" + pcs: dict[str, str] = {} + for c in request.get_required_resources(self.req, "clusters"): + cluster = icv1alpha1.InferenceCluster.model_validate(c) + if cluster.status and cluster.status.providerConfigRef and cluster.status.providerConfigRef.name: + pcs[_name(cluster.metadata)] = cluster.status.providerConfigRef.name + if cluster.status and cluster.status.gateway and cluster.status.gateway.caCertificate: + self.cluster_cas[_name(cluster.metadata)] = cluster.status.gateway.caCertificate + + labels = _labels(self.xr.metadata) + for g in request.get_required_resources(self.req, "gateways"): + gw = igv1alpha1.InferenceGateway.model_validate(g) + pc = pcs.get(gw.spec.clusterName) + if pc is None: + # The gateway's cluster hasn't published a ProviderConfig yet. + # compose-inference-gateway reports that on the gateway; there's + # nothing useful this service can add. + continue + if not (gw.status and gw.status.clientCACertificate): + # Its client PKI hasn't issued. Every composed endpoint's backend + # names this gateway's client certificate Secret, which is issued + # from the same CA and so doesn't exist on its cluster yet, and + # Envoy Gateway fails a backend closed when the Secret naming its + # certificate is missing. A cluster becomes schedulable once any + # gateway has published, so this one can be behind. + continue + candidate = ServingGateway(gw, pc) + if candidate.serves(labels): + self.gateways.append(candidate) + self.gateways.sort(key=lambda g: g.name) + + def resolve_endpoints(self) -> None: + """Group ready endpoints by the priority of the entry that selected them. + + An endpoint matched by more than one entry belongs to the first that + matched it, so a canary entry and a catch-all entry can't both weight + the same endpoint. + """ + seen: set[str] = set() for i, entry in enumerate(self.xr.spec.endpoints): - weight = entry.weight if entry.weight is not None else 1 - group: list[mev1alpha1.ModelEndpoint] = [] - for d in request.get_required_resources(self.req, f"endpoints-{i}") or []: + matched: list[mev1alpha1.ModelEndpoint] = [] + for d in request.get_required_resources(self.req, f"endpoints-{i}"): ep = mev1alpha1.ModelEndpoint.model_validate(d) key = f"{_namespace(ep.metadata)}/{_name(ep.metadata)}" - if key in seen_names: + if key in seen: + continue + seen.add(key) + self.total += 1 + if not _endpoint_ready(d): continue - seen_names.add(key) - group.append(ep) - self.endpoints.append(ep) - self.groups.append((weight, group)) + self.ready_count += 1 + matched.append(ep) + priority = entry.priority if entry.priority is not None else 0 + weight = entry.weight if entry.weight is not None else 1 + self.tiers.setdefault(priority, []).append((weight, matched)) + + def credential_ready(self, ep: mev1alpha1.ModelEndpoint) -> bool: + """Whether this endpoint's credential resolved to a usable Secret. + + The endpoint's own EndpointReady is supposed to keep an unusable one out + of the route, but it's written by another XR on an independent reconcile + loop. In the window between a Secret being deleted and that XR noticing, + this function sees a ready endpoint and an unresolved credential. Reading + the dict unguarded there raises, which fails the whole composition and + withdraws the route from every gateway serving the service, over one + endpoint of possibly many. + """ + ref = ep.spec.credentialRef + if ref is None: + return True + secret = self.credentials.get(_name(ep.metadata)) + if secret is None: + return False + return (ref.key or "apiKey") in secret.get("data", {}) + + def resolve_credentials(self) -> bool: + """Require the Secret behind each ready endpoint's credentialRef. - if not self.endpoints: - response.set_conditions( + The endpoints only become known once their requirements resolve, so + these are requested on a later pass than the endpoints themselves. Until + they resolve nothing is composed, because composing a route whose + backends have no credential would send unauthenticated requests to a + provider. + """ + wanted: dict[str, str] = {} + for entries in self.tiers.values(): + for _, eps in entries: + for ep in eps: + if ep.spec.credentialRef: + wanted[_name(ep.metadata)] = ep.spec.credentialRef.name + if not wanted: + return True + + for endpoint, secret in sorted(wanted.items()): + response.require_resources( self.rsp, - resource.Condition( - typ=CONDITION_TYPE_ENDPOINTS_RESOLVED, - status="False", - reason=CONDITION_REASON_NO_ENDPOINTS, - message="No ModelEndpoints matched the configured selectors", - ), + name=f"credential-{endpoint}", + api_version="v1", + kind="Secret", + namespace=self.ns, + match_name=secret, ) - response.warning(self.rsp, "No ModelEndpoints matched the configured selectors") - return False + for endpoint in sorted(wanted): + key = f"credential-{endpoint}" + if key not in self.req.required_resources: + self.not_ready( + CONDITION_REASON_WAITING_FOR_RESOURCES, + "Waiting for endpoint credential Secrets to resolve", + ) + return False + found = request.get_required_resources(self.req, key) + if found: + self.credentials[endpoint] = found[0] - ready = sum(1 for ep in self.endpoints if ep.status and ep.status.routing and ep.status.routing.backendName) - waiting = len(self.endpoints) - ready - msg = f"Matched {len(self.endpoints)} endpoint(s)" - if waiting > 0: - msg += f"; {waiting} waiting for Backend" + return True - response.set_conditions( - self.rsp, - resource.Condition( - typ=CONDITION_TYPE_ENDPOINTS_RESOLVED, - status="True", - reason=CONDITION_REASON_RESOLVED, - message=msg, + def cluster_ca_ready(self, ep: mev1alpha1.ModelEndpoint) -> bool: + """Whether this endpoint's cluster has published the CA the backend has + to pin. + + Only composed endpoints pin one. A cluster publishes the hostname their + origin is built from only once it has published its CA, so normally both + are present, but the two come from another XR's status on an independent + loop and a cluster withdraws its status when its gateway address goes + away. Composing the backend anyway would reference a ConfigMap nothing + composes, and Envoy Gateway fails that route closed. + """ + cluster = _labels(ep.metadata).get(_LABEL_CLUSTER, "") + if not cluster: + return True + return cluster in self.cluster_cas + + def drop_unusable_endpoints(self) -> None: + """Leave out any endpoint this can't compose a working backend for, + rather than composing one that can't carry a request. + + That means a credential that didn't resolve to a usable Secret, or a + cluster that hasn't published the CA the backend pins. An endpoint's own + EndpointReady says much the same, but it's written by another XR on an + independent loop, so in the window between a Secret or a cluster status + going away and that XR noticing, this one sees a ready endpoint and + neither. Dropping only that endpoint keeps the rest of the service + serving; raising here would withdraw the route from every gateway. + """ + no_credential: list[str] = [] + no_ca: list[str] = [] + for entries in self.tiers.values(): + for _, eps in entries: + for ep in list(eps): + if not self.credential_ready(ep): + no_credential.append(_name(ep.metadata)) + elif not self.cluster_ca_ready(ep): + no_ca.append(_name(ep.metadata)) + else: + continue + eps.remove(ep) + self.ready_count -= 1 + if no_credential: + response.warning( + self.rsp, + "Endpoints left out of the route, their credential Secret missing or missing its key: " + + ", ".join(sorted(no_credential)), + ) + if no_ca: + response.warning( + self.rsp, + "Endpoints left out of the route, their cluster has published no gateway CA: " + + ", ".join(sorted(no_ca)), + ) + + def compose_routes(self) -> None: + """One route per gateway, plus each gateway's copy of the backends.""" + for gw in self.gateways: + self.compose_backends(gw) + self.compose_route(gw) + + def compose_backends(self, gw: ServingGateway) -> None: + """Per endpoint: how to reach it, what it speaks, and its credential. + + Plus, once per cluster rather than per endpoint, the CA certificate the + gateway validates that cluster's gateway against. + """ + clusters: set[str] = set() + for entries in self.tiers.values(): + for _, eps in entries: + for ep in eps: + self.compose_backend(gw, ep) + cluster = _labels(ep.metadata).get(_LABEL_CLUSTER, "") + if cluster: + clusters.add(cluster) + for cluster in sorted(clusters): + self.compose_cluster_ca(gw, cluster) + + def compose_cluster_ca(self, gw: ServingGateway, cluster: str) -> None: + """Copy one cluster gateway's CA certificate to a gateway's cluster. + + A ConfigMap because a CA certificate is public, and because Envoy Gateway + reads a Backend's caCertificateRefs from one. Keyed and named by the + cluster, so several ModelServices reaching the same cluster converge on + identical content rather than fighting over it. + """ + resource.update( + self.rsp.desired.resources[f"cluster-ca-{gw.name}-{cluster}"], + _wrap( + gw.provider_config, + { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": names.cluster_ca(cluster), "namespace": REMOTE_NAMESPACE}, + "data": {"ca.crt": self.cluster_cas[cluster]}, + }, ), ) - return True - def _backend_ref(self, ep: mev1alpha1.ModelEndpoint, weight: int) -> dict: - """Build an HTTPRoute backendRef for a ready endpoint.""" - # Callers pass only ready endpoints; this guard also narrows the type. - if not ep.status or not ep.status.routing or not ep.status.routing.backendName: - raise ValueError("endpoint has no backend name") - # Derive the backend Service port from the endpoint's URL. - # compose-model-endpoint creates a Service with this port. - ref: dict = { - "name": ep.status.routing.backendName, - "port": _port_from_url(ep.spec.url), - "weight": weight, + def compose_backend(self, gw: ServingGateway, ep: mev1alpha1.ModelEndpoint) -> None: + ep_name = _name(ep.metadata) + name = names.backend(self.ns, self.svc, ep_name) + scheme, _, host = ep.spec.origin.partition("://") + hostname, _, port = host.partition(":") + tls = scheme == "https" + number = int(port) if port else (443 if tls else 80) + + # Addressed by hostname, never by address. Envoy Gateway emits a single + # STRICT_DNS cluster for a route whose backends are all hostnames, which + # is what carries the per-priority localities failover needs. An address + # makes it an EDS cluster instead, where the per-endpoint metadata + # naming the chosen backend is never stamped, so the model rewrite, the + # host rewrite and the credential all silently stop applying while + # traffic keeps flowing. The ModelEndpoint XRD rejects an address, so + # this is a hostname. + spec: dict = {"endpoints": [{"fqdn": {"hostname": hostname, "port": number}}]} + if tls: + # A Modelplane-composed endpoint is a cluster gateway, whose + # certificate is signed by its own cluster's CA rather than a public + # one, and which requires a client certificate in return. That pair + # is what makes a fleet gateway the only thing able to reach the + # engines behind it. + # + # Anything else is a public endpoint, validated against the system + # trust store. Presenting a client certificate to a provider would + # be meaningless, and pinning our own CA would reject them. + cluster = _labels(ep.metadata).get(_LABEL_CLUSTER, "") + if cluster: + # drop_unusable_endpoints has already left out any composed + # endpoint whose cluster hasn't published a CA, so there is one + # to pin and a ConfigMap composed to hold it. Falling back to the + # public trust store here instead would leave the backend unable + # to complete a handshake, presenting no client certificate to a + # gateway that requires one, while the endpoint and the route + # both reported ready. + spec["tls"] = { + "caCertificateRefs": [{"kind": "ConfigMap", "group": "", "name": names.cluster_ca(cluster)}], + "sni": hostname, + "clientCertificateRef": {"kind": "Secret", "group": "", "name": _CLIENT_CERT_SECRET}, + } + else: + spec["tls"] = {"wellKnownCACertificates": "System", "sni": hostname} + backend: dict = { + "apiVersion": "gateway.envoyproxy.io/v1alpha1", + "kind": "Backend", + "metadata": {"name": name, "namespace": REMOTE_NAMESPACE}, + "spec": spec, + } + resource.update(self.rsp.desired.resources[f"backend-{gw.name}-{ep_name}"], _wrap(gw.provider_config, backend)) + + api = ep.spec.api + schema: dict = {"name": api.schema_ if api and api.schema_ else "OpenAI"} + prefix = api.prefix if api and api.prefix else "/v1" + schema["prefix"] = prefix + service_backend: dict = { + "apiVersion": "aigateway.envoyproxy.io/v1beta1", + "kind": "AIServiceBackend", + "metadata": {"name": name, "namespace": REMOTE_NAMESPACE}, + "spec": { + "schema": schema, + "backendRef": {"group": "gateway.envoyproxy.io", "kind": "Backend", "name": name}, + }, } - if ep.spec.rewritePath: - ref["filters"] = [ + # A backend Modelplane doesn't operate isn't told which tenant is + # calling. Our own endpoints keep the header, because the cluster gateway + # and the engine behind it are ours. + # + # Whether we operate it is decided by whether we composed it, not by + # whether it carries a credential: a third party can need no key, or + # authenticate by client certificate, and inferring from credentialRef + # would disclose the caller to it. + if not _composed_by_modelplane(ep): + service_backend["spec"]["headerMutation"] = {"remove": [_CALLER_HEADER]} + resource.update( + self.rsp.desired.resources[f"aibackend-{gw.name}-{ep_name}"], + _wrap(gw.provider_config, service_backend), + ) + + if not ep.spec.credentialRef: + return + secret = self.credentials.get(ep_name) + secret_name = names.credential(self.ns, self.svc, ep_name) + key = ep.spec.credentialRef.key or "apiKey" + # The AI Gateway reads the credential from a fixed key, so a Secret + # using another name is republished under the expected one rather than + # forcing the key onto whoever writes the Secret. + data = secret.get("data", {}) if secret else {} + resource.update( + self.rsp.desired.resources[f"credential-{gw.name}-{ep_name}"], + _wrap( + gw.provider_config, + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": secret_name, "namespace": REMOTE_NAMESPACE}, + "type": "Opaque", + "data": {"apiKey": data[key]}, + }, + ), + ) + resource.update( + self.rsp.desired.resources[f"credpolicy-{gw.name}-{ep_name}"], + _wrap( + gw.provider_config, { - "type": "URLRewrite", - "urlRewrite": { - "path": { - "type": "ReplacePrefixMatch", - "replacePrefixMatch": ep.spec.rewritePath, - }, + "apiVersion": "aigateway.envoyproxy.io/v1beta1", + "kind": "BackendSecurityPolicy", + "metadata": {"name": name, "namespace": REMOTE_NAMESPACE}, + "spec": { + "type": "APIKey", + "apiKey": {"secretRef": {"name": secret_name}}, + "targetRefs": [ + { + "group": "aigateway.envoyproxy.io", + "kind": "AIServiceBackend", + "name": name, + } + ], }, - } - ] - return ref - - def compose_httproute(self) -> None: - """Compose an HTTPRoute that splits traffic across matched endpoints. - - A single rule matches the service prefix and fans out to all ready - endpoints via weighted backendRefs. Traffic is split across selector - entries in proportion to their weights; each entry's weight is spread - evenly across the endpoints it matched. Each backendRef carries its - own URLRewrite filter derived from the endpoint's rewritePath, so - endpoints with different path conventions are rewritten correctly - per-backend. This is a Gateway API Extended feature supported by - Traefik Proxy. - """ - match_prefix = f"/{_namespace(self.xr.metadata)}/{_name(self.xr.metadata)}/" - match = {"path": {"type": "PathPrefix", "value": match_prefix}} + }, + ), + ) - # Only ready endpoints (those with a Backend) can receive traffic. - ready_groups = [ - (weight, [ep for ep in eps if ep.status and ep.status.routing and ep.status.routing.backendName]) - for weight, eps in self.groups - ] + def compose_route(self, gw: ServingGateway) -> None: + """The AIGatewayRoute matching this service's model name. - backend_refs = [self._backend_ref(ep, w) for ep, w in _distribute_weights(ready_groups)] + One rule, matching the model header exactly. Exact rather than a regex + because only exact matches appear in the gateway's /v1/models, and a + service a caller can't discover is a service they can't use. - rule: dict = {"matches": [match]} - if backend_refs: - rule["backendRefs"] = backend_refs + Every ready endpoint is a backendRef carrying its own weight, priority + and upstream model name, so the request that wins is translated for + whichever backend served it. + """ + # A ModelService's priorities are an ordering, and Envoy's are levels it + # walks from 0 upwards, so they're renumbered to 0..N-1 over the tiers + # that actually have a ready endpoint. Passing them through would leave + # gaps: a user may write 0 and 5, and a tier whose endpoints are all + # unready drops out entirely, which during a deployment roll can leave a + # route whose only tier is priority 1 with no priority 0 at all. + populated = [p for p in sorted(self.tiers) if _distribute_weights(self.tiers[p])] + refs: list[dict] = [] + for level, priority in enumerate(populated): + for ep, weight in _distribute_weights(self.tiers[priority]): + ref: dict = { + "name": names.backend(self.ns, self.svc, _name(ep.metadata)), + "weight": weight, + "priority": level, + } + if ep.spec.model: + ref["modelNameOverride"] = ep.spec.model + refs.append(ref) resource.update( - self.rsp.desired.resources["httproute"], - { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": {"namespace": _namespace(self.xr.metadata)}, - "spec": { - "parentRefs": [{"name": _GATEWAY_NAME, "namespace": _NAMESPACE_SYSTEM}], - "rules": [rule], + self.rsp.desired.resources[f"route-{gw.name}"], + _wrap( + gw.provider_config, + { + "apiVersion": "aigateway.envoyproxy.io/v1beta1", + "kind": "AIGatewayRoute", + "metadata": {"name": names.route(self.ns, self.svc), "namespace": REMOTE_NAMESPACE}, + "spec": { + "parentRefs": [ + { + "group": "gateway.networking.k8s.io", + "kind": "Gateway", + "name": _GATEWAY_NAME, + } + ], + "rules": [ + { + "matches": [ + { + "headers": [ + { + "type": "Exact", + "name": _MODEL_HEADER, + "value": names.model(self.ns, self.svc), + } + ] + } + ], + "backendRefs": refs, + "timeouts": {"request": _REQUEST_TIMEOUT}, + "streamIdleTimeout": _STREAM_IDLE_TIMEOUT, + } + ], + "llmRequestCosts": _LLM_REQUEST_COSTS, + }, }, - }, + # Readiness tracks the route being accepted, not merely written. + # A route Envoy AI Gateway rejects, for a missing + # AIServiceBackend or a rule it won't take, would otherwise leave + # the service reporting RoutingReady while no caller can reach it. + cel_query=_ROUTE_ACCEPTED_CEL, + ), ) def write_status(self) -> None: - status = v1alpha1.Status() - gateway_ip = self.gateway.status.address if self.gateway and self.gateway.status else None - if gateway_ip: - status.address = ( - f"{_GATEWAY_SCHEME}://{gateway_ip}/{_namespace(self.xr.metadata)}/{_name(self.xr.metadata)}" - ) + """Publish the model callers name, the gateways serving it, and counts.""" + status = v1alpha1.Status( + model=names.model(self.ns, self.svc), + endpoints=v1alpha1.Endpoints(total=self.total, ready=self.ready_count), + ) + served = [] + for gw in self.gateways: + entry = v1alpha1.Gateway(name=gw.name) + if gw.xr.spec.hostname: + entry.hostname = gw.xr.spec.hostname + if gw.xr.status and gw.xr.status.address: + entry.address = gw.xr.status.address + served.append(entry) + if served: + status.gateways = served resource.update_status(self.rsp.desired.composite, status) - def derive_conditions(self) -> None: - """RoutingReady: HTTPRoute is composed and Accepted with backends.""" - if "httproute" not in self.rsp.desired.resources: - response.set_conditions( - self.rsp, - resource.Condition( - typ=CONDITION_TYPE_ROUTING_READY, - status="False", - reason=CONDITION_REASON_WAITING_FOR_GATEWAY, - ), - ) - return - - backend_refs_observed = any( - ep.status and ep.status.routing and ep.status.routing.backendName for ep in self.endpoints + def not_ready(self, reason: str, message: str) -> None: + response.set_conditions( + self.rsp, + resource.Condition( + typ=CONDITION_TYPE_ROUTING_READY, + status="False", + reason=reason, + message=message, + ), ) - route_ready = _has_parent_condition(self.req, "httproute", "Accepted") and backend_refs_observed + response.normal(self.rsp, message) - if route_ready: - self.rsp.desired.resources["httproute"].ready = fnv1.READY_TRUE + def derive_conditions(self) -> None: + """RoutingReady once every composed route has been applied. + Every gateway, not any: a service reachable through some of the gateways + that should serve it is a residency or capacity problem worth surfacing, + not a success. + """ + pending = [ + gw.name + for gw in self.gateways + if resource.get_condition(self.req.observed.resources.get(f"route-{gw.name}"), "Ready").status != "True" + ] + if pending: + self.not_ready( + CONDITION_REASON_WAITING_FOR_ROUTES, + f"Waiting for routes on gateways: {', '.join(pending)}", + ) + return response.set_conditions( self.rsp, resource.Condition( typ=CONDITION_TYPE_ROUTING_READY, - status="True" if route_ready else "False", - reason=CONDITION_REASON_ROUTE_CONFIGURED if route_ready else CONDITION_REASON_CONFIGURING, + status="True", + reason=CONDITION_REASON_ROUTES_ACCEPTED, ), ) diff --git a/functions/compose-model-service/function/names.py b/functions/compose-model-service/function/names.py new file mode 100644 index 000000000..bf4d74d7c --- /dev/null +++ b/functions/compose-model-service/function/names.py @@ -0,0 +1,95 @@ +# Copyright 2026 The Modelplane Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Names for the objects a ModelService composes on a gateway's cluster. + +Everything lands in one namespace there, so a name has to carry the +control-plane namespace it came from or two services called "assistant" in +different namespaces would collide. Names are derived rather than generated +because the objects reference each other: an AIGatewayRoute's backendRefs name +AIServiceBackends, which name Backends, and a BackendSecurityPolicy names an +AIServiceBackend back. +""" + +import hashlib + +# Kubernetes object names are limited to 253 characters. A ModelService's +# namespace, its name, and an endpoint's name can each be 253 on their own, so +# a joined name can overflow and has to be shortened deterministically. +_MAX_NAME = 253 + +# Enough of a SHA-256 to make a collision between two truncated names +# implausible, while leaving most of the readable prefix intact. +_HASH_LEN = 8 + + +def _fit(name: str) -> str: + """Shorten a name to fit Kubernetes' limit, keeping it unique and stable. + + Truncating alone would map two long names onto one object, so a hash of the + whole name replaces the tail. The hash is of the untruncated name, so the + result is stable across reconciles and identical in every function that + derives it. + """ + if len(name) <= _MAX_NAME: + return name + digest = hashlib.sha256(name.encode()).hexdigest()[:_HASH_LEN] + return f"{name[: _MAX_NAME - _HASH_LEN - 1]}-{digest}" + + +def route(namespace: str, service: str) -> str: + """The AIGatewayRoute for a ModelService. + + Also the name of the HTTPRoute the AI Gateway generates from it, and so of + the Envoy route the access log reports. + """ + return _fit(f"{namespace}-{service}") + + +def backend(namespace: str, service: str, endpoint: str) -> str: + """The Backend, AIServiceBackend and BackendSecurityPolicy for one endpoint + of one ModelService. + + Scoped to the service rather than the endpoint alone. Two ModelServices + selecting one endpoint each get their own copies, which costs a little + duplicated config and avoids two composites owning one object. + """ + return _fit(f"{namespace}-{service}-{endpoint}") + + +def credential(namespace: str, service: str, endpoint: str) -> str: + """The propagated Secret holding one endpoint's backend credential.""" + return _fit(f"{namespace}-{service}-{endpoint}-credential") + + +def model(namespace: str, service: str) -> str: + """The name a caller passes as the request's model. + + Namespaced, so two services can't collide and the namespace serving a + caller is legible in what it passes. Not run through _fit: this is a value + in a request body and a header match, not an object name, and shortening it + would make the name a caller uses depend on a hash. + """ + return f"{namespace}/{service}" + + +def cluster_ca(cluster: str) -> str: + """The ConfigMap holding one cluster gateway's CA certificate. + + Named for the cluster rather than the service, because a CA certificate is a + fact about a cluster and identical for every service that reaches it. Several + ModelServices composing the same ConfigMap with the same content is + server-side apply converging, not a conflict. + """ + return _fit(f"cluster-ca-{cluster}") diff --git a/functions/compose-model-service/tests/test_fn.py b/functions/compose-model-service/tests/test_fn.py index 29ac698fc..8f7144b02 100644 --- a/functions/compose-model-service/tests/test_fn.py +++ b/functions/compose-model-service/tests/test_fn.py @@ -14,17 +14,33 @@ """Tests for the compose-model-service function.""" +import base64 import dataclasses import unittest from crossplane.function import logging, resource from crossplane.function.proto.v1 import run_function_pb2 as fnv1 -from function import fn +from function import fn, names from google.protobuf import duration_pb2 as durationpb from google.protobuf import json_format from google.protobuf import struct_pb2 as structpb +from models.ai.modelplane.inferencecluster import v1alpha1 as icv1alpha1 +from models.ai.modelplane.inferencegateway import v1alpha1 as igv1alpha1 +from models.ai.modelplane.modelendpoint import v1alpha1 as mev1alpha1 from models.ai.modelplane.modelservice import v1alpha1 -from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 + +_NS = "ml-team" + +# What a cluster publishes as status.gateway.caCertificate, which a composed +# endpoint's backend pins so it can tell it reached that cluster's gateway. +_CLUSTER_CA = "-----BEGIN CERTIFICATE-----\ncluster\n-----END CERTIFICATE-----\n" + +# What a gateway publishes as status.clientCACertificate. A cluster gateway +# accepts client certificates signed by it, which is how this gateway proves +# itself, so a gateway without one can't reach a composed endpoint at all. +_CLIENT_CA = "-----BEGIN CERTIFICATE-----\nclient\n-----END CERTIFICATE-----\n" +_SVC = "assistant" +_MODEL = f"{_NS}/{_SVC}" @dataclasses.dataclass @@ -36,721 +52,913 @@ class Case: want: fnv1.RunFunctionResponse -def setUpModule() -> None: - logging.configure(level=logging.Level.DISABLED) +def _service(entries: list[v1alpha1.Endpoint], labels: dict[str, str] | None = None) -> dict: + xr = v1alpha1.ModelService( + apiVersion="modelplane.ai/v1alpha1", + kind="ModelService", + metadata={"name": _SVC, "namespace": _NS, **({"labels": labels} if labels else {})}, + spec=v1alpha1.Spec(endpoints=entries), + ) + return xr.model_dump(exclude_none=True, mode="json", by_alias=True) + +def _entry(deployment: str, *, priority: int | None = None, weight: int | None = None) -> v1alpha1.Endpoint: + kwargs = {} + if priority is not None: + kwargs["priority"] = priority + if weight is not None: + kwargs["weight"] = weight + return v1alpha1.Endpoint(selector=v1alpha1.Selector(matchLabels={"modelplane.ai/deployment": deployment}), **kwargs) -def _gateway() -> fnv1.Resource: - """The InferenceGateway every case resolves for its address and parentRef.""" - return fnv1.Resource( - resource=resource.dict_to_struct( + +def _endpoint( + name: str, + *, + origin: str, + model: str | None = None, + credential: str | None = None, + prefix: str | None = None, + schema: str | None = None, + ready: bool = True, + composed: bool = False, +) -> dict: + """A ModelEndpoint as observed, built from the generated model so a field the + XRD doesn't define can't creep in. + + composed marks it as one Modelplane composed for a replica, which it does by + carrying the cluster label compose-model-deployment stamps. That is what + decides whether the caller's identity travels to the backend, so a fixture + for a self-hosted endpoint has to set it. + """ + api = None + if prefix or schema: + api = mev1alpha1.Api(**({"prefix": prefix} if prefix else {}), **({"schema": schema} if schema else {})) + ep = mev1alpha1.ModelEndpoint( + apiVersion="modelplane.ai/v1alpha1", + kind="ModelEndpoint", + metadata={"name": name, "namespace": _NS}, + spec=mev1alpha1.Spec( + origin=origin, + **({"model": model} if model else {}), + **({"api": api} if api else {}), + **({"credentialRef": mev1alpha1.CredentialRef(name=credential)} if credential else {}), + ), + ) + d = ep.model_dump(exclude_none=True, mode="json", by_alias=True) + if composed: + d["metadata"]["labels"] = {"modelplane.ai/cluster": "gw-eu", "modelplane.ai/deployment": "d"} + d["status"] = { + "conditions": [ { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "InferenceGateway", - "metadata": {"name": "default"}, - "spec": {"backend": "Traefik"}, - "status": {"address": "34.55.100.10"}, + "type": "EndpointReady", + "status": "True" if ready else "False", + "reason": "EndpointUsable" if ready else "CredentialMissing", + "lastTransitionTime": "2026-06-08T00:00:00Z", } - ) + ] + } + return d + + +def _gateway( + name: str, + cluster: str, + *, + selector: dict[str, str] | None = None, + address: str | None = None, + client_ca: str | None = _CLIENT_CA, +) -> dict: + """An InferenceGateway as this function sees it. + + Publishes a client CA by default, because a gateway whose client PKI hasn't + issued yet has no client certificate for a composed endpoint's backend to + name. Pass client_ca=None for that window. + """ + gw = igv1alpha1.InferenceGateway( + apiVersion="modelplane.ai/v1alpha1", + kind="InferenceGateway", + metadata={"name": name}, + spec=igv1alpha1.Spec( + clusterName=cluster, + **({"serviceSelector": igv1alpha1.ServiceSelector(matchLabels=selector)} if selector else {}), + ), + ) + d = gw.model_dump(exclude_none=True, mode="json", by_alias=True) + status: dict = {} + if address: + status["address"] = address + if client_ca: + status["clientCACertificate"] = client_ca + if status: + d["status"] = status + return d + + +def _cluster(name: str, *, provider_config: str | None = None, ca: str | None = _CLUSTER_CA) -> dict: + """An InferenceCluster as this function sees it. + + Publishes a gateway CA by default, because a cluster publishes the hostname a + composed endpoint's origin is built from only once it has published its CA, + so a composed endpoint's cluster always has one. Pass ca=None for the window + where a cluster has withdrawn it. + """ + c = icv1alpha1.InferenceCluster( + apiVersion="modelplane.ai/v1alpha1", + kind="InferenceCluster", + metadata={"name": name}, + spec=icv1alpha1.Spec( + cluster=icv1alpha1.Cluster( + source="Existing", + existing=icv1alpha1.Existing( + secretRef=icv1alpha1.SecretRef(name=f"{name}-kubeconfig", key="kubeconfig") + ), + ) + ), ) + d = c.model_dump(exclude_none=True, mode="json", by_alias=True) + status: dict = {} + if provider_config: + status["providerConfigRef"] = {"name": provider_config} + if ca: + status["gateway"] = {"caCertificate": ca} + if status: + d["status"] = status + return d + + +def _secret(name: str, data: dict[str, str]) -> dict: + return { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": name, "namespace": _NS}, + "data": {k: base64.b64encode(v.encode()).decode() for k, v in data.items()}, + } -def _endpoint( - name: str, *, url: str = "http://10.0.0.1/v1", rewrite_path: str | None = None, backend: str | None = None -) -> fnv1.Resource: - """A matched ModelEndpoint. Omit backend for one that isn't ready yet.""" - ep: dict = { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelEndpoint", - "metadata": {"name": name, "namespace": "ml-team"}, - "spec": {"url": url}, +def _required(**resources) -> dict: # noqa: ANN003 + return { + name: fnv1.Resources(items=[fnv1.Resource(resource=resource.dict_to_struct(r)) for r in items]) + for name, items in resources.items() } - if rewrite_path is not None: - ep["spec"]["rewritePath"] = rewrite_path - if backend is not None: - ep["status"] = {"routing": {"backendName": backend}} - return fnv1.Resource(resource=resource.dict_to_struct(ep)) -def _gateway_selector() -> fnv1.ResourceSelector: - return fnv1.ResourceSelector(api_version="modelplane.ai/v1alpha1", kind="InferenceGateway", match_name="default") +def _manifest(rsp: fnv1.RunFunctionResponse, key: str) -> dict: + return resource.struct_to_dict(rsp.desired.resources[key].resource)["spec"]["forProvider"]["manifest"] -def _endpoint_selector(labels: dict[str, str]) -> fnv1.ResourceSelector: - sel = fnv1.ResourceSelector(api_version="modelplane.ai/v1alpha1", kind="ModelEndpoint") - sel.match_labels.labels.update(labels) - return sel +def setUpModule() -> None: + logging.configure(level=logging.Level.DISABLED) + +class TestGating(unittest.IsolatedAsyncioTestCase): + """Passes where there's nothing to compose compose nothing, and say why.""" -class TestFunctionRunner(unittest.IsolatedAsyncioTestCase): - """Tests for FunctionRunner.RunFunction.""" + maxDiff = None @classmethod def setUpClass(cls) -> None: cls.runner = fn.FunctionRunner() - async def test_compose(self) -> None: # noqa: PLR0915 - """The function composes an HTTPRoute from a ModelService.""" + async def test_compose(self) -> None: + entries = [_entry("kimi-k2")] + cases = [ + Case( + name="unresolved requirements", + req=fnv1.RunFunctionRequest( + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_service(entries)))), + ), + want=fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), + desired=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct( + {"status": {"model": _MODEL, "endpoints": {"total": 0, "ready": 0}}} + ) + ) + ), + context=structpb.Struct(), + requirements=fnv1.Requirements( + resources={ + "gateways": fnv1.ResourceSelector( + api_version="modelplane.ai/v1alpha1", kind="InferenceGateway" + ), + "clusters": fnv1.ResourceSelector( + api_version="modelplane.ai/v1alpha1", kind="InferenceCluster" + ), + "endpoints-0": fnv1.ResourceSelector( + api_version="modelplane.ai/v1alpha1", + kind="ModelEndpoint", + namespace=_NS, + match_labels=fnv1.MatchLabels(labels={"modelplane.ai/deployment": "kimi-k2"}), + ), + } + ), + conditions=[ + fnv1.Condition( + type=fn.CONDITION_TYPE_ROUTING_READY, + status=fnv1.STATUS_CONDITION_FALSE, + reason=fn.CONDITION_REASON_WAITING_FOR_RESOURCES, + message="Waiting for gateways and endpoints to resolve", + ) + ], + results=[ + fnv1.Result( + severity=fnv1.SEVERITY_NORMAL, message="Waiting for gateways and endpoints to resolve" + ) + ], + ), + ), + ] + for case in cases: + with self.subTest(case.name): + got = await self.runner.RunFunction(case.req, None) + self.assertEqual( + json_format.MessageToDict(case.want), + json_format.MessageToDict(got), + "-want, +got", + ) - xr = v1alpha1.ModelService( - metadata=metav1.ObjectMeta(name="test-service", namespace="ml-team"), - spec=v1alpha1.Spec( - endpoints=[v1alpha1.Endpoint(selector=v1alpha1.Selector(matchLabels={"app": "model"}))], + async def test_no_gateway_serves_this_service(self) -> None: + """A service no gateway selects is unreachable, and says so rather than + composing a route nobody serves.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct(_service([_entry("kimi-k2")], labels={"region": "us"})) + ) + ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu", selector={"region": "eu"})], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc")], + **{"endpoints-0": [_endpoint("kimi-a", origin="https://a.example.com")]}, + ), + ) + got = await self.runner.RunFunction(req, None) + self.assertEqual(len(got.desired.resources), 0, "composes nothing") + self.assertEqual(next(iter(got.conditions)).reason, fn.CONDITION_REASON_NO_GATEWAY) + + async def test_no_ready_endpoints(self) -> None: + """An endpoint that isn't ready is kept out of the route entirely, and a + service with none reports why instead of composing an empty route.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("kimi-k2")]))) + ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc")], + **{ + "endpoints-0": [ + _endpoint("kimi-a", origin="https://a.example.com", ready=False), + _endpoint("kimi-b", origin="https://b.example.com", ready=False), + ] + }, ), - ).model_dump(exclude_none=True, mode="json") + ) + got = await self.runner.RunFunction(req, None) + self.assertEqual(len(got.desired.resources), 0) + cond = next(iter(got.conditions)) + self.assertEqual(cond.reason, fn.CONDITION_REASON_NO_ENDPOINTS) + self.assertEqual(cond.message, "None of the 2 selected ModelEndpoints is ready to carry traffic") + self.assertEqual( + resource.struct_to_dict(got.desired.composite.resource)["status"]["endpoints"], + {"total": 2, "ready": 0}, + ) - # Case 1: endpoints with ready backends compose HTTPRoute with backendRefs. - req1 = fnv1.RunFunctionRequest( - observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(xr))), + async def test_a_cluster_without_a_provider_config_is_skipped(self) -> None: + """A gateway whose cluster hasn't published a ProviderConfig can't be + composed onto. compose-inference-gateway reports that on the gateway.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("kimi-k2")]))) + ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu")], + **{"endpoints-0": [_endpoint("kimi-a", origin="https://a.example.com")]}, + ), ) - req1.required_resources["inference-gateway"].items.append(_gateway()) - req1.required_resources["endpoints-0"].items.append(_endpoint("ep-1", rewrite_path="/v1/", backend="svc-1")) + got = await self.runner.RunFunction(req, None) + self.assertEqual(next(iter(got.conditions)).reason, fn.CONDITION_REASON_NO_GATEWAY) - want1 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( + +class TestComposition(unittest.IsolatedAsyncioTestCase): + maxDiff = None + + @classmethod + def setUpClass(cls) -> None: + cls.runner = fn.FunctionRunner() + + async def _run(self, req: fnv1.RunFunctionRequest) -> fnv1.RunFunctionResponse: + return await self.runner.RunFunction(req, None) + + async def test_route_and_backends(self) -> None: + """The design's own example: a 90/10 canary across two self-hosted + deployments at priority 0, with a provider as failover at priority 1.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( composite=fnv1.Resource( resource=resource.dict_to_struct( - {"status": {"address": "http://34.55.100.10/ml-team/test-service"}} - ), - ), - resources={ - "httproute": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": {"namespace": "ml-team"}, - "spec": { - "parentRefs": [{"name": "modelplane", "namespace": "modelplane-system"}], - "rules": [ - { - "matches": [ - {"path": {"type": "PathPrefix", "value": "/ml-team/test-service/"}} - ], - "backendRefs": [ - { - "name": "svc-1", - "port": 80, - "weight": 1, - "filters": [ - { - "type": "URLRewrite", - "urlRewrite": { - "path": { - "type": "ReplacePrefixMatch", - "replacePrefixMatch": "/v1/", - }, - }, - } - ], - }, - ], - } - ], - }, - } - ), - ), + _service( + [ + _entry("kimi-k2", priority=0, weight=90), + _entry("kimi-k2-next", priority=0, weight=10), + _entry("together", priority=1), + ] + ) + ) + ) + ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu", address="34.56.129.3")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc")], + **{ + "endpoints-0": [ + _endpoint( + "kimi-k2-eu-0", + origin="http://gw-eu.clusters.example.com", + model="ml-team/kimi-k2", + prefix="/ml-team/kimi-k2-eu-0/v1", + composed=True, + ) + ], + "endpoints-1": [ + _endpoint( + "kimi-next-eu-0", + origin="http://gw-eu.clusters.example.com", + model="ml-team/kimi-k2-next", + prefix="/ml-team/kimi-k2-next-eu-0/v1", + composed=True, + ) + ], + "endpoints-2": [ + _endpoint( + "together-kimi", + origin="https://api.together.xyz", + model="moonshotai/Kimi-K2-Instruct", + credential="together-api-key", + ) + ], + "credential-together-kimi": [_secret("together-api-key", {"apiKey": "sk-together"})], }, ), - conditions=[ - fnv1.Condition( - type="EndpointsResolved", - status=fnv1.STATUS_CONDITION_TRUE, - reason="Resolved", - message="Matched 1 endpoint(s)", - ), - fnv1.Condition( - type="RoutingReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Configuring", - ), - ], - context=structpb.Struct(), - ) - want1.requirements.resources["inference-gateway"].CopyFrom(_gateway_selector()) - want1.requirements.resources["endpoints-0"].CopyFrom(_endpoint_selector({"app": "model"})) - - # Case 2: no endpoints produces warning. - req2 = fnv1.RunFunctionRequest( - observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(xr))), - ) - req2.required_resources["endpoints-0"].SetInParent() - - want2 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State(), - conditions=[ - fnv1.Condition( - type="EndpointsResolved", - status=fnv1.STATUS_CONDITION_FALSE, - reason="NoEndpoints", - message="No ModelEndpoints matched the configured selectors", - ), + ) + got = await self._run(req) + + route = _manifest(got, "route-eu") + rule = route["spec"]["rules"][0] + self.assertEqual( + rule["matches"], + [{"headers": [{"type": "Exact", "name": "x-ai-eg-model", "value": _MODEL}]}], + "Exact, because only exact matches appear in the gateway's /v1/models", + ) + self.assertEqual( + rule["backendRefs"], + [ + { + "name": names.backend(_NS, _SVC, "kimi-k2-eu-0"), + "weight": 9, + "priority": 0, + "modelNameOverride": "ml-team/kimi-k2", + }, + { + "name": names.backend(_NS, _SVC, "kimi-next-eu-0"), + "weight": 1, + "priority": 0, + "modelNameOverride": "ml-team/kimi-k2-next", + }, + { + "name": names.backend(_NS, _SVC, "together-kimi"), + "weight": 1, + "priority": 1, + "modelNameOverride": "moonshotai/Kimi-K2-Instruct", + }, ], - results=[ - fnv1.Result( - severity=fnv1.SEVERITY_WARNING, - message="No ModelEndpoints matched the configured selectors", - ), + "90/10 reduces to 9/1 within priority 0; the provider sits alone at priority 1", + ) + # Declaring costs is also what makes the gateway ask a backend for usage + # on a streamed response. Without it streamed requests report no tokens + # at all and the usage record is silently empty. + self.assertEqual( + route["spec"]["llmRequestCosts"], + [ + {"metadataKey": "llm_input_token", "type": "InputToken"}, + {"metadataKey": "llm_output_token", "type": "OutputToken"}, + {"metadataKey": "llm_total_token", "type": "TotalToken"}, ], - context=structpb.Struct(), + "spelled out, because comparing this to the constant that produced it cannot fail", ) - want2.requirements.resources["inference-gateway"].CopyFrom(_gateway_selector()) - want2.requirements.resources["endpoints-0"].CopyFrom(_endpoint_selector({"app": "model"})) + self.assertIn( + "streamIdleTimeout", + rule, + "without this a backend hanging before the first token never fails over", + ) + + # Every backend must be addressed by hostname. An address makes Envoy + # Gateway emit an EDS cluster, where the model rewrite, host rewrite and + # credential all silently stop applying. + for ep in ("kimi-k2-eu-0", "kimi-next-eu-0", "together-kimi"): + spec = _manifest(got, f"backend-eu-{ep}")["spec"] + self.assertIn("fqdn", spec["endpoints"][0], f"{ep} is addressed by hostname") + self.assertNotIn("ip", spec["endpoints"][0]) - # Case 3: endpoint without backend name โ€” route has no backendRefs. - req3 = fnv1.RunFunctionRequest( - observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(xr))), + self.assertEqual( + _manifest(got, "backend-eu-together-kimi")["spec"], + { + "endpoints": [{"fqdn": {"hostname": "api.together.xyz", "port": 443}}], + "tls": {"wellKnownCACertificates": "System", "sni": "api.together.xyz"}, + }, + "an https origin gets TLS originated to it, with SNI", + ) + self.assertEqual( + _manifest(got, "backend-eu-kimi-k2-eu-0")["spec"], + {"endpoints": [{"fqdn": {"hostname": "gw-eu.clusters.example.com", "port": 80}}]}, + ) + self.assertEqual( + _manifest(got, "aibackend-eu-kimi-k2-eu-0")["spec"]["schema"], + {"name": "OpenAI", "prefix": "/ml-team/kimi-k2-eu-0/v1"}, + "the per-replica path its cluster gateway serves this replica on", ) - req3.required_resources["inference-gateway"].items.append(_gateway()) - req3.required_resources["endpoints-0"].items.append(_endpoint("ep-1")) - want3 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct( - {"status": {"address": "http://34.55.100.10/ml-team/test-service"}} - ), - ), - resources={ - "httproute": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": {"namespace": "ml-team"}, - "spec": { - "parentRefs": [{"name": "modelplane", "namespace": "modelplane-system"}], - "rules": [ - { - "matches": [ - {"path": {"type": "PathPrefix", "value": "/ml-team/test-service/"}} - ], - } - ], - }, - } - ), - ), - }, - ), - conditions=[ - fnv1.Condition( - type="EndpointsResolved", - status=fnv1.STATUS_CONDITION_TRUE, - reason="Resolved", - message="Matched 1 endpoint(s); 1 waiting for Backend", - ), - fnv1.Condition( - type="RoutingReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Configuring", - ), + # A third-party backend must not be told which tenant is calling. Our + # own endpoints keep the header, because the engine behind them is ours. + self.assertEqual( + _manifest(got, "aibackend-eu-together-kimi")["spec"]["headerMutation"], + {"remove": ["x-modelplane-caller"]}, + ) + self.assertNotIn("headerMutation", _manifest(got, "aibackend-eu-kimi-k2-eu-0")["spec"]) + + self.assertEqual( + _manifest(got, "credential-eu-together-kimi")["data"], + {"apiKey": base64.b64encode(b"sk-together").decode()}, + "republished under the key the AI Gateway reads, base64 copied verbatim", + ) + self.assertEqual( + _manifest(got, "credpolicy-eu-together-kimi")["spec"]["targetRefs"], + [ + { + "group": "aigateway.envoyproxy.io", + "kind": "AIServiceBackend", + "name": names.backend(_NS, _SVC, "together-kimi"), + } ], - context=structpb.Struct(), ) - want3.requirements.resources["inference-gateway"].CopyFrom(_gateway_selector()) - want3.requirements.resources["endpoints-0"].CopyFrom(_endpoint_selector({"app": "model"})) + self.assertNotIn("credpolicy-eu-kimi-k2-eu-0", got.desired.resources, "our own endpoints need no credential") - # Case 4: two endpoints with the same rewritePath produce one rule with two backendRefs. - req4 = fnv1.RunFunctionRequest( - observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(xr))), + self.assertEqual( + resource.struct_to_dict(got.desired.composite.resource)["status"], + { + "model": _MODEL, + "endpoints": {"total": 3, "ready": 3}, + "gateways": [{"name": "eu", "address": "34.56.129.3"}], + }, ) - req4.required_resources["inference-gateway"].items.append(_gateway()) - req4.required_resources["endpoints-0"].items.append(_endpoint("ep-1", rewrite_path="/v1/", backend="svc-ep-1")) - req4.required_resources["endpoints-0"].items.append(_endpoint("ep-2", rewrite_path="/v1/", backend="svc-ep-2")) - want4 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct( - {"status": {"address": "http://34.55.100.10/ml-team/test-service"}} - ), - ), - resources={ - "httproute": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": {"namespace": "ml-team"}, - "spec": { - "parentRefs": [{"name": "modelplane", "namespace": "modelplane-system"}], - "rules": [ - { - "matches": [ - {"path": {"type": "PathPrefix", "value": "/ml-team/test-service/"}} - ], - "backendRefs": [ - { - "name": "svc-ep-1", - "port": 80, - "weight": 1, - "filters": [ - { - "type": "URLRewrite", - "urlRewrite": { - "path": { - "type": "ReplacePrefixMatch", - "replacePrefixMatch": "/v1/", - }, - }, - } - ], - }, - { - "name": "svc-ep-2", - "port": 80, - "weight": 1, - "filters": [ - { - "type": "URLRewrite", - "urlRewrite": { - "path": { - "type": "ReplacePrefixMatch", - "replacePrefixMatch": "/v1/", - }, - }, - } - ], - }, - ], - } - ], - }, - } - ), - ), - }, + async def test_fans_out_over_every_serving_gateway(self) -> None: + """Two gateways serving one service each get their own route and their + own copy of the backends, on their own cluster.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("kimi-k2")]))) ), - conditions=[ - fnv1.Condition( - type="EndpointsResolved", - status=fnv1.STATUS_CONDITION_TRUE, - reason="Resolved", - message="Matched 2 endpoint(s)", - ), - fnv1.Condition( - type="RoutingReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Configuring", - ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu"), _gateway("us", "gw-us")], + clusters=[ + _cluster("gw-eu", provider_config="gw-eu-pc"), + _cluster("gw-us", provider_config="gw-us-pc"), + ], + **{"endpoints-0": [_endpoint("kimi-a", origin="https://a.example.com")]}, + ), + ) + got = await self._run(req) + self.assertEqual( + sorted(got.desired.resources), + [ + "aibackend-eu-kimi-a", + "aibackend-us-kimi-a", + "backend-eu-kimi-a", + "backend-us-kimi-a", + "route-eu", + "route-us", ], - context=structpb.Struct(), ) - want4.requirements.resources["inference-gateway"].CopyFrom(_gateway_selector()) - want4.requirements.resources["endpoints-0"].CopyFrom(_endpoint_selector({"app": "model"})) - - # Case 5: two endpoints with different rewritePaths get per-backendRef filters. - req5 = fnv1.RunFunctionRequest( - observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(xr))), + for key, pc in (("route-eu", "gw-eu-pc"), ("route-us", "gw-us-pc")): + self.assertEqual( + resource.struct_to_dict(got.desired.resources[key].resource)["spec"]["providerConfigRef"], + {"kind": "ClusterProviderConfig", "name": pc}, + ) + + async def test_service_selector_scopes_a_gateway(self) -> None: + """Residency falls out of labels: a gateway scoped to a region serves + only the services labelled for it, and an unlabelled gateway serves + everything.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct(_service([_entry("kimi-k2")], labels={"example.org/region": "eu"})) + ) + ), + required_resources=_required( + gateways=[ + _gateway("eu", "gw-eu", selector={"example.org/region": "eu"}), + _gateway("us", "gw-us", selector={"example.org/region": "us"}), + _gateway("any", "gw-any"), + ], + clusters=[ + _cluster("gw-eu", provider_config="gw-eu-pc"), + _cluster("gw-us", provider_config="gw-us-pc"), + _cluster("gw-any", provider_config="gw-any-pc"), + ], + **{"endpoints-0": [_endpoint("kimi-a", origin="https://a.example.com")]}, + ), ) - req5.required_resources["inference-gateway"].items.append(_gateway()) - req5.required_resources["endpoints-0"].items.append(_endpoint("ep-a", rewrite_path="/v1/", backend="svc-a")) - req5.required_resources["endpoints-0"].items.append( - _endpoint("ep-b", url="https://api.groq.com/openai/v1", rewrite_path="/openai/v1/", backend="svc-groq") + got = await self._run(req) + self.assertEqual( + sorted(k for k in got.desired.resources if k.startswith("route-")), + ["route-any", "route-eu"], + "the us gateway's selector doesn't match, so it composes no route there", ) - want5 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( + async def test_weights_spread_within_a_tier(self) -> None: + """An entry's weight is written once but applied per backend, so it is + spread over however many endpoints the entry matched, and the ratio + between entries survives.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( composite=fnv1.Resource( - resource=resource.dict_to_struct( - {"status": {"address": "http://34.55.100.10/ml-team/test-service"}} - ), - ), - resources={ - "httproute": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": {"namespace": "ml-team"}, - "spec": { - "parentRefs": [{"name": "modelplane", "namespace": "modelplane-system"}], - "rules": [ - { - "matches": [ - {"path": {"type": "PathPrefix", "value": "/ml-team/test-service/"}} - ], - "backendRefs": [ - { - "name": "svc-a", - "port": 80, - "weight": 1, - "filters": [ - { - "type": "URLRewrite", - "urlRewrite": { - "path": { - "type": "ReplacePrefixMatch", - "replacePrefixMatch": "/v1/", - }, - }, - } - ], - }, - { - "name": "svc-groq", - "port": 443, - "weight": 1, - "filters": [ - { - "type": "URLRewrite", - "urlRewrite": { - "path": { - "type": "ReplacePrefixMatch", - "replacePrefixMatch": "/openai/v1/", - }, - }, - } - ], - }, - ], - } - ], - }, - } - ), - ), + resource=resource.dict_to_struct(_service([_entry("big", weight=90), _entry("small", weight=10)])) + ) + ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc")], + **{ + "endpoints-0": [_endpoint(f"big-{i}", origin=f"https://big-{i}.example.com") for i in range(3)], + "endpoints-1": [_endpoint("small-0", origin="https://small-0.example.com")], }, ), - conditions=[ - fnv1.Condition( - type="EndpointsResolved", - status=fnv1.STATUS_CONDITION_TRUE, - reason="Resolved", - message="Matched 2 endpoint(s)", - ), - fnv1.Condition( - type="RoutingReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Configuring", - ), - ], - context=structpb.Struct(), - ) - want5.requirements.resources["inference-gateway"].CopyFrom(_gateway_selector()) - want5.requirements.resources["endpoints-0"].CopyFrom(_endpoint_selector({"app": "model"})) - - # Case 6: two weighted selector groups (80/20) split traffic proportionally. - # The weight-80 group is spread across its three endpoints as 27/27/26; - # the weight-20 group goes to its single endpoint. - xr6 = v1alpha1.ModelService( - metadata=metav1.ObjectMeta(name="test-service", namespace="ml-team"), - spec=v1alpha1.Spec( - endpoints=[ - v1alpha1.Endpoint(weight=80, selector=v1alpha1.Selector(matchLabels={"app": "prod"})), - v1alpha1.Endpoint(weight=20, selector=v1alpha1.Selector(matchLabels={"app": "canary"})), - ], + ) + got = await self._run(req) + refs = _manifest(got, "route-eu")["spec"]["rules"][0]["backendRefs"] + weights = [r["weight"] for r in refs] + # 90 over three endpoints is 30 each, 10 over one is 10, then the whole + # set is reduced by its greatest common divisor to the smallest + # equivalent integers. The ratio is what matters, not the magnitude. + self.assertEqual(weights, [3, 3, 3, 1]) + self.assertEqual(sum(weights[:3]) / sum(weights), 0.9) + self.assertTrue(all(w > 0 for w in weights), "a backend weighted 0 is dropped, not deprioritised") + + async def test_a_weight_below_its_endpoint_count_still_gives_every_endpoint_traffic(self) -> None: + """Weight 1 over five endpoints must not floor any of them to 0, which + would drop them from the load assignment rather than sharing traffic.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("many", weight=1)]))) + ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc")], + **{"endpoints-0": [_endpoint(f"many-{i}", origin=f"https://many-{i}.example.com") for i in range(5)]}, ), - ).model_dump(exclude_none=True, mode="json") - - req6 = fnv1.RunFunctionRequest( - observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(xr6))), ) - req6.required_resources["inference-gateway"].items.append(_gateway()) - req6.required_resources["endpoints-0"].items.append(_endpoint("ep-1", backend="svc-ep-1")) - req6.required_resources["endpoints-0"].items.append(_endpoint("ep-2", backend="svc-ep-2")) - req6.required_resources["endpoints-0"].items.append(_endpoint("ep-3", backend="svc-ep-3")) - req6.required_resources["endpoints-1"].items.append(_endpoint("ep-4", backend="svc-ep-4")) - - want6 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( + got = await self._run(req) + weights = [r["weight"] for r in _manifest(got, "route-eu")["spec"]["rules"][0]["backendRefs"]] + self.assertEqual(weights, [1, 1, 1, 1, 1]) + + async def test_an_endpoint_matched_twice_belongs_to_the_first_entry(self) -> None: + """Otherwise a canary entry and a catch-all entry would both weight the + same endpoint, and its share would depend on entry order twice over.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( composite=fnv1.Resource( resource=resource.dict_to_struct( - {"status": {"address": "http://34.55.100.10/ml-team/test-service"}} - ), - ), - resources={ - "httproute": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": {"namespace": "ml-team"}, - "spec": { - "parentRefs": [{"name": "modelplane", "namespace": "modelplane-system"}], - "rules": [ - { - "matches": [ - {"path": {"type": "PathPrefix", "value": "/ml-team/test-service/"}} - ], - "backendRefs": [ - {"name": "svc-ep-1", "port": 80, "weight": 27}, - {"name": "svc-ep-2", "port": 80, "weight": 27}, - {"name": "svc-ep-3", "port": 80, "weight": 26}, - {"name": "svc-ep-4", "port": 80, "weight": 20}, - ], - } - ], - }, - } - ), - ), - }, + _service([_entry("kimi-k2", priority=0), _entry("kimi-k2", priority=1)]) + ) + ) ), - conditions=[ - fnv1.Condition( - type="EndpointsResolved", - status=fnv1.STATUS_CONDITION_TRUE, - reason="Resolved", - message="Matched 4 endpoint(s)", - ), - fnv1.Condition( - type="RoutingReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Configuring", - ), - ], - context=structpb.Struct(), - ) - want6.requirements.resources["inference-gateway"].CopyFrom(_gateway_selector()) - want6.requirements.resources["endpoints-0"].CopyFrom(_endpoint_selector({"app": "prod"})) - want6.requirements.resources["endpoints-1"].CopyFrom(_endpoint_selector({"app": "canary"})) - - # Case 7: a default-weight (1) group of two endpoints beside a weight-3 - # group. Scaling up keeps both first-group endpoints at weight 1 rather - # than rounding one to 0, preserving the 1:3 split. - xr7 = v1alpha1.ModelService( - metadata=metav1.ObjectMeta(name="test-service", namespace="ml-team"), - spec=v1alpha1.Spec( - endpoints=[ - v1alpha1.Endpoint(selector=v1alpha1.Selector(matchLabels={"app": "prod"})), - v1alpha1.Endpoint(weight=3, selector=v1alpha1.Selector(matchLabels={"app": "canary"})), - ], + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc")], + **{ + "endpoints-0": [_endpoint("kimi-a", origin="https://a.example.com")], + "endpoints-1": [_endpoint("kimi-a", origin="https://a.example.com")], + }, ), - ).model_dump(exclude_none=True, mode="json") - - req7 = fnv1.RunFunctionRequest( - observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(xr7))), ) - req7.required_resources["inference-gateway"].items.append(_gateway()) - req7.required_resources["endpoints-0"].items.append(_endpoint("ep-1", backend="svc-ep-1")) - req7.required_resources["endpoints-0"].items.append(_endpoint("ep-2", backend="svc-ep-2")) - req7.required_resources["endpoints-1"].items.append(_endpoint("ep-3", backend="svc-ep-3")) + got = await self._run(req) + refs = _manifest(got, "route-eu")["spec"]["rules"][0]["backendRefs"] + self.assertEqual(len(refs), 1) + self.assertEqual(refs[0]["priority"], 0) + self.assertEqual( + resource.struct_to_dict(got.desired.composite.resource)["status"]["endpoints"], + {"total": 1, "ready": 1}, + ) - want7 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct( - {"status": {"address": "http://34.55.100.10/ml-team/test-service"}} - ), - ), - resources={ - "httproute": fnv1.Resource( - resource=resource.dict_to_struct( + async def test_ready_once_every_route_is_applied(self) -> None: + """Every gateway, not any: a service reachable through only some of the + gateways that should serve it is worth surfacing.""" + base = { + "gateways": [_gateway("eu", "gw-eu"), _gateway("us", "gw-us")], + "clusters": [ + _cluster("gw-eu", provider_config="gw-eu-pc"), + _cluster("gw-us", provider_config="gw-us-pc"), + ], + } + observed_route = fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "status": { + "conditions": [ { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": {"namespace": "ml-team"}, - "spec": { - "parentRefs": [{"name": "modelplane", "namespace": "modelplane-system"}], - "rules": [ - { - "matches": [ - {"path": {"type": "PathPrefix", "value": "/ml-team/test-service/"}} - ], - "backendRefs": [ - {"name": "svc-ep-1", "port": 80, "weight": 1}, - {"name": "svc-ep-2", "port": 80, "weight": 1}, - {"name": "svc-ep-3", "port": 80, "weight": 6}, - ], - } - ], - }, + "type": "Ready", + "status": "True", + "reason": "Available", + "lastTransitionTime": "2026-06-08T00:00:00Z", } - ), - ), - }, + ] + }, + } + ) + ) + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("kimi-k2")]))), + resources={"route-eu": observed_route}, ), - conditions=[ - fnv1.Condition( - type="EndpointsResolved", - status=fnv1.STATUS_CONDITION_TRUE, - reason="Resolved", - message="Matched 3 endpoint(s)", - ), - fnv1.Condition( - type="RoutingReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Configuring", - ), - ], - context=structpb.Struct(), - ) - want7.requirements.resources["inference-gateway"].CopyFrom(_gateway_selector()) - want7.requirements.resources["endpoints-0"].CopyFrom(_endpoint_selector({"app": "prod"})) - want7.requirements.resources["endpoints-1"].CopyFrom(_endpoint_selector({"app": "canary"})) - - # Case 8: equal weights on single-endpoint groups reduce from [2, 2] to [1, 1]. - xr8 = v1alpha1.ModelService( - metadata=metav1.ObjectMeta(name="test-service", namespace="ml-team"), - spec=v1alpha1.Spec( - endpoints=[ - v1alpha1.Endpoint(weight=2, selector=v1alpha1.Selector(matchLabels={"app": "prod"})), - v1alpha1.Endpoint(weight=2, selector=v1alpha1.Selector(matchLabels={"app": "canary"})), - ], + required_resources=_required( + **base, **{"endpoints-0": [_endpoint("kimi-a", origin="https://a.example.com")]} ), - ).model_dump(exclude_none=True, mode="json") - - req8 = fnv1.RunFunctionRequest( - observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(xr8))), ) - req8.required_resources["inference-gateway"].items.append(_gateway()) - req8.required_resources["endpoints-0"].items.append(_endpoint("ep-1", backend="svc-ep-1")) - req8.required_resources["endpoints-1"].items.append(_endpoint("ep-2", backend="svc-ep-2")) - - want8 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( + got = await self._run(req) + cond = next(iter(got.conditions)) + self.assertEqual(cond.reason, fn.CONDITION_REASON_WAITING_FOR_ROUTES) + self.assertEqual(cond.message, "Waiting for routes on gateways: us") + + req.observed.resources["route-us"].CopyFrom(observed_route) + got = await self._run(req) + self.assertEqual(next(iter(got.conditions)).reason, fn.CONDITION_REASON_ROUTES_ACCEPTED) + + async def test_a_third_party_needing_no_key_still_loses_the_caller_header(self) -> None: + """Whether Modelplane operates an endpoint decides whether the caller's + identity travels to it, and that isn't the same question as whether the + endpoint needs a credential. A provider authenticating by client + certificate, or a free one, would otherwise be told which tenant is + calling.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("kimi-k2")]))) + ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc")], + # No credential, and not composed by Modelplane. + **{"endpoints-0": [_endpoint("free-provider", origin="https://free.example.com")]}, + ), + ) + got = await self._run(req) + self.assertEqual( + _manifest(got, "aibackend-eu-free-provider")["spec"]["headerMutation"], + {"remove": ["x-modelplane-caller"]}, + ) + self.assertNotIn("credpolicy-eu-free-provider", got.desired.resources, "no credential means no policy") + + async def test_priorities_are_renumbered_without_gaps(self) -> None: + """A ModelService's priorities are an ordering; Envoy's are levels it + walks from 0. A user writing 0 and 5, or a tier whose endpoints all go + unready during a roll, would otherwise leave gaps in what Envoy gets.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( composite=fnv1.Resource( resource=resource.dict_to_struct( - {"status": {"address": "http://34.55.100.10/ml-team/test-service"}} - ), - ), - resources={ - "httproute": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": {"namespace": "ml-team"}, - "spec": { - "parentRefs": [{"name": "modelplane", "namespace": "modelplane-system"}], - "rules": [ - { - "matches": [ - {"path": {"type": "PathPrefix", "value": "/ml-team/test-service/"}} - ], - "backendRefs": [ - {"name": "svc-ep-1", "port": 80, "weight": 1}, - {"name": "svc-ep-2", "port": 80, "weight": 1}, - ], - } - ], - }, - } - ), - ), + _service([_entry("a", priority=0), _entry("b", priority=5), _entry("c", priority=9)]) + ) + ) + ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc")], + **{ + # The middle tier has no ready endpoint, so it drops out and + # must not leave a hole behind it. + "endpoints-0": [_endpoint("a-0", origin="https://a.example.com")], + "endpoints-1": [_endpoint("b-0", origin="https://b.example.com", ready=False)], + "endpoints-2": [_endpoint("c-0", origin="https://c.example.com")], }, ), - conditions=[ - fnv1.Condition( - type="EndpointsResolved", - status=fnv1.STATUS_CONDITION_TRUE, - reason="Resolved", - message="Matched 2 endpoint(s)", - ), - fnv1.Condition( - type="RoutingReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Configuring", - ), - ], - context=structpb.Struct(), - ) - want8.requirements.resources["inference-gateway"].CopyFrom(_gateway_selector()) - want8.requirements.resources["endpoints-0"].CopyFrom(_endpoint_selector({"app": "prod"})) - want8.requirements.resources["endpoints-1"].CopyFrom(_endpoint_selector({"app": "canary"})) - - # Case 9: a weight so large it would exceed Gateway API's per-backendRef - # maximum after scaling is clamped, keeping every endpoint >= 1. - xr9 = v1alpha1.ModelService( - metadata=metav1.ObjectMeta(name="test-service", namespace="ml-team"), - spec=v1alpha1.Spec( - endpoints=[ - v1alpha1.Endpoint(weight=1000000, selector=v1alpha1.Selector(matchLabels={"app": "prod"})), - v1alpha1.Endpoint(weight=1, selector=v1alpha1.Selector(matchLabels={"app": "canary"})), - ], + ) + got = await self._run(req) + refs = _manifest(got, "route-eu")["spec"]["rules"][0]["backendRefs"] + self.assertEqual( + [(r["name"].rsplit("-", 1)[-1], r["priority"]) for r in refs], + [("0", 0), ("0", 1)], + "two tiers survive, renumbered 0 and 1", + ) + + async def test_a_composed_endpoint_gets_mutual_tls(self) -> None: + """A cluster gateway's certificate is signed by its own cluster's CA, not + a public one, and it refuses a request that arrives without a client + certificate. Validating against the system trust store would fail, and + omitting the client certificate would be refused, so a composed endpoint + needs both halves or it carries no traffic at all.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("kimi-k2")]))) + ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc", ca="-----BEGIN CERTIFICATE-----\nx\n")], + **{ + "endpoints-0": [ + _endpoint( + "kimi-eu-0", + origin="https://gw-eu.clusters.example.com", + model="ml-team/kimi-k2", + composed=True, + ) + ] + }, ), - ).model_dump(exclude_none=True, mode="json") + ) + got = await self._run(req) + self.assertEqual( + _manifest(got, "backend-eu-kimi-eu-0")["spec"]["tls"], + { + "caCertificateRefs": [{"kind": "ConfigMap", "group": "", "name": "cluster-ca-gw-eu"}], + "sni": "gw-eu.clusters.example.com", + "clientCertificateRef": {"kind": "Secret", "group": "", "name": "fleet-gateway-client"}, + }, + ) + self.assertEqual( + _manifest(got, "cluster-ca-eu-gw-eu")["data"], + {"ca.crt": "-----BEGIN CERTIFICATE-----\nx\n"}, + "the cluster's CA is copied to the gateway's cluster so Envoy can read it", + ) - req9 = fnv1.RunFunctionRequest( - observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(xr9))), + async def test_a_provider_is_validated_against_the_system_store(self) -> None: + """Pinning our own CA would reject a real provider, and presenting a + client certificate to one would be meaningless.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("together")]))) + ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc", ca="-----BEGIN CERTIFICATE-----\nx\n")], + **{"endpoints-0": [_endpoint("together", origin="https://api.together.xyz")]}, + ), + ) + got = await self._run(req) + self.assertEqual( + _manifest(got, "backend-eu-together")["spec"]["tls"], + {"wellKnownCACertificates": "System", "sni": "api.together.xyz"}, ) - req9.required_resources["inference-gateway"].items.append(_gateway()) - req9.required_resources["endpoints-0"].items.append(_endpoint("ep-1", backend="svc-ep-1")) - req9.required_resources["endpoints-1"].items.append(_endpoint("ep-2", backend="svc-ep-2")) - req9.required_resources["endpoints-1"].items.append(_endpoint("ep-3", backend="svc-ep-3")) - want9 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct( - {"status": {"address": "http://34.55.100.10/ml-team/test-service"}} - ), - ), - resources={ - "httproute": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": {"namespace": "ml-team"}, - "spec": { - "parentRefs": [{"name": "modelplane", "namespace": "modelplane-system"}], - "rules": [ - { - "matches": [ - {"path": {"type": "PathPrefix", "value": "/ml-team/test-service/"}} - ], - "backendRefs": [ - {"name": "svc-ep-1", "port": 80, "weight": 1000000}, - {"name": "svc-ep-2", "port": 80, "weight": 1}, - {"name": "svc-ep-3", "port": 80, "weight": 1}, - ], - } - ], - }, - } - ), - ), + async def test_an_endpoint_whose_credential_vanished_is_dropped_not_fatal(self) -> None: + """EndpointReady is written by another XR on its own reconcile loop, so + between a credential Secret being deleted and that XR noticing, this + function sees a ready endpoint with no credential. Raising there would + withdraw the route from every gateway serving the service over one + endpoint; the rest must keep serving.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("a"), _entry("b")]))) + ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc")], + **{ + "endpoints-0": [_endpoint("good", origin="https://a.example.com")], + # Ready, but its Secret resolved to nothing. + "endpoints-1": [_endpoint("gone", origin="https://b.example.com", credential="vanished")], + "credential-gone": [], }, ), - conditions=[ - fnv1.Condition( - type="EndpointsResolved", - status=fnv1.STATUS_CONDITION_TRUE, - reason="Resolved", - message="Matched 3 endpoint(s)", - ), - fnv1.Condition( - type="RoutingReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Configuring", - ), - ], - context=structpb.Struct(), ) - want9.requirements.resources["inference-gateway"].CopyFrom(_gateway_selector()) - want9.requirements.resources["endpoints-0"].CopyFrom(_endpoint_selector({"app": "prod"})) - want9.requirements.resources["endpoints-1"].CopyFrom(_endpoint_selector({"app": "canary"})) + got = await self._run(req) + refs = _manifest(got, "route-eu")["spec"]["rules"][0]["backendRefs"] + self.assertEqual([r["name"] for r in refs], [names.backend(_NS, _SVC, "good")]) + self.assertNotIn("backend-eu-gone", got.desired.resources) + self.assertTrue( + any("gone" in r.message for r in got.results), + "the dropped endpoint is reported rather than silently omitted", + ) - cases = [ - Case(name="endpoints with ready backends compose HTTPRoute with backendRefs", req=req1, want=want1), - Case(name="no endpoints produces warning and EndpointsResolved=False", req=req2, want=want2), - Case(name="endpoint without backend composes HTTPRoute without backendRefs", req=req3, want=want3), - Case(name="same rewritePath produces one rule with two backendRefs", req=req4, want=want4), - Case(name="different rewritePaths produce per-backendRef URLRewrite filters", req=req5, want=want5), - Case(name="weighted selector groups split traffic proportionally", req=req6, want=want6), - Case(name="group weights scale up so no endpoint rounds to zero", req=req7, want=want7), - Case(name="equal group weights reduce to smallest equivalent weights", req=req8, want=want8), - Case(name="weights clamp to the Gateway API maximum", req=req9, want=want9), - ] + async def test_a_credential_secret_missing_its_key_is_dropped(self) -> None: + """A Secret that exists but lacks the named key is the likelier mistake, + and would otherwise reach the provider as an empty credential.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("a")])))), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc")], + **{ + "endpoints-0": [_endpoint("wrongkey", origin="https://a.example.com", credential="k")], + "credential-wrongkey": [_secret("k", {"token": "sk-1"})], + }, + ), + ) + got = await self._run(req) + self.assertEqual(next(iter(got.conditions)).reason, fn.CONDITION_REASON_NO_ENDPOINTS) + self.assertEqual(len(got.desired.resources), 0) + + async def test_a_composed_endpoint_whose_cluster_withdrew_its_ca_is_dropped(self) -> None: + """A composed endpoint's backend pins its cluster's CA and presents a + client certificate, which is what lets it through that cluster's gateway. + + The cluster's status is written by another XR on its own loop and is + withdrawn when its gateway address goes away, so this function can see a + ready composed endpoint whose cluster publishes no CA. Composing the + backend anyway would point caCertificateRefs at a ConfigMap nothing + composes, and Envoy Gateway fails that route closed, so the endpoint is + left out and reported instead. + """ + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("a"), _entry("b")]))) + ), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc", ca=None)], + **{ + "endpoints-0": [_endpoint("public", origin="https://a.example.com")], + "endpoints-1": [_endpoint("selfhosted", origin="https://b.example.com", composed=True)], + }, + ), + ) + got = await self._run(req) + + refs = _manifest(got, "route-eu")["spec"]["rules"][0]["backendRefs"] + self.assertEqual([r["name"] for r in refs], [names.backend(_NS, _SVC, "public")]) + self.assertNotIn("cluster-ca-eu-gw-eu", got.desired.resources) + self.assertTrue( + any("selfhosted" in r.message for r in got.results), + "the dropped endpoint is reported rather than silently omitted", + ) - for case in cases: - with self.subTest(case.name): - got = await self.runner.RunFunction(case.req, None) - self.assertEqual( - json_format.MessageToDict(case.want), - json_format.MessageToDict(got), - "-want, +got", - ) + async def test_a_composed_endpoint_pins_its_cluster_ca_and_its_client_cert(self) -> None: + """The other half: with the CA published, the backend pins it, sets SNI to + the cluster's hostname and attaches the client certificate, and the CA is + composed as a ConfigMap on the gateway's cluster.""" + req = fnv1.RunFunctionRequest( + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("a")])))), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu")], + clusters=[_cluster("gw-eu", provider_config="gw-eu-pc")], + **{"endpoints-0": [_endpoint("selfhosted", origin="https://gw-eu.example.org", composed=True)]}, + ), + ) + got = await self._run(req) + + self.assertEqual( + _manifest(got, "backend-eu-selfhosted")["spec"]["tls"], + { + "caCertificateRefs": [{"kind": "ConfigMap", "group": "", "name": names.cluster_ca("gw-eu")}], + "sni": "gw-eu.example.org", + "clientCertificateRef": {"kind": "Secret", "group": "", "name": "fleet-gateway-client"}, + }, + ) + self.assertEqual(_manifest(got, "cluster-ca-eu-gw-eu")["data"], {"ca.crt": _CLUSTER_CA}) + + async def test_a_gateway_whose_client_pki_has_not_issued_serves_nothing(self) -> None: + """Every composed endpoint's backend names this gateway's client + certificate Secret, issued from the same CA it publishes. A cluster + becomes schedulable once any gateway has published, so a second gateway + can still be waiting on its own PKI, and composing a route for it would + name a Secret that doesn't exist on its cluster. + """ + req = fnv1.RunFunctionRequest( + observed=fnv1.State(composite=fnv1.Resource(resource=resource.dict_to_struct(_service([_entry("a")])))), + required_resources=_required( + gateways=[_gateway("eu", "gw-eu"), _gateway("us", "gw-us", client_ca=None)], + clusters=[ + _cluster("gw-eu", provider_config="gw-eu-pc"), + _cluster("gw-us", provider_config="gw-us-pc"), + ], + **{"endpoints-0": [_endpoint("selfhosted", origin="https://gw-eu.example.org", composed=True)]}, + ), + ) + got = await self._run(req) + + self.assertIn("route-eu", got.desired.resources) + self.assertNotIn("route-us", got.desired.resources) + + +class TestNames(unittest.TestCase): + def test_long_names_stay_within_the_limit_and_stay_unique(self) -> None: + """A namespace, a service and an endpoint name can each be 253 + characters, so a joined name can overflow. Truncating alone would map + two names onto one object.""" + long = "e" * 250 + a = names.backend("n" * 250, "s" * 250, long + "a") + b = names.backend("n" * 250, "s" * 250, long + "b") + self.assertLessEqual(len(a), 253) + self.assertLessEqual(len(b), 253) + self.assertNotEqual(a, b) + self.assertEqual(a, names.backend("n" * 250, "s" * 250, long + "a"), "stable across calls") + + def test_the_model_a_caller_names_is_never_hashed(self) -> None: + """It's a value in a request body, not an object name. Shortening it + would make what a caller types depend on a hash.""" + self.assertEqual(names.model("n" * 250, "s" * 250), f"{'n' * 250}/{'s' * 250}") diff --git a/functions/compose-serving-stack/function/fn.py b/functions/compose-serving-stack/function/fn.py index ef64c381f..8a2c5fe89 100644 --- a/functions/compose-serving-stack/function/fn.py +++ b/functions/compose-serving-stack/function/fn.py @@ -71,6 +71,58 @@ # writes status.addresses. _GATEWAY_READY_CEL = "has(object.status.addresses) && object.status.addresses.size() > 0" +# The cluster gateway's own PKI, issued by cert-manager, which the serving stack +# already installs. Composition functions are called repeatedly and must be a +# pure function of their inputs, so they can't generate key material; a +# controller has to. The private keys never leave this cluster. +# +# A self-signed issuer signs a CA, the CA signs the gateway's serving +# certificate, and the CA's certificate is published in status so an +# InferenceGateway can validate against it. One CA per cluster rather than one +# per fleet: no shared private key has to be distributed, and compromising one +# cluster doesn't let anyone impersonate another. +_SELFSIGNED_ISSUER = "modelplane-selfsigned" +_CA_ISSUER = "modelplane-cluster-ca" +_CA_SECRET = "modelplane-cluster-ca" +_GATEWAY_SERVING_SECRET = "cluster-gateway-serving" + +# The trust-manager Bundle republishing the CA certificate, and so also the +# ConfigMap it syncs, which is what the control plane reads. See +# compose_gateway_pki. +_CA_BUNDLE = "modelplane-cluster-ca" + +# Where the CAs whose client certificates the gateway accepts are assembled. +_CLIENT_CA_BUNDLE = "modelplane-fleet-gateway-cas" + +# A cert-manager Certificate is Ready once it has issued. +_CERTIFICATE_READY_CEL = ( + "has(object.status) && has(object.status.conditions) && " + "object.status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')" +) + +# The ProviderConfigs carry no readiness of their own: they're configuration +# rather than infrastructure, so nothing external reports on them. +_ALWAYS_READY = frozenset({"provider-config-kubernetes", "provider-config-helm"}) + +# A Gateway API policy reports acceptance per attachment, under status.ancestors +# rather than status.conditions. Envoy Gateway answers a ClientTrafficPolicy it +# can't translate by setting Accepted=False here and a 500 direct response on +# every route of the target listener, so a policy that isn't accepted takes the +# cluster gateway down rather than leaving it unprotected. Without this the +# Object reports ready on creation and the cluster looks healthy while every +# request fails. +_POLICY_ACCEPTED_CEL = ( + "has(object.status) && has(object.status.ancestors) && " + "object.status.ancestors.exists(a, has(a.conditions) && " + "a.conditions.exists(c, c.type == 'Accepted' && c.status == 'True'))" +) + +# A trust-manager Bundle is Synced once it has written its target ConfigMaps. +_BUNDLE_SYNCED_CEL = ( + "has(object.status) && has(object.status.conditions) && " + "object.status.conditions.exists(c, c.type == 'Synced' && c.status == 'True')" +) + # Secret type that names the kubeconfig entry in the XR's secrets. Every other # entry's type is a provider identity type, which both ProviderConfigs stamp # verbatim as their identity.type. @@ -94,9 +146,12 @@ # to, so HTTPRoute -> InferencePool backendRefs (disaggregated serving) route. _AI_GATEWAY_NAMESPACE = "envoy-ai-gateway-system" _AI_GATEWAY_REPO = "oci://docker.io/envoyproxy" -_AI_GATEWAY_VERSION = "v0.7.0" +_AI_GATEWAY_VERSION = "v1.1.0" _AI_GATEWAY_CONTROLLER_FQDN = f"ai-gateway-controller.{_AI_GATEWAY_NAMESPACE}.svc.cluster.local" _AI_GATEWAY_CONTROLLER_PORT = 1063 +# The header the fleet gateway stamps the authenticated caller's identity onto. +# Also mapped into AI Gateway request metadata (see compose_ai_gateway). +_CALLER_HEADER = "x-modelplane-caller" # Gateway API Inference Extension (GAIE) CRDs, providing the InferencePool that @@ -167,6 +222,7 @@ def _modelexpress_crd_key(doc: dict) -> str: def _helm_release( + *, chart: str, repo: str, version: str, @@ -318,6 +374,11 @@ def _prometheus_release(version: str, provider_config: str) -> helmv1beta1.Relea ) +def _pem(cert: str) -> str: + """A PEM certificate ending in a newline, so several concatenate cleanly.""" + return cert if cert.endswith("\n") else cert + "\n" + + def _pc_name(xr: v1alpha1.ServingStack) -> str: """Derive the ProviderConfig name from the XR.""" return resource.child_name(_name(xr.metadata), "cluster") @@ -353,6 +414,7 @@ def compose(self) -> None: self.compose_provider_configs() self.compose_usages() self.compose_cert_manager() + self.compose_trust_manager() self.compose_envoy_gateway() self.compose_ai_gateway() self.compose_gaie_crds() @@ -373,6 +435,7 @@ def compose(self) -> None: self.compose_node_feature_discovery() self.compose_dra_driver() self.compose_gateway() + self.compose_gateway_pki() self.write_status() self.mark_readiness() @@ -454,6 +517,18 @@ def compose_provider_configs(self) -> None: ), ) + def serves_gateway(self) -> bool: + """Whether this cluster's gateway should be serving. + + A cluster given a hostname is fleet facing, and a fleet-facing gateway + serves mutually authenticated HTTPS or nothing at all, so it waits for a + fleet gateway CA to demand a client certificate against. A cluster with + no hostname isn't fleet facing and serves plain HTTP; it never publishes + a hostname, so it is never schedulable and nothing routes to it. + """ + gw = self.xr.spec.gateway or v1alpha1.Gateway() + return not gw.hostname or bool(gw.clientCAs or []) + def compose_usages(self) -> None: """Compose Usages ordering the Envoy Gateway teardown. @@ -469,31 +544,37 @@ def compose_usages(self) -> None: # GatewayClass Object protected by Gateway Object. The GatewayClass # has a gateway-exists-finalizer that the EG controller won't remove # while Gateways reference it. - resource.update( - self.rsp.desired.resources["usage-gateway-class-by-gateway"], - usagev1beta1.Usage( - spec=usagev1beta1.Spec( - of=usagev1beta1.Of( - apiVersion="kubernetes.m.crossplane.io/v1alpha1", - kind="Object", - resourceSelector=usagev1beta1.ResourceSelectorModel( - matchControllerRef=True, - matchLabels={_LABEL_RESOURCE: "gateway-class"}, + # + # Only while there is a Gateway to be protected by. A Usage whose "by" + # selector matches nothing errors on every reconcile, and compose_gateway + # withholds the Gateway from a fleet-facing cluster with no fleet gateway + # CA to trust. + if self.serves_gateway(): + resource.update( + self.rsp.desired.resources["usage-gateway-class-by-gateway"], + usagev1beta1.Usage( + spec=usagev1beta1.Spec( + of=usagev1beta1.Of( + apiVersion="kubernetes.m.crossplane.io/v1alpha1", + kind="Object", + resourceSelector=usagev1beta1.ResourceSelectorModel( + matchControllerRef=True, + matchLabels={_LABEL_RESOURCE: "gateway-class"}, + ), ), - ), - by=usagev1beta1.By( - apiVersion="kubernetes.m.crossplane.io/v1alpha1", - kind="Object", - resourceSelector=usagev1beta1.ResourceSelector( - matchControllerRef=True, - matchLabels={_LABEL_RESOURCE: "gateway"}, + by=usagev1beta1.By( + apiVersion="kubernetes.m.crossplane.io/v1alpha1", + kind="Object", + resourceSelector=usagev1beta1.ResourceSelector( + matchControllerRef=True, + matchLabels={_LABEL_RESOURCE: "gateway"}, + ), ), + replayDeletion=True, ), - replayDeletion=True, ), - ), - ) - self.rsp.desired.resources["usage-gateway-class-by-gateway"].ready = fnv1.READY_TRUE + ) + self.rsp.desired.resources["usage-gateway-class-by-gateway"].ready = fnv1.READY_TRUE # Envoy Gateway Release protected by GatewayClass Object. The EG # controller must be running to process the GatewayClass's @@ -539,7 +620,82 @@ def compose_cert_manager(self) -> None: version=v.certManager, # ty: ignore[invalid-argument-type] # XRD defaults this version and forbids null namespace="cert-manager", provider_config=_pc_name(self.xr), - values={"crds": {"enabled": True, "keep": False}}, + # Keep the CRDs on uninstall. The PKI below, and every + # InferenceGateway's client PKI, are Certificates and Issuers + # composed as provider-kubernetes Objects. Take their CRDs away + # and provider-kubernetes can't observe them: the RESTMapper + # returns a no-kind-match, which isn't a not-found, so Observe + # errors and never releases the Object's finalizer. Since those + # Objects belong to other XRs, nothing here can order itself + # after them on teardown. + values={"crds": {"enabled": True, "keep": True}}, + ), + ) + + def compose_trust_manager(self) -> None: + """Compose trust-manager. Gated on ProviderConfigs being observed. + + It publishes the cluster CA's certificate into a ConfigMap so the control + plane can read it without reading the Secret that holds the private key + too. See compose_gateway_pki. + + trust-manager only reads source Secrets from one namespace, its "trust + namespace", which defaults to the namespace it runs in. The CA lives in + modelplane-system, so it runs there rather than moving the CA to it: the + Secret is cert-manager's, and a copy of it on the way to a namespace + chosen for trust-manager's convenience would be another copy of the key. + + Installed on every cluster, because any cluster may host an + InferenceGateway and a gateway's client PKI publishes its CA through a + Bundle on the cluster it runs on. A cluster that serves no traffic of its + own still needs this; gating it on anything fleet-facing would leave a + gateway-only cluster unable to publish a CA, and since a cluster only + becomes schedulable once some gateway has, that would wedge the fleet. + + Gated on our own self-signed Issuer existing, because this chart contains + an Issuer and a Certificate for its own webhook and Helm applies custom + resources last. Installed alongside cert-manager those lose a race with + cert-manager's validating webhook, Helm marks the release failed, and + provider-helm only rolls a failed release back when spec.rollbackLimit is + set, which nothing here does. The release then stays failed, so + trust-manager never starts, and because creating a Bundle goes through + trust-manager's own webhook in turn, no CA is ever published. + + An Issuer we composed proves the same webhook admits the same kind of + object, and it's a provider-kubernetes Object rather than a Helm release, + so it retries forever instead of failing terminally. + """ + pc_observed = self.provider_configs_observed() + if not (pc_observed or "trust-manager" in self.req.observed.resources): + return + cert_manager_admits = ( + resource.get_condition(self.req.observed.resources.get("gateway-selfsigned-issuer"), "Ready").status + == "True" + ) + if not (cert_manager_admits or "trust-manager" in self.req.observed.resources): + return + + v = self.xr.spec.versions or v1alpha1.Versions() + resource.update( + self.rsp.desired.resources["trust-manager"], + _helm_release( + chart="trust-manager", + repo="oci://quay.io/jetstack/charts", + version=v.trustManager, # ty: ignore[invalid-argument-type] # XRD defaults this version and forbids null + namespace="modelplane-system", + provider_config=_pc_name(self.xr), + values={ + # Kept for the same reason as cert-manager's: the Bundles + # are Objects owned by other XRs, and an Object whose CRD + # has gone can't be observed, so it never finalizes. + "crds": {"enabled": True, "keep": True}, + "app": {"trust": {"namespace": "modelplane-system"}}, + # The default package is a public-CA trust store, for + # Bundles that set useDefaultCAs. These trust one private CA + # each, so disabling it drops an init container and the + # image pull it waits on. + "defaultPackage": {"enabled": False}, + }, ), ) @@ -608,6 +764,28 @@ def compose_ai_gateway(self) -> None: The controller runs the ext-proc extension server that Envoy Gateway's extensionManager delegates InferencePool backend resolution to. + + logRequestHeaderAttributes copies the caller identity into the + io.envoy.ai_gateway metadata namespace, via a header_to_metadata filter + on each listener, so the fleet gateway's access log can read the caller + from metadata rather than from the request header. The distinction + matters because the header is stripped again before the request reaches + a backend Modelplane doesn't operate, so as not to disclose a tenant's + identity to a third-party provider. Reading the log from the header + instead would lose the caller from exactly those records, which is where + provider spend gets attributed. + + This is deliberately not left unset. Unset, the controller defaults the + mapping to "agent-session-id:session.id", which we don't use, and any + non-empty mapping makes the PostTranslateModify hook walk every listener + looking for an HTTP connection manager and error on the first filter + chain without one. One TCPRoute or UDPRoute Gateway elsewhere on this + cluster's Envoy Gateway then gets the whole xDS update rejected, taking + every Gateway including ours to Programmed=False. So this trades a + conditional, loud, upstream-tracked failure (envoyproxy/ai-gateway#2600, + fix in flight as #2601) for silent loss of the caller dimension on every + request served by a third-party endpoint. Setting an empty string + restores the workaround at that cost. """ pc_observed = self.provider_configs_observed() if not (pc_observed or "ai-gateway-crds" in self.req.observed.resources): @@ -631,6 +809,7 @@ def compose_ai_gateway(self) -> None: version=_AI_GATEWAY_VERSION, namespace=_AI_GATEWAY_NAMESPACE, provider_config=_pc_name(self.xr), + values={"controller": {"logRequestHeaderAttributes": f"{_CALLER_HEADER}:caller"}}, ), ) @@ -648,8 +827,6 @@ def compose_gaie_crds(self) -> None: self.rsp.desired.resources[key], _k8s_object(_pc_name(self.xr), doc), ) - if resource.get_condition(self.req.observed.resources.get(key), "Ready").status == "True": - self.rsp.desired.resources[key].ready = fnv1.READY_TRUE def compose_modelexpress_crds(self) -> None: """Compose the ModelExpress CRDs (ModelMetadata, ModelCacheEntry) as @@ -666,8 +843,6 @@ def compose_modelexpress_crds(self) -> None: self.rsp.desired.resources[key], _k8s_object(_pc_name(self.xr), doc), ) - if resource.get_condition(self.req.observed.resources.get(key), "Ready").status == "True": - self.rsp.desired.resources[key].ready = fnv1.READY_TRUE def compose_modelexpress(self) -> None: """Compose the shared ModelExpress server in `default`. @@ -834,8 +1009,6 @@ def compose_modelexpress(self) -> None: cel_query=_MODELEXPRESS_SERVER_READY_CEL, ), ) - if resource.get_condition(self.req.observed.resources.get("modelexpress-server"), "Ready").status == "True": - self.rsp.desired.resources["modelexpress-server"].ready = fnv1.READY_TRUE def compose_prometheus(self) -> None: """Compose the kube-prometheus-stack. Gated on ProviderConfigs being @@ -1134,6 +1307,213 @@ def compose_dra_driver(self) -> None: ), ) + def compose_gateway_pki(self) -> None: + """Compose the cluster gateway's certificate, and the requirement that a + caller present one of its own. + + Only once the cluster has a name: a certificate needs a subject, and a + cluster with no name carries no traffic anyway, because an + InferenceGateway addresses a cluster by name. + + cert-manager does the key generation, which a composition function can't: + it runs on every reconcile and has to be a pure function of its inputs. + + The ClientTrafficPolicy is what makes the fleet gateway the only thing + that can reach the engines behind this gateway. Until at least one + InferenceGateway has published a CA there is nothing to trust, and + requiring a certificate signed by an empty set would refuse everything, + so the requirement waits for the first one, and serves_gateway withholds + the listener it would have governed until then. + """ + pc_observed = self.provider_configs_observed() + pc = _pc_name(self.xr) + gw = self.xr.spec.gateway or v1alpha1.Gateway() + + # Composed on every cluster, not just a fleet-facing one, because it is + # what tells trust-manager that cert-manager is ready for it (see + # compose_trust_manager). Any cluster may host an InferenceGateway, whose + # client PKI needs trust-manager whether or not the cluster it runs on + # serves traffic itself, and a self-signed Issuer costs nothing. + if pc_observed or "gateway-selfsigned-issuer" in self.req.observed.resources: + resource.update( + self.rsp.desired.resources["gateway-selfsigned-issuer"], + _k8s_object( + pc, + { + "apiVersion": "cert-manager.io/v1", + "kind": "Issuer", + "metadata": {"name": _SELFSIGNED_ISSUER, "namespace": "modelplane-system"}, + "spec": {"selfSigned": {}}, + }, + ), + ) + + if not gw.hostname: + return + + certs: list[tuple[str, dict]] = [ + ( + "gateway-ca-certificate", + { + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": {"name": _CA_ISSUER, "namespace": "modelplane-system"}, + "spec": { + "isCA": True, + # Truncated to the 64-byte X.509 commonName limit: the + # gateway hostname is a full Service FQDN, so the prefix + # plus the name overflows it. Cosmetic anyway, since the + # fleet gateway trusts this CA by its certificate and + # validates the serving one by SAN, not by this name. + "commonName": f"modelplane cluster CA {gw.hostname}"[:64], + "secretName": _CA_SECRET, + "duration": "87600h", + "renewBefore": "8760h", + "privateKey": {"algorithm": "ECDSA", "size": 256}, + "issuerRef": {"name": _SELFSIGNED_ISSUER, "kind": "Issuer", "group": "cert-manager.io"}, + }, + }, + ), + ( + "gateway-ca-issuer", + { + "apiVersion": "cert-manager.io/v1", + "kind": "Issuer", + "metadata": {"name": _CA_ISSUER, "namespace": "modelplane-system"}, + "spec": {"ca": {"secretName": _CA_SECRET}}, + }, + ), + ( + "gateway-serving-certificate", + { + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": {"name": _GATEWAY_SERVING_SECRET, "namespace": "modelplane-system"}, + "spec": { + "secretName": _GATEWAY_SERVING_SECRET, + "dnsNames": [gw.hostname], + "duration": "2160h", + "renewBefore": "720h", + "privateKey": {"algorithm": "ECDSA", "size": 256, "rotationPolicy": "Always"}, + "issuerRef": {"name": _CA_ISSUER, "kind": "Issuer", "group": "cert-manager.io"}, + }, + }, + ), + ] + for key, manifest in certs: + if not (pc_observed or key in self.req.observed.resources): + continue + cel = _CERTIFICATE_READY_CEL if manifest["kind"] == "Certificate" else None + resource.update(self.rsp.desired.resources[key], _k8s_object(pc, manifest, cel_query=cel)) + + # Republish the CA certificate on its own, so the control plane can read + # it without reading the private key next to it. + # + # cert-manager writes ca.crt and tls.key into one Secret. Observing that + # Secret would mean provider-kubernetes copying the whole thing into the + # Object's status, private key included, where anyone who can get objects + # could read it and mint a certificate any cluster would accept. Running + # provider-kubernetes with --sanitize-secrets, as prerequisites.yaml + # does, is no answer on its own: it redacts the data, so the read comes + # back empty and mTLS is silently disabled instead. + # + # A Bundle takes one named key from a Secret and writes it to a ConfigMap, + # so the key is read once, in-cluster, by a controller already entitled to + # it. trust-manager also rejects any PEM block that isn't a CERTIFICATE, + # so it can't be made to republish a key by naming the wrong source key. + if pc_observed or "gateway-ca-bundle" in self.req.observed.resources: + resource.update( + self.rsp.desired.resources["gateway-ca-bundle"], + _k8s_object( + pc, + { + "apiVersion": "trust.cert-manager.io/v1alpha1", + "kind": "Bundle", + # Cluster-scoped, and it names the ConfigMap it syncs. + "metadata": {"name": _CA_BUNDLE}, + "spec": { + "sources": [{"secret": {"name": _CA_SECRET, "key": "ca.crt"}}], + "target": { + "configMap": {"key": "ca.crt"}, + # A target syncs to every namespace by default. + # Only modelplane-system reads it. + "namespaceSelector": { + "matchLabels": {"kubernetes.io/metadata.name": "modelplane-system"} + }, + }, + }, + }, + cel_query=_BUNDLE_SYNCED_CEL, + ), + ) + + # Observed, not managed: trust-manager owns this ConfigMap, and this only + # needs to read the certificate back out so status can publish it. + if pc_observed or "gateway-ca-configmap" in self.req.observed.resources: + resource.update( + self.rsp.desired.resources["gateway-ca-configmap"], + _k8s_object( + pc, + { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": _CA_BUNDLE, "namespace": "modelplane-system"}, + }, + management_policies=["Observe"], + ), + ) + + # With nothing to trust there is no HTTPS listener either (see + # compose_gateway), so there is nothing to attach a policy to. + client_cas = gw.clientCAs or [] + if not client_cas: + return + if not (pc_observed or "gateway-client-ca-bundle" in self.req.observed.resources): + return + # One ConfigMap holding every fleet gateway's CA, concatenated, which is + # what a PEM trust bundle is. + resource.update( + self.rsp.desired.resources["gateway-client-ca-bundle"], + _k8s_object( + pc, + { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": _CLIENT_CA_BUNDLE, "namespace": "modelplane-system"}, + "data": { + "ca.crt": "".join(_pem(ca.certificate) for ca in sorted(client_cas, key=lambda c: c.name)) + }, + }, + ), + ) + resource.update( + self.rsp.desired.resources["gateway-client-auth"], + _k8s_object( + pc, + { + "apiVersion": "gateway.envoyproxy.io/v1alpha1", + "kind": "ClientTrafficPolicy", + "metadata": {"name": "cluster-gateway-client-auth", "namespace": "modelplane-system"}, + "spec": { + "targetRefs": [ + { + "group": "gateway.networking.k8s.io", + "kind": "Gateway", + "name": "inference-gateway", + "sectionName": "https", + } + ], + "tls": { + "clientValidation": { + "caCertificateRefs": [{"kind": "ConfigMap", "group": "", "name": _CLIENT_CA_BUNDLE}] + } + }, + }, + }, + cel_query=_POLICY_ACCEPTED_CEL, + ), + ) + def compose_gateway(self) -> None: """Compose the gateway namespace, EnvoyProxy, GatewayClass, and Gateway on the remote cluster. Gated on ProviderConfigs being observed.""" @@ -1142,11 +1522,62 @@ def compose_gateway(self) -> None: gw = self.xr.spec.gateway or v1alpha1.Gateway() + listeners: list[dict] = [] if gw.listeners: listeners = [{"name": ln.name, "protocol": ln.protocol, "port": ln.port} for ln in gw.listeners] else: listeners = [{"name": "http", "protocol": "HTTP", "port": 80}] + # A cluster given a hostname is fleet facing, and a fleet-facing gateway + # serves mutually authenticated HTTPS or it serves nothing at all. + # + # Nothing at all, because there are only unsafe alternatives. The + # gateway's Service is a public load balancer with a port per listener, + # and the model-serving HTTPRoutes carry no sectionName, so they attach + # to every listener there is: an HTTP listener alongside HTTPS, or left + # in place while no CA is trusted, serves the engines to anything on the + # internet with no certificate asked for. An HTTPS listener without its + # ClientTrafficPolicy is worse, because it looks like it asks. Nothing in + # the cluster wants either: the endpoint picker is an ext_proc the + # gateway calls, not a client of it. + # + # So while no fleet gateway has published a CA, this composes no Gateway, + # which leaves the routes nothing to attach to and no load balancer to + # reach. The cluster publishes no hostname in that state either, so it + # takes no new work, and one that loses its last fleet gateway stops + # serving the work it already has rather than serving it in the clear. + # + # A cluster with no hostname isn't fleet facing and keeps the plain HTTP + # listener. It is never schedulable, so nothing routes to it. + # + # The namespace and the EnvoyProxy are composed either way: the PKI and + # trust-manager live in that namespace, and withholding it would stop the + # CA that this gate is waiting for from ever being issued. + serve_gateway = self.serves_gateway() + if not serve_gateway: + # Nothing else reports this. With no Gateway there is no address, so + # the cluster publishes no hostname and every ModelDeployment + # targeting it says only that it found insufficient capacity, which + # points at the node pools rather than at the missing front door. + response.warning( + self.rsp, + f"Gateway {gw.hostname} not served: no InferenceGateway has published a client CA for this " + "cluster to trust, and serving without one would accept unauthenticated callers", + ) + if gw.hostname: + listeners = [ + { + "name": "https", + "protocol": "HTTPS", + "port": 443, + "hostname": gw.hostname, + "tls": { + "mode": "Terminate", + "certificateRefs": [{"name": _GATEWAY_SERVING_SECRET}], + }, + } + ] + # The Gateway (and the model-serving HTTPRoutes that target it) live in # modelplane-system on the remote cluster. Create the namespace; unlike # the old KServe path (whose chart created its kserve namespace), nothing @@ -1220,7 +1651,7 @@ def compose_gateway(self) -> None: ), ) - if pc_observed or "gateway" in self.req.observed.resources: + if serve_gateway and (pc_observed or "gateway" in self.req.observed.resources): resource.update( self.rsp.desired.resources["gateway"], _k8s_object( @@ -1248,6 +1679,22 @@ def compose_gateway(self) -> None: ), ) + def observed_ca_certificate(self) -> str | None: + """The cluster CA's certificate, read off the ConfigMap trust-manager + syncs. A ConfigMap holds it as plain text, so unlike a Secret there is + nothing to decode. + + Absent until cert-manager has issued and trust-manager has synced, which + is why an InferenceGateway composes no backend for this cluster and the + cluster publishes no hostname before then. + """ + obj = self.req.observed.resources.get("gateway-ca-configmap") + if obj is None: + return None + d = resource.struct_to_dict(obj.resource) + data = d.get("status", {}).get("atProvider", {}).get("manifest", {}).get("data", {}) + return data.get("ca.crt") or None + def write_status(self) -> None: """Extract the gateway address from the observed Gateway Object and write it to the XR's status.""" @@ -1266,54 +1713,30 @@ def write_status(self) -> None: gateway_address = addresses[0].get("value") status = v1alpha1.Status() - if gateway_address: - status.gateway = v1alpha1.GatewayModel(address=gateway_address) + ca = self.observed_ca_certificate() + if gateway_address or ca: + status.gateway = v1alpha1.GatewayModel() + if gateway_address: + status.gateway.address = gateway_address + if ca: + status.gateway.caCertificate = ca resource.update_status(self.rsp.desired.composite, status) def mark_readiness(self) -> None: - """Mark composed resources as ready. Resources that don't need external - readiness tracking are always marked ready. Others are marked ready when - their observed condition is True.""" - # These resources don't have meaningful readiness signals โ€” mark them - # ready unconditionally so they don't block the XR. - always_ready = [ - "provider-config-kubernetes", - "provider-config-helm", - ] - for r in always_ready: - if r in self.rsp.desired.resources: - self.rsp.desired.resources[r].ready = fnv1.READY_TRUE - - condition_ready = [ - "cert-manager", - "envoy-gateway", - "ai-gateway-crds", - "ai-gateway", - "prometheus", - "grove", - "kai-scheduler", - "kai-queue-root", - "kai-queue", - "modelexpress-server-sa", - "modelexpress-server-role", - "modelexpress-server-rolebinding", - "modelexpress-server-svc", - "modelexpress-server", - "leader-worker-set", - "node-feature-discovery", - "dra-driver", - "dra-driver-critical-pods-quota", - "gateway-namespace", - "gateway-proxy", - "gateway-class", - "gateway", - ] - for r in condition_ready: + """Mark each composed resource ready once its observed counterpart is. + + The pipeline has no auto-ready function, so a desired resource's + readiness is whatever this says, and anything it says nothing about holds + the XR not-Ready however healthy it is. That makes an unschedulable + cluster the cost of forgetting one, so this covers whatever is composed + rather than a list naming each resource. + """ + for key, res in self.rsp.desired.resources.items(): if ( - r in self.rsp.desired.resources - and resource.get_condition(self.req.observed.resources.get(r), "Ready").status == "True" + key in _ALWAYS_READY + or resource.get_condition(self.req.observed.resources.get(key), "Ready").status == "True" ): - self.rsp.desired.resources[r].ready = fnv1.READY_TRUE + res.ready = fnv1.READY_TRUE def provider_configs_observed(self) -> bool: """Check if both ProviderConfigs have been persisted by Crossplane from diff --git a/functions/compose-serving-stack/function/gaie_crds.yaml b/functions/compose-serving-stack/function/gaie_crds.yaml index ded4537fc..eb1edca6b 100644 --- a/functions/compose-serving-stack/function/gaie_crds.yaml +++ b/functions/compose-serving-stack/function/gaie_crds.yaml @@ -1,5 +1,5 @@ -# Gateway API Inference Extension (GAIE) CRDs, vendored from the v1.0.1 release: -# https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/v1.0.1/manifests.yaml +# Gateway API Inference Extension (GAIE) CRDs, vendored from the v1.0.2 release: +# https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/v1.0.2/manifests.yaml # # When re-vendoring (bump the release in the URL above), strip each CRD's # top-level status and metadata.creationTimestamp. The upstream manifests carry @@ -11,7 +11,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - inference.networking.k8s.io/bundle-version: v1.0.1 + inference.networking.k8s.io/bundle-version: v1.0.2 name: inferenceobjectives.inference.networking.x-k8s.io spec: group: inference.networking.x-k8s.io @@ -247,7 +247,7 @@ kind: CustomResourceDefinition metadata: annotations: api-approved.kubernetes.io: https://github.com/kubernetes-sigs/gateway-api-inference-extension/pull/1173 - inference.networking.k8s.io/bundle-version: v1.0.1 + inference.networking.k8s.io/bundle-version: v1.0.2 name: inferencepools.inference.networking.k8s.io spec: group: inference.networking.k8s.io @@ -441,6 +441,8 @@ spec: An object must match every label in this map to be selected. The matching logic is an AND operation on all entries.' + maxProperties: 64 + minProperties: 1 type: object required: - matchLabels @@ -669,7 +671,7 @@ kind: CustomResourceDefinition metadata: annotations: api-approved.kubernetes.io: unapproved, experimental-only - inference.networking.k8s.io/bundle-version: v1.0.1 + inference.networking.k8s.io/bundle-version: v1.0.2 name: inferencepools.inference.networking.x-k8s.io spec: group: inference.networking.x-k8s.io diff --git a/functions/compose-serving-stack/tests/test_fn.py b/functions/compose-serving-stack/tests/test_fn.py index a4ccb4412..05b997b2d 100644 --- a/functions/compose-serving-stack/tests/test_fn.py +++ b/functions/compose-serving-stack/tests/test_fn.py @@ -133,6 +133,61 @@ def setUpModule() -> None: }, } +_TRUST_MANAGER = { + "apiVersion": "helm.m.crossplane.io/v1beta1", + "kind": "Release", + "metadata": {"annotations": {"crossplane.io/external-name": "mp-trust-manager"}}, + "spec": { + "forProvider": { + "chart": { + "name": "trust-manager", + "repository": "oci://quay.io/jetstack/charts", + "version": "v0.24.0", + }, + "namespace": "modelplane-system", + "values": { + "crds": { + "enabled": True, + "keep": False, + }, + # It only reads source Secrets from its own namespace, and the + # cluster CA is in modelplane-system. + "app": { + "trust": { + "namespace": "modelplane-system", + }, + }, + "defaultPackage": { + "enabled": False, + }, + }, + }, + "providerConfigRef": { + "kind": "ProviderConfig", + "name": _PC_NAME, + }, + }, +} + +# The self-signed Issuer the cluster CA chains from. Composed on every cluster, +# fleet facing or not, because it is what tells trust-manager that cert-manager +# is ready for it, and any cluster may host an InferenceGateway. +_GATEWAY_SELFSIGNED_ISSUER = { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "spec": { + "forProvider": { + "manifest": { + "apiVersion": "cert-manager.io/v1", + "kind": "Issuer", + "metadata": {"name": "modelplane-selfsigned", "namespace": "modelplane-system"}, + "spec": {"selfSigned": {}}, + } + }, + "providerConfigRef": {"kind": "ProviderConfig", "name": _PC_NAME}, + }, +} + _CERT_MANAGER = { "apiVersion": "helm.m.crossplane.io/v1beta1", "kind": "Release", @@ -142,13 +197,15 @@ def setUpModule() -> None: "chart": { "name": "cert-manager", "repository": "https://charts.jetstack.io", - "version": "v1.17.1", + "version": "v1.21.1", }, "namespace": "cert-manager", "values": { "crds": { "enabled": True, - "keep": False, + # Kept, because Certificates and Issuers composed as Objects + # by other XRs can't be finalized once their CRD has gone. + "keep": True, }, }, }, @@ -171,7 +228,7 @@ def setUpModule() -> None: "chart": { "name": "gateway-helm", "repository": "oci://docker.io/envoyproxy", - "version": "v1.8.1", + "version": "v1.8.4", }, "namespace": "envoy-gateway-system", "values": { @@ -224,7 +281,7 @@ def setUpModule() -> None: "chart": { "name": "ai-gateway-crds-helm", "repository": "oci://docker.io/envoyproxy", - "version": "v0.7.0", + "version": "v1.1.0", }, "namespace": "envoy-ai-gateway-system", }, @@ -244,9 +301,10 @@ def setUpModule() -> None: "chart": { "name": "ai-gateway-helm", "repository": "oci://docker.io/envoyproxy", - "version": "v0.7.0", + "version": "v1.1.0", }, "namespace": "envoy-ai-gateway-system", + "values": {"controller": {"logRequestHeaderAttributes": "x-modelplane-caller:caller"}}, }, "providerConfigRef": { "kind": "ProviderConfig", @@ -832,6 +890,7 @@ def _base_request( nvidia_driver_root: str = "/home/kubernetes/bin/nvidia", name: str = "test-backend", stack: Literal["Standard", "Dynamo"] = "Dynamo", + gateway: v1alpha1.Gateway | None = None, ) -> fnv1.RunFunctionRequest: """Build the base RunFunctionRequest used by all test cases. @@ -839,6 +898,9 @@ def _base_request( nvidiaDriverRoot override and the critical-pods quota. Defaults the stack to Dynamo so the Grove/KAI and ModelExpress fixtures below apply; the Standard path has its own test. + + Defaults to no gateway, which is a cluster that hasn't been given a hostname + and so composes no PKI. The PKI tests pass one. """ spec = v1alpha1.Spec( secrets=[ @@ -848,6 +910,8 @@ def _base_request( nvidiaDriverRoot=nvidia_driver_root, stack=stack, ) + if gateway is not None: + spec.gateway = gateway return fnv1.RunFunctionRequest( observed=fnv1.State( composite=fnv1.Resource( @@ -865,6 +929,31 @@ def _base_request( ) +def _observe_provider_configs(req: fnv1.RunFunctionRequest) -> None: + """Mark both ProviderConfigs observed, which is what ungates every resource + targeting the remote cluster.""" + req.observed.resources["provider-config-helm"].CopyFrom( + fnv1.Resource( + resource=resource.dict_to_struct({"apiVersion": "helm.m.crossplane.io/v1beta1", "kind": "ProviderConfig"}), + ), + ) + req.observed.resources["provider-config-kubernetes"].CopyFrom( + fnv1.Resource( + resource=resource.dict_to_struct( + {"apiVersion": "kubernetes.m.crossplane.io/v1alpha1", "kind": "ProviderConfig"} + ), + ), + ) + + +# A cluster with a hostname and one fleet gateway's CA trusted, which is what +# makes it compose its PKI and serve HTTPS. +_GATEWAY_WITH_PKI = v1alpha1.Gateway( + hostname="eu.clusters.example.org", + clientCAs=[v1alpha1.ClientCA(name="fleet", certificate="-----BEGIN CERTIFICATE-----\nfleet\n")], +) + + class TestFunctionRunner(unittest.IsolatedAsyncioTestCase): """Tests for FunctionRunner.RunFunction.""" @@ -1092,6 +1181,9 @@ async def test_second_pass(self) -> None: "gateway-namespace": fnv1.Resource( resource=resource.dict_to_struct(_GATEWAY_NAMESPACE), ), + "gateway-selfsigned-issuer": fnv1.Resource( + resource=resource.dict_to_struct(_GATEWAY_SELFSIGNED_ISSUER), + ), "gateway-proxy": fnv1.Resource( resource=resource.dict_to_struct(_GATEWAY_PROXY), ), @@ -1403,6 +1495,9 @@ async def test_third_pass(self) -> None: "gateway-namespace": fnv1.Resource( resource=resource.dict_to_struct(_GATEWAY_NAMESPACE), ), + "gateway-selfsigned-issuer": fnv1.Resource( + resource=resource.dict_to_struct(_GATEWAY_SELFSIGNED_ISSUER), + ), "gateway-proxy": fnv1.Resource( resource=resource.dict_to_struct(_GATEWAY_PROXY), ), @@ -1468,3 +1563,322 @@ async def test_third_pass(self) -> None: json_format.MessageToDict(got), "-want, +got", ) + + async def test_no_composed_object_observes_a_secret(self) -> None: + """No composed Object reads a Secret, which is what keeps the CA private + keys off the control plane. + + provider-kubernetes copies an observed object's whole manifest into the + Object's status, so observing a Secret publishes every key in it to + anyone who can get objects. cert-manager keeps ca.crt and tls.key in one + Secret, so observing the CA's Secret to read the certificate leaks the + key that signs for the whole cluster. Running the provider with + --sanitize-secrets, as prerequisites.yaml does, doesn't make that safe: + the read comes back redacted and mTLS is silently disabled instead. + trust-manager exists here to avoid the choice. + + Asserted over everything composed rather than over the PKI, because the + cost of reintroducing this anywhere is the same. + """ + req = _base_request(gateway=_GATEWAY_WITH_PKI) + _observe_provider_configs(req) + + got = await self.runner.RunFunction(req, None) + + observed_secrets = [] + for key, res in got.desired.resources.items(): + d = resource.struct_to_dict(res.resource) + if d.get("kind") != "Object": + continue + manifest = d["spec"]["forProvider"]["manifest"] + if manifest["kind"] == "Secret" and "Observe" in d["spec"].get("managementPolicies", []): + observed_secrets.append(key) + self.assertEqual(observed_secrets, [], "these observe a Secret, so its private keys reach the control plane") + + async def test_gateway_pki_publishes_the_ca_without_its_key(self) -> None: + """The CA certificate reaches the control plane through a trust-manager + Bundle, which copies one named key into a ConfigMap, rather than through + the Secret that also holds the private key.""" + req = _base_request(gateway=_GATEWAY_WITH_PKI) + _observe_provider_configs(req) + + got = await self.runner.RunFunction(req, None) + + def manifest(key: str) -> dict: + return resource.struct_to_dict(got.desired.resources[key].resource)["spec"]["forProvider"]["manifest"] + + self.assertEqual( + manifest("gateway-ca-bundle"), + { + "apiVersion": "trust.cert-manager.io/v1alpha1", + "kind": "Bundle", + "metadata": {"name": "modelplane-cluster-ca"}, + "spec": { + "sources": [{"secret": {"name": "modelplane-cluster-ca", "key": "ca.crt"}}], + "target": { + "configMap": {"key": "ca.crt"}, + "namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "modelplane-system"}}, + }, + }, + }, + ) + # Named after the Bundle, because that's the ConfigMap a Bundle syncs. + self.assertEqual( + manifest("gateway-ca-configmap"), + { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": "modelplane-cluster-ca", "namespace": "modelplane-system"}, + }, + ) + self.assertEqual( + resource.struct_to_dict(got.desired.resources["gateway-ca-configmap"].resource)["spec"][ + "managementPolicies" + ], + ["Observe"], + "trust-manager owns this ConfigMap; Crossplane must not write it", + ) + + async def test_ca_common_name_fits_the_x509_limit(self) -> None: + """The derived gateway hostname is a full Service FQDN, so the CA + certificate commonName that embeds it must be truncated to the 64-byte + X.509 limit, which cert-manager's webhook rejects if exceeded.""" + long_hostname = "g" + "a" * 62 + ".modelplane-system.svc.cluster.local" + req = _base_request( + gateway=v1alpha1.Gateway( + hostname=long_hostname, + clientCAs=[v1alpha1.ClientCA(name="fleet", certificate="-----BEGIN CERTIFICATE-----\nfleet\n")], + ) + ) + _observe_provider_configs(req) + + got = await self.runner.RunFunction(req, None) + + cn = resource.struct_to_dict(got.desired.resources["gateway-ca-certificate"].resource)["spec"]["forProvider"][ + "manifest" + ]["spec"]["commonName"] + self.assertLessEqual(len(cn.encode()), 64, "CA commonName exceeds the 64-byte X.509 limit") + + async def test_ca_certificate_published_from_the_observed_configmap(self) -> None: + """status.gateway.caCertificate comes from the ConfigMap trust-manager + syncs, as plain text rather than base64.""" + req = _base_request(gateway=_GATEWAY_WITH_PKI) + _observe_provider_configs(req) + req.observed.resources["gateway-ca-configmap"].CopyFrom( + fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "status": { + "atProvider": { + "manifest": { + "apiVersion": "v1", + "kind": "ConfigMap", + "data": {"ca.crt": "-----BEGIN CERTIFICATE-----\ncluster\n"}, + } + } + }, + } + ), + ), + ) + + got = await self.runner.RunFunction(req, None) + + self.assertEqual( + resource.struct_to_dict(got.desired.composite.resource)["status"]["gateway"]["caCertificate"], + "-----BEGIN CERTIFICATE-----\ncluster\n", + ) + + async def test_no_ca_certificate_before_the_bundle_syncs(self) -> None: + """With no observed ConfigMap the cluster publishes no CA and no + hostname, so nothing composes a backend it couldn't authenticate.""" + req = _base_request(gateway=_GATEWAY_WITH_PKI) + _observe_provider_configs(req) + + got = await self.runner.RunFunction(req, None) + + self.assertEqual(resource.struct_to_dict(got.desired.composite.resource)["status"], {}) + + async def test_client_auth_required_once_a_ca_is_trusted(self) -> None: + """The ClientTrafficPolicy is what refuses a request arriving without a + client certificate. It governs the https listener alone, and the CAs are + concatenated in name order so the ConfigMap doesn't churn.""" + req = _base_request( + gateway=v1alpha1.Gateway( + hostname="eu.clusters.example.org", + clientCAs=[ + v1alpha1.ClientCA(name="second", certificate="-----BEGIN CERTIFICATE-----\ntwo\n"), + v1alpha1.ClientCA(name="first", certificate="-----BEGIN CERTIFICATE-----\none\n"), + ], + ) + ) + _observe_provider_configs(req) + + got = await self.runner.RunFunction(req, None) + + def manifest(key: str) -> dict: + return resource.struct_to_dict(got.desired.resources[key].resource)["spec"]["forProvider"]["manifest"] + + self.assertEqual( + manifest("gateway-client-ca-bundle")["data"], + {"ca.crt": "-----BEGIN CERTIFICATE-----\none\n-----BEGIN CERTIFICATE-----\ntwo\n"}, + ) + self.assertEqual( + manifest("gateway-client-auth")["spec"], + { + "targetRefs": [ + { + "group": "gateway.networking.k8s.io", + "kind": "Gateway", + "name": "inference-gateway", + "sectionName": "https", + } + ], + "tls": { + "clientValidation": { + "caCertificateRefs": [ + {"kind": "ConfigMap", "group": "", "name": "modelplane-fleet-gateway-cas"} + ] + } + }, + }, + ) + + async def test_no_gateway_at_all_without_a_trusted_ca(self) -> None: + """A fleet-facing cluster with no CA to trust serves nothing. + + Every alternative is unsafe. An HTTP listener serves the engines to + anything on the internet, because the model-serving HTTPRoutes carry no + sectionName and attach to whatever listener exists. An HTTPS listener + without its ClientTrafficPolicy accepts every client while looking like + it doesn't. So no Gateway is composed, which leaves the routes nothing to + attach to and no load balancer to reach. + + The namespace and the EnvoyProxy are still composed: the PKI and + trust-manager live in that namespace, so withholding it would stop the CA + this is waiting for from ever being issued. + """ + req = _base_request(gateway=v1alpha1.Gateway(hostname="eu.clusters.example.org")) + _observe_provider_configs(req) + + got = await self.runner.RunFunction(req, None) + + self.assertNotIn("gateway", got.desired.resources) + self.assertNotIn("gateway-client-auth", got.desired.resources) + self.assertIn("gateway-namespace", got.desired.resources) + self.assertIn("gateway-proxy", got.desired.resources) + + async def test_http_listener_when_not_fleet_facing(self) -> None: + """A cluster with no hostname isn't fleet facing and keeps the plain HTTP + listener. It never publishes a hostname, so it is never schedulable and + nothing routes to it.""" + req = _base_request() + _observe_provider_configs(req) + + got = await self.runner.RunFunction(req, None) + + listeners = resource.struct_to_dict(got.desired.resources["gateway"].resource)["spec"]["forProvider"][ + "manifest" + ]["spec"]["listeners"] + self.assertEqual([listener["protocol"] for listener in listeners], ["HTTP"]) + + async def test_https_replaces_the_http_listener(self) -> None: + """HTTPS replaces HTTP rather than joining it. The serving HTTPRoutes + carry no sectionName, so they attach to every listener on the Gateway: an + HTTP listener left alongside would serve the engines on the same public + load balancer with no certificate asked for.""" + req = _base_request(gateway=_GATEWAY_WITH_PKI) + _observe_provider_configs(req) + + got = await self.runner.RunFunction(req, None) + + listeners = resource.struct_to_dict(got.desired.resources["gateway"].resource)["spec"]["forProvider"][ + "manifest" + ]["spec"]["listeners"] + self.assertEqual([listener["protocol"] for listener in listeners], ["HTTPS"]) + + async def test_trust_manager_waits_for_cert_manager_to_admit_an_issuer(self) -> None: + """trust-manager isn't composed until an Issuer we composed exists. + + Its chart contains an Issuer and a Certificate for its own webhook, and + Helm applies custom resources last. Installed alongside cert-manager + those lose a race with cert-manager's validating webhook, Helm marks the + release failed, and provider-helm only rolls a failed release back when + spec.rollbackLimit is set, which nothing here sets. The release then + stays failed and trust-manager never starts. + + Our own self-signed Issuer is the signal because the same webhook admits + the same kind of object, and because it's a provider-kubernetes Object, + which retries forever where a failed release is terminal. + """ + req = _base_request(gateway=_GATEWAY_WITH_PKI) + _observe_provider_configs(req) + + got = await self.runner.RunFunction(req, None) + self.assertNotIn("trust-manager", got.desired.resources, "the Issuer reports no readiness yet") + + req.observed.resources["gateway-selfsigned-issuer"].CopyFrom( + fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "status": {"conditions": [{"type": "Ready", "status": "True"}]}, + } + ), + ), + ) + + got = await self.runner.RunFunction(req, None) + self.assertIn("trust-manager", got.desired.resources) + + async def test_trust_manager_installs_on_a_cluster_that_serves_nothing(self) -> None: + """A cluster with no hostname still gets trust-manager. + + Such a cluster is a gateway host and nothing else, a shape the + getting-started docs describe. The InferenceGateway on it composes a + Bundle to publish its client CA, which needs trust-manager's CRD and + webhook on that same cluster. Withholding it would leave the gateway + unable to publish a CA, and since a cluster only becomes schedulable once + some gateway has, that would wedge every cluster in the fleet. + """ + req = _base_request() + _observe_provider_configs(req) + req.observed.resources["gateway-selfsigned-issuer"].CopyFrom( + fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "status": {"conditions": [{"type": "Ready", "status": "True"}]}, + } + ), + ), + ) + + got = await self.runner.RunFunction(req, None) + + self.assertIn("trust-manager", got.desired.resources) + self.assertIn("gateway-selfsigned-issuer", got.desired.resources) + # It composes no CA of its own: it serves no traffic, so it needs no + # serving certificate. + self.assertNotIn("gateway-ca-certificate", got.desired.resources) + + async def test_trust_manager_survives_cert_manager_going_unready(self) -> None: + """Once composed, trust-manager stays composed. Dropping it because + cert-manager restarted would uninstall it, taking both Bundles and so + both CA ConfigMaps with it, and every gateway would stop trusting every + cluster until it came back.""" + req = _base_request(gateway=_GATEWAY_WITH_PKI) + _observe_provider_configs(req) + req.observed.resources["trust-manager"].CopyFrom( + fnv1.Resource( + resource=resource.dict_to_struct({"apiVersion": "helm.m.crossplane.io/v1beta1", "kind": "Release"}), + ), + ) + + got = await self.runner.RunFunction(req, None) + + self.assertIn("trust-manager", got.desired.resources) diff --git a/nix/checks.nix b/nix/checks.nix index 8ba4ead38..4500e29d2 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -85,12 +85,15 @@ in # Validate the example manifests the docs show against the generated Pydantic # models, so an example that drifts from the live API schema fails CI. Covers # everything under docs/manifests/, including the API-reference examples under - # docs/manifests/reference/. Reuses compose-inference-gateway's venv, which - # already provides crossplane-models, pydantic, and pyyaml. + # docs/manifests/reference/. The venv names exactly what the validator + # imports. It used to borrow a composition function's venv for pyyaml and + # pydantic, which broke as soon as that function stopped parsing YAML. docs-manifests = let venv = pythonSet.mkVirtualEnv "docs-manifests-validate-env" { - compose-inference-gateway = [ ]; + crossplane-models = [ ]; + pyyaml = [ ]; + pydantic = [ ]; }; in pkgs.runCommand "modelplane-docs-manifests" { } '' diff --git a/pyproject.toml b/pyproject.toml index 4b846464d..1e1e3c7fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,16 @@ dev = [ # Type stubs for the dynamically generated protobuf modules, so ty can # resolve the Struct and Duration types the functions and tests use. "types-protobuf>=4.24", + # Imported by the docs manifest validator (docs/utils/validate). Declared here + # rather than borrowed from whichever composition function happens to pull + # them in, so dropping a function's dependency can't break the check. + # + # pydantic can't be declared where it belongs, on crossplane-models, whose + # generated modules all import it: the Crossplane CLI writes that package's + # pyproject.toml, and nix run .#build deletes and recreates the whole + # schemas/ tree, so an edit there survives until the next build. + "pyyaml>=6.0", + "pydantic>=2.0", ] [tool.ruff] diff --git a/schemas/.lock.json b/schemas/.lock.json index 6fbde5b4b..cff1c9b4e 100644 --- a/schemas/.lock.json +++ b/schemas/.lock.json @@ -1,6 +1,6 @@ { "packages": { - "fs://apis": "b64f43119c18c7d6cb8a1bce9e67e57f37a915a48cafebdf253260ad01a23473", + "fs://apis": "fb3ef5b64be0ad72ed8db8b20fb71a97688d160446474dceb587eaf8a017b67c", "git://https://github.com/crossplane/crossplane/cluster/crds": "90d8b72ad8b829f0bcd7d7d5a98eaa0d579f244a", "xpkg://xpkg.upbound.io/upbound/provider-aws-ec2:v2.6.0": "sha256:acc26c8d2710e0306185b6c626a2f8c8fe0fdf89874e85efe7944a3668322865", "xpkg://xpkg.upbound.io/upbound/provider-aws-efs:v2.6.0": "sha256:00f1bbbb3c0f1948b6dd45c841a083d63e41850f5a559a15580915311f404911", diff --git a/schemas/python/models/ai/modelplane/inferencecluster/v1alpha1.py b/schemas/python/models/ai/modelplane/inferencecluster/v1alpha1.py index 78ef1f9b0..e04434296 100644 --- a/schemas/python/models/ai/modelplane/inferencecluster/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/inferencecluster/v1alpha1.py @@ -241,6 +241,21 @@ class NodePool(BaseModel): """ +class Metadata(BaseModel): + labels: dict[str, constr(max_length=63)] | None = Field(None, max_length=16) + """ + Labels stamped onto every ModelReplica and ModelEndpoint composed on this cluster, so a fact about the cluster is declared once here rather than repeated on each of them. + This is how a self-hosted endpoint gets its region: a ModelService selects endpoints by label, so a service scoped to a region selects only the endpoints in it. These are your labels, under your own prefix. Modelplane carries and matches them, and never interprets them, so "eu" means no more to it than "prod". + """ + + +class Placement(BaseModel): + metadata: Metadata | None = None + """ + Metadata to project. + """ + + class Taint(BaseModel): effect: Literal['NoSchedule', 'NoExecute'] key: constr(min_length=1) @@ -257,6 +272,10 @@ class Spec(BaseModel): """ GPU node pools available on this cluster. Each pool references an InferenceClass that describes the hardware shape and (for provisioned clusters) how to create the pool. System pools for control-plane components are provisioned automatically. """ + placement: Placement | None = None + """ + Facts about where this cluster is, projected onto everything Modelplane composes here. + """ stack: Literal['Standard', 'Dynamo'] | None = 'Standard' """ Which serving stack the cluster installs and composes. Standard (the default) is the Modelplane-composed serving layer: a Deployment or LeaderWorkerSet, Gateway API, and the endpoint picker. Dynamo swaps in NVIDIA's components: Grove with the KAI Scheduler gang-schedules multi-node engines, and ModelExpress distributes weights. A single-node (Standalone) engine's workload kind is unaffected - it stays a Deployment - but if it references a ModelCache on a Dynamo cluster it still gets the ModelExpress P2P env and IPC_LOCK, so it can seed peers and load from them like a gang. @@ -286,7 +305,15 @@ class Condition(BaseModel): class Gateway(BaseModel): address: str | None = None """ - External IP of the inference gateway on the remote cluster. Used by ModelDeployment for unified endpoint routing. + External address of the inference gateway on the remote cluster. Modelplane resolves status.gateway.hostname to this itself, on each InferenceGateway's cluster, so a platform publishes no DNS for it. + """ + caCertificate: constr(max_length=16384) | None = None + """ + PEM certificate of the CA that signed this gateway's serving certificate. An InferenceGateway validates against it, so it reaches the cluster it meant to rather than whatever else answers on that address. Written once cert-manager on the cluster has issued. + """ + hostname: str | None = None + """ + The internal name an InferenceGateway addresses this cluster's gateway by, derived by Modelplane and resolved to status.gateway.address on each gateway's cluster. Published once the gateway has an address and traffic to it is mutually authenticated. ModelDeployment composes a ModelEndpoint origin from it, and withholds the endpoint while it's unset. """ diff --git a/schemas/python/models/ai/modelplane/inferencegateway/v1alpha1.py b/schemas/python/models/ai/modelplane/inferencegateway/v1alpha1.py index 9df058d84..51bbe08bf 100644 --- a/schemas/python/models/ai/modelplane/inferencegateway/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/inferencegateway/v1alpha1.py @@ -5,11 +5,25 @@ from typing import Literal -from pydantic import AwareDatetime, BaseModel +from pydantic import AwareDatetime, BaseModel, Field, constr from ....io.k8s.apimachinery.pkg.apis.meta import v1 +class SecretSelector(BaseModel): + matchLabels: dict[str, constr(max_length=63)] = Field( + ..., max_length=16, min_length=1 + ) + + +class Auth(BaseModel): + secretSelector: SecretSelector + """ + Selects Secrets holding caller API keys. Each key in a selected Secret is one caller: the entry's name is the caller's identity and its value is the key. So adding a caller means writing a Secret, not editing this gateway. + The gateway stamps the resolved identity onto every request and every usage record, and never forwards the caller's key. Ranking one caller above another is not Modelplane's decision to make, so it publishes the identity and leaves acting on it to whatever does decide. + """ + + class CompositionRef(BaseModel): name: str @@ -42,40 +56,51 @@ class Crossplane(BaseModel): resourceRefs: list[ResourceRef] | None = None -class Metallb(BaseModel): - addressPool: str +class ServiceSelector(BaseModel): + matchLabels: dict[str, constr(max_length=63)] = Field( + ..., max_length=16, min_length=1 + ) + + +class CertificateRef(BaseModel): + name: constr(min_length=1, max_length=253) + + +class Tls(BaseModel): + certificateRefs: list[CertificateRef] = Field(..., max_length=8, min_length=1) """ - IP address range for the MetalLB pool (e.g. "172.18.255.200-172.18.255.250"). Must be within the cluster's network CIDR. + Secrets holding the gateway's certificate, of type kubernetes.io/tls, in the same namespace as this Modelplane's other gateway Secrets. Modelplane copies them to the gateway's cluster. """ -class Traefik(BaseModel): - loadBalancer: Literal['MetalLB'] | None = None +class Spec(BaseModel): + auth: Auth | None = None """ - Load balancer implementation for the gateway Service. Omit for cloud environments where a native LB controller is available. + Authenticates callers against keys this gateway holds. Omit it and the gateway authenticates nobody, so anything that can reach the address can invoke any ModelService it serves. That is only appropriate behind something that has already established who is calling. + Modelplane authenticates callers; it does not authorize them. Every accepted key can reach every ModelService this gateway serves, and /v1/models lists them all regardless of key. To narrow what a key can reach, narrow the gateway with serviceSelector or run a separate gateway with its own keys. """ - metallb: Metallb | None = None + clusterName: constr(min_length=1, max_length=253) """ - MetalLB configuration. Required when loadBalancer is MetalLB. Use for kind or bare-metal clusters. + The InferenceCluster this gateway runs on, which decides its region and its address. A gateway doesn't move: unlike a ModelDeployment, whose replicas re-place when their cluster goes away, a gateway stays where it was put. Availability comes from running more of them, because failing over would change the address callers use and could move traffic out of the jurisdiction the gateway exists to hold. + The cluster needs no GPU pools. A cluster with none is a gateway and nothing else, which is what a region with callers but no accelerators wants. A cluster that serves models can host a gateway too, and does so at most once. """ - version: str + crossplane: Crossplane | None = None """ - Traefik Helm chart version. + Configures how Crossplane will reconcile this composite resource """ - - -class Spec(BaseModel): - backend: Literal['Traefik'] = 'Traefik' + hostname: constr(min_length=1, max_length=253) | None = None """ - Gateway implementation. + The name this gateway answers on. Point it at status.address once the gateway has one. + Omit it and the gateway answers on its address alone, over plain HTTP. That is the getting-started shape, and also the shape for anyone terminating TLS on an edge of their own in front of the gateway. """ - crossplane: Crossplane | None = None + serviceSelector: ServiceSelector | None = None """ - Configures how Crossplane will reconcile this composite resource + Selects the ModelServices this gateway serves, by their labels. Absent, it serves every one. + This is how a gateway is scoped: to a region, so an EU service is only reachable through EU gateways; to your public services on an internet-facing front door; or to a named set on a dedicated gateway. These are your labels, under your own prefix. Modelplane matches them and never interprets them, so a region means no more to it than any other label. """ - traefik: Traefik | None = None + tls: Tls | None = None """ - Traefik Proxy configuration. Required when backend is Traefik. + Serves callers over HTTPS. Without it the caller's hop is unencrypted, so anything reachable from an untrusted network wants this or an edge that terminates TLS in front. """ @@ -88,15 +113,36 @@ class Condition(BaseModel): type: str +class Endpoints(BaseModel): + anthropic: str | None = None + """ + Base URL for Anthropic's Messages API. + """ + openAI: str | None = None + """ + Base URL for the OpenAI API. A caller sets its SDK's base_url to this and names a ModelService as the model. + """ + + class Status(BaseModel): address: str | None = None """ - External address of the control plane gateway. Backend-agnostic โ€” works for any routing implementation. + The address this gateway answers on, and what spec.hostname should point at. It is also the target to health check, at /healthz, to decide whether this gateway is in rotation. + /healthz answers 200 whenever this gateway's proxy is running and serving. It says nothing about whether any ModelService is reachable through it, so a gateway with no healthy backend stays in rotation and answers requests with a 503. Read each ModelService's RoutingReady for that. + """ + clientCACertificate: constr(max_length=16384) | None = None + """ + PEM certificate of the CA that signs this gateway's client certificate. Every InferenceCluster accepts client certificates from it, which is how this gateway proves itself to a cluster gateway and how anything else is refused. + One CA per gateway rather than one per Modelplane, so that no private key has to be distributed: each is generated on the cluster that uses it and only its certificate travels. Note that every cluster gateway trusts every fleet gateway's CA and checks the signing CA rather than the subject, so this bounds where the keys live, not what one of them can reach. """ conditions: list[Condition] | None = None """ Conditions of the resource. """ + endpoints: Endpoints | None = None + """ + The paths this gateway serves. + """ class InferenceGateway(BaseModel): diff --git a/schemas/python/models/ai/modelplane/infrastructure/servingstack/v1alpha1.py b/schemas/python/models/ai/modelplane/infrastructure/servingstack/v1alpha1.py index 80f78301e..92ccf69cf 100644 --- a/schemas/python/models/ai/modelplane/infrastructure/servingstack/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/infrastructure/servingstack/v1alpha1.py @@ -56,6 +56,17 @@ class Dynamo(BaseModel): """ +class ClientCA(BaseModel): + certificate: constr(min_length=1, max_length=16384) + """ + The CA certificate, PEM encoded. + """ + name: constr(min_length=1, max_length=253) + """ + The InferenceGateway this CA belongs to. + """ + + class Listener(BaseModel): name: constr(min_length=1, max_length=63) """ @@ -76,6 +87,15 @@ class Gateway(BaseModel): """ GatewayClass name. Override if the cluster already has a GatewayClass named envoy. """ + clientCAs: list[ClientCA] | None = Field(None, max_length=32) + """ + PEM certificates of the CAs whose client certificates this gateway accepts, one per InferenceGateway in the fleet. Projected from the InferenceCluster, which reads them from each gateway's status. + Presenting one of these is how a caller proves it is a fleet gateway. Requests without one are refused, which is what makes a fleet gateway the only thing that can reach the engines behind this cluster's gateway. + """ + hostname: constr(min_length=1, max_length=253) | None = None + """ + The name this cluster's gateway is reached by, projected from the InferenceCluster. The gateway serves a certificate for it, so an InferenceGateway can originate TLS and know it reached the right cluster. Without it the gateway serves plain HTTP and carries no traffic, since an InferenceGateway addresses a cluster by name. + """ listeners: list[Listener] | None = Field(None, max_length=8) """ Gateway listeners. Defaults to a single HTTP listener on port 80 if not specified. @@ -114,17 +134,13 @@ class Standard(BaseModel): class Versions(BaseModel): - certManager: constr(min_length=1, max_length=32) | None = 'v1.17.1' + certManager: constr(min_length=1, max_length=32) | None = 'v1.21.1' """ cert-manager chart version. """ - envoyGateway: constr(min_length=1, max_length=32) | None = 'v1.8.1' - """ - Envoy Gateway chart version. Must support InferencePool backend resources (the disaggregated-serving routing path), which requires v1.8.x or newer; older releases lack the Gateway API CRDs (ListenerSet) the AI Gateway needs. - """ - gatewayApi: constr(min_length=1, max_length=32) | None = 'v1.5.1' + envoyGateway: constr(min_length=1, max_length=32) | None = 'v1.8.4' """ - Gateway API CRD version. + Envoy Gateway chart version. Must support InferencePool backend resources (the disaggregated-serving routing path), which requires v1.8.x or newer; older releases lack the Gateway API CRDs (ListenerSet) the AI Gateway needs. Envoy AI Gateway v1.1.x is tested against Envoy Gateway v1.8.x with Gateway API v1.5.x, so v1.9.x is out of range until the AI Gateway release that pairs with it. """ nodeFeatureDiscovery: constr(min_length=1, max_length=32) | None = '0.18.3' """ @@ -138,6 +154,10 @@ class Versions(BaseModel): """ kube-prometheus-stack chart version. """ + trustManager: constr(min_length=1, max_length=32) | None = 'v0.24.0' + """ + trust-manager chart version. trust-manager distributes the cluster gateway's CA certificate without its private key, which is what lets the control plane read the certificate to hand to a fleet gateway. + """ class Spec(BaseModel): @@ -189,6 +209,10 @@ class GatewayModel(BaseModel): """ The gateway's external address, once assigned by the cloud load balancer. """ + caCertificate: constr(max_length=16384) | None = None + """ + PEM certificate of the CA that signed this gateway's serving certificate. An InferenceGateway validates the gateway against it, so it reaches the cluster it meant to and not whatever answers on that address. + """ class Status(BaseModel): diff --git a/schemas/python/models/ai/modelplane/modeldeployment/v1alpha1.py b/schemas/python/models/ai/modelplane/modeldeployment/v1alpha1.py index 7346228c5..e10cb238c 100644 --- a/schemas/python/models/ai/modelplane/modeldeployment/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/modeldeployment/v1alpha1.py @@ -53,7 +53,7 @@ class ClusterSelector(BaseModel): class Selector(BaseModel): - cel: constr(min_length=1, max_length=10240) | None = None + cel: constr(min_length=1, max_length=8192) | None = None """ A DRA CEL expression evaluated against one device. Reads device.driver, device.attributes[""]. (typed), and device.capacity[""]. (a Quantity), with quantity() and semver() helpers, e.g. device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("141Gi")) >= 0. """ @@ -122,6 +122,7 @@ class Container(BaseModel): args: list[str] | None = None """ Container args, passed through to the serving engine. Includes the model identifier (e.g. --model=...) and any parallelism flags. + Pass --served-model-name $(MODELPLANE_SERVED_MODEL_NAME), the variable Modelplane injects, so the engine answers to the name a gateway routes to. A caller names a ModelService and the gateway rewrites the request's model to the deployment's, so an engine started under a literal name returns 404 for every request. Nothing enforces this: a CEL rule requiring the reference exceeds the schema's rule cost budget however tightly args is bounded. """ command: list[str] | None = None """ diff --git a/schemas/python/models/ai/modelplane/modelendpoint/v1alpha1.py b/schemas/python/models/ai/modelplane/modelendpoint/v1alpha1.py index e33e621f0..8572bba37 100644 --- a/schemas/python/models/ai/modelplane/modelendpoint/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/modelendpoint/v1alpha1.py @@ -5,11 +5,33 @@ from typing import Literal -from pydantic import AwareDatetime, BaseModel, constr +from pydantic import AwareDatetime, BaseModel, Field, constr from ....io.k8s.apimachinery.pkg.apis.meta import v1 +class Api(BaseModel): + prefix: constr(min_length=1, max_length=512) | None = '/v1' + """ + The path the backend serves that API under: /v1 for most, /openai/v1 for Groq, and a per-replica path for a Modelplane-composed endpoint, whose cluster gateway distinguishes replicas by path. + """ + schema_: Literal['OpenAI', 'Anthropic'] | None = Field('OpenAI', alias='schema') + """ + The API the backend speaks. A gateway translates between this and whatever the caller sent, so an OpenAI client can reach an Anthropic backend and the reverse. + """ + + +class CredentialRef(BaseModel): + key: constr(min_length=1, max_length=253) | None = 'apiKey' + """ + The Secret key holding the credential. + """ + name: constr(min_length=1, max_length=253) + """ + Secret in this ModelEndpoint's namespace, with the credential under the key named by key. + """ + + class CompositionRef(BaseModel): name: str @@ -42,17 +64,27 @@ class Crossplane(BaseModel): class Spec(BaseModel): + api: Api | None = None + """ + The API this backend speaks, and where it serves it. Defaults to the OpenAI API under /v1, which is what most providers and every Modelplane-composed endpoint serve. + """ + credentialRef: CredentialRef | None = None + """ + Secret holding this backend's credential, which the gateway attaches on the way out. The credential never reaches the caller, and the caller's own credential never reaches the backend. An endpoint whose Secret is missing carries no traffic and says so in its conditions. + """ crossplane: Crossplane | None = None """ Configures how Crossplane will reconcile this composite resource """ - rewritePath: str | None = None + model: constr(min_length=1, max_length=253) | None = None """ - Path prefix that requests should be rewritten to when routed through this endpoint. Used by ModelService to configure URLRewrite on its HTTPRoute. For Modelplane- composed endpoints this is the per-replica serving path on the remote cluster's gateway, e.g. /ml-team/qwen-demo/. + The name this backend knows the model by, which a gateway rewrites the request's model to on the way out. Unset, the caller's model name passes through unchanged. + A caller names a ModelService and gets back whichever model actually served, the way asking OpenAI for gpt-4o returns gpt-4o-2024-08-06. """ - url: constr(min_length=1) + origin: constr(min_length=1, max_length=2048) """ - URL of the inference endpoint. Used to configure routing to this endpoint. + Scheme and host of the backend, with no path: an https origin gets TLS originated to it. A port is only needed for a non-default one. + The host must be a name, never an address. Envoy AI Gateway applies per-backend model rewriting, credentials and priority failover only when every backend in a route is addressed by hostname; given an address it keeps passing traffic but silently stops applying them, which would send a caller's own model name to a provider with no credential attached. """ @@ -65,22 +97,11 @@ class Condition(BaseModel): type: str -class Routing(BaseModel): - backendName: str | None = None - """ - Crossplane-generated name of the Backend resource composed by this endpoint. - """ - - class Status(BaseModel): conditions: list[Condition] | None = None """ Conditions of the resource. """ - routing: Routing | None = None - """ - Routing details for this endpoint. ModelService reads backendName to build HTTPRoute backendRefs. - """ class ModelEndpoint(BaseModel): diff --git a/schemas/python/models/ai/modelplane/modelservice/v1alpha1.py b/schemas/python/models/ai/modelplane/modelservice/v1alpha1.py index 565aebaa8..d2816d01c 100644 --- a/schemas/python/models/ai/modelplane/modelservice/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/modelservice/v1alpha1.py @@ -5,7 +5,7 @@ from typing import Literal -from pydantic import AwareDatetime, BaseModel, Field, conint +from pydantic import AwareDatetime, BaseModel, Field, conint, constr from ....io.k8s.apimachinery.pkg.apis.meta import v1 @@ -42,14 +42,25 @@ class Crossplane(BaseModel): class Selector(BaseModel): - matchLabels: dict[str, str] + matchLabels: dict[str, constr(max_length=63)] = Field( + ..., max_length=16, min_length=1 + ) class Endpoint(BaseModel): + priority: conint(ge=0, le=63) | None = 0 + """ + Lower is preferred. Entries at the same priority share traffic by weight; a higher number is only tried when nothing below it has a healthy endpoint, which is what makes a provider a failover for capacity you run. + A request that fails over is retried against the next endpoint and gets that endpoint's own model name, credential and path. Retrying is only possible until the first byte reaches the caller, because after that the tokens are already sent, so a backend that dies mid-stream truncates the response instead. + """ selector: Selector + """ + Selects ModelEndpoints in this ModelService's namespace. Scope a service to a region by selecting only endpoints in it; Modelplane stamps an InferenceCluster's labels onto every endpoint composed there, so the region is declared once on the cluster. + """ weight: conint(ge=1, le=1000000) | None = 1 """ - Weight determines the share of traffic sent to this entry's endpoints, relative to the other entries. An entry with weight 2 receives twice the traffic of an entry with weight 1. The weight is spread as evenly as possible across all endpoints the entry matches. + Share of traffic for this entry relative to the other entries at the same priority, spread as evenly as possible across the endpoints it matches. A pair of entries weighted 90 and 10 is a canary. + At least 1. A weight of 0 doesn't deprioritise a backend, it drops it from the gateway's load assignment entirely, which is indistinguishable from removing the entry and easy to mistake for parking it. Remove the entry instead. """ @@ -58,9 +69,11 @@ class Spec(BaseModel): """ Configures how Crossplane will reconcile this composite resource """ - endpoints: list[Endpoint] = Field(..., min_length=1) + endpoints: list[Endpoint] = Field(..., max_length=32, min_length=1) """ - Endpoints to route traffic to. Each entry selects a set of ModelEndpoints by label. Traffic is split across entries in proportion to their weights, and load-balanced as evenly as possible across the endpoints an entry matches. + A priority order over ModelEndpoints, each entry selecting a set of them by label. + The two knobs work on different timescales. priority is failure: a tier is only used once the tiers above it have no healthy endpoints left. weight is everything that isn't failure, and is how you shift traffic deliberately, whether canarying a new deployment or preferring capacity you've already paid for until it stops keeping up. + Modelplane never adjusts a weight. It is whatever it was last written to be, by a person or by something watching the fleet's load and cost. """ @@ -73,15 +86,37 @@ class Condition(BaseModel): type: str -class Status(BaseModel): +class Endpoints(BaseModel): + ready: int | None = None + total: int | None = None + + +class Gateway(BaseModel): address: str | None = None + hostname: str | None = None """ - Public address where this service is reachable. + The name that gateway answers on, if it has one. """ + name: str | None = None + + +class Status(BaseModel): conditions: list[Condition] | None = None """ Conditions of the resource. """ + endpoints: Endpoints | None = None + """ + Observed endpoint counts, across all priorities. + """ + gateways: list[Gateway] | None = None + """ + The InferenceGateways serving this service, which is every gateway whose serviceSelector matches it. Empty means no gateway serves this service and no caller can reach it. + """ + model: str | None = None + """ + The name a caller passes as the request's model. Namespaced, so two services can't collide and the namespace serving a caller is legible in what it passes. + """ class ModelService(BaseModel): diff --git a/uv.lock b/uv.lock index 8b1e07fcb..a5a8419cd 100644 --- a/uv.lock +++ b/uv.lock @@ -177,7 +177,6 @@ dependencies = [ { name = "crossplane-function-sdk-python" }, { name = "crossplane-models" }, { name = "grpcio" }, - { name = "pyyaml" }, ] [package.metadata] @@ -186,7 +185,6 @@ requires-dist = [ { name = "crossplane-function-sdk-python", specifier = ">=0.14.0" }, { name = "crossplane-models", editable = "schemas/python" }, { name = "grpcio", specifier = ">=1.73.1" }, - { name = "pyyaml", specifier = ">=6.0" }, ] [[package]] @@ -506,6 +504,8 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ { name = "crossplane-function-sdk-python" }, + { name = "pydantic" }, + { name = "pyyaml" }, { name = "types-protobuf" }, ] @@ -514,6 +514,8 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "crossplane-function-sdk-python", specifier = ">=0.14.0" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "types-protobuf", specifier = ">=4.24" }, ]