diff --git a/api/v1alpha1/envoyextensionypolicy_types.go b/api/v1alpha1/envoyextensionypolicy_types.go index f4bb6aa9d56..6cee71c8d2d 100644 --- a/api/v1alpha1/envoyextensionypolicy_types.go +++ b/api/v1alpha1/envoyextensionypolicy_types.go @@ -46,12 +46,12 @@ type EnvoyExtensionPolicySpec struct { // TargetRef TargetRef gwapiv1a2.PolicyTargetReferenceWithSectionName `json:"targetRef"` - // WASM is a list of Wasm extensions to be loaded by the Gateway. + // Wasm is a list of Wasm extensions to be loaded by the Gateway. // Order matters, as the extensions will be loaded in the order they are // defined in this list. // // +optional - WASM []Wasm `json:"wasm,omitempty"` + Wasm []Wasm `json:"wasm,omitempty"` // ExtProc is an ordered list of external processing filters // that should added to the envoy filter chain diff --git a/api/v1alpha1/wasm_types.go b/api/v1alpha1/wasm_types.go index 8a5fc25a277..425c8e45892 100644 --- a/api/v1alpha1/wasm_types.go +++ b/api/v1alpha1/wasm_types.go @@ -31,14 +31,16 @@ type Wasm struct { // RootID is a unique ID for a set of extensions in a VM which will share a // RootContext and Contexts if applicable (e.g., an Wasm HttpFilter and an Wasm AccessLog). // If left blank, all extensions with a blank root_id with the same vm_id will share Context(s). - // RootID *string `json:"rootID,omitempty"` + // RootID must match the root_id parameter used to register the Context in the Wasm code. + RootID *string `json:"rootID,omitempty"` // Code is the wasm code for the extension. Code WasmCodeSource `json:"code"` // Config is the configuration for the Wasm extension. // This configuration will be passed as a JSON string to the Wasm extension. - Config *apiextensionsv1.JSON `json:"config"` + // +optional + Config *apiextensionsv1.JSON `json:"config,omitempty"` // FailOpen is a switch used to control the behavior when a fatal error occurs // during the initialization or the execution of the Wasm extension. @@ -61,7 +63,7 @@ type WasmCodeSource struct { // Type is the type of the source of the wasm code. // Valid WasmCodeSourceType values are "HTTP" or "Image". // - // +kubebuilder:validation:Enum=HTTP;Image + // +kubebuilder:validation:Enum=HTTP;Image;ConfigMap // +unionDiscriminator Type WasmCodeSourceType `json:"type"` @@ -78,8 +80,9 @@ type WasmCodeSource struct { Image *ImageWasmCodeSource `json:"image,omitempty"` // SHA256 checksum that will be used to verify the wasm code. - // +optional - // SHA256 *string `json:"sha256,omitempty"` + // + // kubebuilder:validation:Pattern=`^[a-f0-9]{64}$` + SHA256 string `json:"sha256"` } // WasmCodeSourceType specifies the types of sources for the wasm code. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 151bd89585a..7dcd9227d55 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -844,8 +844,8 @@ func (in *EnvoyExtensionPolicyList) DeepCopyObject() runtime.Object { func (in *EnvoyExtensionPolicySpec) DeepCopyInto(out *EnvoyExtensionPolicySpec) { *out = *in in.TargetRef.DeepCopyInto(&out.TargetRef) - if in.WASM != nil { - in, out := &in.WASM, &out.WASM + if in.Wasm != nil { + in, out := &in.Wasm, &out.Wasm *out = make([]Wasm, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) @@ -4083,6 +4083,11 @@ func (in *TracingProvider) DeepCopy() *TracingProvider { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Wasm) DeepCopyInto(out *Wasm) { *out = *in + if in.RootID != nil { + in, out := &in.RootID, &out.RootID + *out = new(string) + **out = **in + } in.Code.DeepCopyInto(&out.Code) if in.Config != nil { in, out := &in.Config, &out.Config diff --git a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml index f73b96b26ce..7f183ade028 100644 --- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml +++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml @@ -340,7 +340,7 @@ spec: rule: '!has(self.sectionName)' wasm: description: |- - WASM is a list of Wasm extensions to be loaded by the Gateway. + Wasm is a list of Wasm extensions to be loaded by the Gateway. Order matters, as the extensions will be loaded in the order they are defined in this list. items: @@ -426,6 +426,13 @@ spec: - pullSecret - url type: object + sha256: + description: |- + SHA256 checksum that will be used to verify the wasm code. + + + kubebuilder:validation:Pattern=`^[a-f0-9]{64}$` + type: string type: allOf: - enum: @@ -434,11 +441,13 @@ spec: - enum: - HTTP - Image + - ConfigMap description: |- Type is the type of the source of the wasm code. Valid WasmCodeSourceType values are "HTTP" or "Image". type: string required: + - sha256 - type type: object config: @@ -462,9 +471,15 @@ spec: Wasm extension if multiple extensions are handled by the same vm_id and root_id. It's also used for logging/debugging. type: string + rootID: + description: |- + RootID is a unique ID for a set of extensions in a VM which will share a + RootContext and Contexts if applicable (e.g., an Wasm HttpFilter and an Wasm AccessLog). + If left blank, all extensions with a blank root_id with the same vm_id will share Context(s). + RootID must match the root_id parameter used to register the Context in the Wasm code. + type: string required: - code - - config - name type: object type: array diff --git a/internal/gatewayapi/envoyextensionpolicy.go b/internal/gatewayapi/envoyextensionpolicy.go index ca843d4d7a9..3c0305db71b 100644 --- a/internal/gatewayapi/envoyextensionpolicy.go +++ b/internal/gatewayapi/envoyextensionpolicy.go @@ -6,6 +6,7 @@ package gatewayapi import ( + "errors" "fmt" "sort" "strings" @@ -293,6 +294,19 @@ func resolveEEPolicyRouteTargetRef(policy *egv1a1.EnvoyExtensionPolicy, routes m func (t *Translator) translateEnvoyExtensionPolicyForRoute(policy *egv1a1.EnvoyExtensionPolicy, route RouteContext, xdsIR XdsIRMap, resources *Resources) error { + var ( + extProcs []ir.ExtProc + wasms []ir.Wasm + err, errs error + ) + + if extProcs, err = t.buildExtProcs(policy, resources); err != nil { + errs = errors.Join(errs, err) + } + if wasms, err = t.buildWasms(policy); err != nil { + errs = errors.Join(errs, err) + } + // Apply IR to all relevant routes prefix := irRoutePrefix(route) for _, ir := range xdsIR { @@ -300,17 +314,14 @@ func (t *Translator) translateEnvoyExtensionPolicyForRoute(policy *egv1a1.EnvoyE for _, r := range http.Routes { // Apply if there is a match if strings.HasPrefix(r.Name, prefix) { - if extProcs, err := t.buildExtProcs(policy, resources); err == nil { - r.ExtProcs = extProcs - } else { - return err - } + r.ExtProcs = extProcs + r.Wasms = wasms } } } } - return nil + return errs } func (t *Translator) buildExtProcs(policy *egv1a1.EnvoyExtensionPolicy, resources *Resources) ([]ir.ExtProc, error) { @@ -320,21 +331,24 @@ func (t *Translator) buildExtProcs(policy *egv1a1.EnvoyExtensionPolicy, resource return nil, nil } - if len(policy.Spec.ExtProc) > 0 { - for idx, ep := range policy.Spec.ExtProc { - name := irConfigNameForEEP(policy, idx) - extProcIR, err := t.buildExtProc(name, utils.NamespacedName(policy), ep, idx, resources) - if err != nil { - return nil, err - } - extProcIRList = append(extProcIRList, *extProcIR) + for idx, ep := range policy.Spec.ExtProc { + name := irConfigNameForEEP(policy, idx) + extProcIR, err := t.buildExtProc(name, utils.NamespacedName(policy), ep, idx, resources) + if err != nil { + return nil, err } + extProcIRList = append(extProcIRList, *extProcIR) } return extProcIRList, nil } func (t *Translator) translateEnvoyExtensionPolicyForGateway(policy *egv1a1.EnvoyExtensionPolicy, gateway *GatewayContext, xdsIR XdsIRMap, resources *Resources) error { + var ( + extProcs []ir.ExtProc + wasms []ir.Wasm + err, errs error + ) irKey := t.getIRKey(gateway.Gateway) // Should exist since we've validated this @@ -345,9 +359,11 @@ func (t *Translator) translateEnvoyExtensionPolicyForGateway(policy *egv1a1.Envo string(policy.Spec.TargetRef.Name), ) - extProcs, err := t.buildExtProcs(policy, resources) - if err != nil { - return err + if extProcs, err = t.buildExtProcs(policy, resources); err != nil { + errs = errors.Join(errs, err) + } + if wasms, err = t.buildWasms(policy); err != nil { + errs = errors.Join(errs, err) } for _, http := range ir.HTTP { @@ -360,13 +376,21 @@ func (t *Translator) translateEnvoyExtensionPolicyForGateway(policy *egv1a1.Envo // targeting a lesser specific scope(Gateway). for _, r := range http.Routes { // if already set - there's a route level policy, so skip + if r.ExtProcs != nil || + r.Wasms != nil { + continue + } + if r.ExtProcs == nil { r.ExtProcs = extProcs } + if r.Wasms == nil { + r.Wasms = wasms + } } } - return nil + return errs } func (t *Translator) buildExtProc( @@ -428,3 +452,56 @@ func irConfigNameForEEP(policy *egv1a1.EnvoyExtensionPolicy, idx int) string { utils.NamespacedName(policy).String(), idx) } + +func (t *Translator) buildWasms(policy *egv1a1.EnvoyExtensionPolicy) ([]ir.Wasm, error) { + var wasmIRList []ir.Wasm + + if policy == nil { + return nil, nil + } + + for idx, wasm := range policy.Spec.Wasm { + name := irConfigNameForEEP(policy, idx) + wasmIR, err := t.buildWasm(name, wasm) + if err != nil { + return nil, err + } + wasmIRList = append(wasmIRList, *wasmIR) + } + return wasmIRList, nil +} + +func (t *Translator) buildWasm(name string, wasm egv1a1.Wasm) (*ir.Wasm, error) { + var ( + failOpen = false + httpWasmCode *ir.HTTPWasmCode + ) + + if wasm.FailOpen != nil { + failOpen = *wasm.FailOpen + } + + switch wasm.Code.Type { + case egv1a1.HTTPWasmCodeSourceType: + httpWasmCode = &ir.HTTPWasmCode{ + URL: wasm.Code.HTTP.URL, + SHA256: wasm.Code.SHA256, + } + case egv1a1.ImageWasmCodeSourceType: + return nil, fmt.Errorf("OCI image Wasm code source is not supported yet") + default: + // should never happen because of kubebuilder validation, just a sanity check + return nil, fmt.Errorf("unsupported Wasm code source type %q", wasm.Code.Type) + } + + wasmIR := &ir.Wasm{ + Name: name, + RootID: wasm.RootID, + WasmName: wasm.Name, + Config: wasm.Config, + FailOpen: failOpen, + HTTPWasmCode: httpWasmCode, + } + + return wasmIR, nil +} diff --git a/internal/gatewayapi/testdata/envoyextensionpolicy-with-wasm.in.yaml b/internal/gatewayapi/testdata/envoyextensionpolicy-with-wasm.in.yaml new file mode 100644 index 00000000000..640e1bc189e --- /dev/null +++ b/internal/gatewayapi/testdata/envoyextensionpolicy-with-wasm.in.yaml @@ -0,0 +1,112 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + hostnames: + - www.example.com + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/foo" + backendRefs: + - name: service-1 + port: 8080 +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-2 + spec: + hostnames: + - www.example.com + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/bar" + backendRefs: + - name: service-1 + port: 8080 +envoyextensionpolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + namespace: envoy-gateway + name: policy-for-gateway # This policy should attach httproute-2 + spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + wasm: + - name: wasm-filter-1 + code: + type: HTTP + http: + url: https://www.example.com/wasm-filter-1.wasm + sha256: 746df05c8f3a0b07a46c0967cfbc5cbe5b9d48d0f79b6177eeedf8be6c8b34b5 + config: + parameter1: + key1: value1 + key2: value2 + parameter2: value3 + - name: wasm-filter-2 + code: + type: HTTP + http: + url: https://www.example.com/wasm-filter-2.wasm + sha256: a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980 + config: + parameter1: value1 + parameter2: value2 +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + namespace: default + name: policy-for-http-route # This policy should attach httproute-1 + spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + namespace: default + wasm: + - name: wasm-filter-3 + code: + type: HTTP + http: + url: https://www.test.com/wasm-filter-3.wasm + sha256: a1f0b78b8c1320690327800e3a5de10e7dbba7b6c752e702193a395a52c727b6 + config: + parameter1: + key1: value1 + parameter2: + key2: + key3: value3 + failOpen: true diff --git a/internal/gatewayapi/testdata/envoyextensionpolicy-with-wasm.out.yaml b/internal/gatewayapi/testdata/envoyextensionpolicy-with-wasm.out.yaml new file mode 100755 index 00000000000..24b531e70f5 --- /dev/null +++ b/internal/gatewayapi/testdata/envoyextensionpolicy-with-wasm.out.yaml @@ -0,0 +1,317 @@ +envoyExtensionPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + creationTimestamp: null + name: policy-for-http-route + namespace: default + spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + namespace: default + wasm: + - code: + http: + url: https://www.test.com/wasm-filter-3.wasm + sha256: a1f0b78b8c1320690327800e3a5de10e7dbba7b6c752e702193a395a52c727b6 + type: HTTP + config: + parameter1: + key1: value1 + parameter2: + key2: + key3: value3 + failOpen: true + name: wasm-filter-3 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + creationTimestamp: null + name: policy-for-gateway + namespace: envoy-gateway + spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + wasm: + - code: + http: + url: https://www.example.com/wasm-filter-1.wasm + sha256: 746df05c8f3a0b07a46c0967cfbc5cbe5b9d48d0f79b6177eeedf8be6c8b34b5 + type: HTTP + config: + parameter1: + key1: value1 + key2: value2 + parameter2: value3 + name: wasm-filter-1 + - code: + http: + url: https://www.example.com/wasm-filter-2.wasm + sha256: a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980 + type: HTTP + config: + parameter1: value1 + parameter2: value2 + name: wasm-filter-2 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: 'This policy is being overridden by other envoyExtensionPolicies + for these routes: [default/httproute-1]' + reason: Overridden + status: "True" + type: Overridden + controllerName: gateway.envoyproxy.io/gatewayclass-controller +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + creationTimestamp: null + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: All + name: http + port: 80 + protocol: HTTP + status: + listeners: + - attachedRoutes: 2 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-1 + namespace: default + spec: + hostnames: + - www.example.com + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: /foo + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-2 + namespace: default + spec: + hostnames: + - www.example.com + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: /bar + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - address: null + name: envoy-gateway/gateway-1/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + name: envoy-gateway/gateway-1 +xdsIR: + envoy-gateway/gateway-1: + accessLog: + text: + - path: /dev/stdout + http: + - address: 0.0.0.0 + hostnames: + - '*' + isHTTP2: false + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - backendWeights: + invalid: 0 + valid: 0 + destination: + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + hostname: www.example.com + isHTTP2: false + name: httproute/default/httproute-1/rule/0/match/0/www_example_com + pathMatch: + distinct: false + name: "" + prefix: /foo + wasm: + - config: + parameter1: + key1: value1 + parameter2: + key2: + key3: value3 + failOpen: true + httpWasmCode: + sha256: a1f0b78b8c1320690327800e3a5de10e7dbba7b6c752e702193a395a52c727b6 + url: https://www.test.com/wasm-filter-3.wasm + name: envoyextensionpolicy/default/policy-for-http-route/0 + wasmName: wasm-filter-3 + - backendWeights: + invalid: 0 + valid: 0 + destination: + name: httproute/default/httproute-2/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + hostname: www.example.com + isHTTP2: false + name: httproute/default/httproute-2/rule/0/match/0/www_example_com + pathMatch: + distinct: false + name: "" + prefix: /bar + wasm: + - config: + parameter1: + key1: value1 + key2: value2 + parameter2: value3 + failOpen: false + httpWasmCode: + sha256: 746df05c8f3a0b07a46c0967cfbc5cbe5b9d48d0f79b6177eeedf8be6c8b34b5 + url: https://www.example.com/wasm-filter-1.wasm + name: envoyextensionpolicy/envoy-gateway/policy-for-gateway/0 + wasmName: wasm-filter-1 + - config: + parameter1: value1 + parameter2: value2 + failOpen: false + httpWasmCode: + sha256: a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980 + url: https://www.example.com/wasm-filter-2.wasm + name: envoyextensionpolicy/envoy-gateway/policy-for-gateway/1 + wasmName: wasm-filter-2 diff --git a/internal/ir/xds.go b/internal/ir/xds.go index a0522e788a7..7a87d9ac18b 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -481,6 +481,8 @@ type HTTPRoute struct { Retry *Retry `json:"retry,omitempty" yaml:"retry,omitempty"` // External Processing extensions ExtProcs []ExtProc `json:"extProc,omitempty" yaml:"extProc,omitempty"` + // Wasm extensions + Wasms []Wasm `json:"wasm,omitempty" yaml:"wasm,omitempty"` // Security holds the features associated with SecurityPolicy Security *SecurityFeatures `json:"security,omitempty" yaml:"security,omitempty"` @@ -1900,3 +1902,41 @@ type ExtProc struct { // Authority is the hostname:port of the HTTP External Processing service. Authority string `json:"authority"` } + +// Wasm holds the information associated with the Wasm extensions. +// +k8s:deepcopy-gen=true +type Wasm struct { + // Name is a unique name for an Wasm configuration. + // The xds translator only generates one ExtProc filter for each unique name. + Name string `json:"name"` + + // RootID is a unique ID for a set of extensions in a VM which will share a + // RootContext and Contexts if applicable (e.g., an Wasm HttpFilter and an Wasm AccessLog). + // If left blank, all extensions with a blank root_id with the same vm_id will share Context(s). + RootID *string `json:"rootID,omitempty"` + + // WasmName is used to identify the Wasm extension if multiple extensions are + // handled by the same vm_id and root_id. + // It's also used for logging/debugging. + WasmName string `json:"wasmName"` + + // Config is the configuration for the Wasm extension. + // This configuration will be passed as a JSON string to the Wasm extension. + Config *apiextensionsv1.JSON `json:"config"` + + // FailOpen is a switch used to control the behavior when a fatal error occurs + // during the initialization or the execution of the Wasm extension. + FailOpen bool `json:"failOpen"` + + // HTTPWasmCode is the HTTP Wasm code source. + HTTPWasmCode *HTTPWasmCode `json:"httpWasmCode,omitempty"` +} + +// HTTPWasmCode holds the information associated with the HTTP Wasm code source. +type HTTPWasmCode struct { + // URL is the URL of the Wasm code. + URL string `json:"url"` + + // SHA256 checksum that will be used to verify the wasm code. + SHA256 string `json:"sha256"` +} diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index 94e14661375..f9602aac288 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -1024,6 +1024,13 @@ func (in *HTTPRoute) DeepCopyInto(out *HTTPRoute) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Wasms != nil { + in, out := &in.Wasms, &out.Wasms + *out = make([]Wasm, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.Security != nil { in, out := &in.Security, &out.Security *out = new(SecurityFeatures) @@ -2394,6 +2401,36 @@ func (in *UnstructuredRef) DeepCopy() *UnstructuredRef { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Wasm) DeepCopyInto(out *Wasm) { + *out = *in + if in.RootID != nil { + in, out := &in.RootID, &out.RootID + *out = new(string) + **out = **in + } + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } + if in.HTTPWasmCode != nil { + in, out := &in.HTTPWasmCode, &out.HTTPWasmCode + *out = new(HTTPWasmCode) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Wasm. +func (in *Wasm) DeepCopy() *Wasm { + if in == nil { + return nil + } + out := new(Wasm) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Xds) DeepCopyInto(out *Xds) { *out = *in diff --git a/internal/xds/translator/extproc.go b/internal/xds/translator/extproc.go index 19dd8480753..ac03a49d08e 100644 --- a/internal/xds/translator/extproc.go +++ b/internal/xds/translator/extproc.go @@ -32,9 +32,9 @@ type extProc struct { var _ httpFilter = &extProc{} -// patchHCM builds and appends the ext_authz Filters to the HTTP Connection Manager +// patchHCM builds and appends the ext_proc Filters to the HTTP Connection Manager // if applicable, and it does not already exist. -// Note: this method creates an ext_authz filter for each route that contains an ExtAuthz config. +// Note: this method creates an ext_proc filter for each route that contains an ExtAuthz config. // The filter is disabled by default. It is enabled on the route level. func (*extProc) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPListener) error { var errs error @@ -70,7 +70,7 @@ func (*extProc) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPLi return errs } -// buildHCMExtProcFilter returns an ext_authp HTTP filter from the provided IR HTTPRoute. +// buildHCMExtProcFilter returns an ext_proc HTTP filter from the provided IR HTTPRoute. func buildHCMExtProcFilter(extProc ir.ExtProc) (*hcmv3.HttpFilter, error) { extAuthProto := extProcConfig(extProc) if err := extAuthProto.ValidateAll(); err != nil { @@ -128,7 +128,7 @@ func routeContainsExtProc(irRoute *ir.HTTPRoute) bool { return len(irRoute.ExtProcs) > 0 } -// patchResources patches the cluster resources for the external auth services. +// patchResources patches the cluster resources for the external services. func (*extProc) patchResources(tCtx *types.ResourceVersionTable, routes []*ir.HTTPRoute) error { if tCtx == nil || tCtx.XdsResources == nil { diff --git a/internal/xds/translator/httpfilters.go b/internal/xds/translator/httpfilters.go index 218d40502f7..143c8d77320 100644 --- a/internal/xds/translator/httpfilters.go +++ b/internal/xds/translator/httpfilters.go @@ -103,12 +103,14 @@ func newOrderedHTTPFilter(filter *hcmv3.HttpFilter) *OrderedHTTPFilter { order = 5 case filter.Name == jwtAuthn: order = 6 - case filter.Name == extProcFilter: + case isFilterType(filter, extProcFilter): order = 7 - case filter.Name == localRateLimitFilter: + case isFilterType(filter, wasmFilter): order = 8 - case filter.Name == wellknown.HTTPRateLimit: + case filter.Name == localRateLimitFilter: order = 9 + case filter.Name == wellknown.HTTPRateLimit: + order = 10 case filter.Name == wellknown.Router: order = 100 } diff --git a/internal/xds/translator/httpfilters_test.go b/internal/xds/translator/httpfilters_test.go index 4ce8921dc5e..90773ce6a36 100644 --- a/internal/xds/translator/httpfilters_test.go +++ b/internal/xds/translator/httpfilters_test.go @@ -30,6 +30,9 @@ func Test_sortHTTPFilters(t *testing.T) { httpFilterForTest(wellknown.HTTPRateLimit), httpFilterForTest(wellknown.Fault), httpFilterForTest(extAuthFilter + "-route1"), + httpFilterForTest(wasmFilter + "-route1"), + httpFilterForTest(extProcFilter + "-route1"), + httpFilterForTest(localRateLimitFilter), }, want: []*hcmv3.HttpFilter{ httpFilterForTest(wellknown.Fault), @@ -38,6 +41,9 @@ func Test_sortHTTPFilters(t *testing.T) { httpFilterForTest(basicAuthFilter), httpFilterForTest(oauth2Filter + "-route1"), httpFilterForTest(jwtAuthn), + httpFilterForTest(extProcFilter + "-route1"), + httpFilterForTest(wasmFilter + "-route1"), + httpFilterForTest(localRateLimitFilter), httpFilterForTest(wellknown.HTTPRateLimit), httpFilterForTest(wellknown.Router), }, diff --git a/internal/xds/translator/jwt.go b/internal/xds/translator/jwt.go index 822a5adf159..b6bf2275efa 100644 --- a/internal/xds/translator/jwt.go +++ b/internal/xds/translator/jwt.go @@ -265,47 +265,16 @@ func (*jwt) patchResources(tCtx *types.ResourceVersionTable, routes []*ir.HTTPRo return errors.New("xds resource table is nil") } - var errs error + var err, errs error for _, route := range routes { if !routeContainsJWTAuthn(route) { continue } for i := range route.Security.JWT.Providers { - var ( - jwks *urlCluster - ds *ir.DestinationSetting - tSocket *corev3.TransportSocket - err error - ) - provider := route.Security.JWT.Providers[i] - jwks, err = url2Cluster(provider.RemoteJWKS.URI) - if err != nil { - errs = errors.Join(errs, err) - continue - } - - ds = &ir.DestinationSetting{ - Weight: ptr.To[uint32](1), - Endpoints: []*ir.DestinationEndpoint{ir.NewDestEndpoint(jwks.hostname, jwks.port)}, - } - - clusterArgs := &xdsClusterArgs{ - name: jwks.name, - settings: []*ir.DestinationSetting{ds}, - endpointType: jwks.endpointType, - } - if jwks.tls { - tSocket, err = buildXdsUpstreamTLSSocket(jwks.hostname) - if err != nil { - errs = errors.Join(errs, err) - continue - } - clusterArgs.tSocket = tSocket - } - if err = addXdsCluster(tCtx, clusterArgs); err != nil && !errors.Is(err, ErrXdsClusterExists) { + if err = addClusterFromURL(provider.RemoteJWKS.URI, tCtx); err != nil { errs = errors.Join(errs, err) } } diff --git a/internal/xds/translator/testdata/in/xds-ir/wasm.yaml b/internal/xds/translator/testdata/in/xds-ir/wasm.yaml new file mode 100644 index 00000000000..a879c182731 --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/wasm.yaml @@ -0,0 +1,84 @@ +http: +- address: 0.0.0.0 + hostnames: + - '*' + isHTTP2: false + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - backendWeights: + invalid: 0 + valid: 0 + destination: + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + hostname: www.example.com + isHTTP2: false + name: httproute/default/httproute-1/rule/0/match/0/www_example_com + pathMatch: + distinct: false + name: "" + prefix: /foo + wasm: + - config: + parameter1: + key1: value1 + parameter2: + key2: + key3: value3 + failOpen: true + httpWasmCode: + sha256: a1f0b78b8c1320690327800e3a5de10e7dbba7b6c752e702193a395a52c727b6 + url: https://www.test.com/wasm-filter-3.wasm + name: envoyextensionpolicy/default/policy-for-http-route/0 + wasmName: wasm-filter-3 + - backendWeights: + invalid: 0 + valid: 0 + destination: + name: httproute/default/httproute-2/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + hostname: www.example.com + isHTTP2: false + name: httproute/default/httproute-2/rule/0/match/0/www_example_com + pathMatch: + distinct: false + name: "" + prefix: /bar + wasm: + - config: + parameter1: + key1: value1 + key2: value2 + parameter2: value3 + failOpen: false + httpWasmCode: + sha256: 746df05c8f3a0b07a46c0967cfbc5cbe5b9d48d0f79b6177eeedf8be6c8b34b5 + url: https://www.example.com/wasm-filter-1.wasm + name: envoyextensionpolicy/envoy-gateway/policy-for-gateway/0 + wasmName: wasm-filter-1 + rootID: my-root-id + - config: + parameter1: value1 + parameter2: value2 + failOpen: false + httpWasmCode: + sha256: a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980 + url: https://www.example.com/wasm-filter-2.wasm + name: envoyextensionpolicy/envoy-gateway/policy-for-gateway/1 + wasmName: wasm-filter-2 diff --git a/internal/xds/translator/testdata/out/xds-ir/wasm.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/wasm.clusters.yaml new file mode 100755 index 00000000000..a70d771a1db --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/wasm.clusters.yaml @@ -0,0 +1,106 @@ +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_ONLY + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: httproute/default/httproute-1/rule/0 + lbPolicy: LEAST_REQUEST + name: httproute/default/httproute-1/rule/0 + outlierDetection: {} + perConnectionBufferLimitBytes: 32768 + type: EDS +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_ONLY + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: httproute/default/httproute-2/rule/0 + lbPolicy: LEAST_REQUEST + name: httproute/default/httproute-2/rule/0 + outlierDetection: {} + perConnectionBufferLimitBytes: 32768 + type: EDS +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_ONLY + dnsRefreshRate: 30s + lbPolicy: LEAST_REQUEST + loadAssignment: + clusterName: www_test_com_443 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: www.test.com + portValue: 443 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: www_test_com_443/backend/0 + name: www_test_com_443 + outlierDetection: {} + perConnectionBufferLimitBytes: 32768 + respectDnsTtl: true + transportSocket: + name: envoy.transport_sockets.tls + typedConfig: + '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + commonTlsContext: + validationContext: + trustedCa: + filename: /etc/ssl/certs/ca-certificates.crt + sni: www.test.com + type: STRICT_DNS +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_ONLY + dnsRefreshRate: 30s + lbPolicy: LEAST_REQUEST + loadAssignment: + clusterName: www_example_com_443 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: www.example.com + portValue: 443 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: www_example_com_443/backend/0 + name: www_example_com_443 + outlierDetection: {} + perConnectionBufferLimitBytes: 32768 + respectDnsTtl: true + transportSocket: + name: envoy.transport_sockets.tls + typedConfig: + '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + commonTlsContext: + validationContext: + trustedCa: + filename: /etc/ssl/certs/ca-certificates.crt + sni: www.example.com + type: STRICT_DNS diff --git a/internal/xds/translator/testdata/out/xds-ir/wasm.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/wasm.endpoints.yaml new file mode 100755 index 00000000000..05442a9a15b --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/wasm.endpoints.yaml @@ -0,0 +1,24 @@ +- clusterName: httproute/default/httproute-1/rule/0 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 7.7.7.7 + portValue: 8080 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: httproute/default/httproute-1/rule/0/backend/0 +- clusterName: httproute/default/httproute-2/rule/0 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 7.7.7.7 + portValue: 8080 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: httproute/default/httproute-2/rule/0/backend/0 diff --git a/internal/xds/translator/testdata/out/xds-ir/wasm.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/wasm.listeners.yaml new file mode 100755 index 00000000000..8f0cc2eaf23 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/wasm.listeners.yaml @@ -0,0 +1,93 @@ +- address: + socketAddress: + address: 0.0.0.0 + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + commonHttpProtocolOptions: + headersWithUnderscoresAction: REJECT_REQUEST + http2ProtocolOptions: + initialConnectionWindowSize: 1048576 + initialStreamWindowSize: 65536 + maxConcurrentStreams: 100 + httpFilters: + - disabled: true + name: envoy.filters.http.wasm/envoyextensionpolicy/default/policy-for-http-route/0 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + config: + configuration: + '@type': type.googleapis.com/google.protobuf.StringValue + value: '{"parameter1":{"key1":"value1"},"parameter2":{"key2":{"key3":"value3"}}}' + failOpen: true + name: wasm-filter-3 + vmConfig: + code: + remote: + httpUri: + cluster: www_test_com_443 + timeout: 10s + uri: https://www.test.com/wasm-filter-3.wasm + sha256: a1f0b78b8c1320690327800e3a5de10e7dbba7b6c752e702193a395a52c727b6 + runtime: envoy.wasm.runtime.v8 + vmId: envoyextensionpolicy/default/policy-for-http-route/0 + - disabled: true + name: envoy.filters.http.wasm/envoyextensionpolicy/envoy-gateway/policy-for-gateway/0 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + config: + configuration: + '@type': type.googleapis.com/google.protobuf.StringValue + value: '{"parameter1":{"key1":"value1","key2":"value2"},"parameter2":"value3"}' + name: wasm-filter-1 + rootId: my-root-id + vmConfig: + code: + remote: + httpUri: + cluster: www_example_com_443 + timeout: 10s + uri: https://www.example.com/wasm-filter-1.wasm + sha256: 746df05c8f3a0b07a46c0967cfbc5cbe5b9d48d0f79b6177eeedf8be6c8b34b5 + runtime: envoy.wasm.runtime.v8 + vmId: envoyextensionpolicy/envoy-gateway/policy-for-gateway/0 + - disabled: true + name: envoy.filters.http.wasm/envoyextensionpolicy/envoy-gateway/policy-for-gateway/1 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + config: + configuration: + '@type': type.googleapis.com/google.protobuf.StringValue + value: '{"parameter1":"value1","parameter2":"value2"}' + name: wasm-filter-2 + vmConfig: + code: + remote: + httpUri: + cluster: www_example_com_443 + timeout: 10s + uri: https://www.example.com/wasm-filter-2.wasm + sha256: a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980 + runtime: envoy.wasm.runtime.v8 + vmId: envoyextensionpolicy/envoy-gateway/policy-for-gateway/1 + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + suppressEnvoyHeaders: true + mergeSlashes: true + normalizePath: true + pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: envoy-gateway/gateway-1/http + serverHeaderTransformation: PASS_THROUGH + statPrefix: http + useRemoteAddress: true + drainType: MODIFY_ONLY + name: envoy-gateway/gateway-1/http + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/wasm.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/wasm.routes.yaml new file mode 100755 index 00000000000..8fb6f03a0f0 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/wasm.routes.yaml @@ -0,0 +1,32 @@ +- ignorePortInHostMatching: true + name: envoy-gateway/gateway-1/http + virtualHosts: + - domains: + - www.example.com + name: envoy-gateway/gateway-1/http/www_example_com + routes: + - match: + pathSeparatedPrefix: /foo + name: httproute/default/httproute-1/rule/0/match/0/www_example_com + route: + cluster: httproute/default/httproute-1/rule/0 + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.wasm/envoyextensionpolicy/default/policy-for-http-route/0: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + config: {} + - match: + pathSeparatedPrefix: /bar + name: httproute/default/httproute-2/rule/0/match/0/www_example_com + route: + cluster: httproute/default/httproute-2/rule/0 + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.wasm/envoyextensionpolicy/envoy-gateway/policy-for-gateway/0: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + config: {} + envoy.filters.http.wasm/envoyextensionpolicy/envoy-gateway/policy-for-gateway/1: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + config: {} diff --git a/internal/xds/translator/translator_test.go b/internal/xds/translator/translator_test.go index a13ee7c8428..b11dc243098 100644 --- a/internal/xds/translator/translator_test.go +++ b/internal/xds/translator/translator_test.go @@ -310,6 +310,9 @@ func TestTranslateXds(t *testing.T) { { name: "ext-proc", }, + { + name: "wasm", + }, { name: "jwt-optional", }, diff --git a/internal/xds/translator/utils.go b/internal/xds/translator/utils.go index e3002d9a8a3..f4d24c9965d 100644 --- a/internal/xds/translator/utils.go +++ b/internal/xds/translator/utils.go @@ -13,6 +13,8 @@ import ( "strconv" "strings" + "k8s.io/utils/ptr" + "github.com/envoyproxy/gateway/internal/ir" "github.com/envoyproxy/gateway/internal/xds/types" @@ -161,3 +163,39 @@ func createExtServiceXDSCluster(rd *ir.RouteDestination, tCtx *types.ResourceVer } return nil } + +// addClusterFromURL adds a cluster to the resource version table from the provided URL. +func addClusterFromURL(url string, tCtx *types.ResourceVersionTable) error { + var ( + uc *urlCluster + ds *ir.DestinationSetting + tSocket *corev3.TransportSocket + err error + ) + + if uc, err = url2Cluster(url); err != nil { + return err + } + + ds = &ir.DestinationSetting{ + Weight: ptr.To[uint32](1), + Endpoints: []*ir.DestinationEndpoint{ir.NewDestEndpoint(uc.hostname, uc.port)}, + } + + clusterArgs := &xdsClusterArgs{ + name: uc.name, + settings: []*ir.DestinationSetting{ds}, + endpointType: uc.endpointType, + } + if uc.tls { + if tSocket, err = buildXdsUpstreamTLSSocket(uc.hostname); err != nil { + return err + } + clusterArgs.tSocket = tSocket + } + + if err = addXdsCluster(tCtx, clusterArgs); err != nil && !errors.Is(err, ErrXdsClusterExists) { + return err + } + return nil +} diff --git a/internal/xds/translator/wasm.go b/internal/xds/translator/wasm.go new file mode 100644 index 00000000000..1c8c03951ca --- /dev/null +++ b/internal/xds/translator/wasm.go @@ -0,0 +1,213 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package translator + +import ( + "errors" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + wasmfilterv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/wasm/v3" + hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + wasmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/wasm/v3" + "github.com/golang/protobuf/ptypes/duration" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/envoyproxy/gateway/internal/ir" + "github.com/envoyproxy/gateway/internal/xds/types" +) + +const ( + wasmFilter = "envoy.filters.http.wasm" + vmRuntimeV8 = "envoy.wasm.runtime.v8" +) + +func init() { + registerHTTPFilter(&wasm{}) +} + +type wasm struct { +} + +var _ httpFilter = &wasm{} + +// patchHCM builds and appends the wasm Filters to the HTTP Connection Manager +// if applicable, and it does not already exist. +// Note: this method creates a wasm filter for each route that contains an wasm config. +// The filter is disabled by default. It is enabled on the route level. +func (*wasm) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPListener) error { + var errs error + + if mgr == nil { + return errors.New("hcm is nil") + } + if irListener == nil { + return errors.New("ir listener is nil") + } + + for _, route := range irListener.Routes { + if !routeContainsWasm(route) { + continue + } + for _, ep := range route.Wasms { + if hcmContainsFilter(mgr, wasmFilterName(ep)) { + continue + } + filter, err := buildHCMWasmFilter(ep) + if err != nil { + errs = errors.Join(errs, err) + continue + } + mgr.HttpFilters = append(mgr.HttpFilters, filter) + } + } + + return errs +} + +// buildHCMWasmFilter returns a wasm HTTP filter from the provided IR HTTPRoute. +func buildHCMWasmFilter(wasm ir.Wasm) (*hcmv3.HttpFilter, error) { + var ( + wasmProto *wasmfilterv3.Wasm + wasmAny *anypb.Any + err error + ) + + if wasmProto, err = wasmConfig(wasm); err != nil { + return nil, err + } + if err = wasmProto.ValidateAll(); err != nil { + return nil, err + } + if wasmAny, err = anypb.New(wasmProto); err != nil { + return nil, err + } + + // All wasm filters for all Routes are aggregated on HCM and disabled by default + // Per-route config is used to enable the relevant filters on appropriate routes + return &hcmv3.HttpFilter{ + Name: wasmFilterName(wasm), + Disabled: true, + ConfigType: &hcmv3.HttpFilter_TypedConfig{ + TypedConfig: wasmAny, + }, + }, nil +} + +func wasmFilterName(wasm ir.Wasm) string { + return perRouteFilterName(wasmFilter, wasm.Name) +} + +func wasmConfig(wasm ir.Wasm) (*wasmfilterv3.Wasm, error) { + var ( + uc *urlCluster + pluginConfig = "" + configAny *anypb.Any + filterConfig *wasmfilterv3.Wasm + err error + ) + + // We only support HTTP Wasm code source for now + if uc, err = url2Cluster(wasm.HTTPWasmCode.URL); err != nil { + return nil, err + } + + if wasm.Config != nil { + pluginConfig = string(wasm.Config.Raw) + } + + if configAny, err = anypb.New(wrapperspb.String(pluginConfig)); err != nil { + return nil, err + } + + filterConfig = &wasmfilterv3.Wasm{ + Config: &wasmv3.PluginConfig{ + Name: wasm.WasmName, + Vm: &wasmv3.PluginConfig_VmConfig{ + VmConfig: &wasmv3.VmConfig{ + VmId: wasm.Name, // Do not share VMs across different filters + Runtime: vmRuntimeV8, + Code: &corev3.AsyncDataSource{ + Specifier: &corev3.AsyncDataSource_Remote{ + Remote: &corev3.RemoteDataSource{ + HttpUri: &corev3.HttpUri{ + Uri: wasm.HTTPWasmCode.URL, + HttpUpstreamType: &corev3.HttpUri_Cluster{ + Cluster: uc.name, + }, + Timeout: &duration.Duration{ + Seconds: defaultExtServiceRequestTimeout, + }, + }, + Sha256: wasm.HTTPWasmCode.SHA256, + }, + }, + }, + }, + }, + Configuration: configAny, + FailOpen: wasm.FailOpen, + }, + } + + if wasm.RootID != nil { + filterConfig.Config.RootId = *wasm.RootID + } + + return filterConfig, nil +} + +// routeContainsWasm returns true if Wasms exists for the provided route. +func routeContainsWasm(irRoute *ir.HTTPRoute) bool { + if irRoute == nil { + return false + } + + return len(irRoute.Wasms) > 0 +} + +// patchResources patches the cluster resources for the http wasm code source. +func (*wasm) patchResources(tCtx *types.ResourceVersionTable, + routes []*ir.HTTPRoute) error { + if tCtx == nil || tCtx.XdsResources == nil { + return errors.New("xds resource table is nil") + } + + var err, errs error + for _, route := range routes { + if !routeContainsWasm(route) { + continue + } + + for _, w := range route.Wasms { + if err = addClusterFromURL(w.HTTPWasmCode.URL, tCtx); err != nil { + errs = errors.Join(errs, err) + } + } + } + + return errs +} + +// patchRoute patches the provided route with the wasm config if applicable. +// Note: this method enables the corresponding wasm filter for the provided route. +func (*wasm) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute) error { + if route == nil { + return errors.New("xds route is nil") + } + if irRoute == nil { + return errors.New("ir route is nil") + } + + for _, ep := range irRoute.Wasms { + filterName := wasmFilterName(ep) + if err := enableFilterOnRoute(route, filterName); err != nil { + return err + } + } + return nil +} diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index 23ebdd9f259..86e6d8320a0 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -645,7 +645,7 @@ _Appears in:_ | Field | Type | Required | Description | | --- | --- | --- | --- | | `targetRef` | _[PolicyTargetReferenceWithSectionName](https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io/v1alpha2.PolicyTargetReferenceWithSectionName)_ | true | TargetRef is the name of the Gateway resource this policy
is being attached to.
This Policy and the TargetRef MUST be in the same namespace
for this Policy to have effect and be applied to the Gateway.
TargetRef | -| `wasm` | _[Wasm](#wasm) array_ | false | WASM is a list of Wasm extensions to be loaded by the Gateway.
Order matters, as the extensions will be loaded in the order they are
defined in this list. | +| `wasm` | _[Wasm](#wasm) array_ | false | Wasm is a list of Wasm extensions to be loaded by the Gateway.
Order matters, as the extensions will be loaded in the order they are
defined in this list. | | `extProc` | _[ExtProc](#extproc) array_ | true | ExtProc is an ordered list of external processing filters
that should added to the envoy filter chain | @@ -3143,8 +3143,9 @@ _Appears in:_ | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | _string_ | true | Name is a unique name for this Wasm extension. It is used to identify the
Wasm extension if multiple extensions are handled by the same vm_id and root_id.
It's also used for logging/debugging. | +| `rootID` | _string_ | true | RootID is a unique ID for a set of extensions in a VM which will share a
RootContext and Contexts if applicable (e.g., an Wasm HttpFilter and an Wasm AccessLog).
If left blank, all extensions with a blank root_id with the same vm_id will share Context(s).
RootID must match the root_id parameter used to register the Context in the Wasm code. | | `code` | _[WasmCodeSource](#wasmcodesource)_ | true | Code is the wasm code for the extension. | -| `config` | _[JSON](#json)_ | true | Config is the configuration for the Wasm extension.
This configuration will be passed as a JSON string to the Wasm extension. | +| `config` | _[JSON](#json)_ | false | Config is the configuration for the Wasm extension.
This configuration will be passed as a JSON string to the Wasm extension. | | `failOpen` | _boolean_ | false | FailOpen is a switch used to control the behavior when a fatal error occurs
during the initialization or the execution of the Wasm extension.
If FailOpen is set to true, the system bypasses the Wasm extension and
allows the traffic to pass through. Otherwise, if it is set to false or
not set (defaulting to false), the system blocks the traffic and returns
an HTTP 5xx error. | @@ -3162,6 +3163,7 @@ _Appears in:_ | `type` | _[WasmCodeSourceType](#wasmcodesourcetype)_ | true | Type is the type of the source of the wasm code.
Valid WasmCodeSourceType values are "HTTP" or "Image". | | `http` | _[HTTPWasmCodeSource](#httpwasmcodesource)_ | false | HTTP is the HTTP URL containing the wasm code.

Note that the HTTP server must be accessible from the Envoy proxy. | | `image` | _[ImageWasmCodeSource](#imagewasmcodesource)_ | false | Image is the OCI image containing the wasm code.

Note that the image must be accessible from the Envoy Gateway. | +| `sha256` | _string_ | true | SHA256 checksum that will be used to verify the wasm code.

kubebuilder:validation:Pattern=`^[a-f0-9]{64}$` | #### WasmCodeSourceType diff --git a/test/e2e/testdata/wasm.yaml b/test/e2e/testdata/wasm.yaml new file mode 100644 index 00000000000..76723cafb8c --- /dev/null +++ b/test/e2e/testdata/wasm.yaml @@ -0,0 +1,55 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: http-with-http-wasm-source + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + hostnames: ["www.example.com"] + rules: + - matches: + - path: + type: PathPrefix + value: /wasm-http + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: http-without-wasm + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + hostnames: ["www.example.com"] + rules: + - matches: + - path: + type: PathPrefix + value: /no-wasm + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyExtensionPolicy +metadata: + name: http-wasm-source-test + namespace: gateway-conformance-infra +spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: http-with-http-wasm-source + wasm: + - name: wasm-filter + rootID: my_root_id + code: + type: HTTP + http: + url: https://raw.githubusercontent.com/envoyproxy/envoy/main/examples/wasm-cc/lib/envoy_filter_http_wasm_example.wasm + sha256: 79c9f85128bb0177b6511afa85d587224efded376ac0ef76df56595f1e6315c0 diff --git a/test/e2e/tests/utils.go b/test/e2e/tests/utils.go index 6903534558b..a07cf6cb464 100644 --- a/test/e2e/tests/utils.go +++ b/test/e2e/tests/utils.go @@ -9,7 +9,6 @@ import ( "context" "fmt" "io" - "testing" "time" @@ -216,6 +215,7 @@ func EnvoyExtensionPolicyMustBeAccepted(t *testing.T, client client.Client, poli } if policyAcceptedByAncestor(policy.Status.Ancestors, controllerName, ancestorRef) { + t.Logf("EnvoyExtensionPolicy has been accepted: %v", policy) return true, nil } diff --git a/test/e2e/tests/wasm.go b/test/e2e/tests/wasm.go new file mode 100644 index 00000000000..d4a36029f3b --- /dev/null +++ b/test/e2e/tests/wasm.go @@ -0,0 +1,117 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +//go:build e2e +// +build e2e + +package tests + +import ( + "testing" + + "k8s.io/apimachinery/pkg/types" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + + "github.com/envoyproxy/gateway/internal/gatewayapi" +) + +func init() { + ConformanceTests = append(ConformanceTests, WasmTest) +} + +// WasmTest tests Wasm extension for an http route with HTTP Wasm configured. +var WasmTest = suite.ConformanceTest{ + ShortName: "Wasm", + Description: "Test Wasm extension that adds response headers", + Manifests: []string{"testdata/wasm.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + t.Run("http route with http wasm source", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "http-with-http-wasm-source", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + + ancestorRef := gwv1a2.ParentReference{ + Group: gatewayapi.GroupPtr(gwv1.GroupName), + Kind: gatewayapi.KindPtr(gatewayapi.KindGateway), + Namespace: gatewayapi.NamespacePtr(gwNN.Namespace), + Name: gwv1.ObjectName(gwNN.Name), + } + EnvoyExtensionPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "http-wasm-source-test", Namespace: ns}, suite.ControllerName, ancestorRef) + + expectedResponse := http.ExpectedResponse{ + Request: http.Request{ + Host: "www.example.com", + Path: "/wasm-http", + }, + + // Set the expected request properties to empty strings. + // This is a workaround to avoid the test failure. + // These values can't be extracted from the json format response + // body because the test wasm code appends a "Hello, world" text + // to the response body, invalidating the json format. + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Host: "", + Method: "", + Path: "", + Headers: nil, + }, + }, + Namespace: "", + + Response: http.Response{ + StatusCode: 200, + Headers: map[string]string{ + "x-wasm-custom": "FOO", // response header added by wasm + }, + }, + } + + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expectedResponse) + }) + + t.Run("http route without wasm", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "http-without-wasm", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + + ancestorRef := gwv1a2.ParentReference{ + Group: gatewayapi.GroupPtr(gwv1.GroupName), + Kind: gatewayapi.KindPtr(gatewayapi.KindGateway), + Namespace: gatewayapi.NamespacePtr(gwNN.Namespace), + Name: gwv1.ObjectName(gwNN.Name), + } + EnvoyExtensionPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "http-wasm-source-test", Namespace: ns}, suite.ControllerName, ancestorRef) + + expectedResponse := http.ExpectedResponse{ + Request: http.Request{ + Host: "www.example.com", + Path: "/no-wasm", + }, + Response: http.Response{ + StatusCode: 200, + AbsentHeaders: []string{"x-wasm-custom"}, + }, + Namespace: ns, + } + + req := http.MakeRequest(t, &expectedResponse, gwAddr, "HTTP", "http") + cReq, cResp, err := suite.RoundTripper.CaptureRoundTrip(req) + if err != nil { + t.Errorf("failed to get expected response: %v", err) + } + + if err := http.CompareRequest(t, &req, cReq, cResp, expectedResponse); err != nil { + t.Errorf("failed to compare request and response: %v", err) + } + }) + }, +}