From 70e585ac0332879ed5a76dde9e3c7dc4343503c8 Mon Sep 17 00:00:00 2001 From: "Ankit Kr. Chowdhury" Date: Sat, 15 Aug 2026 20:30:24 +0000 Subject: [PATCH 1/5] feat: implement canary rollout logic with PromQL-based threshold evaluation and monitoring --- .agents/AGENTS.md | 2 +- .agents/skills/agentrax-context/SKILL.md | 3 + api/v1alpha1/agentdeployment_types.go | 15 + api/v1alpha1/zz_generated.deepcopy.go | 8 + cmd/main.go | 40 +- .../bases/agentrax.io_agentdeployments.yaml | 17 + .../gateway.networking.k8s.io_httproutes.yaml | 6046 +++++++++++++++++ config/rbac/role.yaml | 12 + docs/agentrax.md | 18 +- go.mod | 17 +- go.sum | 51 +- .../controller/agentdeployment_controller.go | 120 +- internal/controller/suite_test.go | 3 + internal/rollout/canary.go | 643 ++ internal/rollout/canary_test.go | 496 ++ internal/rollout/promql.go | 184 + internal/rollout/promql_test.go | 321 + 17 files changed, 7949 insertions(+), 47 deletions(-) create mode 100644 config/crd/external/gateway.networking.k8s.io_httproutes.yaml create mode 100644 internal/rollout/canary.go create mode 100644 internal/rollout/canary_test.go create mode 100644 internal/rollout/promql.go create mode 100644 internal/rollout/promql_test.go diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 0d1be80..721c951 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -9,7 +9,7 @@ 2. **Reconciler Pattern**: - Fetch the object first; if not found (`apierrors.IsNotFound`), return `ctrl.Result{}` immediately — it was deleted. - Always update `status` last, after all child resources are reconciled. Never update status mid-reconcile. - - Use `controllerutil.CreateOrUpdate` for all owned child resources (Deployment, Service, ServiceMonitor, HPA). + - Use `controllerutil.CreateOrUpdate` for all owned child resources (Deployment, Service, ServiceMonitor, HPA, HTTPRoute). - Requeue transient errors with `ctrl.Result{RequeueAfter: ...}`, not `ctrl.Result{Requeue: true}`. 3. **Owner References & Finalizers**: diff --git a/.agents/skills/agentrax-context/SKILL.md b/.agents/skills/agentrax-context/SKILL.md index 4105f90..8c4cedd 100644 --- a/.agents/skills/agentrax-context/SKILL.md +++ b/.agents/skills/agentrax-context/SKILL.md @@ -56,8 +56,11 @@ description: Project context and settled architecture decisions for the Agentrax | Canary | Prometheus unreachable during pause | Fail-safe rollback after 60 s; never hang | | Canary | Second rollout triggered mid-rollout | Webhook rejects; never run two concurrent canaries on the same `AgentDeployment` | | Canary | HPA during active canary | Pause stable HPA; no canary HPA; resume only after promote/rollback | +| Canary | Out-of-band deletion of HTTPRoute/Deployment | Self-healed on next reconcile cycle in `Step()` preserving active traffic split | +| Canary | Rollout failed or aborted | Operator sets `RolloutFailed`, preserves `status.canaryVersion`, no retry loop | | Quota | Two concurrent near-limit creates | In-flight reservation; one wins, one is rejected | | Quota | Quota lowered below current usage | Set `OverQuota` condition; never forcibly delete existing resources | +| Quota | `TenantQuota` deleted while agents exist | Surface `TenantQuotaNotFound` on `QuotaLimited` condition; never crash | | MCP | Ungraceful pod termination | TTL/heartbeat expires the entry within one TTL window (default 90 s) | | MCP | Pod `Ready` but MCP handshake fails | Do not register; surface `MCPHandshakeFailed` condition | | Deletion | `AgentDeployment` deleted | Finalizer ensures MCP deregistration before `Service` is GC'd | diff --git a/api/v1alpha1/agentdeployment_types.go b/api/v1alpha1/agentdeployment_types.go index f93a2c9..bdf01ca 100644 --- a/api/v1alpha1/agentdeployment_types.go +++ b/api/v1alpha1/agentdeployment_types.go @@ -162,6 +162,21 @@ type AgentDeploymentStatus struct { // CanaryWeight is the current percentage of traffic routed to the canary (0–100). CanaryWeight int32 `json:"canaryWeight,omitempty"` + // CanaryStepIndex is the index of the currently executing rollout step. + // Persisted so the state machine survives operator restarts. + // +optional + CanaryStepIndex int `json:"canaryStepIndex,omitempty"` + + // PauseStartedAt records when the current pause step began. + // Used to enforce maximum pause extensions and the fail-safe rollback timeout. + // +optional + PauseStartedAt *metav1.Time `json:"pauseStartedAt,omitempty"` + + // PromUnreachableSince records when Prometheus last became unreachable. + // When non-nil and age exceeds FailSafeTimeout, a fail-safe rollback fires. + // +optional + PromUnreachableSince *metav1.Time `json:"promUnreachableSince,omitempty"` + // Registered is true when this agent is currently registered in the MCP registry. Registered bool `json:"registered,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 4943ea5..7177715 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -119,6 +119,14 @@ func (in *AgentDeploymentSpec) DeepCopy() *AgentDeploymentSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentDeploymentStatus) DeepCopyInto(out *AgentDeploymentStatus) { *out = *in + if in.PauseStartedAt != nil { + in, out := &in.PauseStartedAt, &out.PauseStartedAt + *out = (*in).DeepCopy() + } + if in.PromUnreachableSince != nil { + in, out := &in.PromUnreachableSince, &out.PromUnreachableSince + *out = (*in).DeepCopy() + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]metav1.Condition, len(*in)) diff --git a/cmd/main.go b/cmd/main.go index f8afa3b..7a6957a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -21,6 +21,7 @@ import ( "crypto/tls" "flag" "os" + "time" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -39,10 +40,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1" "github.com/gitcommitankit/agentrax/internal/controller" + "github.com/gitcommitankit/agentrax/internal/metrics" "github.com/gitcommitankit/agentrax/internal/quota" + "github.com/gitcommitankit/agentrax/internal/rollout" agentraxwebhook "github.com/gitcommitankit/agentrax/internal/webhook" // +kubebuilder:scaffold:imports ) @@ -58,6 +62,7 @@ func init() { utilruntime.Must(autoscalingv2.AddToScheme(scheme)) utilruntime.Must(apiextensionsv1.AddToScheme(scheme)) utilruntime.Must(monitoringv1.AddToScheme(scheme)) + utilruntime.Must(gatewayv1.Install(scheme)) utilruntime.Must(agentraxv1alpha1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme @@ -71,6 +76,9 @@ func main() { var secureMetrics bool var enableHTTP2 bool var gpuResourceName string + var prometheusURL string + var gatewayName string + var gatewayNamespace string var tlsOpts []func(*tls.Config) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") @@ -84,6 +92,13 @@ func main() { "If set, HTTP/2 will be enabled for the metrics and webhook servers") flag.StringVar(&gpuResourceName, "gpu-resource-name", quota.DefaultGPUResourceName, "Kubernetes resource name used to count GPU units in AgentDeployment resource limits.") + flag.StringVar(&prometheusURL, "prometheus-url", "", + "URL of the Prometheus HTTP API (e.g. http://prometheus-operated.monitoring.svc:9090). "+ + "Required for Canary rollout strategy; if empty, canary is unavailable.") + flag.StringVar(&gatewayName, "gateway-name", "agentrax-gateway", + "Name of the Gateway API Gateway object used for canary traffic splitting.") + flag.StringVar(&gatewayNamespace, "gateway-namespace", "agentrax-system", + "Namespace of the Gateway API Gateway object used for canary traffic splitting.") opts := zap.Options{ Development: true, } @@ -171,10 +186,29 @@ func main() { // Shared quota enforcer used by both the webhook validator and TenantQuota reconciler. quotaEnforcer := quota.NewEnforcer(gpuResourceName) + // Build the CanaryController when --prometheus-url is provided. + // When nil, AgentDeployments with strategy=Canary behave as Recreate. + var canaryController *rollout.Controller + if prometheusURL != "" { + setupLog.Info("canary rollout enabled", "prometheusURL", prometheusURL, + "gatewayName", gatewayName, "gatewayNamespace", gatewayNamespace) + canaryController = &rollout.Controller{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + PromClient: metrics.NewClient(prometheusURL), + GatewayName: gatewayName, + GatewayNamespace: gatewayNamespace, + FailSafeTimeout: 60 * time.Second, + } + } else { + setupLog.Info("canary rollout disabled (no --prometheus-url)") + } + if err = (&controller.AgentDeploymentReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - GPUResourceName: gpuResourceName, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + GPUResourceName: gpuResourceName, + CanaryController: canaryController, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "AgentDeployment") os.Exit(1) diff --git a/config/crd/bases/agentrax.io_agentdeployments.yaml b/config/crd/bases/agentrax.io_agentdeployments.yaml index 1ffa8c7..1282a25 100644 --- a/config/crd/bases/agentrax.io_agentdeployments.yaml +++ b/config/crd/bases/agentrax.io_agentdeployments.yaml @@ -377,6 +377,11 @@ spec: status: description: AgentDeploymentStatus defines the observed state of an AgentDeployment. properties: + canaryStepIndex: + description: |- + CanaryStepIndex is the index of the currently executing rollout step. + Persisted so the state machine survives operator restarts. + type: integer canaryVersion: description: CanaryVersion is the container image tag of the canary deployment, if one is in progress. @@ -447,6 +452,12 @@ spec: description: CurrentReplicas is the number of replicas currently running. format: int32 type: integer + pauseStartedAt: + description: |- + PauseStartedAt records when the current pause step began. + Used to enforce maximum pause extensions and the fail-safe rollback timeout. + format: date-time + type: string phase: description: Phase is the high-level lifecycle phase of this deployment. enum: @@ -456,6 +467,12 @@ spec: - RolloutFailed - Degraded type: string + promUnreachableSince: + description: |- + PromUnreachableSince records when Prometheus last became unreachable. + When non-nil and age exceeds FailSafeTimeout, a fail-safe rollback fires. + format: date-time + type: string registered: description: Registered is true when this agent is currently registered in the MCP registry. diff --git a/config/crd/external/gateway.networking.k8s.io_httproutes.yaml b/config/crd/external/gateway.networking.k8s.io_httproutes.yaml new file mode 100644 index 0000000..41b296f --- /dev/null +++ b/config/crd/external/gateway.networking.k8s.io_httproutes.yaml @@ -0,0 +1,6046 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + api-approved.kubernetes.io: https://github.com/kubernetes-sigs/gateway-api/pull/2997 + gateway.networking.k8s.io/bundle-version: v1.1.0 + gateway.networking.k8s.io/channel: standard + creationTimestamp: null + 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 + 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-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. + + + 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. + + + + + + When the BackendRef points to a Kubernetes Service, implementations SHOULD + honor the appProtocol field if it is set for the target Service Port. + + + Implementations supporting appProtocol SHOULD recognize the Kubernetes + Standard Application Protocols defined in KEP-3726. + + + If a Service appProtocol isn't specified, an implementation MAY infer the + backend protocol through its own means. Implementations MAY infer the + protocol from the Route type referring to the backend Service. + + + If a Route is not able to send traffic to the backend using the specified + protocol then the backend is considered invalid. Implementations MUST set the + "ResolvedRefs" condition to "False" with the "UnsupportedProtocol" reason. + + + + 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: + 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' + required: + - backendRef + type: object + 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 + -------------|--------------|----------------|---------- + /foo/bar | /foo | /xyz | /xyz/bar + /foo/bar | /foo | /xyz/ | /xyz/bar + /foo/bar | /foo/ | /xyz | /xyz/bar + /foo/bar | /foo/ | /xyz/ | /xyz/bar + /foo | /foo | /xyz | /xyz + /foo/ | /foo | /xyz | /xyz/ + /foo/bar | /foo | | /bar + /foo/ | /foo | | / + /foo | /foo | | / + /foo/ | /foo | / | / + /foo | /foo | / | / + 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 + 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 + 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 + -------------|--------------|----------------|---------- + /foo/bar | /foo | /xyz | /xyz/bar + /foo/bar | /foo | /xyz/ | /xyz/bar + /foo/bar | /foo/ | /xyz | /xyz/bar + /foo/bar | /foo/ | /xyz/ | /xyz/bar + /foo | /foo | /xyz | /xyz + /foo/ | /foo | /xyz | /xyz/ + /foo/bar | /foo | | /bar + /foo/ | /foo | | / + /foo | /foo | | / + /foo/ | /foo | / | / + /foo | /foo | / | / + 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.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-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: May specify either httpRouteFilterRequestRedirect + or httpRouteFilterRequestRewrite, but not both + rule: '!(self.exists(f, f.type == ''RequestRedirect'') + && self.exists(f, f.type == ''URLRewrite''))' + - 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 + 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 can not 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 can not 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: + 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' + required: + - backendRef + type: object + 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 + -------------|--------------|----------------|---------- + /foo/bar | /foo | /xyz | /xyz/bar + /foo/bar | /foo | /xyz/ | /xyz/bar + /foo/bar | /foo/ | /xyz | /xyz/bar + /foo/bar | /foo/ | /xyz/ | /xyz/bar + /foo | /foo | /xyz | /xyz + /foo/ | /foo | /xyz | /xyz/ + /foo/bar | /foo | | /bar + /foo/ | /foo | | / + /foo | /foo | | / + /foo/ | /foo | / | / + /foo | /foo | / | / + 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 + 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 + 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 + -------------|--------------|----------------|---------- + /foo/bar | /foo | /xyz | /xyz/bar + /foo/bar | /foo | /xyz/ | /xyz/bar + /foo/bar | /foo/ | /xyz | /xyz/bar + /foo/bar | /foo/ | /xyz/ | /xyz/bar + /foo | /foo | /xyz | /xyz + /foo/ | /foo | /xyz | /xyz/ + /foo/bar | /foo | | /bar + /foo/ | /foo | | / + /foo | /foo | | / + /foo/ | /foo | / | / + /foo | /foo | / | / + 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.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-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: 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\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\n```\nmatch:\n\n\n\tpath:\n\t + \ value: \"/foo\"\n\theaders:\n\t- name: \"version\"\n\t + \ value \"v1\"\n\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: 8 + type: array + 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 + type: array + 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 non-existent parent. + * The Route is of a type that the controller does not support. + * The Route is in a namespace the controller does not have access to. + items: + description: "Condition contains details for one aspect of + the current state of this API Resource.\n---\nThis struct + is intended for direct use as an array at the field path + .status.conditions. For example,\n\n\n\ttype FooStatus + struct{\n\t // Represents the observations of a foo's + current state.\n\t // Known .status.conditions.type are: + \"Available\", \"Progressing\", and \"Degraded\"\n\t // + +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // + +listType=map\n\t // +listMapKey=type\n\t Conditions + []metav1.Condition `json:\"conditions,omitempty\" patchStrategy:\"merge\" + patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t + \ // other fields\n\t}" + 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. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + 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: + - controllerName + - parentRef + type: object + maxItems: 32 + type: array + 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 + 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-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. + + + 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. + + + + + + When the BackendRef points to a Kubernetes Service, implementations SHOULD + honor the appProtocol field if it is set for the target Service Port. + + + Implementations supporting appProtocol SHOULD recognize the Kubernetes + Standard Application Protocols defined in KEP-3726. + + + If a Service appProtocol isn't specified, an implementation MAY infer the + backend protocol through its own means. Implementations MAY infer the + protocol from the Route type referring to the backend Service. + + + If a Route is not able to send traffic to the backend using the specified + protocol then the backend is considered invalid. Implementations MUST set the + "ResolvedRefs" condition to "False" with the "UnsupportedProtocol" reason. + + + + 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: + 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' + required: + - backendRef + type: object + 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 + -------------|--------------|----------------|---------- + /foo/bar | /foo | /xyz | /xyz/bar + /foo/bar | /foo | /xyz/ | /xyz/bar + /foo/bar | /foo/ | /xyz | /xyz/bar + /foo/bar | /foo/ | /xyz/ | /xyz/bar + /foo | /foo | /xyz | /xyz + /foo/ | /foo | /xyz | /xyz/ + /foo/bar | /foo | | /bar + /foo/ | /foo | | / + /foo | /foo | | / + /foo/ | /foo | / | / + /foo | /foo | / | / + 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 + 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 + 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 + -------------|--------------|----------------|---------- + /foo/bar | /foo | /xyz | /xyz/bar + /foo/bar | /foo | /xyz/ | /xyz/bar + /foo/bar | /foo/ | /xyz | /xyz/bar + /foo/bar | /foo/ | /xyz/ | /xyz/bar + /foo | /foo | /xyz | /xyz + /foo/ | /foo | /xyz | /xyz/ + /foo/bar | /foo | | /bar + /foo/ | /foo | | / + /foo | /foo | | / + /foo/ | /foo | / | / + /foo | /foo | / | / + 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.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-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: May specify either httpRouteFilterRequestRedirect + or httpRouteFilterRequestRewrite, but not both + rule: '!(self.exists(f, f.type == ''RequestRedirect'') + && self.exists(f, f.type == ''URLRewrite''))' + - 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 + 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 can not 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 can not 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: + 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' + required: + - backendRef + type: object + 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 + -------------|--------------|----------------|---------- + /foo/bar | /foo | /xyz | /xyz/bar + /foo/bar | /foo | /xyz/ | /xyz/bar + /foo/bar | /foo/ | /xyz | /xyz/bar + /foo/bar | /foo/ | /xyz/ | /xyz/bar + /foo | /foo | /xyz | /xyz + /foo/ | /foo | /xyz | /xyz/ + /foo/bar | /foo | | /bar + /foo/ | /foo | | / + /foo | /foo | | / + /foo/ | /foo | / | / + /foo | /foo | / | / + 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 + 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 + 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 + -------------|--------------|----------------|---------- + /foo/bar | /foo | /xyz | /xyz/bar + /foo/bar | /foo | /xyz/ | /xyz/bar + /foo/bar | /foo/ | /xyz | /xyz/bar + /foo/bar | /foo/ | /xyz/ | /xyz/bar + /foo | /foo | /xyz | /xyz + /foo/ | /foo | /xyz | /xyz/ + /foo/bar | /foo | | /bar + /foo/ | /foo | | / + /foo | /foo | | / + /foo/ | /foo | / | / + /foo | /foo | / | / + 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.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-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: 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\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\n```\nmatch:\n\n\n\tpath:\n\t + \ value: \"/foo\"\n\theaders:\n\t- name: \"version\"\n\t + \ value \"v1\"\n\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: 8 + type: array + 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 + type: array + 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 non-existent parent. + * The Route is of a type that the controller does not support. + * The Route is in a namespace the controller does not have access to. + items: + description: "Condition contains details for one aspect of + the current state of this API Resource.\n---\nThis struct + is intended for direct use as an array at the field path + .status.conditions. For example,\n\n\n\ttype FooStatus + struct{\n\t // Represents the observations of a foo's + current state.\n\t // Known .status.conditions.type are: + \"Available\", \"Progressing\", and \"Degraded\"\n\t // + +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // + +listType=map\n\t // +listMapKey=type\n\t Conditions + []metav1.Condition `json:\"conditions,omitempty\" patchStrategy:\"merge\" + patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t + \ // other fields\n\t}" + 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. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + 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: + - controllerName + - parentRef + type: object + maxItems: 32 + type: array + required: + - parents + type: object + required: + - spec + type: object + served: true + storage: false + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: null diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 25b554a..c2b186e 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -98,6 +98,18 @@ rules: - patch - update - watch +- apiGroups: + - gateway.networking.k8s.io + resources: + - httproutes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - monitoring.coreos.com resources: diff --git a/docs/agentrax.md b/docs/agentrax.md index f1a1e18..06887a8 100644 --- a/docs/agentrax.md +++ b/docs/agentrax.md @@ -35,11 +35,21 @@ The canonical list of top-level end-to-end test scenarios. Do not add new top-le | Phase | Focus | Status | Milestone | | ----- | ------------------- | :---------: | ---------------------------------------------------------------------------------------- | | 0 | Scaffolding | **Done** | `make install` applies CRDs; `make run` starts both controllers | -| 1 | Core reconciliation | **Up Next** | `AgentDeployment` → Deployment/Service/ServiceMonitor; status/conditions; finalizer stub | -| 2 | Multi-tenancy | Pending | `TenantQuota`, validating + mutating webhook, quota enforcement | -| 3 | Autoscaling | Pending | Prometheus Adapter integration, managed HPA | -| 4 | Canary rollout | Pending | Rollout state machine, Gateway API traffic shifting, PromQL threshold evaluation | +| 1 | Core reconciliation | **Done** | `AgentDeployment` → Deployment/Service/ServiceMonitor; status/conditions; finalizer stub | +| 2 | Multi-tenancy | **Done** | `TenantQuota`, validating + mutating webhook, quota enforcement | +| 3 | Autoscaling | **Done** | Prometheus Adapter integration, managed HPA | +| 4 | Canary rollout | **Done** | Rollout state machine, Gateway API traffic shifting, PromQL threshold evaluation | | 5 | MCP registry | Pending | Registrar, registry HTTP handler, discovery API | | 6 | Hardening & demo | Pending | E2e tests in CI, Helm chart, README, recorded demo | Phases 2, 3, and 5 are independent of each other and can run in parallel once Phase 1 is complete. Phase 4 depends on both 2 and 3. + +### Phase 4 — Canary Rollout (§6) + +The canary controller (`internal/rollout.Controller`) is a pure helper driven by the `AgentDeploymentReconciler` each reconcile cycle. When `spec.rollout.strategy: Canary` and `spec.image` differs from `status.stableVersion`, the reconciler transitions to `RolloutInProgress` and calls `Step()` on every subsequent reconcile. + +**State machine**: `setWeight` steps create the canary Deployment and upsert a Gateway API `HTTPRoute` with the target weight split. `pause` steps query Prometheus for request count (sample gate), error rate, and p99 latency via `internal/metrics.Client`. If Prometheus is unreachable for longer than `--fail-safe-timeout` (default 60s), a fail-safe rollback fires. Sample-size gating extends the pause window up to `3×pause_duration` before forcing evaluation. Promotion updates the stable Deployment image and restores the HPA; rollback reverts everything and sets `phase=RolloutFailed`. + +**New operator flags**: `--prometheus-url` (required for Canary), `--gateway-name`, `--gateway-namespace`. + +**New status fields**: `canaryStepIndex`, `pauseStartedAt`, `promUnreachableSince` — all persisted so the state machine is re-entrant across operator restarts. diff --git a/go.mod b/go.mod index 3ee41c1..993ed6f 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( k8s.io/apimachinery v0.31.0 k8s.io/client-go v0.31.0 sigs.k8s.io/controller-runtime v0.19.0 + sigs.k8s.io/gateway-api v1.1.0 ) require ( @@ -22,16 +23,16 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/emicklei/go-restful/v3 v3.12.0 // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.19.6 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.22.4 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect @@ -43,7 +44,7 @@ require ( github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect - github.com/imdario/mergo v0.3.6 // indirect + github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -70,14 +71,14 @@ require ( go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect - golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect + golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.21.0 // indirect golang.org/x/term v0.21.0 // indirect golang.org/x/text v0.16.0 // indirect - golang.org/x/time v0.3.0 // indirect + golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect @@ -91,7 +92,7 @@ require ( k8s.io/apiserver v0.31.0 // indirect k8s.io/component-base v0.31.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108 // indirect k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect diff --git a/go.sum b/go.sum index 8db3520..9d208d0 100644 --- a/go.sum +++ b/go.sum @@ -11,15 +11,14 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= -github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= -github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk= +github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= +github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -35,13 +34,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -66,8 +64,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= -github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= -github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -76,11 +74,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -121,13 +116,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -159,8 +149,8 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= -golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= +golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -187,8 +177,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -220,7 +210,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= @@ -237,17 +226,19 @@ k8s.io/component-base v0.31.0 h1:/KIzGM5EvPNQcYgwq5NwoQBaOlVFrghoVGr8lG6vNRs= k8s.io/component-base v0.31.0/go.mod h1:TYVuzI1QmN4L5ItVdMSXKvH7/DtvIuas5/mm8YT3rTo= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108 h1:Q8Z7VlGhcJgBHJHYugJ/K/7iB8a2eSxCyxdVjJp+lLY= +k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 h1:2770sDpzrjjsAtVhSeUFseziht227YAWYHLGNM8QPwY= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.19.0 h1:nWVM7aq+Il2ABxwiCizrVDSlmDcshi9llbaFbC0ji/Q= sigs.k8s.io/controller-runtime v0.19.0/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4= +sigs.k8s.io/gateway-api v1.1.0 h1:DsLDXCi6jR+Xz8/xd0Z1PYl2Pn0TyaFMOPPZIj4inDM= +sigs.k8s.io/gateway-api v1.1.0/go.mod h1:ZH4lHrL2sDi0FHZ9jjneb8kKnGzFWyrTya35sWUTrRs= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= \ No newline at end of file +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/internal/controller/agentdeployment_controller.go b/internal/controller/agentdeployment_controller.go index a3f517d..fad2237 100644 --- a/internal/controller/agentdeployment_controller.go +++ b/internal/controller/agentdeployment_controller.go @@ -36,11 +36,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" "github.com/go-logr/logr" monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1" + "github.com/gitcommitankit/agentrax/internal/rollout" "github.com/gitcommitankit/agentrax/internal/scaling" ) @@ -75,6 +78,11 @@ type AgentDeploymentReconciler struct { // Used when computing quota headroom for HPA max-replicas capping. GPUResourceName string + // CanaryController drives the canary rollout state machine. When nil (i.e., + // --prometheus-url was not supplied), canary strategy is unavailable and + // AgentDeployments that request it are treated as Recreate. + CanaryController *rollout.Controller + // hasServiceMonitorCRD is set once during SetupWithManager and determines // whether ServiceMonitor reconciliation is attempted at all. hasServiceMonitorCRD bool @@ -109,6 +117,7 @@ func (r *AgentDeploymentReconciler) SetDeregister(fn func(ctx context.Context, a // +kubebuilder:rbac:groups=monitoring.coreos.com,resources=servicemonitors,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list;watch // +kubebuilder:rbac:groups=agentrax.io,resources=tenantquotas,verbs=get;list;watch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch;create;update;patch;delete // Reconcile drives the AgentDeployment's observed state toward its declared spec. // It creates and self-heals a Deployment, Service, and (when Prometheus Operator is present) @@ -167,6 +176,33 @@ func (r *AgentDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, fmt.Errorf("reconciling servicemonitor: %w", err) } + // 5.5. Handle canary rollout lifecycle. + if r.CanaryController != nil { + // Manual abort: spec.rollout.abort=true triggers immediate rollback. + if isAbortRequested(ad) { + if err := r.CanaryController.Rollback(ctx, ad, "ManualAbort", "spec.rollout.abort was set to true"); err != nil { + return ctrl.Result{}, fmt.Errorf("aborting canary: %w", err) + } + return ctrl.Result{}, nil + } + // Active rollout: step the state machine and return — HPA and status + // updates are owned by the canary controller during rollout. + if ad.Status.Phase == agentraxv1alpha1.PhaseRolloutInProgress { + result, err := r.CanaryController.Step(ctx, ad) + if err != nil { + return ctrl.Result{}, fmt.Errorf("stepping canary: %w", err) + } + return result, nil + } + // New canary: image changed while strategy is Canary. + if isCanaryTriggered(ad) { + if err := r.startCanary(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("starting canary: %w", err) + } + return ctrl.Result{RequeueAfter: 5 * time.Second}, nil + } + } + // 6. Reconcile the managed HPA (skip during active canary — Phase 4 owns it). // reconcileHPA also returns the quota evaluation state so updateStatus can // write the correct QuotaLimited condition onto the freshly re-fetched object. @@ -488,7 +524,14 @@ func (r *AgentDeploymentReconciler) updateStatus(ctx context.Context, ad *agentr dep.Status.AvailableReplicas == replicas if rolloutComplete { - latest.Status.Phase = agentraxv1alpha1.PhaseRunning + // If a rollout failed, preserve PhaseRolloutFailed until the user changes spec.image or reverts to stable. + if latest.Status.Phase == agentraxv1alpha1.PhaseRolloutFailed && latest.Spec.Image != latest.Status.StableVersion { + // Retain PhaseRolloutFailed while spec.image is not the stable version. + } else { + latest.Status.Phase = agentraxv1alpha1.PhaseRunning + latest.Status.CanaryVersion = "" + apimeta.RemoveStatusCondition(&latest.Status.Conditions, "RolloutFailed") + } // Derive StableVersion from the image the Deployment controller // applied — not from latest.Spec.Image — so it reflects what is // actually running, even if the spec was updated again since. @@ -677,6 +720,8 @@ func (r *AgentDeploymentReconciler) desiredServiceMonitor(ad *agentraxv1alpha1.A // It uses an uncached API reader to check once whether the ServiceMonitor CRD is // installed, stores the result on the reconciler, and conditionally adds an // Owns watch for ServiceMonitor so that out-of-band deletions trigger a reconcile. +// When CanaryController is non-nil it also watches HTTPRoute objects owned by this +// controller so out-of-band HTTPRoute deletions trigger a reconcile. func (r *AgentDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error { // Check CRD presence once at startup using the uncached reader so we don't // require apiextensionsv1 to be registered in the caching informer scheme. @@ -699,6 +744,79 @@ func (r *AgentDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error { if r.hasServiceMonitorCRD { bldr = bldr.Owns(&monitoringv1.ServiceMonitor{}) } + if r.CanaryController != nil { + // Watch HTTPRoutes owned by this controller so out-of-band deletions + // (e.g. manual kubectl delete) trigger a reconcile and self-heal. + bldr = bldr.Owns(&gatewayv1.HTTPRoute{}) + } return bldr.Complete(r) } + +// ── Canary helpers ──────────────────────────────────────────────────────────── + +// isCanaryTriggered returns true when all conditions are met: +// 1. spec.rollout.strategy == "Canary" +// 2. status.stableVersion is set (at least one successful deploy has completed) +// 3. spec.image differs from status.stableVersion +// 4. status.phase is not already RolloutInProgress +// 5. status.phase is not RolloutFailed for this exact image (prevents re-trigger loop) +func isCanaryTriggered(ad *agentraxv1alpha1.AgentDeployment) bool { + if ad.Spec.Rollout.Strategy != "Canary" { + return false + } + if ad.Status.StableVersion == "" { + // No stable version yet — treat first deploy as Recreate. + return false + } + if ad.Spec.Image == ad.Status.StableVersion { + return false + } + if ad.Status.Phase == agentraxv1alpha1.PhaseRolloutInProgress { + return false + } + if ad.Status.Phase == agentraxv1alpha1.PhaseRolloutFailed && ad.Spec.Image == ad.Status.CanaryVersion { + // Do not retry the exact image that just failed or was aborted. + return false + } + return true +} + +// isAbortRequested returns true when spec.rollout.abort is true and a canary +// rollout is currently in progress. Abort outside of a rollout is ignored. +func isAbortRequested(ad *agentraxv1alpha1.AgentDeployment) bool { + return ad.Spec.Rollout.Abort && ad.Status.Phase == agentraxv1alpha1.PhaseRolloutInProgress +} + +// startCanary transitions an AgentDeployment from its current phase into +// RolloutInProgress. It records the new canary version in status, resets step +// tracking fields, and pauses the stable HPA so Phase 4 owns replica counts. +func (r *AgentDeploymentReconciler) startCanary(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + logger := log.FromContext(ctx).WithValues( + "name", ad.Name, "namespace", ad.Namespace, + "newImage", ad.Spec.Image, "stableImage", ad.Status.StableVersion, + ) + logger.Info("starting canary rollout") + + // 1. Pause the stable HPA so the canary controller owns replica counts. + if err := r.CanaryController.PauseHPA(ctx, ad); err != nil { + return fmt.Errorf("pausing HPA before canary: %w", err) + } + + // 2. Record canary state in status. + latest := &agentraxv1alpha1.AgentDeployment{} + if err := r.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, latest); err != nil { + return fmt.Errorf("re-fetching AD to start canary: %w", err) + } + latest.Status.Phase = agentraxv1alpha1.PhaseRolloutInProgress + latest.Status.CanaryVersion = ad.Spec.Image + latest.Status.CanaryStepIndex = 0 + latest.Status.CanaryWeight = 0 + latest.Status.PauseStartedAt = nil + latest.Status.PromUnreachableSince = nil + if err := r.Status().Update(ctx, latest); err != nil { + return fmt.Errorf("updating status to RolloutInProgress: %w", err) + } + + return nil +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index f58d907..adc0d32 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -41,6 +41,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1" "github.com/gitcommitankit/agentrax/internal/quota" @@ -119,6 +120,8 @@ var _ = BeforeSuite(func() { // Register apiextensions types so serviceMonitorCRDExists can decode CRD objects // when called from SetupWithManager via the uncached API reader. Expect(apiextensionsv1.AddToScheme(scheme.Scheme)).To(Succeed()) + // Register Gateway API types so HTTPRoute objects can be created in canary tests. + Expect(gatewayv1.Install(scheme.Scheme)).To(Succeed()) // +kubebuilder:scaffold:scheme diff --git a/internal/rollout/canary.go b/internal/rollout/canary.go new file mode 100644 index 0000000..1c55786 --- /dev/null +++ b/internal/rollout/canary.go @@ -0,0 +1,643 @@ +/* +Copyright 2026. + +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. +*/ + +package rollout + +import ( + "context" + "fmt" + "time" + + appsv1 "k8s.io/api/apps/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + + agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1" + "github.com/gitcommitankit/agentrax/internal/metrics" + "github.com/gitcommitankit/agentrax/internal/scaling" +) + +const ( + // canaryVariantLabel is the pod label that distinguishes canary pods from stable pods. + canaryVariantLabel = "agentrax.io/variant" + // canaryVariantValue is the value of canaryVariantLabel for canary pods. + canaryVariantValue = "canary" + // stableVariantValue is the value of canaryVariantLabel for stable pods. + stableVariantValue = "stable" + + // maxPauseExtensionMultiplier is the maximum total pause duration as a multiple + // of the configured pause duration. After this ceiling the evaluator runs regardless + // of sample size. + maxPauseExtensionMultiplier = 3 + + // canaryDeploymentSuffix is appended to the AgentDeployment name to form + // the canary Deployment name, e.g. "my-agent-canary". + canaryDeploymentSuffix = "-canary" +) + +// Controller manages the canary lifecycle for one AgentDeployment. +// It is a pure helper — it does not implement reconcile.Reconciler. +// The AgentDeploymentReconciler calls Step() each reconcile cycle when +// status.phase == RolloutInProgress, and Rollback() when abort is requested. +type Controller struct { + // Client is the controller-runtime client for Kubernetes API access. + Client client.Client + // Scheme is required by controllerutil.SetControllerReference. + Scheme *runtime.Scheme + // PromClient is the Prometheus query client used for threshold evaluation. + PromClient *metrics.Client + // GatewayName is the name of the Gateway API Gateway object. + GatewayName string + // GatewayNamespace is the namespace of the Gateway API Gateway object. + GatewayNamespace string + // FailSafeTimeout is how long Prometheus must be unreachable before + // an automatic rollback fires. + FailSafeTimeout time.Duration +} + +// Step advances the canary state machine by one cycle. +// It is called every reconcile when status.phase == RolloutInProgress. +// The returned ctrl.Result tells the reconciler when to requeue. +func (c *Controller) Step(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) (ctrl.Result, error) { + logger := log.FromContext(ctx).WithValues( + "name", ad.Name, "namespace", ad.Namespace, + "canaryStepIndex", ad.Status.CanaryStepIndex, + ) + + // Self-heal canary resources (Deployment and HTTPRoute) if they drift or are deleted out-of-band. + if ad.Status.CanaryVersion != "" { + if err := c.ensureCanaryDeployment(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("self-healing canary deployment: %w", err) + } + } + if ad.Status.CanaryWeight > 0 { + if err := c.ensureHTTPRoute(ctx, ad, 100-ad.Status.CanaryWeight, ad.Status.CanaryWeight); err != nil { + return ctrl.Result{}, fmt.Errorf("self-healing httproute: %w", err) + } + } + + steps := ad.Spec.Rollout.Steps + idx := ad.Status.CanaryStepIndex + + if idx >= len(steps) { + // All steps completed — this should only happen if Step is called + // after promotion was already triggered. Return without action. + logger.Info("all canary steps already executed; promoting") + return ctrl.Result{}, c.promote(ctx, ad) + } + + step := steps[idx] + + switch { + case step.SetWeight != nil: + return c.executeSetWeight(ctx, ad, idx, *step.SetWeight) + case step.Pause != nil: + return c.executePause(ctx, ad, idx, step.Pause.Duration) + default: + // Malformed step — webhook should have rejected this; fail safe. + return ctrl.Result{}, c.Rollback(ctx, ad, "InvalidStep", + fmt.Sprintf("step %d has neither setWeight nor pause", idx)) + } +} + +// Rollback triggers an immediate rollback of the canary, restoring the stable image. +// It may be called by Step (on threshold breach / fail-safe) or directly by the +// reconciler (on spec.rollout.abort). +func (c *Controller) Rollback(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment, reason, message string) error { + logger := log.FromContext(ctx).WithValues("name", ad.Name, "namespace", ad.Namespace) + logger.Info("rolling back canary", "reason", reason, "message", message) + + // 1. Delete canary Deployment. + if err := c.deleteCanaryDeployment(ctx, ad); err != nil { + return fmt.Errorf("deleting canary deployment during rollback: %w", err) + } + + // 2. Reset HTTPRoute to 100% stable, then delete it. + // Deleting the HTTPRoute is cleaner than leaving it at 100%; the stable + // Service continues to receive all traffic directly from the parent Gateway. + if err := c.deleteHTTPRoute(ctx, ad); err != nil { + return fmt.Errorf("deleting httproute during rollback: %w", err) + } + + // 3. Restore stable HPA. + if err := c.restoreHPA(ctx, ad); err != nil { + return fmt.Errorf("restoring HPA during rollback: %w", err) + } + + // 4. Update status. + latest := &agentraxv1alpha1.AgentDeployment{} + if err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, latest); err != nil { + return fmt.Errorf("re-fetching AD for rollback status update: %w", err) + } + latest.Status.Phase = agentraxv1alpha1.PhaseRolloutFailed + // Preserve CanaryVersion so the reconciler remembers which image failed + // and does not immediately re-trigger a canary for the same image. + if latest.Status.CanaryVersion == "" { + latest.Status.CanaryVersion = ad.Spec.Image + } + latest.Status.CanaryWeight = 0 + latest.Status.CanaryStepIndex = 0 + latest.Status.PauseStartedAt = nil + latest.Status.PromUnreachableSince = nil + + apimeta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ + Type: "RolloutFailed", + Status: metav1.ConditionTrue, + Reason: reason, + Message: message, + ObservedGeneration: latest.Generation, + }) + apimeta.RemoveStatusCondition(&latest.Status.Conditions, agentraxv1alpha1.ConditionSampleInsufficient) + + if err := c.Client.Status().Update(ctx, latest); err != nil { + return fmt.Errorf("updating status after rollback: %w", err) + } + + return nil +} + +// ── SetWeight step ──────────────────────────────────────────────────────────── + +// executeSetWeight applies a traffic-weight step: creates the canary Deployment +// if needed, upserts the HTTPRoute with the target weight split, updates +// status.canaryWeight, and advances to the next step. +func (c *Controller) executeSetWeight(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment, idx int, weight int32) (ctrl.Result, error) { + logger := log.FromContext(ctx).WithValues("name", ad.Name, "step", idx, "weight", weight) + logger.Info("executing setWeight step") + + // Ensure the canary Deployment is running with the new image. + if err := c.ensureCanaryDeployment(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("ensuring canary deployment (step %d): %w", idx, err) + } + + stableWeight := int32(100) - weight + + // Upsert the HTTPRoute with the target traffic split. + if err := c.ensureHTTPRoute(ctx, ad, stableWeight, weight); err != nil { + return ctrl.Result{}, fmt.Errorf("ensuring httproute (step %d): %w", idx, err) + } + + // Update status fields: advance step, record new weight. + latest := &agentraxv1alpha1.AgentDeployment{} + if err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, latest); err != nil { + return ctrl.Result{}, fmt.Errorf("re-fetching AD after setWeight: %w", err) + } + latest.Status.CanaryWeight = weight + latest.Status.CanaryStepIndex = idx + 1 + latest.Status.PauseStartedAt = nil // clear any stale pause time + if err := c.Client.Status().Update(ctx, latest); err != nil { + return ctrl.Result{}, fmt.Errorf("updating status after setWeight: %w", err) + } + + // If the new step index is a setWeight:100 that was the last step, promote + // immediately on the next reconcile (requeue now). + return ctrl.Result{Requeue: true}, nil +} + +// ── Pause step ──────────────────────────────────────────────────────────────── + +// executePause waits for the pause duration to elapse while monitoring canary +// metrics. It may: +// - extend the pause if the sample size is insufficient (up to maxPauseExtensionMultiplier × duration) +// - trigger a rollback if thresholds are breached +// - trigger a rollback if Prometheus is unreachable for > FailSafeTimeout +// - advance to the next step when the pause duration elapses and thresholds are OK +func (c *Controller) executePause(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment, idx int, duration time.Duration) (ctrl.Result, error) { + logger := log.FromContext(ctx).WithValues("name", ad.Name, "step", idx, "duration", duration) + + now := metav1.Now() + + // Record start time on first entry. + latest := &agentraxv1alpha1.AgentDeployment{} + if err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, latest); err != nil { + return ctrl.Result{}, fmt.Errorf("fetching AD in pause step: %w", err) + } + + if latest.Status.PauseStartedAt == nil { + logger.Info("starting pause window") + latest.Status.PauseStartedAt = &now + if err := c.Client.Status().Update(ctx, latest); err != nil { + return ctrl.Result{}, fmt.Errorf("recording pause start time: %w", err) + } + // Requeue after the pause duration so we wake up to evaluate. + return ctrl.Result{RequeueAfter: duration}, nil + } + + elapsed := now.Time.Sub(latest.Status.PauseStartedAt.Time) + maxWait := time.Duration(maxPauseExtensionMultiplier) * duration + + // ── Evaluate thresholds ─────────────────────────────────────────────────── + result, evalErr := Evaluate(ctx, c.PromClient, latest, duration) + if evalErr != nil { + logger.Error(evalErr, "Prometheus unreachable during pause evaluation") + // Mark when Prometheus first became unreachable. + if latest.Status.PromUnreachableSince == nil { + latest.Status.PromUnreachableSince = &now + if updateErr := c.Client.Status().Update(ctx, latest); updateErr != nil { + return ctrl.Result{}, fmt.Errorf("recording prom unreachable time: %w", updateErr) + } + } + // Fire fail-safe rollback if Prometheus has been down too long. + unreachableDuration := now.Time.Sub(latest.Status.PromUnreachableSince.Time) + if unreachableDuration >= c.FailSafeTimeout { + return ctrl.Result{}, c.Rollback(ctx, latest, "PrometheusUnavailable", + fmt.Sprintf("Prometheus unreachable for %s (fail-safe timeout: %s)", unreachableDuration, c.FailSafeTimeout)) + } + // Not yet at fail-safe timeout; requeue in 10s and keep waiting. + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + // Prometheus is reachable — clear any stale unreachable timestamp. + if latest.Status.PromUnreachableSince != nil { + latest.Status.PromUnreachableSince = nil + if err := c.Client.Status().Update(ctx, latest); err != nil { + return ctrl.Result{}, fmt.Errorf("clearing prom unreachable time: %w", err) + } + } + + // ── Threshold breach → rollback ─────────────────────────────────────────── + if result.ThresholdBreached { + logger.Info("threshold breached — rolling back", "reason", result.BreachReason) + return ctrl.Result{}, c.Rollback(ctx, latest, "ThresholdBreached", result.BreachReason) + } + + // ── Sample too small ────────────────────────────────────────────────────── + if result.SampleTooSmall { + apimeta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ + Type: agentraxv1alpha1.ConditionSampleInsufficient, + Status: metav1.ConditionTrue, + Reason: "InsufficientSample", + Message: fmt.Sprintf("request count %.0f below minimum %.0f; extending pause", result.SampleCount, float64(latest.Spec.Rollout.Rollback.MinRequestSample)), + ObservedGeneration: latest.Generation, + }) + if err := c.Client.Status().Update(ctx, latest); err != nil { + return ctrl.Result{}, fmt.Errorf("setting SampleInsufficient condition: %w", err) + } + // If we are still within the max extension window, wait longer. + if elapsed < maxWait { + remaining := duration - (elapsed % duration) + logger.Info("extending pause due to insufficient sample", "elapsed", elapsed, "maxWait", maxWait, "nextCheck", remaining) + return ctrl.Result{RequeueAfter: remaining}, nil + } + // Max extensions exhausted — force evaluation (fall through to advancement below). + logger.Info("max pause extensions exhausted; advancing despite small sample") + } + + // ── Pause duration elapsed and thresholds OK ────────────────────────────── + if elapsed < duration { + // Requeue for the remainder of the pause. + remaining := duration - elapsed + return ctrl.Result{RequeueAfter: remaining}, nil + } + + // Advance to next step. + logger.Info("pause complete; advancing to next step") + apimeta.RemoveStatusCondition(&latest.Status.Conditions, agentraxv1alpha1.ConditionSampleInsufficient) + latest.Status.CanaryStepIndex = idx + 1 + latest.Status.PauseStartedAt = nil + if err := c.Client.Status().Update(ctx, latest); err != nil { + return ctrl.Result{}, fmt.Errorf("advancing step index after pause: %w", err) + } + + return ctrl.Result{Requeue: true}, nil +} + +// ── Promotion ───────────────────────────────────────────────────────────────── + +// promote promotes the canary to stable: updates the stable Deployment image, +// deletes the canary Deployment and HTTPRoute, restores the HPA, and clears +// all canary status fields. +func (c *Controller) promote(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + logger := log.FromContext(ctx).WithValues("name", ad.Name, "namespace", ad.Namespace) + logger.Info("promoting canary to stable", "newImage", ad.Status.CanaryVersion) + + // 1. Update stable Deployment image. + dep := &appsv1.Deployment{} + if err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, dep); err != nil { + return fmt.Errorf("fetching stable deployment for promotion: %w", err) + } + if len(dep.Spec.Template.Spec.Containers) > 0 { + dep.Spec.Template.Spec.Containers[0].Image = ad.Status.CanaryVersion + } + if err := c.Client.Update(ctx, dep); err != nil { + return fmt.Errorf("updating stable deployment image during promotion: %w", err) + } + + // 2. Delete canary Deployment. + if err := c.deleteCanaryDeployment(ctx, ad); err != nil { + return fmt.Errorf("deleting canary deployment during promotion: %w", err) + } + + // 3. Delete HTTPRoute (traffic fully on stable Service again). + if err := c.deleteHTTPRoute(ctx, ad); err != nil { + return fmt.Errorf("deleting httproute during promotion: %w", err) + } + + // 4. Restore HPA. + if err := c.restoreHPA(ctx, ad); err != nil { + return fmt.Errorf("restoring HPA during promotion: %w", err) + } + + // 5. Update status. + latest := &agentraxv1alpha1.AgentDeployment{} + if err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, latest); err != nil { + return fmt.Errorf("re-fetching AD for promotion status update: %w", err) + } + promoted := ad.Status.CanaryVersion + latest.Status.StableVersion = promoted + latest.Status.CanaryVersion = "" + latest.Status.CanaryWeight = 0 + latest.Status.CanaryStepIndex = 0 + latest.Status.PauseStartedAt = nil + latest.Status.PromUnreachableSince = nil + latest.Status.Phase = agentraxv1alpha1.PhaseRunning + + apimeta.RemoveStatusCondition(&latest.Status.Conditions, "RolloutFailed") + apimeta.RemoveStatusCondition(&latest.Status.Conditions, agentraxv1alpha1.ConditionSampleInsufficient) + + if err := c.Client.Status().Update(ctx, latest); err != nil { + return fmt.Errorf("updating status after promotion: %w", err) + } + + logger.Info("canary promoted successfully", "stableVersion", promoted) + return nil +} + +// ── Canary Deployment ───────────────────────────────────────────────────────── + +// ensureCanaryDeployment creates or updates the canary Deployment (-canary) +// with the new image from spec. The canary Deployment is pinned at 1 replica +// and is not managed by HPA — the goal is to get representative traffic samples, +// not to scale the canary independently. +func (c *Controller) ensureCanaryDeployment(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + canaryName := ad.Name + canaryDeploymentSuffix + desired := c.desiredCanaryDeployment(ad, canaryName) + + existing := &appsv1.Deployment{} + err := c.Client.Get(ctx, types.NamespacedName{Name: canaryName, Namespace: ad.Namespace}, existing) + if apierrors.IsNotFound(err) { + if err := controllerutil.SetControllerReference(ad, desired, c.Scheme); err != nil { + return fmt.Errorf("setting owner ref on canary deployment: %w", err) + } + if err := c.Client.Create(ctx, desired); err != nil { + return fmt.Errorf("creating canary deployment: %w", err) + } + return nil + } + if err != nil { + return fmt.Errorf("getting canary deployment: %w", err) + } + + // Update image if it has drifted. + if len(existing.Spec.Template.Spec.Containers) > 0 && + existing.Spec.Template.Spec.Containers[0].Image != ad.Status.CanaryVersion { + existing.Spec.Template.Spec.Containers[0].Image = ad.Status.CanaryVersion + if err := c.Client.Update(ctx, existing); err != nil { + return fmt.Errorf("updating canary deployment image: %w", err) + } + } + return nil +} + +// desiredCanaryDeployment builds the spec for the canary Deployment. +func (c *Controller) desiredCanaryDeployment(ad *agentraxv1alpha1.AgentDeployment, name string) *appsv1.Deployment { + one := int32(1) + port := ad.Spec.Port + if port == 0 { + port = 8080 + } + labels := map[string]string{ + "app.kubernetes.io/name": ad.Name, + "app.kubernetes.io/managed-by": "agentrax", + "agentrax.io/tenant": ad.Spec.TenantRef, + canaryVariantLabel: canaryVariantValue, + } + + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ad.Namespace, + Labels: labels, + }, + Spec: appsv1.DeploymentSpec{ + // Pinned at 1 replica — not HPA-managed. + Replicas: &one, + Selector: &metav1.LabelSelector{MatchLabels: labels}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: labels}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "agent", + Image: ad.Status.CanaryVersion, + Ports: []corev1.ContainerPort{{ContainerPort: port, Protocol: corev1.ProtocolTCP}}, + Resources: ad.Spec.Resources, + Env: ad.Spec.Env, + Args: ad.Spec.Args, + }}, + }, + }, + }, + } +} + +// deleteCanaryDeployment removes the canary Deployment. Not-found is tolerated. +func (c *Controller) deleteCanaryDeployment(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + dep := &appsv1.Deployment{} + canaryName := ad.Name + canaryDeploymentSuffix + err := c.Client.Get(ctx, types.NamespacedName{Name: canaryName, Namespace: ad.Namespace}, dep) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("getting canary deployment for deletion: %w", err) + } + if err := c.Client.Delete(ctx, dep); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("deleting canary deployment: %w", err) + } + return nil +} + +// ── HTTPRoute ───────────────────────────────────────────────────────────────── + +// ensureHTTPRoute creates or updates an HTTPRoute that splits traffic between +// the stable and canary Services according to the given weights (0–100). +// The HTTPRoute forwards all requests matching "/" to both backends, weighted +// by stableWeight and canaryWeight respectively. +func (c *Controller) ensureHTTPRoute(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment, stableWeight, canaryWeight int32) error { + desired := c.desiredHTTPRoute(ad, stableWeight, canaryWeight) + + existing := &gatewayv1.HTTPRoute{} + err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, existing) + if apierrors.IsNotFound(err) { + if err := controllerutil.SetControllerReference(ad, desired, c.Scheme); err != nil { + return fmt.Errorf("setting owner ref on httproute: %w", err) + } + if err := c.Client.Create(ctx, desired); err != nil { + return fmt.Errorf("creating httproute: %w", err) + } + return nil + } + if err != nil { + return fmt.Errorf("getting httproute: %w", err) + } + + // Update weights if they have changed. + existing.Spec = desired.Spec + if err := c.Client.Update(ctx, existing); err != nil { + return fmt.Errorf("updating httproute weights: %w", err) + } + return nil +} + +// desiredHTTPRoute builds an HTTPRoute that routes traffic between the stable +// and canary Services by weight. +func (c *Controller) desiredHTTPRoute(ad *agentraxv1alpha1.AgentDeployment, stableWeight, canaryWeight int32) *gatewayv1.HTTPRoute { + nsPtr := gatewayv1.Namespace(c.GatewayNamespace) + canaryServiceName := gatewayv1.ObjectName(ad.Name + canaryDeploymentSuffix) + stableServiceName := gatewayv1.ObjectName(ad.Name) + portNumber := gatewayv1.PortNumber(ad.Spec.Port) + if portNumber == 0 { + portNumber = 8080 + } + backendKind := gatewayv1.Kind("Service") + backendGroup := gatewayv1.Group("") + pathType := gatewayv1.PathMatchPathPrefix + pathValue := "/" + + return &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: ad.Name, + Namespace: ad.Namespace, + Labels: map[string]string{ + "app.kubernetes.io/name": ad.Name, + "app.kubernetes.io/managed-by": "agentrax", + }, + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{ + Name: gatewayv1.ObjectName(c.GatewayName), + Namespace: &nsPtr, + }}, + }, + Rules: []gatewayv1.HTTPRouteRule{{ + Matches: []gatewayv1.HTTPRouteMatch{{ + Path: &gatewayv1.HTTPPathMatch{ + Type: &pathType, + Value: &pathValue, + }, + }}, + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Kind: &backendKind, + Group: &backendGroup, + Name: stableServiceName, + Port: &portNumber, + }, + Weight: &stableWeight, + }, + }, + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Kind: &backendKind, + Group: &backendGroup, + Name: canaryServiceName, + Port: &portNumber, + }, + Weight: &canaryWeight, + }, + }, + }, + }}, + }, + } +} + +// deleteHTTPRoute removes the HTTPRoute for the AgentDeployment. Not-found is tolerated. +func (c *Controller) deleteHTTPRoute(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + route := &gatewayv1.HTTPRoute{} + err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, route) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("getting httproute for deletion: %w", err) + } + if err := c.Client.Delete(ctx, route); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("deleting httproute: %w", err) + } + return nil +} + +// ── HPA management ──────────────────────────────────────────────────────────── + +// PauseHPA deletes the AgentDeployment's HPA so the canary controller owns +// replica counts exclusively during the rollout. The reconciler's reconcileHPA +// already skips HPA reconciliation when phase == RolloutInProgress, so the HPA +// will not be recreated until promotion or rollback completes. +func (c *Controller) PauseHPA(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + hpa := &autoscalingv2.HorizontalPodAutoscaler{} + err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, hpa) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("getting HPA for pause: %w", err) + } + if err := c.Client.Delete(ctx, hpa); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("pausing HPA: %w", err) + } + return nil +} + +// restoreHPA re-creates the HPA after promotion or rollback using the stable AD spec. +// It passes headroom=0 as a conservative initial value; the main reconciler will +// recompute the correct quota headroom on its next cycle and update accordingly. +func (c *Controller) restoreHPA(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + // headroom=0 is conservative: the main reconciler will fix it on the next cycle. + hpa := scaling.BuildHPA(ad, 0) + if err := controllerutil.SetControllerReference(ad, hpa, c.Scheme); err != nil { + return fmt.Errorf("setting owner ref on restored HPA: %w", err) + } + + existing := &autoscalingv2.HorizontalPodAutoscaler{} + createErr := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, existing) + if apierrors.IsNotFound(createErr) { + if err := c.Client.Create(ctx, hpa); err != nil && !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("creating HPA during restore: %w", err) + } + return nil + } + if createErr != nil { + return fmt.Errorf("checking HPA existence during restore: %w", createErr) + } + // Already exists — the main reconciler will reconcile it properly on its next cycle. + return nil +} diff --git a/internal/rollout/canary_test.go b/internal/rollout/canary_test.go new file mode 100644 index 0000000..bd29b23 --- /dev/null +++ b/internal/rollout/canary_test.go @@ -0,0 +1,496 @@ +/* +Copyright 2026. + +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. +*/ + +package rollout + +import ( + "context" + "testing" + "time" + + appsv1 "k8s.io/api/apps/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1" + "github.com/gitcommitankit/agentrax/internal/metrics" +) + +// ── Controller unit tests ────────────────────────────────────────────────── + +// newTestController constructs a rollout.Controller with a fake client initialized with the scheme. +func newTestController(initObjs ...runtime.Object) (*Controller, client.Client, *runtime.Scheme) { + s := runtime.NewScheme() + _ = agentraxv1alpha1.AddToScheme(s) + _ = appsv1.AddToScheme(s) + _ = corev1.AddToScheme(s) + _ = autoscalingv2.AddToScheme(s) + _ = gatewayv1.Install(s) + + b := fake.NewClientBuilder().WithScheme(s).WithStatusSubresource(&agentraxv1alpha1.AgentDeployment{}) + if len(initObjs) > 0 { + b = b.WithRuntimeObjects(initObjs...) + } + cl := b.Build() + + ctrl := &Controller{ + Client: cl, + Scheme: s, + PromClient: metrics.NewClient("http://localhost:9090"), + GatewayName: "agentrax-gateway", + GatewayNamespace: "agentrax-system", + FailSafeTimeout: 60 * time.Second, + } + return ctrl, cl, s +} + +// TestController_Step_SetWeight verifies that executing a setWeight step creates the canary +// Deployment, creates the HTTPRoute with the specified weights, and advances the step index. +func TestController_Step_SetWeight(t *testing.T) { + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: "img:v2", + TenantRef: "team-a", + Rollout: agentraxv1alpha1.RolloutPolicy{ + Strategy: "Canary", + Steps: []agentraxv1alpha1.CanaryStep{ + {SetWeight: int32Ptr(20)}, + {Pause: &metav1.Duration{Duration: 30 * time.Second}}, + }, + }, + }, + Status: agentraxv1alpha1.AgentDeploymentStatus{ + Phase: agentraxv1alpha1.PhaseRolloutInProgress, + StableVersion: "img:v1", + CanaryVersion: "img:v2", + CanaryStepIndex: 0, + }, + } + + c, cl, _ := newTestController(ad) + res, err := c.Step(context.Background(), ad) + if err != nil { + t.Fatalf("Step failed: %v", err) + } + if !res.Requeue { + t.Errorf("expected Requeue=true after setWeight") + } + + // Verify canary Deployment was created + canaryDep := &appsv1.Deployment{} + if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent-canary", Namespace: "default"}, canaryDep); err != nil { + t.Errorf("canary deployment not found: %v", err) + } else if len(canaryDep.Spec.Template.Spec.Containers) == 0 || canaryDep.Spec.Template.Spec.Containers[0].Image != "img:v2" { + t.Errorf("expected canary deployment image img:v2") + } + + // Verify HTTPRoute was created with 80/20 weights + route := &gatewayv1.HTTPRoute{} + if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, route); err != nil { + t.Errorf("httproute not found: %v", err) + } else { + if len(route.Spec.Rules) == 0 || len(route.Spec.Rules[0].BackendRefs) < 2 { + t.Errorf("expected 2 backend refs in HTTPRoute") + } else { + w0 := *route.Spec.Rules[0].BackendRefs[0].Weight + w1 := *route.Spec.Rules[0].BackendRefs[1].Weight + if w0 != 80 || w1 != 20 { + t.Errorf("expected weights (80, 20), got (%d, %d)", w0, w1) + } + } + } + + // Verify status was advanced + updated := &agentraxv1alpha1.AgentDeployment{} + _ = cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, updated) + if updated.Status.CanaryStepIndex != 1 { + t.Errorf("expected CanaryStepIndex=1, got %d", updated.Status.CanaryStepIndex) + } + if updated.Status.CanaryWeight != 20 { + t.Errorf("expected CanaryWeight=20, got %d", updated.Status.CanaryWeight) + } +} + +// TestController_Step_SelfHealing verifies that Step recreates canary Deployment and HTTPRoute if deleted out-of-band. +func TestController_Step_SelfHealing(t *testing.T) { + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: "img:v2", + TenantRef: "team-a", + Rollout: agentraxv1alpha1.RolloutPolicy{ + Strategy: "Canary", + Steps: []agentraxv1alpha1.CanaryStep{ + {SetWeight: int32Ptr(20)}, + {Pause: &metav1.Duration{Duration: 30 * time.Second}}, + }, + }, + }, + Status: agentraxv1alpha1.AgentDeploymentStatus{ + Phase: agentraxv1alpha1.PhaseRolloutInProgress, + StableVersion: "img:v1", + CanaryVersion: "img:v2", + CanaryWeight: 20, + CanaryStepIndex: 1, // During pause step + }, + } + + c, cl, _ := newTestController(ad) + // Canary deployment and HTTPRoute do NOT exist initially (simulating deletion). + _, _ = c.Step(context.Background(), ad) + + // Verify both were restored by self-healing + canaryDep := &appsv1.Deployment{} + if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent-canary", Namespace: "default"}, canaryDep); err != nil { + t.Errorf("expected self-healing to recreate canary deployment: %v", err) + } + route := &gatewayv1.HTTPRoute{} + if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, route); err != nil { + t.Errorf("expected self-healing to recreate httproute: %v", err) + } +} + +// TestController_Rollback verifies that Rollback tears down canary resources, restores HPA, and sets PhaseRolloutFailed. +func TestController_Rollback(t *testing.T) { + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: "img:v2", + TenantRef: "team-a", + Replicas: agentraxv1alpha1.ScalingPolicy{Min: 1, Max: 5, Metric: "queueDepth", Target: 10}, + Rollout: agentraxv1alpha1.RolloutPolicy{ + Strategy: "Canary", + Abort: true, + }, + }, + Status: agentraxv1alpha1.AgentDeploymentStatus{ + Phase: agentraxv1alpha1.PhaseRolloutInProgress, + StableVersion: "img:v1", + CanaryVersion: "img:v2", + CanaryWeight: 20, + }, + } + + canaryDep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "test-agent-canary", Namespace: "default"}} + route := &gatewayv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}} + + c, cl, _ := newTestController(ad, canaryDep, route) + + err := c.Rollback(context.Background(), ad, "ManualAbort", "spec.rollout.abort was true") + if err != nil { + t.Fatalf("Rollback failed: %v", err) + } + + // Verify canary deployment and httproute were deleted + if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent-canary", Namespace: "default"}, &appsv1.Deployment{}); err == nil { + t.Errorf("expected canary deployment to be deleted") + } + if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, &gatewayv1.HTTPRoute{}); err == nil { + t.Errorf("expected httproute to be deleted") + } + + // Verify HPA was restored + hpa := &autoscalingv2.HorizontalPodAutoscaler{} + if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, hpa); err != nil { + t.Errorf("expected HPA to be restored: %v", err) + } + + // Verify status was updated to RolloutFailed and canaryVersion preserved + updated := &agentraxv1alpha1.AgentDeployment{} + _ = cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, updated) + if updated.Status.Phase != agentraxv1alpha1.PhaseRolloutFailed { + t.Errorf("expected Phase=RolloutFailed, got %s", updated.Status.Phase) + } + if updated.Status.CanaryVersion != "img:v2" { + t.Errorf("expected CanaryVersion=img:v2 to be preserved, got %s", updated.Status.CanaryVersion) + } +} + +// TestController_Promote verifies that promote updates the stable deployment image and cleans up canary resources. +func TestController_Promote(t *testing.T) { + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: "img:v2", + TenantRef: "team-a", + Replicas: agentraxv1alpha1.ScalingPolicy{Min: 1, Max: 5, Metric: "queueDepth", Target: 10}, + }, + Status: agentraxv1alpha1.AgentDeploymentStatus{ + Phase: agentraxv1alpha1.PhaseRolloutInProgress, + StableVersion: "img:v1", + CanaryVersion: "img:v2", + }, + } + + stableDep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "agent", Image: "img:v1"}}, + }, + }, + }, + } + canaryDep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "test-agent-canary", Namespace: "default"}} + + c, cl, _ := newTestController(ad, stableDep, canaryDep) + + err := c.promote(context.Background(), ad) + if err != nil { + t.Fatalf("promote failed: %v", err) + } + + // Verify stable deployment image was updated + updatedDep := &appsv1.Deployment{} + _ = cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, updatedDep) + if len(updatedDep.Spec.Template.Spec.Containers) == 0 || updatedDep.Spec.Template.Spec.Containers[0].Image != "img:v2" { + t.Errorf("expected stable deployment image img:v2, got %v", updatedDep.Spec.Template.Spec.Containers) + } + + // Verify status was updated to Running + updatedAD := &agentraxv1alpha1.AgentDeployment{} + _ = cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, updatedAD) + if updatedAD.Status.Phase != agentraxv1alpha1.PhaseRunning { + t.Errorf("expected Phase=Running after promote, got %s", updatedAD.Status.Phase) + } +} + +// TestController_PauseHPA_RestoreHPA verifies pausing and restoring the HPA. +func TestController_PauseHPA_RestoreHPA(t *testing.T) { + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: "img:v1", + TenantRef: "team-a", + Replicas: agentraxv1alpha1.ScalingPolicy{Min: 1, Max: 5, Metric: "queueDepth", Target: 10}, + }, + } + existingHPA := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + } + + c, cl, _ := newTestController(ad, existingHPA) + + // Pause HPA + if err := c.PauseHPA(context.Background(), ad); err != nil { + t.Fatalf("PauseHPA failed: %v", err) + } + if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, &autoscalingv2.HorizontalPodAutoscaler{}); err == nil { + t.Errorf("expected HPA to be deleted during PauseHPA") + } + + // Restore HPA + if err := c.restoreHPA(context.Background(), ad); err != nil { + t.Fatalf("restoreHPA failed: %v", err) + } + restoredHPA := &autoscalingv2.HorizontalPodAutoscaler{} + if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, restoredHPA); err != nil { + t.Errorf("expected HPA to be restored: %v", err) + } +} + +// TestController_ExecutePause_InitialEntry verifies that the first entry into a pause step records PauseStartedAt and requeues. +func TestController_ExecutePause_InitialEntry(t *testing.T) { + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: "img:v2", + TenantRef: "team-a", + Rollout: agentraxv1alpha1.RolloutPolicy{ + Strategy: "Canary", + Steps: []agentraxv1alpha1.CanaryStep{ + {Pause: &metav1.Duration{Duration: 30 * time.Second}}, + }, + }, + }, + Status: agentraxv1alpha1.AgentDeploymentStatus{ + Phase: agentraxv1alpha1.PhaseRolloutInProgress, + StableVersion: "img:v1", + CanaryVersion: "img:v2", + CanaryStepIndex: 0, + }, + } + + c, cl, _ := newTestController(ad) + res, err := c.executePause(context.Background(), ad, 0, 30*time.Second) + if err != nil { + t.Fatalf("executePause failed: %v", err) + } + if res.RequeueAfter != 30*time.Second { + t.Errorf("expected RequeueAfter=30s, got %v", res.RequeueAfter) + } + + updated := &agentraxv1alpha1.AgentDeployment{} + _ = cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, updated) + if updated.Status.PauseStartedAt == nil { + t.Errorf("expected PauseStartedAt to be recorded") + } +} + +// TestController_ExecutePause_Success_Advance verifies that when pause duration has elapsed and metrics pass, the step index advances. +func TestController_ExecutePause_Success_Advance(t *testing.T) { + srv := prometheusServer(t, []float64{200.0, 0.01, 100.0}) + defer srv.Close() + + past := metav1.NewTime(time.Now().Add(-40 * time.Second)) + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: "img:v2", + TenantRef: "team-a", + Rollout: agentraxv1alpha1.RolloutPolicy{ + Strategy: "Canary", + Steps: []agentraxv1alpha1.CanaryStep{ + {Pause: &metav1.Duration{Duration: 30 * time.Second}}, + {SetWeight: int32Ptr(100)}, + }, + Rollback: agentraxv1alpha1.RollbackPolicy{ + MaxErrorRate: "5%", + MaxP99LatencyMs: 500, + MinRequestSample: 100, + }, + }, + }, + Status: agentraxv1alpha1.AgentDeploymentStatus{ + Phase: agentraxv1alpha1.PhaseRolloutInProgress, + StableVersion: "img:v1", + CanaryVersion: "img:v2", + CanaryStepIndex: 0, + PauseStartedAt: &past, + }, + } + + c, cl, _ := newTestController(ad) + c.PromClient = metrics.NewClient(srv.URL) + + res, err := c.executePause(context.Background(), ad, 0, 30*time.Second) + if err != nil { + t.Fatalf("executePause failed: %v", err) + } + if !res.Requeue { + t.Errorf("expected Requeue=true after pause completion to process next step") + } + + updated := &agentraxv1alpha1.AgentDeployment{} + _ = cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, updated) + if updated.Status.CanaryStepIndex != 1 { + t.Errorf("expected CanaryStepIndex=1 after advancement, got %d", updated.Status.CanaryStepIndex) + } + if updated.Status.PauseStartedAt != nil { + t.Errorf("expected PauseStartedAt to be reset to nil") + } +} + +// TestController_ExecutePause_PrometheusUnreachable_FailSafe verifies that if Prometheus is down past the fail-safe timeout, rollback is triggered. +func TestController_ExecutePause_PrometheusUnreachable_FailSafe(t *testing.T) { + started := metav1.NewTime(time.Now().Add(-100 * time.Second)) + unreachableSince := metav1.NewTime(time.Now().Add(-70 * time.Second)) // > 60s + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: "img:v2", + TenantRef: "team-a", + Rollout: agentraxv1alpha1.RolloutPolicy{ + Strategy: "Canary", + Steps: []agentraxv1alpha1.CanaryStep{ + {Pause: &metav1.Duration{Duration: 30 * time.Second}}, + }, + }, + }, + Status: agentraxv1alpha1.AgentDeploymentStatus{ + Phase: agentraxv1alpha1.PhaseRolloutInProgress, + StableVersion: "img:v1", + CanaryVersion: "img:v2", + CanaryStepIndex: 0, + PauseStartedAt: &started, + PromUnreachableSince: &unreachableSince, + }, + } + + c, cl, _ := newTestController(ad) + c.PromClient = metrics.NewClient("http://127.0.0.1:1") // Unreachable port + c.FailSafeTimeout = 60 * time.Second + + _, err := c.executePause(context.Background(), ad, 0, 30*time.Second) + if err != nil { + t.Fatalf("executePause failed: %v", err) + } + updated := &agentraxv1alpha1.AgentDeployment{} + _ = cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, updated) + if updated.Status.Phase != agentraxv1alpha1.PhaseRolloutFailed { + t.Errorf("expected Phase=RolloutFailed after fail-safe timeout, got %s", updated.Status.Phase) + } +} + +// TestController_ExecutePause_SampleTooSmall verifies that when sample count is below minRequestSample, the pause is extended and ConditionSampleInsufficient is set. +func TestController_ExecutePause_SampleTooSmall(t *testing.T) { + srv := prometheusServer(t, []float64{50.0, 0.0, 50.0}) // count=50 < min 100 + defer srv.Close() + + past := metav1.NewTime(time.Now().Add(-10 * time.Second)) + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: "img:v2", + TenantRef: "team-a", + Rollout: agentraxv1alpha1.RolloutPolicy{ + Strategy: "Canary", + Steps: []agentraxv1alpha1.CanaryStep{ + {Pause: &metav1.Duration{Duration: 30 * time.Second}}, + }, + Rollback: agentraxv1alpha1.RollbackPolicy{ + MinRequestSample: 100, + }, + }, + }, + Status: agentraxv1alpha1.AgentDeploymentStatus{ + Phase: agentraxv1alpha1.PhaseRolloutInProgress, + StableVersion: "img:v1", + CanaryVersion: "img:v2", + CanaryStepIndex: 0, + PauseStartedAt: &past, + }, + } + + c, cl, _ := newTestController(ad) + c.PromClient = metrics.NewClient(srv.URL) + + res, err := c.executePause(context.Background(), ad, 0, 30*time.Second) + if err != nil { + t.Fatalf("executePause failed: %v", err) + } + if res.RequeueAfter == 0 { + t.Errorf("expected non-zero RequeueAfter for pause extension") + } + + updated := &agentraxv1alpha1.AgentDeployment{} + _ = cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, updated) + cond := apimeta.FindStatusCondition(updated.Status.Conditions, agentraxv1alpha1.ConditionSampleInsufficient) + if cond == nil || cond.Status != metav1.ConditionTrue { + t.Errorf("expected ConditionSampleInsufficient to be True") + } +} + +func int32Ptr(i int32) *int32 { return &i } diff --git a/internal/rollout/promql.go b/internal/rollout/promql.go new file mode 100644 index 0000000..7b4538a --- /dev/null +++ b/internal/rollout/promql.go @@ -0,0 +1,184 @@ +/* +Copyright 2026. + +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. +*/ + +// Package rollout implements the canary rollout state machine for AgentDeployment. +// It drives progressive traffic shifting via Gateway API HTTPRoute, evaluates +// PromQL-based rollback thresholds, and handles automatic rollback on breach or +// Prometheus unavailability (fail-safe). +package rollout + +import ( + "context" + "fmt" + "time" + + agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1" + "github.com/gitcommitankit/agentrax/internal/metrics" +) + +// ── PromQL query templates ──────────────────────────────────────────────────── + +// requestCountQuery returns a PromQL expression that sums the total number of +// HTTP requests received by the canary pods over the given window. +// The label selectors match the canary Deployment's pod labels: +// - app.kubernetes.io/name= +// - agentrax.io/variant=canary +func requestCountQuery(adName, namespace string, window time.Duration) string { + return fmt.Sprintf( + `sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,variant="canary"}[%s]))`, + namespace, adName, promDuration(window), + ) +} + +// errorRateQuery returns a PromQL expression computing the fraction of 5xx +// responses out of total requests for the canary, over the given window. +// Returns 0 if no requests have been received (safe division). +func errorRateQuery(adName, namespace string, window time.Duration) string { + d := promDuration(window) + return fmt.Sprintf( + `sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,variant="canary",code=~"5.."}[%s]))`+ + ` / on() group_left `+ + `clamp_min(sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,variant="canary"}[%s])), 1)`, + namespace, adName, d, + namespace, adName, d, + ) +} + +// p99LatencyQuery returns a PromQL expression for the 99th-percentile request +// latency in milliseconds for the canary pods over the given window. +func p99LatencyQuery(adName, namespace string, window time.Duration) string { + return fmt.Sprintf( + `histogram_quantile(0.99, sum by (le) (`+ + `rate(http_request_duration_milliseconds_bucket{namespace=%q,app_kubernetes_io_name=%q,variant="canary"}[%s])))`, + namespace, adName, promDuration(window), + ) +} + +// promDuration formats a time.Duration into the Prometheus duration string +// understood by range selectors and the rate()/increase() functions. +// e.g. 5*time.Minute → "5m0s". +func promDuration(d time.Duration) string { + return d.String() +} + +// ── Evaluation ──────────────────────────────────────────────────────────────── + +// EvaluationResult holds the outcome of a single canary threshold evaluation cycle. +type EvaluationResult struct { + // SampleCount is the total request count observed in the evaluation window. + SampleCount float64 + // ErrorRate is the fraction of 5xx responses (0.0–1.0). + ErrorRate float64 + // P99LatencyMs is the 99th-percentile latency in milliseconds. + P99LatencyMs float64 + // SampleTooSmall is true when SampleCount < minRequestSample, meaning + // threshold evaluation was skipped to avoid false positives. + SampleTooSmall bool + // ThresholdBreached is true when any rollback threshold was exceeded. + ThresholdBreached bool + // BreachReason is a human-readable description of the breach, if any. + BreachReason string +} + +// Evaluate queries Prometheus for all canary metrics and evaluates them against +// the rollback policy defined in the AgentDeployment spec. +// +// A non-nil error means Prometheus was unreachable or returned a malformed response. +// The caller is responsible for tracking how long Prometheus has been unreachable +// and triggering a fail-safe rollback after FailSafeTimeout. +// +// When Prometheus is reachable but SampleCount < minRequestSample, EvaluationResult +// has SampleTooSmall=true and ThresholdBreached=false — the caller should extend +// the pause rather than act. +func Evaluate( + ctx context.Context, + promClient *metrics.Client, + ad *agentraxv1alpha1.AgentDeployment, + window time.Duration, +) (EvaluationResult, error) { + name := ad.Name + ns := ad.Namespace + policy := ad.Spec.Rollout.Rollback + + // ── 1. Sample-size gate ─────────────────────────────────────────────────── + sampleCount, err := promClient.QueryScalar(ctx, requestCountQuery(name, ns, window)) + if err != nil { + return EvaluationResult{}, fmt.Errorf("querying request count: %w", err) + } + + minSample := float64(policy.MinRequestSample) + if minSample <= 0 { + minSample = 100 // conservative default if not configured + } + + if sampleCount < minSample { + return EvaluationResult{ + SampleCount: sampleCount, + SampleTooSmall: true, + }, nil + } + + // ── 2. Error rate threshold ─────────────────────────────────────────────── + errorRate, err := promClient.QueryScalar(ctx, errorRateQuery(name, ns, window)) + if err != nil { + return EvaluationResult{}, fmt.Errorf("querying error rate: %w", err) + } + + if policy.MaxErrorRate != "" { + maxRate, parseErr := agentraxv1alpha1.ParseErrorRate(policy.MaxErrorRate) + if parseErr != nil { + // Misconfigured spec — treat as a breach to fail safe. + return EvaluationResult{ + SampleCount: sampleCount, + ErrorRate: errorRate, + ThresholdBreached: true, + BreachReason: fmt.Sprintf("invalid maxErrorRate %q: %v", policy.MaxErrorRate, parseErr), + }, nil + } + if errorRate > maxRate { + return EvaluationResult{ + SampleCount: sampleCount, + ErrorRate: errorRate, + ThresholdBreached: true, + BreachReason: fmt.Sprintf("error rate %.2f%% exceeds threshold %.2f%%", + errorRate*100, maxRate*100), + }, nil + } + } + + // ── 3. p99 latency threshold ────────────────────────────────────────────── + p99Ms, err := promClient.QueryScalar(ctx, p99LatencyQuery(name, ns, window)) + if err != nil { + return EvaluationResult{}, fmt.Errorf("querying p99 latency: %w", err) + } + + if policy.MaxP99LatencyMs > 0 && p99Ms > float64(policy.MaxP99LatencyMs) { + return EvaluationResult{ + SampleCount: sampleCount, + ErrorRate: errorRate, + P99LatencyMs: p99Ms, + ThresholdBreached: true, + BreachReason: fmt.Sprintf("p99 latency %.1fms exceeds threshold %dms", + p99Ms, policy.MaxP99LatencyMs), + }, nil + } + + return EvaluationResult{ + SampleCount: sampleCount, + ErrorRate: errorRate, + P99LatencyMs: p99Ms, + }, nil +} diff --git a/internal/rollout/promql_test.go b/internal/rollout/promql_test.go new file mode 100644 index 0000000..67a62f1 --- /dev/null +++ b/internal/rollout/promql_test.go @@ -0,0 +1,321 @@ +/* +Copyright 2026. + +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. +*/ + +package rollout + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1" + "github.com/gitcommitankit/agentrax/internal/metrics" +) + +// ── helpers ──────────────────────────────────────────────────────────────────── + +// prometheusServer creates an httptest.Server that returns fixed Prometheus +// instant query responses in round-robin sequence. Each successive HTTP request +// returns the next value from responses. Evaluate calls the Prometheus API +// exactly three times per cycle: (1) request count, (2) error rate, (3) p99. +// Pass values in that order. +func prometheusServer(t *testing.T, responses []float64) *httptest.Server { + t.Helper() + idx := 0 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + val := 0.0 + if idx < len(responses) { + val = responses[idx] + idx++ + } + body := buildVectorResponse(val) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) +} + +// errorServer returns a Prometheus server that always responds with HTTP 500. +func errorServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) +} + +// buildVectorResponse serializes a single-element Prometheus vector response. +func buildVectorResponse(value float64) []byte { + resp := map[string]interface{}{ + "status": "success", + "data": map[string]interface{}{ + "resultType": "vector", + "result": []interface{}{ + map[string]interface{}{ + "metric": map[string]interface{}{}, + "value": []interface{}{1234567890.0, fmt.Sprintf("%g", value)}, + }, + }, + }, + } + b, _ := json.Marshal(resp) + return b +} + +// makeAD returns a minimal AgentDeployment for testing. +func makeAD(name, ns string, maxErrorRate string, maxP99Ms int32, minSample int32) *agentraxv1alpha1.AgentDeployment { + return &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: "img:v1", + TenantRef: "tq", + Rollout: agentraxv1alpha1.RolloutPolicy{ + Strategy: "Canary", + Rollback: agentraxv1alpha1.RollbackPolicy{ + MaxErrorRate: maxErrorRate, + MaxP99LatencyMs: maxP99Ms, + MinRequestSample: minSample, + }, + }, + }, + } +} + +// ── Query template tests ─────────────────────────────────────────────────────── + +// TestQueryTemplates verifies that the three PromQL query builders produce valid +// queries that embed the AgentDeployment name, namespace, and window correctly. +func TestQueryTemplates(t *testing.T) { + ad := makeAD("my-agent", "tenant-prod", "1%", 200, 100) + window := 2 * time.Minute + + reqQuery := requestCountQuery(ad.Name, ad.Namespace, window) + errQuery := errorRateQuery(ad.Name, ad.Namespace, window) + p99Query := p99LatencyQuery(ad.Name, ad.Namespace, window) + + for name, q := range map[string]string{ + "requestCount": reqQuery, + "errorRate": errQuery, + "p99Latency": p99Query, + } { + if !strings.Contains(q, `namespace="tenant-prod"`) { + t.Errorf("%s query missing namespace selector: %q", name, q) + } + if !strings.Contains(q, `app_kubernetes_io_name="my-agent"`) { + t.Errorf("%s query missing agent name selector: %q", name, q) + } + if !strings.Contains(q, `variant="canary"`) { + t.Errorf("%s query missing variant=canary selector: %q", name, q) + } + if !strings.Contains(q, "[2m0s]") { + t.Errorf("%s query missing duration window [2m0s]: %q", name, q) + } + } +} + +// ── Evaluate tests ───────────────────────────────────────────────────────────── + +// TestEvaluate_AllWithinThresholds verifies that when request count is sufficient +// and both error rate and latency are below configured maxima, Evaluate returns +// ThresholdBreached=false and SampleTooSmall=false. +func TestEvaluate_AllWithinThresholds(t *testing.T) { + // Sequence: count=200, errorRate=0.005 (0.5%), p99=80ms (0.080s). + srv := prometheusServer(t, []float64{200.0, 0.005, 0.080}) + defer srv.Close() + + ad := makeAD("agent", "ns", "1%", 200, 100) + result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ThresholdBreached { + t.Errorf("expected ThresholdBreached=false, got reason: %s", result.BreachReason) + } + if result.SampleTooSmall { + t.Errorf("expected SampleTooSmall=false, got count=%.0f", result.SampleCount) + } +} + +// TestEvaluate_SampleTooSmall verifies that when request count is below +// minRequestSample, SampleTooSmall=true and ThresholdBreached=false (never evaluate). +func TestEvaluate_SampleTooSmall(t *testing.T) { + // Sequence: count=42 (below min 100); error rate and p99 queries should not occur. + srv := prometheusServer(t, []float64{42.0}) + defer srv.Close() + + ad := makeAD("agent", "ns", "1%", 200, 100) + result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.SampleTooSmall { + t.Errorf("expected SampleTooSmall=true when count(42) < min(100)") + } + if result.ThresholdBreached { + t.Errorf("expected ThresholdBreached=false when sample is too small; got reason: %s", result.BreachReason) + } + if result.SampleCount != 42.0 { + t.Errorf("expected SampleCount=42, got %g", result.SampleCount) + } +} + +// TestEvaluate_ErrorRateBreached verifies that an error rate exceeding MaxErrorRate +// triggers ThresholdBreached=true with an appropriate reason. +func TestEvaluate_ErrorRateBreached(t *testing.T) { + // Sequence: count=500 (sufficient), errorRate=0.03 (3% > 1% max), p99=50ms. + srv := prometheusServer(t, []float64{500.0, 0.03, 0.050}) + defer srv.Close() + + ad := makeAD("agent", "ns", "1%", 200, 100) + result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.ThresholdBreached { + t.Errorf("expected ThresholdBreached=true for error rate 3%% > 1%%") + } + if !strings.Contains(result.BreachReason, "error rate") { + t.Errorf("expected breach reason to mention error rate; got %q", result.BreachReason) + } +} + +// TestEvaluate_P99LatencyBreached verifies that a p99 latency exceeding +// MaxP99LatencyMs triggers ThresholdBreached=true with an appropriate reason. +func TestEvaluate_P99LatencyBreached(t *testing.T) { + // Sequence: count=500 (sufficient), errorRate=0.001 (0.1%), p99=350ms (> 200ms max). + srv := prometheusServer(t, []float64{500.0, 0.001, 350.0}) + defer srv.Close() + + ad := makeAD("agent", "ns", "1%", 200, 100) + result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.ThresholdBreached { + t.Errorf("expected ThresholdBreached=true for p99 350ms > 200ms") + } + if !strings.Contains(result.BreachReason, "p99 latency") { + t.Errorf("expected breach reason to mention p99 latency; got %q", result.BreachReason) + } +} + +// TestEvaluate_PrometheusUnreachable verifies that a down/erroring Prometheus +// returns a non-nil error from Evaluate so the caller can start the fail-safe timer. +func TestEvaluate_PrometheusUnreachable(t *testing.T) { + srv := errorServer() + defer srv.Close() + + ad := makeAD("agent", "ns", "1%", 200, 100) + _, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) + + if err == nil { + t.Fatal("expected error when Prometheus returns HTTP 500; got nil") + } +} + +// TestEvaluate_EmptyVector verifies that an empty Prometheus result vector +// (no vector elements returned) returns an error from Evaluate. +func TestEvaluate_EmptyVector(t *testing.T) { + // Empty vector response: {"status":"success","data":{"resultType":"vector","result":[]}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body := `{"status":"success","data":{"resultType":"vector","result":[]}}` + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + ad := makeAD("agent", "ns", "1%", 200, 100) + _, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) + + if err == nil { + t.Errorf("expected error on empty vector (0 elements), got nil") + } +} + +// TestEvaluate_DefaultThresholds verifies that when RollbackPolicy fields are +// omitted, sensible defaults (1%, 500ms, min 100) apply. +func TestEvaluate_DefaultThresholds(t *testing.T) { + // Count=50 is below default min(100) -> SampleTooSmall. + srv := prometheusServer(t, []float64{50.0}) + defer srv.Close() + + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "agent", Namespace: "ns"}, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: "img:v1", + TenantRef: "tq", + Rollout: agentraxv1alpha1.RolloutPolicy{ + Strategy: "Canary", + // Rollback policy omitted completely — testing defaults. + }, + }, + } + result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 5*time.Minute) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.SampleTooSmall { + t.Errorf("expected SampleTooSmall=true when count(50) < default min(100)") + } +} + +// TestEvaluate_InvalidMaxErrorRate verifies that a misconfigured MaxErrorRate +// (not parseable as a percentage) results in ThresholdBreached=true as a +// fail-safe rather than silently passing. +func TestEvaluate_InvalidMaxErrorRate(t *testing.T) { + // Sequence: count=200 (sufficient), errorRate=0.01 (parsed but config invalid), p99=100ms. + srv := prometheusServer(t, []float64{200.0, 0.01, 100.0}) + defer srv.Close() + + ad := makeAD("agent", "ns", "not-a-percentage", 500, 100) + result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 5*time.Minute) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.ThresholdBreached { + t.Error("expected ThresholdBreached=true for invalid maxErrorRate (fail-safe)") + } +} + +// ── promDuration helper ─────────────────────────────────────────────────────── + +// TestPromDuration_Format verifies that promDuration formats durations correctly. +func TestPromDuration_Format(t *testing.T) { + cases := []struct { + in time.Duration + want string + }{ + {5 * time.Minute, "5m0s"}, + {time.Hour, "1h0m0s"}, + {30 * time.Second, "30s"}, + } + for _, tc := range cases { + got := promDuration(tc.in) + if got != tc.want { + t.Errorf("promDuration(%v) = %q, want %q", tc.in, got, tc.want) + } + } +} From 773dad7149fe55ae8bfbe220baa2cdc33ba2b9c3 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:51:38 +0000 Subject: [PATCH 2/5] fix: apply CodeRabbit auto-fixes Fixed 8 file(s) based on 15 unresolved review comments. Co-authored-by: CodeRabbit --- docs/agentrax.md | 2 +- go.mod | 16 +- go.sum | 28 +-- .../controller/agentdeployment_controller.go | 57 +++-- internal/rollout/canary.go | 219 ++++++++++++++---- internal/rollout/canary_test.go | 34 ++- internal/rollout/promql.go | 60 ++--- internal/rollout/promql_test.go | 4 +- 8 files changed, 301 insertions(+), 119 deletions(-) diff --git a/docs/agentrax.md b/docs/agentrax.md index 06887a8..e70f062 100644 --- a/docs/agentrax.md +++ b/docs/agentrax.md @@ -48,7 +48,7 @@ Phases 2, 3, and 5 are independent of each other and can run in parallel once Ph The canary controller (`internal/rollout.Controller`) is a pure helper driven by the `AgentDeploymentReconciler` each reconcile cycle. When `spec.rollout.strategy: Canary` and `spec.image` differs from `status.stableVersion`, the reconciler transitions to `RolloutInProgress` and calls `Step()` on every subsequent reconcile. -**State machine**: `setWeight` steps create the canary Deployment and upsert a Gateway API `HTTPRoute` with the target weight split. `pause` steps query Prometheus for request count (sample gate), error rate, and p99 latency via `internal/metrics.Client`. If Prometheus is unreachable for longer than `--fail-safe-timeout` (default 60s), a fail-safe rollback fires. Sample-size gating extends the pause window up to `3×pause_duration` before forcing evaluation. Promotion updates the stable Deployment image and restores the HPA; rollback reverts everything and sets `phase=RolloutFailed`. +**State machine**: `setWeight` steps create the canary Deployment and upsert a Gateway API `HTTPRoute` with the target weight split. `pause` steps query Prometheus for request count (sample gate), error rate, and p99 latency via `internal/metrics.Client`. If Prometheus is unreachable for longer than a fixed 60-second timeout, a fail-safe rollback fires. Sample-size gating extends the pause window up to the lesser of `3×pause_duration` or an absolute 15-minute maximum before forcing evaluation. Promotion updates the stable Deployment image and restores the HPA; rollback reverts everything and sets `phase=RolloutFailed`. **New operator flags**: `--prometheus-url` (required for Canary), `--gateway-name`, `--gateway-namespace`. diff --git a/go.mod b/go.mod index 993ed6f..94aba3a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/gitcommitankit/agentrax -go 1.22.0 +go 1.25.0 require ( github.com/go-logr/logr v1.4.2 @@ -72,14 +72,14 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect - golang.org/x/net v0.26.0 // indirect - golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/term v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/tools v0.45.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect diff --git a/go.sum b/go.sum index 9d208d0..f3277a0 100644 --- a/go.sum +++ b/go.sum @@ -157,34 +157,34 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/controller/agentdeployment_controller.go b/internal/controller/agentdeployment_controller.go index fad2237..3bf982c 100644 --- a/internal/controller/agentdeployment_controller.go +++ b/internal/controller/agentdeployment_controller.go @@ -161,22 +161,9 @@ func (r *AgentDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, nil } - // 3. Reconcile child Deployment. - if err := r.reconcileDeployment(ctx, ad); err != nil { - return ctrl.Result{}, fmt.Errorf("reconciling deployment: %w", err) - } - - // 4. Reconcile child Service. - if err := r.reconcileService(ctx, ad); err != nil { - return ctrl.Result{}, fmt.Errorf("reconciling service: %w", err) - } - - // 5. Reconcile ServiceMonitor when Prometheus Operator is present. - if err := r.reconcileServiceMonitor(ctx, ad); err != nil { - return ctrl.Result{}, fmt.Errorf("reconciling servicemonitor: %w", err) - } - - // 5.5. Handle canary rollout lifecycle. + // 3. Handle canary rollout lifecycle before reconciling stable Deployment. + // During rollout, the stable Deployment image must remain at status.stableVersion, + // not spec.image. Canary handling determines which image to use for stable resources. if r.CanaryController != nil { // Manual abort: spec.rollout.abort=true triggers immediate rollback. if isAbortRequested(ad) { @@ -203,7 +190,22 @@ func (r *AgentDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Requ } } - // 6. Reconcile the managed HPA (skip during active canary — Phase 4 owns it). + // 4. Reconcile child Deployment. + if err := r.reconcileDeployment(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("reconciling deployment: %w", err) + } + + // 5. Reconcile child Service. + if err := r.reconcileService(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("reconciling service: %w", err) + } + + // 6. Reconcile ServiceMonitor when Prometheus Operator is present. + if err := r.reconcileServiceMonitor(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("reconciling servicemonitor: %w", err) + } + + // 7. Reconcile the managed HPA (skip during active canary — Phase 4 owns it). // reconcileHPA also returns the quota evaluation state so updateStatus can // write the correct QuotaLimited condition onto the freshly re-fetched object. hpaResult, qs, err := r.reconcileHPA(ctx, ad) @@ -211,7 +213,7 @@ func (r *AgentDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, fmt.Errorf("reconciling hpa: %w", err) } - // 7. Derive status from the live Deployment and update it — always last. + // 8. Derive status from the live Deployment and update it — always last. // We continue into updateStatus even when hpaResult requests a requeue so // that the QuotaLimited condition is written in the same reconcile cycle. // Return the shorter of the two requeue intervals. @@ -592,15 +594,19 @@ func (r *AgentDeploymentReconciler) detectImagePullFailure(ctx context.Context, // ── Desired-state builders ──────────────────────────────────────────────────── // agentLabels returns the canonical label set applied to all resources owned by ad. +// For stable resources (Deployment, Service), this includes variant=stable. func agentLabels(ad *agentraxv1alpha1.AgentDeployment) map[string]string { return map[string]string{ "app.kubernetes.io/name": ad.Name, "app.kubernetes.io/managed-by": "agentrax", "agentrax.io/tenant": ad.Spec.TenantRef, + "agentrax.io/variant": "stable", } } // desiredDeployment builds the Deployment spec the reconciler wants to exist. +// During a canary rollout (PhaseRolloutInProgress), the stable Deployment image +// is frozen at status.stableVersion; otherwise it tracks spec.image. func (r *AgentDeploymentReconciler) desiredDeployment(ad *agentraxv1alpha1.AgentDeployment) *appsv1.Deployment { port := ad.Spec.Port if port == 0 { @@ -610,6 +616,12 @@ func (r *AgentDeploymentReconciler) desiredDeployment(ad *agentraxv1alpha1.Agent labels := agentLabels(ad) replicas := ad.Spec.Replicas.Min + // During rollout, freeze the stable Deployment at status.stableVersion. + image := ad.Spec.Image + if ad.Status.Phase == agentraxv1alpha1.PhaseRolloutInProgress && ad.Status.StableVersion != "" { + image = ad.Status.StableVersion + } + return &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: ad.Name, @@ -631,7 +643,7 @@ func (r *AgentDeploymentReconciler) desiredDeployment(ad *agentraxv1alpha1.Agent // Use a fixed container name that is always DNS-1035 compliant. // The AgentDeployment name is used at the object level, not container level. Name: "agent", - Image: ad.Spec.Image, + Image: image, Ports: []corev1.ContainerPort{ { Name: "agent", @@ -699,12 +711,13 @@ func (r *AgentDeploymentReconciler) desiredServiceMonitor(ad *agentraxv1alpha1.A Selector: metav1.LabelSelector{ MatchLabels: labels, }, - // Carry the two labels used by the HPA ExternalMetric selector into - // every scraped sample. Prometheus will sanitize the dots to underscores - // during ingestion (app.kubernetes.io/name → app_kubernetes_io_name). + // Carry labels into every scraped sample. Prometheus will sanitize the dots + // to underscores during ingestion (app.kubernetes.io/name → app_kubernetes_io_name, + // agentrax.io/variant → agentrax_io_variant). TargetLabels: []string{ "app.kubernetes.io/name", "app.kubernetes.io/managed-by", + "agentrax.io/variant", }, Endpoints: []monitoringv1.Endpoint{ { diff --git a/internal/rollout/canary.go b/internal/rollout/canary.go index 1c55786..51927fa 100644 --- a/internal/rollout/canary.go +++ b/internal/rollout/canary.go @@ -24,10 +24,12 @@ import ( appsv1 "k8s.io/api/apps/v1" autoscalingv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -88,11 +90,14 @@ func (c *Controller) Step(ctx context.Context, ad *agentraxv1alpha1.AgentDeploym "canaryStepIndex", ad.Status.CanaryStepIndex, ) - // Self-heal canary resources (Deployment and HTTPRoute) if they drift or are deleted out-of-band. + // Self-heal canary resources (Deployment, Service, and HTTPRoute) if they drift or are deleted out-of-band. if ad.Status.CanaryVersion != "" { if err := c.ensureCanaryDeployment(ctx, ad); err != nil { return ctrl.Result{}, fmt.Errorf("self-healing canary deployment: %w", err) } + if err := c.ensureCanaryService(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("self-healing canary service: %w", err) + } } if ad.Status.CanaryWeight > 0 { if err := c.ensureHTTPRoute(ctx, ad, 100-ad.Status.CanaryWeight, ad.Status.CanaryWeight); err != nil { @@ -131,24 +136,43 @@ func (c *Controller) Rollback(ctx context.Context, ad *agentraxv1alpha1.AgentDep logger := log.FromContext(ctx).WithValues("name", ad.Name, "namespace", ad.Namespace) logger.Info("rolling back canary", "reason", reason, "message", message) - // 1. Delete canary Deployment. + // 1. Restore stable Deployment image to status.stableVersion. + if ad.Status.StableVersion != "" { + dep := &appsv1.Deployment{} + if err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, dep); err != nil { + return fmt.Errorf("fetching stable deployment for rollback: %w", err) + } + if len(dep.Spec.Template.Spec.Containers) > 0 { + dep.Spec.Template.Spec.Containers[0].Image = ad.Status.StableVersion + if err := c.Client.Update(ctx, dep); err != nil { + return fmt.Errorf("restoring stable deployment image during rollback: %w", err) + } + } + } + + // 2. Delete canary Deployment. if err := c.deleteCanaryDeployment(ctx, ad); err != nil { return fmt.Errorf("deleting canary deployment during rollback: %w", err) } - // 2. Reset HTTPRoute to 100% stable, then delete it. + // 3. Delete canary Service. + if err := c.deleteCanaryService(ctx, ad); err != nil { + return fmt.Errorf("deleting canary service during rollback: %w", err) + } + + // 4. Reset HTTPRoute to 100% stable, then delete it. // Deleting the HTTPRoute is cleaner than leaving it at 100%; the stable // Service continues to receive all traffic directly from the parent Gateway. if err := c.deleteHTTPRoute(ctx, ad); err != nil { return fmt.Errorf("deleting httproute during rollback: %w", err) } - // 3. Restore stable HPA. + // 5. Restore stable HPA. if err := c.restoreHPA(ctx, ad); err != nil { return fmt.Errorf("restoring HPA during rollback: %w", err) } - // 4. Update status. + // 6. Update status. latest := &agentraxv1alpha1.AgentDeployment{} if err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, latest); err != nil { return fmt.Errorf("re-fetching AD for rollback status update: %w", err) @@ -194,6 +218,11 @@ func (c *Controller) executeSetWeight(ctx context.Context, ad *agentraxv1alpha1. return ctrl.Result{}, fmt.Errorf("ensuring canary deployment (step %d): %w", idx, err) } + // Ensure the canary Service exists. + if err := c.ensureCanaryService(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("ensuring canary service (step %d): %w", idx, err) + } + stableWeight := int32(100) - weight // Upsert the HTTPRoute with the target traffic split. @@ -214,8 +243,8 @@ func (c *Controller) executeSetWeight(ctx context.Context, ad *agentraxv1alpha1. } // If the new step index is a setWeight:100 that was the last step, promote - // immediately on the next reconcile (requeue now). - return ctrl.Result{Requeue: true}, nil + // immediately on the next reconcile (requeue after 1 second). + return ctrl.Result{RequeueAfter: time.Second}, nil } // ── Pause step ──────────────────────────────────────────────────────────────── @@ -249,6 +278,9 @@ func (c *Controller) executePause(ctx context.Context, ad *agentraxv1alpha1.Agen elapsed := now.Time.Sub(latest.Status.PauseStartedAt.Time) maxWait := time.Duration(maxPauseExtensionMultiplier) * duration + if maxWait > 15*time.Minute { + maxWait = 15 * time.Minute + } // ── Evaluate thresholds ─────────────────────────────────────────────────── result, evalErr := Evaluate(ctx, c.PromClient, latest, duration) @@ -323,7 +355,7 @@ func (c *Controller) executePause(ctx context.Context, ad *agentraxv1alpha1.Agen return ctrl.Result{}, fmt.Errorf("advancing step index after pause: %w", err) } - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: time.Second}, nil } // ── Promotion ───────────────────────────────────────────────────────────────── @@ -335,6 +367,11 @@ func (c *Controller) promote(ctx context.Context, ad *agentraxv1alpha1.AgentDepl logger := log.FromContext(ctx).WithValues("name", ad.Name, "namespace", ad.Namespace) logger.Info("promoting canary to stable", "newImage", ad.Status.CanaryVersion) + // 0. Guard against empty CanaryVersion. + if ad.Status.CanaryVersion == "" { + return fmt.Errorf("cannot promote: canaryVersion is empty") + } + // 1. Update stable Deployment image. dep := &appsv1.Deployment{} if err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, dep); err != nil { @@ -352,17 +389,22 @@ func (c *Controller) promote(ctx context.Context, ad *agentraxv1alpha1.AgentDepl return fmt.Errorf("deleting canary deployment during promotion: %w", err) } - // 3. Delete HTTPRoute (traffic fully on stable Service again). + // 3. Delete canary Service. + if err := c.deleteCanaryService(ctx, ad); err != nil { + return fmt.Errorf("deleting canary service during promotion: %w", err) + } + + // 4. Delete HTTPRoute (traffic fully on stable Service again). if err := c.deleteHTTPRoute(ctx, ad); err != nil { return fmt.Errorf("deleting httproute during promotion: %w", err) } - // 4. Restore HPA. + // 5. Restore HPA. if err := c.restoreHPA(ctx, ad); err != nil { return fmt.Errorf("restoring HPA during promotion: %w", err) } - // 5. Update status. + // 6. Update status. latest := &agentraxv1alpha1.AgentDeployment{} if err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, latest); err != nil { return fmt.Errorf("re-fetching AD for promotion status update: %w", err) @@ -398,27 +440,29 @@ func (c *Controller) ensureCanaryDeployment(ctx context.Context, ad *agentraxv1a desired := c.desiredCanaryDeployment(ad, canaryName) existing := &appsv1.Deployment{} - err := c.Client.Get(ctx, types.NamespacedName{Name: canaryName, Namespace: ad.Namespace}, existing) - if apierrors.IsNotFound(err) { - if err := controllerutil.SetControllerReference(ad, desired, c.Scheme); err != nil { - return fmt.Errorf("setting owner ref on canary deployment: %w", err) + existing.Name = canaryName + existing.Namespace = ad.Namespace + + _, err := controllerutil.CreateOrUpdate(ctx, c.Client, existing, func() error { + existing.Labels = desired.Labels + + // Preserve the existing immutable selector on update; only set it on create + if existing.ResourceVersion == "" { + existing.Spec.Selector = desired.Spec.Selector } - if err := c.Client.Create(ctx, desired); err != nil { - return fmt.Errorf("creating canary deployment: %w", err) + + // Reconcile the complete pod template + existing.Spec.Replicas = desired.Spec.Replicas + existing.Spec.Template.Labels = desired.Spec.Template.Labels + existing.Spec.Template.Spec = desired.Spec.Template.Spec + + if err := controllerutil.SetControllerReference(ad, existing, c.Scheme); err != nil { + return fmt.Errorf("setting controller reference: %w", err) } return nil - } + }) if err != nil { - return fmt.Errorf("getting canary deployment: %w", err) - } - - // Update image if it has drifted. - if len(existing.Spec.Template.Spec.Containers) > 0 && - existing.Spec.Template.Spec.Containers[0].Image != ad.Status.CanaryVersion { - existing.Spec.Template.Spec.Containers[0].Image = ad.Status.CanaryVersion - if err := c.Client.Update(ctx, existing); err != nil { - return fmt.Errorf("updating canary deployment image: %w", err) - } + return fmt.Errorf("reconciling canary deployment: %w", err) } return nil } @@ -481,6 +525,86 @@ func (c *Controller) deleteCanaryDeployment(ctx context.Context, ad *agentraxv1a return nil } +// ── Canary Service ──────────────────────────────────────────────────────────── + +// ensureCanaryService creates or updates the canary Service (-canary) +// with a selector that targets only canary pods (variant=canary label). +func (c *Controller) ensureCanaryService(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + canaryName := ad.Name + canaryDeploymentSuffix + desired := c.desiredCanaryService(ad, canaryName) + + existing := &corev1.Service{} + existing.Name = canaryName + existing.Namespace = ad.Namespace + + _, err := controllerutil.CreateOrUpdate(ctx, c.Client, existing, func() error { + existing.Labels = desired.Labels + existing.Spec.Selector = desired.Spec.Selector + existing.Spec.Ports = desired.Spec.Ports + existing.Spec.Type = desired.Spec.Type + + if err := controllerutil.SetControllerReference(ad, existing, c.Scheme); err != nil { + return fmt.Errorf("setting controller reference: %w", err) + } + return nil + }) + if err != nil { + return fmt.Errorf("reconciling canary service: %w", err) + } + return nil +} + +// desiredCanaryService builds the spec for the canary Service. +func (c *Controller) desiredCanaryService(ad *agentraxv1alpha1.AgentDeployment, name string) *corev1.Service { + port := ad.Spec.Port + if port == 0 { + port = 8080 + } + labels := map[string]string{ + "app.kubernetes.io/name": ad.Name, + "app.kubernetes.io/managed-by": "agentrax", + "agentrax.io/tenant": ad.Spec.TenantRef, + canaryVariantLabel: canaryVariantValue, + } + + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ad.Namespace, + Labels: labels, + }, + Spec: corev1.ServiceSpec{ + Selector: labels, + Ports: []corev1.ServicePort{ + { + Name: "agent", + Port: port, + TargetPort: intstr.FromInt32(port), + Protocol: corev1.ProtocolTCP, + }, + }, + Type: corev1.ServiceTypeClusterIP, + }, + } +} + +// deleteCanaryService removes the canary Service. Not-found is tolerated. +func (c *Controller) deleteCanaryService(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + svc := &corev1.Service{} + canaryName := ad.Name + canaryDeploymentSuffix + err := c.Client.Get(ctx, types.NamespacedName{Name: canaryName, Namespace: ad.Namespace}, svc) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("getting canary service for deletion: %w", err) + } + if err := c.Client.Delete(ctx, svc); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("deleting canary service: %w", err) + } + return nil +} + // ── HTTPRoute ───────────────────────────────────────────────────────────────── // ensureHTTPRoute creates or updates an HTTPRoute that splits traffic between @@ -505,6 +629,16 @@ func (c *Controller) ensureHTTPRoute(ctx context.Context, ad *agentraxv1alpha1.A return fmt.Errorf("getting httproute: %w", err) } + // Set controller reference on existing HTTPRoute if not already set. + if err := controllerutil.SetControllerReference(ad, existing, c.Scheme); err != nil { + return fmt.Errorf("setting controller reference on existing httproute: %w", err) + } + + // Skip update if spec already matches. + if equality.Semantic.DeepEqual(existing.Spec, desired.Spec) { + return nil + } + // Update weights if they have changed. existing.Spec = desired.Spec if err := c.Client.Update(ctx, existing); err != nil { @@ -622,22 +756,27 @@ func (c *Controller) PauseHPA(ctx context.Context, ad *agentraxv1alpha1.AgentDep // recompute the correct quota headroom on its next cycle and update accordingly. func (c *Controller) restoreHPA(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { // headroom=0 is conservative: the main reconciler will fix it on the next cycle. - hpa := scaling.BuildHPA(ad, 0) - if err := controllerutil.SetControllerReference(ad, hpa, c.Scheme); err != nil { - return fmt.Errorf("setting owner ref on restored HPA: %w", err) - } + desired := scaling.BuildHPA(ad, 0) existing := &autoscalingv2.HorizontalPodAutoscaler{} - createErr := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, existing) - if apierrors.IsNotFound(createErr) { - if err := c.Client.Create(ctx, hpa); err != nil && !apierrors.IsAlreadyExists(err) { - return fmt.Errorf("creating HPA during restore: %w", err) + existing.Name = desired.Name + existing.Namespace = desired.Namespace + + _, err := controllerutil.CreateOrUpdate(ctx, c.Client, existing, func() error { + existing.Labels = desired.Labels + existing.Spec.ScaleTargetRef = desired.Spec.ScaleTargetRef + existing.Spec.MinReplicas = desired.Spec.MinReplicas + existing.Spec.MaxReplicas = desired.Spec.MaxReplicas + existing.Spec.Metrics = desired.Spec.Metrics + existing.Spec.Behavior = desired.Spec.Behavior + + if err := controllerutil.SetControllerReference(ad, existing, c.Scheme); err != nil { + return fmt.Errorf("setting controller reference: %w", err) } return nil + }) + if err != nil { + return fmt.Errorf("restoring HPA: %w", err) } - if createErr != nil { - return fmt.Errorf("checking HPA existence during restore: %w", createErr) - } - // Already exists — the main reconciler will reconcile it properly on its next cycle. return nil } diff --git a/internal/rollout/canary_test.go b/internal/rollout/canary_test.go index bd29b23..71973af 100644 --- a/internal/rollout/canary_test.go +++ b/internal/rollout/canary_test.go @@ -93,8 +93,8 @@ func TestController_Step_SetWeight(t *testing.T) { if err != nil { t.Fatalf("Step failed: %v", err) } - if !res.Requeue { - t.Errorf("expected Requeue=true after setWeight") + if res.RequeueAfter != time.Second { + t.Errorf("expected RequeueAfter=1s after setWeight, got %v", res.RequeueAfter) } // Verify canary Deployment was created @@ -158,7 +158,10 @@ func TestController_Step_SelfHealing(t *testing.T) { c, cl, _ := newTestController(ad) // Canary deployment and HTTPRoute do NOT exist initially (simulating deletion). - _, _ = c.Step(context.Background(), ad) + _, err := c.Step(context.Background(), ad) + if err != nil { + t.Fatalf("Step failed during self-healing: %v", err) + } // Verify both were restored by self-healing canaryDep := &appsv1.Deployment{} @@ -168,6 +171,17 @@ func TestController_Step_SelfHealing(t *testing.T) { route := &gatewayv1.HTTPRoute{} if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, route); err != nil { t.Errorf("expected self-healing to recreate httproute: %v", err) + } else { + // Validate traffic split is 80/20 + if len(route.Spec.Rules) == 0 || len(route.Spec.Rules[0].BackendRefs) < 2 { + t.Errorf("expected 2 backend refs in restored HTTPRoute") + } else { + w0 := *route.Spec.Rules[0].BackendRefs[0].Weight + w1 := *route.Spec.Rules[0].BackendRefs[1].Weight + if w0 != 80 || w1 != 20 { + t.Errorf("expected weights (80, 20), got (%d, %d)", w0, w1) + } + } } } @@ -389,8 +403,8 @@ func TestController_ExecutePause_Success_Advance(t *testing.T) { if err != nil { t.Fatalf("executePause failed: %v", err) } - if !res.Requeue { - t.Errorf("expected Requeue=true after pause completion to process next step") + if res.RequeueAfter != time.Second { + t.Errorf("expected RequeueAfter=1s after pause completion to process next step, got %v", res.RequeueAfter) } updated := &agentraxv1alpha1.AgentDeployment{} @@ -491,6 +505,16 @@ func TestController_ExecutePause_SampleTooSmall(t *testing.T) { if cond == nil || cond.Status != metav1.ConditionTrue { t.Errorf("expected ConditionSampleInsufficient to be True") } + + // Verify no rollback occurred: phase should remain RolloutInProgress + if updated.Status.Phase != agentraxv1alpha1.PhaseRolloutInProgress { + t.Errorf("expected Phase=RolloutInProgress, got %s (no rollback should occur)", updated.Status.Phase) + } + // Verify canary deployment still exists + canaryDep := &appsv1.Deployment{} + if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent-canary", Namespace: "default"}, canaryDep); err != nil { + t.Errorf("expected canary deployment to still exist (no rollback): %v", err) + } } func int32Ptr(i int32) *int32 { return &i } diff --git a/internal/rollout/promql.go b/internal/rollout/promql.go index 7b4538a..82f4884 100644 --- a/internal/rollout/promql.go +++ b/internal/rollout/promql.go @@ -35,10 +35,10 @@ import ( // HTTP requests received by the canary pods over the given window. // The label selectors match the canary Deployment's pod labels: // - app.kubernetes.io/name= -// - agentrax.io/variant=canary +// - agentrax.io/variant=canary (propagated from pod labels via ServiceMonitor) func requestCountQuery(adName, namespace string, window time.Duration) string { return fmt.Sprintf( - `sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,variant="canary"}[%s]))`, + `sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,agentrax_io_variant="canary"}[%s]))`, namespace, adName, promDuration(window), ) } @@ -49,9 +49,9 @@ func requestCountQuery(adName, namespace string, window time.Duration) string { func errorRateQuery(adName, namespace string, window time.Duration) string { d := promDuration(window) return fmt.Sprintf( - `sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,variant="canary",code=~"5.."}[%s]))`+ + `sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,agentrax_io_variant="canary",code=~"5.."}[%s]))`+ ` / on() group_left `+ - `clamp_min(sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,variant="canary"}[%s])), 1)`, + `clamp_min(sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,agentrax_io_variant="canary"}[%s])), 1)`, namespace, adName, d, namespace, adName, d, ) @@ -62,7 +62,7 @@ func errorRateQuery(adName, namespace string, window time.Duration) string { func p99LatencyQuery(adName, namespace string, window time.Duration) string { return fmt.Sprintf( `histogram_quantile(0.99, sum by (le) (`+ - `rate(http_request_duration_milliseconds_bucket{namespace=%q,app_kubernetes_io_name=%q,variant="canary"}[%s])))`, + `rate(http_request_duration_milliseconds_bucket{namespace=%q,app_kubernetes_io_name=%q,agentrax_io_variant="canary"}[%s])))`, namespace, adName, promDuration(window), ) } @@ -137,26 +137,28 @@ func Evaluate( return EvaluationResult{}, fmt.Errorf("querying error rate: %w", err) } - if policy.MaxErrorRate != "" { - maxRate, parseErr := agentraxv1alpha1.ParseErrorRate(policy.MaxErrorRate) - if parseErr != nil { - // Misconfigured spec — treat as a breach to fail safe. - return EvaluationResult{ - SampleCount: sampleCount, - ErrorRate: errorRate, - ThresholdBreached: true, - BreachReason: fmt.Sprintf("invalid maxErrorRate %q: %v", policy.MaxErrorRate, parseErr), - }, nil - } - if errorRate > maxRate { - return EvaluationResult{ - SampleCount: sampleCount, - ErrorRate: errorRate, - ThresholdBreached: true, - BreachReason: fmt.Sprintf("error rate %.2f%% exceeds threshold %.2f%%", - errorRate*100, maxRate*100), - }, nil - } + maxErrorRateStr := policy.MaxErrorRate + if maxErrorRateStr == "" { + maxErrorRateStr = "1%" // default + } + maxRate, parseErr := agentraxv1alpha1.ParseErrorRate(maxErrorRateStr) + if parseErr != nil { + // Misconfigured spec — treat as a breach to fail safe. + return EvaluationResult{ + SampleCount: sampleCount, + ErrorRate: errorRate, + ThresholdBreached: true, + BreachReason: fmt.Sprintf("invalid maxErrorRate %q: %v", maxErrorRateStr, parseErr), + }, nil + } + if errorRate > maxRate { + return EvaluationResult{ + SampleCount: sampleCount, + ErrorRate: errorRate, + ThresholdBreached: true, + BreachReason: fmt.Sprintf("error rate %.2f%% exceeds threshold %.2f%%", + errorRate*100, maxRate*100), + }, nil } // ── 3. p99 latency threshold ────────────────────────────────────────────── @@ -165,14 +167,18 @@ func Evaluate( return EvaluationResult{}, fmt.Errorf("querying p99 latency: %w", err) } - if policy.MaxP99LatencyMs > 0 && p99Ms > float64(policy.MaxP99LatencyMs) { + maxP99 := policy.MaxP99LatencyMs + if maxP99 <= 0 { + maxP99 = 500 // default 500ms + } + if p99Ms > float64(maxP99) { return EvaluationResult{ SampleCount: sampleCount, ErrorRate: errorRate, P99LatencyMs: p99Ms, ThresholdBreached: true, BreachReason: fmt.Sprintf("p99 latency %.1fms exceeds threshold %dms", - p99Ms, policy.MaxP99LatencyMs), + p99Ms, maxP99), }, nil } diff --git a/internal/rollout/promql_test.go b/internal/rollout/promql_test.go index 67a62f1..846a661 100644 --- a/internal/rollout/promql_test.go +++ b/internal/rollout/promql_test.go @@ -121,8 +121,8 @@ func TestQueryTemplates(t *testing.T) { if !strings.Contains(q, `app_kubernetes_io_name="my-agent"`) { t.Errorf("%s query missing agent name selector: %q", name, q) } - if !strings.Contains(q, `variant="canary"`) { - t.Errorf("%s query missing variant=canary selector: %q", name, q) + if !strings.Contains(q, `agentrax_io_variant="canary"`) { + t.Errorf("%s query missing agentrax_io_variant=canary selector: %q", name, q) } if !strings.Contains(q, "[2m0s]") { t.Errorf("%s query missing duration window [2m0s]: %q", name, q) From d21652f228a70d6d167b9f05bab4729c60d5b26f Mon Sep 17 00:00:00 2001 From: "Ankit Kr. Chowdhury" Date: Sat, 15 Aug 2026 21:32:02 +0000 Subject: [PATCH 3/5] fix(rollout): harden canary rollback, fix module toolchain compatibility, and cleanup test fixtures --- go.mod | 25 ++++++------ go.sum | 54 +++++++++++++------------- internal/rollout/canary.go | 7 ++-- internal/rollout/canary_test.go | 67 +++++++++++++++++---------------- internal/rollout/promql_test.go | 20 +++++----- 5 files changed, 89 insertions(+), 84 deletions(-) diff --git a/go.mod b/go.mod index 94aba3a..550c94d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/gitcommitankit/agentrax -go 1.25.0 +go 1.22.0 require ( github.com/go-logr/logr v1.4.2 @@ -61,25 +61,26 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect - go.opentelemetry.io/otel v1.28.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect - go.opentelemetry.io/otel/metric v1.28.0 // indirect - go.opentelemetry.io/otel/sdk v1.28.0 // indirect - go.opentelemetry.io/otel/trace v1.28.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/sdk v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/term v0.44.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect diff --git a/go.sum b/go.sum index f3277a0..6d0342e 100644 --- a/go.sum +++ b/go.sum @@ -106,8 +106,8 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= @@ -118,26 +118,28 @@ github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= -go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= -go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ= -go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= -go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= -go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE= -go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= -go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= -go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -157,34 +159,34 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= -golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/rollout/canary.go b/internal/rollout/canary.go index 51927fa..698cfd8 100644 --- a/internal/rollout/canary.go +++ b/internal/rollout/canary.go @@ -48,8 +48,6 @@ const ( canaryVariantLabel = "agentrax.io/variant" // canaryVariantValue is the value of canaryVariantLabel for canary pods. canaryVariantValue = "canary" - // stableVariantValue is the value of canaryVariantLabel for stable pods. - stableVariantValue = "stable" // maxPauseExtensionMultiplier is the maximum total pause duration as a multiple // of the configured pause duration. After this ceiling the evaluator runs regardless @@ -139,10 +137,11 @@ func (c *Controller) Rollback(ctx context.Context, ad *agentraxv1alpha1.AgentDep // 1. Restore stable Deployment image to status.stableVersion. if ad.Status.StableVersion != "" { dep := &appsv1.Deployment{} - if err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, dep); err != nil { + err := c.Client.Get(ctx, types.NamespacedName{Name: ad.Name, Namespace: ad.Namespace}, dep) + if err != nil && !apierrors.IsNotFound(err) { return fmt.Errorf("fetching stable deployment for rollback: %w", err) } - if len(dep.Spec.Template.Spec.Containers) > 0 { + if err == nil && len(dep.Spec.Template.Spec.Containers) > 0 { dep.Spec.Template.Spec.Containers[0].Image = ad.Status.StableVersion if err := c.Client.Update(ctx, dep); err != nil { return fmt.Errorf("restoring stable deployment image during rollback: %w", err) diff --git a/internal/rollout/canary_test.go b/internal/rollout/canary_test.go index 71973af..9c67404 100644 --- a/internal/rollout/canary_test.go +++ b/internal/rollout/canary_test.go @@ -36,10 +36,12 @@ import ( "github.com/gitcommitankit/agentrax/internal/metrics" ) +const testCanaryImage = "img:v2" + // ── Controller unit tests ────────────────────────────────────────────────── // newTestController constructs a rollout.Controller with a fake client initialized with the scheme. -func newTestController(initObjs ...runtime.Object) (*Controller, client.Client, *runtime.Scheme) { +func newTestController(initObjs ...runtime.Object) (*Controller, client.Client) { s := runtime.NewScheme() _ = agentraxv1alpha1.AddToScheme(s) _ = appsv1.AddToScheme(s) @@ -61,7 +63,7 @@ func newTestController(initObjs ...runtime.Object) (*Controller, client.Client, GatewayNamespace: "agentrax-system", FailSafeTimeout: 60 * time.Second, } - return ctrl, cl, s + return ctrl, cl } // TestController_Step_SetWeight verifies that executing a setWeight step creates the canary @@ -70,7 +72,7 @@ func TestController_Step_SetWeight(t *testing.T) { ad := &agentraxv1alpha1.AgentDeployment{ ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, Spec: agentraxv1alpha1.AgentDeploymentSpec{ - Image: "img:v2", + Image: testCanaryImage, TenantRef: "team-a", Rollout: agentraxv1alpha1.RolloutPolicy{ Strategy: "Canary", @@ -83,12 +85,12 @@ func TestController_Step_SetWeight(t *testing.T) { Status: agentraxv1alpha1.AgentDeploymentStatus{ Phase: agentraxv1alpha1.PhaseRolloutInProgress, StableVersion: "img:v1", - CanaryVersion: "img:v2", + CanaryVersion: testCanaryImage, CanaryStepIndex: 0, }, } - c, cl, _ := newTestController(ad) + c, cl := newTestController(ad) res, err := c.Step(context.Background(), ad) if err != nil { t.Fatalf("Step failed: %v", err) @@ -101,7 +103,7 @@ func TestController_Step_SetWeight(t *testing.T) { canaryDep := &appsv1.Deployment{} if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent-canary", Namespace: "default"}, canaryDep); err != nil { t.Errorf("canary deployment not found: %v", err) - } else if len(canaryDep.Spec.Template.Spec.Containers) == 0 || canaryDep.Spec.Template.Spec.Containers[0].Image != "img:v2" { + } else if len(canaryDep.Spec.Template.Spec.Containers) == 0 || canaryDep.Spec.Template.Spec.Containers[0].Image != testCanaryImage { t.Errorf("expected canary deployment image img:v2") } @@ -137,7 +139,7 @@ func TestController_Step_SelfHealing(t *testing.T) { ad := &agentraxv1alpha1.AgentDeployment{ ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, Spec: agentraxv1alpha1.AgentDeploymentSpec{ - Image: "img:v2", + Image: testCanaryImage, TenantRef: "team-a", Rollout: agentraxv1alpha1.RolloutPolicy{ Strategy: "Canary", @@ -150,13 +152,13 @@ func TestController_Step_SelfHealing(t *testing.T) { Status: agentraxv1alpha1.AgentDeploymentStatus{ Phase: agentraxv1alpha1.PhaseRolloutInProgress, StableVersion: "img:v1", - CanaryVersion: "img:v2", + CanaryVersion: testCanaryImage, CanaryWeight: 20, CanaryStepIndex: 1, // During pause step }, } - c, cl, _ := newTestController(ad) + c, cl := newTestController(ad) // Canary deployment and HTTPRoute do NOT exist initially (simulating deletion). _, err := c.Step(context.Background(), ad) if err != nil { @@ -190,7 +192,7 @@ func TestController_Rollback(t *testing.T) { ad := &agentraxv1alpha1.AgentDeployment{ ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, Spec: agentraxv1alpha1.AgentDeploymentSpec{ - Image: "img:v2", + Image: testCanaryImage, TenantRef: "team-a", Replicas: agentraxv1alpha1.ScalingPolicy{Min: 1, Max: 5, Metric: "queueDepth", Target: 10}, Rollout: agentraxv1alpha1.RolloutPolicy{ @@ -201,7 +203,7 @@ func TestController_Rollback(t *testing.T) { Status: agentraxv1alpha1.AgentDeploymentStatus{ Phase: agentraxv1alpha1.PhaseRolloutInProgress, StableVersion: "img:v1", - CanaryVersion: "img:v2", + CanaryVersion: testCanaryImage, CanaryWeight: 20, }, } @@ -209,7 +211,7 @@ func TestController_Rollback(t *testing.T) { canaryDep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "test-agent-canary", Namespace: "default"}} route := &gatewayv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}} - c, cl, _ := newTestController(ad, canaryDep, route) + c, cl := newTestController(ad, canaryDep, route) err := c.Rollback(context.Background(), ad, "ManualAbort", "spec.rollout.abort was true") if err != nil { @@ -236,7 +238,7 @@ func TestController_Rollback(t *testing.T) { if updated.Status.Phase != agentraxv1alpha1.PhaseRolloutFailed { t.Errorf("expected Phase=RolloutFailed, got %s", updated.Status.Phase) } - if updated.Status.CanaryVersion != "img:v2" { + if updated.Status.CanaryVersion != testCanaryImage { t.Errorf("expected CanaryVersion=img:v2 to be preserved, got %s", updated.Status.CanaryVersion) } } @@ -246,14 +248,14 @@ func TestController_Promote(t *testing.T) { ad := &agentraxv1alpha1.AgentDeployment{ ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, Spec: agentraxv1alpha1.AgentDeploymentSpec{ - Image: "img:v2", + Image: testCanaryImage, TenantRef: "team-a", Replicas: agentraxv1alpha1.ScalingPolicy{Min: 1, Max: 5, Metric: "queueDepth", Target: 10}, }, Status: agentraxv1alpha1.AgentDeploymentStatus{ Phase: agentraxv1alpha1.PhaseRolloutInProgress, StableVersion: "img:v1", - CanaryVersion: "img:v2", + CanaryVersion: testCanaryImage, }, } @@ -269,7 +271,7 @@ func TestController_Promote(t *testing.T) { } canaryDep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "test-agent-canary", Namespace: "default"}} - c, cl, _ := newTestController(ad, stableDep, canaryDep) + c, cl := newTestController(ad, stableDep, canaryDep) err := c.promote(context.Background(), ad) if err != nil { @@ -279,7 +281,7 @@ func TestController_Promote(t *testing.T) { // Verify stable deployment image was updated updatedDep := &appsv1.Deployment{} _ = cl.Get(context.Background(), types.NamespacedName{Name: "test-agent", Namespace: "default"}, updatedDep) - if len(updatedDep.Spec.Template.Spec.Containers) == 0 || updatedDep.Spec.Template.Spec.Containers[0].Image != "img:v2" { + if len(updatedDep.Spec.Template.Spec.Containers) == 0 || updatedDep.Spec.Template.Spec.Containers[0].Image != testCanaryImage { t.Errorf("expected stable deployment image img:v2, got %v", updatedDep.Spec.Template.Spec.Containers) } @@ -305,7 +307,7 @@ func TestController_PauseHPA_RestoreHPA(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, } - c, cl, _ := newTestController(ad, existingHPA) + c, cl := newTestController(ad, existingHPA) // Pause HPA if err := c.PauseHPA(context.Background(), ad); err != nil { @@ -330,7 +332,7 @@ func TestController_ExecutePause_InitialEntry(t *testing.T) { ad := &agentraxv1alpha1.AgentDeployment{ ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, Spec: agentraxv1alpha1.AgentDeploymentSpec{ - Image: "img:v2", + Image: testCanaryImage, TenantRef: "team-a", Rollout: agentraxv1alpha1.RolloutPolicy{ Strategy: "Canary", @@ -342,12 +344,12 @@ func TestController_ExecutePause_InitialEntry(t *testing.T) { Status: agentraxv1alpha1.AgentDeploymentStatus{ Phase: agentraxv1alpha1.PhaseRolloutInProgress, StableVersion: "img:v1", - CanaryVersion: "img:v2", + CanaryVersion: testCanaryImage, CanaryStepIndex: 0, }, } - c, cl, _ := newTestController(ad) + c, cl := newTestController(ad) res, err := c.executePause(context.Background(), ad, 0, 30*time.Second) if err != nil { t.Fatalf("executePause failed: %v", err) @@ -372,7 +374,7 @@ func TestController_ExecutePause_Success_Advance(t *testing.T) { ad := &agentraxv1alpha1.AgentDeployment{ ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, Spec: agentraxv1alpha1.AgentDeploymentSpec{ - Image: "img:v2", + Image: testCanaryImage, TenantRef: "team-a", Rollout: agentraxv1alpha1.RolloutPolicy{ Strategy: "Canary", @@ -390,13 +392,13 @@ func TestController_ExecutePause_Success_Advance(t *testing.T) { Status: agentraxv1alpha1.AgentDeploymentStatus{ Phase: agentraxv1alpha1.PhaseRolloutInProgress, StableVersion: "img:v1", - CanaryVersion: "img:v2", + CanaryVersion: testCanaryImage, CanaryStepIndex: 0, PauseStartedAt: &past, }, } - c, cl, _ := newTestController(ad) + c, cl := newTestController(ad) c.PromClient = metrics.NewClient(srv.URL) res, err := c.executePause(context.Background(), ad, 0, 30*time.Second) @@ -424,7 +426,7 @@ func TestController_ExecutePause_PrometheusUnreachable_FailSafe(t *testing.T) { ad := &agentraxv1alpha1.AgentDeployment{ ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, Spec: agentraxv1alpha1.AgentDeploymentSpec{ - Image: "img:v2", + Image: testCanaryImage, TenantRef: "team-a", Rollout: agentraxv1alpha1.RolloutPolicy{ Strategy: "Canary", @@ -436,14 +438,14 @@ func TestController_ExecutePause_PrometheusUnreachable_FailSafe(t *testing.T) { Status: agentraxv1alpha1.AgentDeploymentStatus{ Phase: agentraxv1alpha1.PhaseRolloutInProgress, StableVersion: "img:v1", - CanaryVersion: "img:v2", + CanaryVersion: testCanaryImage, CanaryStepIndex: 0, PauseStartedAt: &started, PromUnreachableSince: &unreachableSince, }, } - c, cl, _ := newTestController(ad) + c, cl := newTestController(ad) c.PromClient = metrics.NewClient("http://127.0.0.1:1") // Unreachable port c.FailSafeTimeout = 60 * time.Second @@ -467,7 +469,7 @@ func TestController_ExecutePause_SampleTooSmall(t *testing.T) { ad := &agentraxv1alpha1.AgentDeployment{ ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, Spec: agentraxv1alpha1.AgentDeploymentSpec{ - Image: "img:v2", + Image: testCanaryImage, TenantRef: "team-a", Rollout: agentraxv1alpha1.RolloutPolicy{ Strategy: "Canary", @@ -482,13 +484,14 @@ func TestController_ExecutePause_SampleTooSmall(t *testing.T) { Status: agentraxv1alpha1.AgentDeploymentStatus{ Phase: agentraxv1alpha1.PhaseRolloutInProgress, StableVersion: "img:v1", - CanaryVersion: "img:v2", + CanaryVersion: testCanaryImage, CanaryStepIndex: 0, PauseStartedAt: &past, }, } - c, cl, _ := newTestController(ad) + canaryDep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "test-agent-canary", Namespace: "default"}} + c, cl := newTestController(ad, canaryDep) c.PromClient = metrics.NewClient(srv.URL) res, err := c.executePause(context.Background(), ad, 0, 30*time.Second) @@ -511,8 +514,8 @@ func TestController_ExecutePause_SampleTooSmall(t *testing.T) { t.Errorf("expected Phase=RolloutInProgress, got %s (no rollback should occur)", updated.Status.Phase) } // Verify canary deployment still exists - canaryDep := &appsv1.Deployment{} - if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent-canary", Namespace: "default"}, canaryDep); err != nil { + fetchedCanary := &appsv1.Deployment{} + if err := cl.Get(context.Background(), types.NamespacedName{Name: "test-agent-canary", Namespace: "default"}, fetchedCanary); err != nil { t.Errorf("expected canary deployment to still exist (no rollback): %v", err) } } diff --git a/internal/rollout/promql_test.go b/internal/rollout/promql_test.go index 846a661..e354359 100644 --- a/internal/rollout/promql_test.go +++ b/internal/rollout/promql_test.go @@ -80,7 +80,7 @@ func buildVectorResponse(value float64) []byte { } // makeAD returns a minimal AgentDeployment for testing. -func makeAD(name, ns string, maxErrorRate string, maxP99Ms int32, minSample int32) *agentraxv1alpha1.AgentDeployment { +func makeAD(name, ns string, maxErrorRate string, maxP99Ms int32) *agentraxv1alpha1.AgentDeployment { return &agentraxv1alpha1.AgentDeployment{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Spec: agentraxv1alpha1.AgentDeploymentSpec{ @@ -91,7 +91,7 @@ func makeAD(name, ns string, maxErrorRate string, maxP99Ms int32, minSample int3 Rollback: agentraxv1alpha1.RollbackPolicy{ MaxErrorRate: maxErrorRate, MaxP99LatencyMs: maxP99Ms, - MinRequestSample: minSample, + MinRequestSample: 100, }, }, }, @@ -103,7 +103,7 @@ func makeAD(name, ns string, maxErrorRate string, maxP99Ms int32, minSample int3 // TestQueryTemplates verifies that the three PromQL query builders produce valid // queries that embed the AgentDeployment name, namespace, and window correctly. func TestQueryTemplates(t *testing.T) { - ad := makeAD("my-agent", "tenant-prod", "1%", 200, 100) + ad := makeAD("my-agent", "tenant-prod", "1%", 200) window := 2 * time.Minute reqQuery := requestCountQuery(ad.Name, ad.Namespace, window) @@ -140,7 +140,7 @@ func TestEvaluate_AllWithinThresholds(t *testing.T) { srv := prometheusServer(t, []float64{200.0, 0.005, 0.080}) defer srv.Close() - ad := makeAD("agent", "ns", "1%", 200, 100) + ad := makeAD("agent", "ns", "1%", 200) result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) if err != nil { @@ -161,7 +161,7 @@ func TestEvaluate_SampleTooSmall(t *testing.T) { srv := prometheusServer(t, []float64{42.0}) defer srv.Close() - ad := makeAD("agent", "ns", "1%", 200, 100) + ad := makeAD("agent", "ns", "1%", 200) result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) if err != nil { @@ -185,7 +185,7 @@ func TestEvaluate_ErrorRateBreached(t *testing.T) { srv := prometheusServer(t, []float64{500.0, 0.03, 0.050}) defer srv.Close() - ad := makeAD("agent", "ns", "1%", 200, 100) + ad := makeAD("agent", "ns", "1%", 200) result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) if err != nil { @@ -206,7 +206,7 @@ func TestEvaluate_P99LatencyBreached(t *testing.T) { srv := prometheusServer(t, []float64{500.0, 0.001, 350.0}) defer srv.Close() - ad := makeAD("agent", "ns", "1%", 200, 100) + ad := makeAD("agent", "ns", "1%", 200) result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) if err != nil { @@ -226,7 +226,7 @@ func TestEvaluate_PrometheusUnreachable(t *testing.T) { srv := errorServer() defer srv.Close() - ad := makeAD("agent", "ns", "1%", 200, 100) + ad := makeAD("agent", "ns", "1%", 200) _, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) if err == nil { @@ -245,7 +245,7 @@ func TestEvaluate_EmptyVector(t *testing.T) { })) defer srv.Close() - ad := makeAD("agent", "ns", "1%", 200, 100) + ad := makeAD("agent", "ns", "1%", 200) _, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) if err == nil { @@ -289,7 +289,7 @@ func TestEvaluate_InvalidMaxErrorRate(t *testing.T) { srv := prometheusServer(t, []float64{200.0, 0.01, 100.0}) defer srv.Close() - ad := makeAD("agent", "ns", "not-a-percentage", 500, 100) + ad := makeAD("agent", "ns", "not-a-percentage", 500) result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 5*time.Minute) if err != nil { From c525a2e1b66f80dbbe8512d6564d4dab0138bc60 Mon Sep 17 00:00:00 2001 From: "Ankit Kr. Chowdhury" Date: Sun, 16 Aug 2026 07:11:08 +0000 Subject: [PATCH 4/5] fix: handle absent Prometheus series by adding vector(0) fallback to rollout queries --- internal/rollout/promql.go | 6 +++--- internal/rollout/promql_test.go | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/internal/rollout/promql.go b/internal/rollout/promql.go index 82f4884..f767f3a 100644 --- a/internal/rollout/promql.go +++ b/internal/rollout/promql.go @@ -38,7 +38,7 @@ import ( // - agentrax.io/variant=canary (propagated from pod labels via ServiceMonitor) func requestCountQuery(adName, namespace string, window time.Duration) string { return fmt.Sprintf( - `sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,agentrax_io_variant="canary"}[%s]))`, + `sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,agentrax_io_variant="canary"}[%s])) or vector(0)`, namespace, adName, promDuration(window), ) } @@ -49,9 +49,9 @@ func requestCountQuery(adName, namespace string, window time.Duration) string { func errorRateQuery(adName, namespace string, window time.Duration) string { d := promDuration(window) return fmt.Sprintf( - `sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,agentrax_io_variant="canary",code=~"5.."}[%s]))`+ + `(sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,agentrax_io_variant="canary",code=~"5.."}[%s])) or vector(0))`+ ` / on() group_left `+ - `clamp_min(sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,agentrax_io_variant="canary"}[%s])), 1)`, + `clamp_min(sum(increase(http_requests_total{namespace=%q,app_kubernetes_io_name=%q,agentrax_io_variant="canary"}[%s])) or vector(0), 1)`, namespace, adName, d, namespace, adName, d, ) diff --git a/internal/rollout/promql_test.go b/internal/rollout/promql_test.go index e354359..8d793d9 100644 --- a/internal/rollout/promql_test.go +++ b/internal/rollout/promql_test.go @@ -300,6 +300,29 @@ func TestEvaluate_InvalidMaxErrorRate(t *testing.T) { } } +// TestEvaluate_ZeroSample_AbsentSeries verifies that when Prometheus returns 0.0 +// (e.g. from `or vector(0)` on absent series), Evaluate cleanly flags SampleTooSmall +// without failing or triggering a false breach. +func TestEvaluate_ZeroSample_AbsentSeries(t *testing.T) { + srv := prometheusServer(t, []float64{0.0}) + defer srv.Close() + + ad := makeAD("agent", "ns", "1%", 200) + result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) + if err != nil { + t.Fatalf("unexpected error when series evaluate to 0: %v", err) + } + if !result.SampleTooSmall { + t.Errorf("expected SampleTooSmall=true when count is 0, got %v", result.SampleTooSmall) + } + if result.ThresholdBreached { + t.Errorf("expected ThresholdBreached=false when sample is 0, got %v", result.ThresholdBreached) + } + if result.SampleCount != 0 { + t.Errorf("expected SampleCount=0, got %f", result.SampleCount) + } +} + // ── promDuration helper ─────────────────────────────────────────────────────── // TestPromDuration_Format verifies that promDuration formats durations correctly. From c395736bc03e117b4136e0d22aac836c784cfe34 Mon Sep 17 00:00:00 2001 From: "Ankit Kr. Chowdhury" Date: Sun, 16 Aug 2026 07:19:08 +0000 Subject: [PATCH 5/5] test: add TestEvaluate_ZeroErrors_Absent5xxSeries to verify error rate evaluation when 5xx metrics are missing --- internal/rollout/promql_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/rollout/promql_test.go b/internal/rollout/promql_test.go index 8d793d9..003a5ae 100644 --- a/internal/rollout/promql_test.go +++ b/internal/rollout/promql_test.go @@ -323,6 +323,33 @@ func TestEvaluate_ZeroSample_AbsentSeries(t *testing.T) { } } +// TestEvaluate_ZeroErrors_Absent5xxSeries verifies that when request count is sufficient +// (>= minRequestSample) and 5xx series are absent (evaluating to 0.0 via `or vector(0)`), +// Evaluate completes with ErrorRate=0.0, SampleTooSmall=false, and ThresholdBreached=false. +func TestEvaluate_ZeroErrors_Absent5xxSeries(t *testing.T) { + // Sequence: count=200 (sufficient), errorRate=0.0 (0% errors / absent 5xx series), p99=80ms. + srv := prometheusServer(t, []float64{200.0, 0.0, 80.0}) + defer srv.Close() + + ad := makeAD("agent", "ns", "1%", 200) + result, err := Evaluate(context.Background(), metrics.NewClient(srv.URL), ad, 2*time.Minute) + if err != nil { + t.Fatalf("unexpected error when 5xx series is absent: %v", err) + } + if result.SampleTooSmall { + t.Errorf("expected SampleTooSmall=false when count is 200, got %v", result.SampleTooSmall) + } + if result.ThresholdBreached { + t.Errorf("expected ThresholdBreached=false when errorRate is 0.0, got %v (reason: %s)", result.ThresholdBreached, result.BreachReason) + } + if result.ErrorRate != 0.0 { + t.Errorf("expected ErrorRate=0.0, got %f", result.ErrorRate) + } + if result.SampleCount != 200.0 { + t.Errorf("expected SampleCount=200.0, got %f", result.SampleCount) + } +} + // ── promDuration helper ─────────────────────────────────────────────────────── // TestPromDuration_Format verifies that promDuration formats durations correctly.