diff --git a/apis/applyconfiguration/apis/v1/gatewaybackendtls.go b/apis/applyconfiguration/apis/v1/gatewaybackendtls.go new file mode 100644 index 0000000000..e1ef627077 --- /dev/null +++ b/apis/applyconfiguration/apis/v1/gatewaybackendtls.go @@ -0,0 +1,39 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1 + +// GatewayBackendTLSApplyConfiguration represents an declarative configuration of the GatewayBackendTLS type for use +// with apply. +type GatewayBackendTLSApplyConfiguration struct { + ClientCertificateRef *SecretObjectReferenceApplyConfiguration `json:"clientCertificateRef,omitempty"` +} + +// GatewayBackendTLSApplyConfiguration constructs an declarative configuration of the GatewayBackendTLS type for use with +// apply. +func GatewayBackendTLS() *GatewayBackendTLSApplyConfiguration { + return &GatewayBackendTLSApplyConfiguration{} +} + +// WithClientCertificateRef sets the ClientCertificateRef field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ClientCertificateRef field is set to the value of the last call. +func (b *GatewayBackendTLSApplyConfiguration) WithClientCertificateRef(value *SecretObjectReferenceApplyConfiguration) *GatewayBackendTLSApplyConfiguration { + b.ClientCertificateRef = value + return b +} diff --git a/apis/applyconfiguration/apis/v1/gatewayspec.go b/apis/applyconfiguration/apis/v1/gatewayspec.go index a3f935f651..a86b68fae0 100644 --- a/apis/applyconfiguration/apis/v1/gatewayspec.go +++ b/apis/applyconfiguration/apis/v1/gatewayspec.go @@ -29,6 +29,7 @@ type GatewaySpecApplyConfiguration struct { Listeners []ListenerApplyConfiguration `json:"listeners,omitempty"` Addresses []GatewayAddressApplyConfiguration `json:"addresses,omitempty"` Infrastructure *GatewayInfrastructureApplyConfiguration `json:"infrastructure,omitempty"` + BackendTLS *GatewayBackendTLSApplyConfiguration `json:"backendTLS,omitempty"` } // GatewaySpecApplyConfiguration constructs an declarative configuration of the GatewaySpec type for use with @@ -78,3 +79,11 @@ func (b *GatewaySpecApplyConfiguration) WithInfrastructure(value *GatewayInfrast b.Infrastructure = value return b } + +// WithBackendTLS sets the BackendTLS field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the BackendTLS field is set to the value of the last call. +func (b *GatewaySpecApplyConfiguration) WithBackendTLS(value *GatewayBackendTLSApplyConfiguration) *GatewaySpecApplyConfiguration { + b.BackendTLS = value + return b +} diff --git a/apis/applyconfiguration/apis/v1alpha3/backendtlspolicyspec.go b/apis/applyconfiguration/apis/v1alpha3/backendtlspolicyspec.go index 16b16a8cbc..6082fe08c5 100644 --- a/apis/applyconfiguration/apis/v1alpha3/backendtlspolicyspec.go +++ b/apis/applyconfiguration/apis/v1alpha3/backendtlspolicyspec.go @@ -20,6 +20,8 @@ package v1alpha3 import ( v1alpha2 "sigs.k8s.io/gateway-api/apis/applyconfiguration/apis/v1alpha2" + + v1 "sigs.k8s.io/gateway-api/apis/v1" ) // BackendTLSPolicySpecApplyConfiguration represents an declarative configuration of the BackendTLSPolicySpec type for use @@ -27,6 +29,7 @@ import ( type BackendTLSPolicySpecApplyConfiguration struct { TargetRefs []v1alpha2.LocalPolicyTargetReferenceWithSectionNameApplyConfiguration `json:"targetRefs,omitempty"` Validation *BackendTLSPolicyValidationApplyConfiguration `json:"validation,omitempty"` + Options map[v1.AnnotationKey]v1.AnnotationValue `json:"options,omitempty"` } // BackendTLSPolicySpecApplyConfiguration constructs an declarative configuration of the BackendTLSPolicySpec type for use with @@ -55,3 +58,17 @@ func (b *BackendTLSPolicySpecApplyConfiguration) WithValidation(value *BackendTL b.Validation = value return b } + +// WithOptions puts the entries into the Options field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Options field, +// overwriting an existing map entries in Options field with the same key. +func (b *BackendTLSPolicySpecApplyConfiguration) WithOptions(entries map[v1.AnnotationKey]v1.AnnotationValue) *BackendTLSPolicySpecApplyConfiguration { + if b.Options == nil && len(entries) > 0 { + b.Options = make(map[v1.AnnotationKey]v1.AnnotationValue, len(entries)) + } + for k, v := range entries { + b.Options[k] = v + } + return b +} diff --git a/apis/applyconfiguration/apis/v1alpha3/backendtlspolicyvalidation.go b/apis/applyconfiguration/apis/v1alpha3/backendtlspolicyvalidation.go index 952c5926b6..24316c5268 100644 --- a/apis/applyconfiguration/apis/v1alpha3/backendtlspolicyvalidation.go +++ b/apis/applyconfiguration/apis/v1alpha3/backendtlspolicyvalidation.go @@ -20,6 +20,7 @@ package v1alpha3 import ( v1 "sigs.k8s.io/gateway-api/apis/applyconfiguration/apis/v1" + apisv1 "sigs.k8s.io/gateway-api/apis/v1" v1alpha3 "sigs.k8s.io/gateway-api/apis/v1alpha3" ) @@ -30,6 +31,7 @@ type BackendTLSPolicyValidationApplyConfiguration struct { CACertificateRefs []v1.LocalObjectReferenceApplyConfiguration `json:"caCertificateRefs,omitempty"` WellKnownCACertificates *v1alpha3.WellKnownCACertificatesType `json:"wellKnownCACertificates,omitempty"` Hostname *apisv1.PreciseHostname `json:"hostname,omitempty"` + SubjectAltNames []SubjectAltNameApplyConfiguration `json:"subjectAltNames,omitempty"` } // BackendTLSPolicyValidationApplyConfiguration constructs an declarative configuration of the BackendTLSPolicyValidation type for use with @@ -66,3 +68,16 @@ func (b *BackendTLSPolicyValidationApplyConfiguration) WithHostname(value apisv1 b.Hostname = &value return b } + +// WithSubjectAltNames adds the given value to the SubjectAltNames field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the SubjectAltNames field. +func (b *BackendTLSPolicyValidationApplyConfiguration) WithSubjectAltNames(values ...*SubjectAltNameApplyConfiguration) *BackendTLSPolicyValidationApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithSubjectAltNames") + } + b.SubjectAltNames = append(b.SubjectAltNames, *values[i]) + } + return b +} diff --git a/apis/applyconfiguration/apis/v1alpha3/subjectaltname.go b/apis/applyconfiguration/apis/v1alpha3/subjectaltname.go new file mode 100644 index 0000000000..47486fbbc7 --- /dev/null +++ b/apis/applyconfiguration/apis/v1alpha3/subjectaltname.go @@ -0,0 +1,62 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha3 + +import ( + v1 "sigs.k8s.io/gateway-api/apis/v1" + v1alpha3 "sigs.k8s.io/gateway-api/apis/v1alpha3" +) + +// SubjectAltNameApplyConfiguration represents an declarative configuration of the SubjectAltName type for use +// with apply. +type SubjectAltNameApplyConfiguration struct { + Type *v1alpha3.SubjectAltNameType `json:"type,omitempty"` + Hostname *v1.Hostname `json:"hostname,omitempty"` + URI *v1.AbsoluteURI `json:"uri,omitempty"` +} + +// SubjectAltNameApplyConfiguration constructs an declarative configuration of the SubjectAltName type for use with +// apply. +func SubjectAltName() *SubjectAltNameApplyConfiguration { + return &SubjectAltNameApplyConfiguration{} +} + +// WithType sets the Type field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Type field is set to the value of the last call. +func (b *SubjectAltNameApplyConfiguration) WithType(value v1alpha3.SubjectAltNameType) *SubjectAltNameApplyConfiguration { + b.Type = &value + return b +} + +// WithHostname sets the Hostname field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Hostname field is set to the value of the last call. +func (b *SubjectAltNameApplyConfiguration) WithHostname(value v1.Hostname) *SubjectAltNameApplyConfiguration { + b.Hostname = &value + return b +} + +// WithURI sets the URI field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the URI field is set to the value of the last call. +func (b *SubjectAltNameApplyConfiguration) WithURI(value v1.AbsoluteURI) *SubjectAltNameApplyConfiguration { + b.URI = &value + return b +} diff --git a/apis/applyconfiguration/internal/internal.go b/apis/applyconfiguration/internal/internal.go index cf2182195b..d19f44176d 100644 --- a/apis/applyconfiguration/internal/internal.go +++ b/apis/applyconfiguration/internal/internal.go @@ -497,6 +497,12 @@ var schemaYAML = typed.YAMLObject(`types: type: scalar: string default: "" +- name: io.k8s.sigs.gateway-api.apis.v1.GatewayBackendTLS + map: + fields: + - name: clientCertificateRef + type: + namedType: io.k8s.sigs.gateway-api.apis.v1.SecretObjectReference - name: io.k8s.sigs.gateway-api.apis.v1.GatewayClass map: fields: @@ -575,6 +581,9 @@ var schemaYAML = typed.YAMLObject(`types: elementType: namedType: io.k8s.sigs.gateway-api.apis.v1.GatewayAddress elementRelationship: atomic + - name: backendTLS + type: + namedType: io.k8s.sigs.gateway-api.apis.v1.GatewayBackendTLS - name: gatewayClassName type: scalar: string @@ -1492,6 +1501,11 @@ var schemaYAML = typed.YAMLObject(`types: - name: io.k8s.sigs.gateway-api.apis.v1alpha3.BackendTLSPolicySpec map: fields: + - name: options + type: + map: + elementType: + scalar: string - name: targetRefs type: list: @@ -1515,9 +1529,28 @@ var schemaYAML = typed.YAMLObject(`types: type: scalar: string default: "" + - name: subjectAltNames + type: + list: + elementType: + namedType: io.k8s.sigs.gateway-api.apis.v1alpha3.SubjectAltName + elementRelationship: atomic - name: wellKnownCACertificates type: scalar: string +- name: io.k8s.sigs.gateway-api.apis.v1alpha3.SubjectAltName + map: + fields: + - name: hostname + type: + scalar: string + - name: type + type: + scalar: string + default: "" + - name: uri + type: + scalar: string - name: io.k8s.sigs.gateway-api.apis.v1beta1.Gateway map: fields: diff --git a/apis/applyconfiguration/utils.go b/apis/applyconfiguration/utils.go index c88ae790dd..a931d4a8ac 100644 --- a/apis/applyconfiguration/utils.go +++ b/apis/applyconfiguration/utils.go @@ -53,6 +53,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &apisv1.GatewayApplyConfiguration{} case v1.SchemeGroupVersion.WithKind("GatewayAddress"): return &apisv1.GatewayAddressApplyConfiguration{} + case v1.SchemeGroupVersion.WithKind("GatewayBackendTLS"): + return &apisv1.GatewayBackendTLSApplyConfiguration{} case v1.SchemeGroupVersion.WithKind("GatewayClass"): return &apisv1.GatewayClassApplyConfiguration{} case v1.SchemeGroupVersion.WithKind("GatewayClassSpec"): @@ -199,6 +201,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &apisv1alpha3.BackendTLSPolicySpecApplyConfiguration{} case v1alpha3.SchemeGroupVersion.WithKind("BackendTLSPolicyValidation"): return &apisv1alpha3.BackendTLSPolicyValidationApplyConfiguration{} + case v1alpha3.SchemeGroupVersion.WithKind("SubjectAltName"): + return &apisv1alpha3.SubjectAltNameApplyConfiguration{} // Group=gateway.networking.k8s.io, Version=v1beta1 case v1beta1.SchemeGroupVersion.WithKind("Gateway"): diff --git a/apis/v1/gateway_types.go b/apis/v1/gateway_types.go index 2c8b3d8081..c3c0f1b091 100644 --- a/apis/v1/gateway_types.go +++ b/apis/v1/gateway_types.go @@ -229,6 +229,15 @@ type GatewaySpec struct { // // +optional Infrastructure *GatewayInfrastructure `json:"infrastructure,omitempty"` + + // BackendTLS configures TLS settings for when this Gateway is connecting to + // backends with TLS. + // + // Support: Core + // + // +optional + // + BackendTLS *GatewayBackendTLS `json:"backendTLS,omitempty"` } // Listener embodies the concept of a logical endpoint where a Gateway accepts @@ -374,6 +383,29 @@ const ( UDPProtocolType ProtocolType = "UDP" ) +// GatewayBackendTLS describes backend TLS configuration for gateway. +type GatewayBackendTLS struct { + // ClientCertificateRef is a reference to an object that contains a Client + // Certificate and the associated private key. + // + // References to a resource in different namespace are invalid UNLESS there + // is a ReferenceGrant in the target namespace that allows the certificate + // to be attached. If a ReferenceGrant does not allow this reference, the + // "ResolvedRefs" condition MUST be set to False for this listener with the + // "RefNotPermitted" reason. + // + // ClientCertificateRef can reference to standard Kubernetes resources, i.e. + // Secret, or implementation-specific custom resources. + // + // This setting can be overridden on the service level by use of BackendTLSPolicy. + // + // Support: Core + // + // +optional + // + ClientCertificateRef *SecretObjectReference `json:"clientCertificateRef,omitempty"` +} + // GatewayTLSConfig describes a TLS configuration. // // +kubebuilder:validation:XValidation:message="certificateRefs or options must be specified when mode is Terminate",rule="self.mode == 'Terminate' ? size(self.certificateRefs) > 0 || size(self.options) > 0 : true" diff --git a/apis/v1/shared_types.go b/apis/v1/shared_types.go index 7eeec85f84..5570507fb7 100644 --- a/apis/v1/shared_types.go +++ b/apis/v1/shared_types.go @@ -535,6 +535,19 @@ type Hostname string // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` type PreciseHostname string +// AbsoluteURI represents a Uniform Resource Identifier (URI) as defined by RFC3986. + +// The AbsoluteURI MUST NOT be a relative URI, and it MUST follow the URI syntax and +// encoding rules specified in RFC3986. The AbsoluteURI MUST include both a +// scheme (e.g., "http" or "spiffe") and a scheme-specific-part. URIs that +// include an authority MUST include a fully qualified domain name or +// IP address as the host. + +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=253 +// +kubebuilder:validation:Pattern=`^(([^:/?#]+):)(//([^/?#]*))([^?#]*)(\?([^#]*))?(#(.*))?` +type AbsoluteURI string + // Group refers to a Kubernetes Group. It must either be an empty string or a // RFC 1123 subdomain. // diff --git a/apis/v1/zz_generated.deepcopy.go b/apis/v1/zz_generated.deepcopy.go index 6ce1393c4d..f031dafef4 100644 --- a/apis/v1/zz_generated.deepcopy.go +++ b/apis/v1/zz_generated.deepcopy.go @@ -523,6 +523,26 @@ func (in *GatewayAddress) DeepCopy() *GatewayAddress { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayBackendTLS) DeepCopyInto(out *GatewayBackendTLS) { + *out = *in + if in.ClientCertificateRef != nil { + in, out := &in.ClientCertificateRef, &out.ClientCertificateRef + *out = new(SecretObjectReference) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayBackendTLS. +func (in *GatewayBackendTLS) DeepCopy() *GatewayBackendTLS { + if in == nil { + return nil + } + out := new(GatewayBackendTLS) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GatewayClass) DeepCopyInto(out *GatewayClass) { *out = *in @@ -722,6 +742,11 @@ func (in *GatewaySpec) DeepCopyInto(out *GatewaySpec) { *out = new(GatewayInfrastructure) (*in).DeepCopyInto(*out) } + if in.BackendTLS != nil { + in, out := &in.BackendTLS, &out.BackendTLS + *out = new(GatewayBackendTLS) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewaySpec. diff --git a/apis/v1alpha3/backendtlspolicy_types.go b/apis/v1alpha3/backendtlspolicy_types.go index cafd1a7ff3..83f3b2df26 100644 --- a/apis/v1alpha3/backendtlspolicy_types.go +++ b/apis/v1alpha3/backendtlspolicy_types.go @@ -75,6 +75,21 @@ type BackendTLSPolicySpec struct { // Validation contains backend TLS validation configuration. Validation BackendTLSPolicyValidation `json:"validation"` + + // Options are a list of key/value pairs to enable extended TLS + // configuration for each implementation. For example, configuring the + // minimum TLS version or supported cipher suites. + // + // A set of common keys MAY be defined by the API in the future. To avoid + // any ambiguity, implementation-specific definitions MUST use + // domain-prefixed names, such as `example.com/my-custom-option`. + // Un-prefixed names are reserved for key names defined by Gateway API. + // + // Support: Implementation-specific + // + // +optional + // +kubebuilder:validation:MaxProperties=16 + Options map[v1.AnnotationKey]v1.AnnotationValue `json:"options,omitempty"` } // BackendTLSPolicyValidation contains backend TLS validation configuration. @@ -126,11 +141,52 @@ type BackendTLSPolicyValidation struct { // backends: // // 1. Hostname MUST be used as the SNI to connect to the backend (RFC 6066). - // 2. Hostname MUST be used for authentication and MUST match the certificate - // served by the matching backend. + // 2. If SubjectAltNames is not specified, Hostname MUST be used for + // authentication and MUST match the certificate served by the matching + // backend. // // Support: Core Hostname v1.PreciseHostname `json:"hostname"` + + // SubjectAltNames contains one or more Subject Alternative Names. + // When specified, the certificate served from the backend MUST have at least one + // Subject Alternate Name matching one of the specified SubjectAltNames. + // + // Support: Core + // + // +optional + // +kubebuilder:validation:MaxItems=5 + SubjectAltNames []SubjectAltName `json:"subjectAltNames,omitempty"` +} + +// SubjectAltName represents Subject Alternative Name. +// +kubebuilder:validation:XValidation:message="SubjectAltName element must contain Hostname, if Type is set to Hostname",rule="!(self.type == \"Hostname\" && (!has(self.hostname) || self.hostname == \"\"))" +// +kubebuilder:validation:XValidation:message="SubjectAltName element must not contain Hostname, if Type is not set to Hostname",rule="!(self.type != \"Hostname\" && has(self.hostname) && self.hostname != \"\")" +// +kubebuilder:validation:XValidation:message="SubjectAltName element must contain URI, if Type is set to URI",rule="!(self.type == \"URI\" && (!has(self.uri) || self.uri == \"\"))" +// +kubebuilder:validation:XValidation:message="SubjectAltName element must not contain URI, if Type is not set to URI",rule="!(self.type != \"URI\" && has(self.uri) && self.uri != \"\")" +type SubjectAltName struct { + // Type determines the format of the Subject Alternative Name. Always required. + // + // Support: Core + Type SubjectAltNameType `json:"type"` + + // Hostname contains Subject Alternative Name specified in DNS name format. + // Required when Type is set to Hostname, ignored otherwise. + // + // Support: Core + // + // +optional + Hostname v1.Hostname `json:"hostname,omitempty"` + + // URI contains Subject Alternative Name specified in a full URI format. + // It MUST include both a scheme (e.g., "http" or "ftp") and a scheme-specific-part. + // Common values include SPIFFE IDs like "spiffe://mycluster.example.com/ns/myns/sa/svc1sa". + // Required when Type is set to URI, ignored otherwise. + // + // Support: Core + // + // +optional + URI v1.AbsoluteURI `json:"uri,omitempty"` } // WellKnownCACertificatesType is the type of CA certificate that will be used @@ -142,3 +198,19 @@ const ( // WellKnownCACertificatesSystem indicates that well known system CA certificates should be used. WellKnownCACertificatesSystem WellKnownCACertificatesType = "System" ) + +// SubjectAltNameType is the type of the Subject Alternative Name. +// +kubebuilder:validation:Enum=Hostname;URI +type SubjectAltNameType string + +const ( + // HostnameSubjectAltNameType specifies hostname-based SAN. + // + // Support: Core + HostnameSubjectAltNameType SubjectAltNameType = "Hostname" + + // URISubjectAltNameType specifies URI-based SAN, e.g. SPIFFE id. + // + // Support: Core + URISubjectAltNameType SubjectAltNameType = "URI" +) diff --git a/apis/v1alpha3/zz_generated.deepcopy.go b/apis/v1alpha3/zz_generated.deepcopy.go index 5339c534c9..876ac9f7b6 100644 --- a/apis/v1alpha3/zz_generated.deepcopy.go +++ b/apis/v1alpha3/zz_generated.deepcopy.go @@ -96,6 +96,13 @@ func (in *BackendTLSPolicySpec) DeepCopyInto(out *BackendTLSPolicySpec) { } } in.Validation.DeepCopyInto(&out.Validation) + if in.Options != nil { + in, out := &in.Options, &out.Options + *out = make(map[v1.AnnotationKey]v1.AnnotationValue, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendTLSPolicySpec. @@ -121,6 +128,11 @@ func (in *BackendTLSPolicyValidation) DeepCopyInto(out *BackendTLSPolicyValidati *out = new(WellKnownCACertificatesType) **out = **in } + if in.SubjectAltNames != nil { + in, out := &in.SubjectAltNames, &out.SubjectAltNames + *out = make([]SubjectAltName, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendTLSPolicyValidation. @@ -132,3 +144,18 @@ func (in *BackendTLSPolicyValidation) DeepCopy() *BackendTLSPolicyValidation { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SubjectAltName) DeepCopyInto(out *SubjectAltName) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SubjectAltName. +func (in *SubjectAltName) DeepCopy() *SubjectAltName { + if in == nil { + return nil + } + out := new(SubjectAltName) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/experimental/gateway.networking.k8s.io_backendtlspolicies.yaml b/config/crd/experimental/gateway.networking.k8s.io_backendtlspolicies.yaml index b75915e87a..8a589b7e95 100644 --- a/config/crd/experimental/gateway.networking.k8s.io_backendtlspolicies.yaml +++ b/config/crd/experimental/gateway.networking.k8s.io_backendtlspolicies.yaml @@ -53,6 +53,31 @@ spec: spec: description: Spec defines the desired state of BackendTLSPolicy. properties: + options: + additionalProperties: + description: |- + AnnotationValue is the value of an annotation in Gateway API. This is used + for validation of maps such as TLS options. This roughly matches Kubernetes + annotation validation, although the length validation in that case is based + on the entire size of the annotations struct. + maxLength: 4096 + minLength: 0 + type: string + description: |- + Options are a list of key/value pairs to enable extended TLS + configuration for each implementation. For example, configuring the + minimum TLS version or supported cipher suites. + + + A set of common keys MAY be defined by the API in the future. To avoid + any ambiguity, implementation-specific definitions MUST use + domain-prefixed names, such as `example.com/my-custom-option`. + Un-prefixed names are reserved for key names defined by Gateway API. + + + Support: Implementation-specific + maxProperties: 16 + type: object targetRefs: description: |- TargetRefs identifies an API object to apply the policy to. @@ -199,8 +224,9 @@ spec: 1. Hostname MUST be used as the SNI to connect to the backend (RFC 6066). - 2. Hostname MUST be used for authentication and MUST match the certificate - served by the matching backend. + 2. If SubjectAltNames is not specified, Hostname MUST be used for + authentication and MUST match the certificate served by the matching + backend. Support: Core @@ -208,6 +234,73 @@ spec: minLength: 1 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string + subjectAltNames: + description: |- + SubjectAltNames contains one or more Subject Alternative Names. + When specified, the certificate served from the backend MUST have at least one + Subject Alternate Name matching one of the specified SubjectAltNames. + + + Support: Core + items: + description: SubjectAltName represents Subject Alternative Name. + properties: + hostname: + description: |- + Hostname contains Subject Alternative Name specified in DNS name format. + Required when Type is set to Hostname, ignored otherwise. + + + Support: Core + maxLength: 253 + minLength: 1 + pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + type: + description: |- + Type determines the format of the Subject Alternative Name. Always required. + + + Support: Core + enum: + - Hostname + - URI + type: string + uri: + description: |- + URI contains Subject Alternative Name specified in a full URI format. + It MUST include both a scheme (e.g., "http" or "ftp") and a scheme-specific-part. + Common values include SPIFFE IDs like "spiffe://mycluster.example.com/ns/myns/sa/svc1sa". + Required when Type is set to URI, ignored otherwise. + + + Support: Core + maxLength: 253 + minLength: 1 + pattern: ^(([^:/?#]+):)(//([^/?#]*))([^?#]*)(\?([^#]*))?(#(.*))? + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: SubjectAltName element must contain Hostname, if + Type is set to Hostname + rule: '!(self.type == "Hostname" && (!has(self.hostname) || + self.hostname == ""))' + - message: SubjectAltName element must not contain Hostname, + if Type is not set to Hostname + rule: '!(self.type != "Hostname" && has(self.hostname) && + self.hostname != "")' + - message: SubjectAltName element must contain URI, if Type + is set to URI + rule: '!(self.type == "URI" && (!has(self.uri) || self.uri + == ""))' + - message: SubjectAltName element must not contain URI, if Type + is not set to URI + rule: '!(self.type != "URI" && has(self.uri) && self.uri != + "")' + maxItems: 5 + type: array wellKnownCACertificates: description: |- WellKnownCACertificates specifies whether system CA certificates may be used in diff --git a/config/crd/experimental/gateway.networking.k8s.io_gateways.yaml b/config/crd/experimental/gateway.networking.k8s.io_gateways.yaml index 8f4ad10138..a20f22bfb8 100644 --- a/config/crd/experimental/gateway.networking.k8s.io_gateways.yaml +++ b/config/crd/experimental/gateway.networking.k8s.io_gateways.yaml @@ -140,6 +140,81 @@ spec: - message: Hostname values must be unique rule: 'self.all(a1, a1.type == ''Hostname'' ? self.exists_one(a2, a2.type == a1.type && a2.value == a1.value) : true )' + backendTLS: + description: |+ + BackendTLS configures TLS settings for when this Gateway is connecting to + backends with TLS. + + + Support: Core + + + properties: + clientCertificateRef: + description: |+ + ClientCertificateRef is a reference to an object that contains a Client + Certificate and the associated private key. + + + References to a resource in different namespace are invalid UNLESS there + is a ReferenceGrant in the target namespace that allows the certificate + to be attached. If a ReferenceGrant does not allow this reference, the + "ResolvedRefs" condition MUST be set to False for this listener with the + "RefNotPermitted" reason. + + + ClientCertificateRef can reference to standard Kubernetes resources, i.e. + Secret, or implementation-specific custom resources. + + + This setting can be overridden on the service level by use of BackendTLSPolicy. + + + Support: Core + + + properties: + group: + default: "" + description: |- + Group is the group of the referent. For example, "gateway.networking.k8s.io". + When unspecified or empty string, core API group is inferred. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Secret + description: Kind is kind of the referent. For example "Secret". + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the referent. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referenced object. When unspecified, the local + namespace is inferred. + + + Note that when a namespace different than the local namespace is specified, + a ReferenceGrant object is required in the referent namespace to allow that + namespace's owner to accept the reference. See the ReferenceGrant + documentation for details. + + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + required: + - name + type: object + type: object gatewayClassName: description: |- GatewayClassName used for this Gateway. This is the name of a @@ -1367,6 +1442,81 @@ spec: - message: Hostname values must be unique rule: 'self.all(a1, a1.type == ''Hostname'' ? self.exists_one(a2, a2.type == a1.type && a2.value == a1.value) : true )' + backendTLS: + description: |+ + BackendTLS configures TLS settings for when this Gateway is connecting to + backends with TLS. + + + Support: Core + + + properties: + clientCertificateRef: + description: |+ + ClientCertificateRef is a reference to an object that contains a Client + Certificate and the associated private key. + + + References to a resource in different namespace are invalid UNLESS there + is a ReferenceGrant in the target namespace that allows the certificate + to be attached. If a ReferenceGrant does not allow this reference, the + "ResolvedRefs" condition MUST be set to False for this listener with the + "RefNotPermitted" reason. + + + ClientCertificateRef can reference to standard Kubernetes resources, i.e. + Secret, or implementation-specific custom resources. + + + This setting can be overridden on the service level by use of BackendTLSPolicy. + + + Support: Core + + + properties: + group: + default: "" + description: |- + Group is the group of the referent. For example, "gateway.networking.k8s.io". + When unspecified or empty string, core API group is inferred. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Secret + description: Kind is kind of the referent. For example "Secret". + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the referent. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referenced object. When unspecified, the local + namespace is inferred. + + + Note that when a namespace different than the local namespace is specified, + a ReferenceGrant object is required in the referent namespace to allow that + namespace's owner to accept the reference. See the ReferenceGrant + documentation for details. + + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + required: + - name + type: object + type: object gatewayClassName: description: |- GatewayClassName used for this Gateway. This is the name of a diff --git a/pkg/generated/openapi/zz_generated.openapi.go b/pkg/generated/openapi/zz_generated.openapi.go index 72809856fe..8724bbdd3c 100644 --- a/pkg/generated/openapi/zz_generated.openapi.go +++ b/pkg/generated/openapi/zz_generated.openapi.go @@ -100,6 +100,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "sigs.k8s.io/gateway-api/apis/v1.GRPCRouteStatus": schema_sigsk8sio_gateway_api_apis_v1_GRPCRouteStatus(ref), "sigs.k8s.io/gateway-api/apis/v1.Gateway": schema_sigsk8sio_gateway_api_apis_v1_Gateway(ref), "sigs.k8s.io/gateway-api/apis/v1.GatewayAddress": schema_sigsk8sio_gateway_api_apis_v1_GatewayAddress(ref), + "sigs.k8s.io/gateway-api/apis/v1.GatewayBackendTLS": schema_sigsk8sio_gateway_api_apis_v1_GatewayBackendTLS(ref), "sigs.k8s.io/gateway-api/apis/v1.GatewayClass": schema_sigsk8sio_gateway_api_apis_v1_GatewayClass(ref), "sigs.k8s.io/gateway-api/apis/v1.GatewayClassList": schema_sigsk8sio_gateway_api_apis_v1_GatewayClassList(ref), "sigs.k8s.io/gateway-api/apis/v1.GatewayClassSpec": schema_sigsk8sio_gateway_api_apis_v1_GatewayClassSpec(ref), @@ -173,6 +174,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "sigs.k8s.io/gateway-api/apis/v1alpha3.BackendTLSPolicyList": schema_sigsk8sio_gateway_api_apis_v1alpha3_BackendTLSPolicyList(ref), "sigs.k8s.io/gateway-api/apis/v1alpha3.BackendTLSPolicySpec": schema_sigsk8sio_gateway_api_apis_v1alpha3_BackendTLSPolicySpec(ref), "sigs.k8s.io/gateway-api/apis/v1alpha3.BackendTLSPolicyValidation": schema_sigsk8sio_gateway_api_apis_v1alpha3_BackendTLSPolicyValidation(ref), + "sigs.k8s.io/gateway-api/apis/v1alpha3.SubjectAltName": schema_sigsk8sio_gateway_api_apis_v1alpha3_SubjectAltName(ref), "sigs.k8s.io/gateway-api/apis/v1beta1.Gateway": schema_sigsk8sio_gateway_api_apis_v1beta1_Gateway(ref), "sigs.k8s.io/gateway-api/apis/v1beta1.GatewayClass": schema_sigsk8sio_gateway_api_apis_v1beta1_GatewayClass(ref), "sigs.k8s.io/gateway-api/apis/v1beta1.GatewayClassList": schema_sigsk8sio_gateway_api_apis_v1beta1_GatewayClassList(ref), @@ -3542,6 +3544,27 @@ func schema_sigsk8sio_gateway_api_apis_v1_GatewayAddress(ref common.ReferenceCal } } +func schema_sigsk8sio_gateway_api_apis_v1_GatewayBackendTLS(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "GatewayBackendTLS describes backend TLS configuration for gateway.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "clientCertificateRef": { + SchemaProps: spec.SchemaProps{ + Description: "ClientCertificateRef is a reference to an object that contains a Client Certificate and the associated private key.\n\nReferences to a resource in different namespace are invalid UNLESS there is a ReferenceGrant in the target namespace that allows the certificate to be attached. If a ReferenceGrant does not allow this reference, the \"ResolvedRefs\" condition MUST be set to False for this listener with the \"RefNotPermitted\" reason.\n\nClientCertificateRef can reference to standard Kubernetes resources, i.e. Secret, or implementation-specific custom resources.\n\nThis setting can be overridden on the service level by use of BackendTLSPolicy.\n\nSupport: Core\n\n", + Ref: ref("sigs.k8s.io/gateway-api/apis/v1.SecretObjectReference"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "sigs.k8s.io/gateway-api/apis/v1.SecretObjectReference"}, + } +} + func schema_sigsk8sio_gateway_api_apis_v1_GatewayClass(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -3896,12 +3919,18 @@ func schema_sigsk8sio_gateway_api_apis_v1_GatewaySpec(ref common.ReferenceCallba Ref: ref("sigs.k8s.io/gateway-api/apis/v1.GatewayInfrastructure"), }, }, + "backendTLS": { + SchemaProps: spec.SchemaProps{ + Description: "BackendTLS configures TLS settings for when this Gateway is connecting to backends with TLS.\n\nSupport: Core\n\n", + Ref: ref("sigs.k8s.io/gateway-api/apis/v1.GatewayBackendTLS"), + }, + }, }, Required: []string{"gatewayClassName", "listeners"}, }, }, Dependencies: []string{ - "sigs.k8s.io/gateway-api/apis/v1.GatewayAddress", "sigs.k8s.io/gateway-api/apis/v1.GatewayInfrastructure", "sigs.k8s.io/gateway-api/apis/v1.Listener"}, + "sigs.k8s.io/gateway-api/apis/v1.GatewayAddress", "sigs.k8s.io/gateway-api/apis/v1.GatewayBackendTLS", "sigs.k8s.io/gateway-api/apis/v1.GatewayInfrastructure", "sigs.k8s.io/gateway-api/apis/v1.Listener"}, } } @@ -6833,6 +6862,22 @@ func schema_sigsk8sio_gateway_api_apis_v1alpha3_BackendTLSPolicySpec(ref common. Ref: ref("sigs.k8s.io/gateway-api/apis/v1alpha3.BackendTLSPolicyValidation"), }, }, + "options": { + SchemaProps: spec.SchemaProps{ + Description: "Options are a list of key/value pairs to enable extended TLS configuration for each implementation. For example, configuring the minimum TLS version or supported cipher suites.\n\nA set of common keys MAY be defined by the API in the future. To avoid any ambiguity, implementation-specific definitions MUST use domain-prefixed names, such as `example.com/my-custom-option`. Un-prefixed names are reserved for key names defined by Gateway API.\n\nSupport: Implementation-specific", + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, }, Required: []string{"targetRefs", "validation"}, }, @@ -6872,18 +6917,68 @@ func schema_sigsk8sio_gateway_api_apis_v1alpha3_BackendTLSPolicyValidation(ref c }, "hostname": { SchemaProps: spec.SchemaProps{ - Description: "Hostname is used for two purposes in the connection between Gateways and backends:\n\n1. Hostname MUST be used as the SNI to connect to the backend (RFC 6066). 2. Hostname MUST be used for authentication and MUST match the certificate\n served by the matching backend.\n\nSupport: Core", + Description: "Hostname is used for two purposes in the connection between Gateways and backends:\n\n1. Hostname MUST be used as the SNI to connect to the backend (RFC 6066). 2. If SubjectAltNames is not specified, Hostname MUST be used for\n authentication and MUST match the certificate served by the matching\n backend.\n\nSupport: Core", Default: "", Type: []string{"string"}, Format: "", }, }, + "subjectAltNames": { + SchemaProps: spec.SchemaProps{ + Description: "SubjectAltNames contains one or more Subject Alternative Names. When specified, the certificate served from the backend MUST have at least one Subject Alternate Name matching one of the specified SubjectAltNames.\n\nSupport: Core", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("sigs.k8s.io/gateway-api/apis/v1alpha3.SubjectAltName"), + }, + }, + }, + }, + }, }, Required: []string{"hostname"}, }, }, Dependencies: []string{ - "sigs.k8s.io/gateway-api/apis/v1.LocalObjectReference"}, + "sigs.k8s.io/gateway-api/apis/v1.LocalObjectReference", "sigs.k8s.io/gateway-api/apis/v1alpha3.SubjectAltName"}, + } +} + +func schema_sigsk8sio_gateway_api_apis_v1alpha3_SubjectAltName(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "SubjectAltName represents Subject Alternative Name.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "type": { + SchemaProps: spec.SchemaProps{ + Description: "Type determines the format of the Subject Alternative Name. Always required.\n\nSupport: Core", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "hostname": { + SchemaProps: spec.SchemaProps{ + Description: "Hostname contains Subject Alternative Name specified in DNS name format. Required when Type is set to Hostname, ignored otherwise.\n\nSupport: Core", + Type: []string{"string"}, + Format: "", + }, + }, + "uri": { + SchemaProps: spec.SchemaProps{ + Description: "URI contains Subject Alternative Name specified in a full URI format. It MUST include both a scheme (e.g., \"http\" or \"ftp\") and a scheme-specific-part. Common values include SPIFFE IDs like \"spiffe://mycluster.example.com/ns/myns/sa/svc1sa\". Required when Type is set to URI, ignored otherwise.\n\nSupport: Core", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"type"}, + }, + }, } } diff --git a/pkg/test/cel/backendtlspolicy_test.go b/pkg/test/cel/backendtlspolicy_test.go index 1740a002db..7a8972e35c 100644 --- a/pkg/test/cel/backendtlspolicy_test.go +++ b/pkg/test/cel/backendtlspolicy_test.go @@ -103,6 +103,167 @@ func TestBackendTLSPolicyValidation(t *testing.T) { }, wantErrors: []string{"spec.validation.hostname in body should be at least 1 chars long"}, }, + { + name: "valid BackendTLSPolicyValidation with SubjectAltName type Hostname", + routeConfig: gatewayv1a3.BackendTLSPolicyValidation{ + CACertificateRefs: []v1beta1.LocalObjectReference{ + { + Group: "group", + Kind: "kind", + Name: "name", + }, + }, + Hostname: "foo.example.com", + SubjectAltNames: []gatewayv1a3.SubjectAltName{ + { + Type: "Hostname", + Hostname: "foo.example.com", + }, + }, + }, + wantErrors: []string{}, + }, + { + name: "valid BackendTLSPolicyValidation with SubjectAltName type URI", + routeConfig: gatewayv1a3.BackendTLSPolicyValidation{ + CACertificateRefs: []v1beta1.LocalObjectReference{ + { + Group: "group", + Kind: "kind", + Name: "name", + }, + }, + Hostname: "foo.example.com", + SubjectAltNames: []gatewayv1a3.SubjectAltName{ + { + Type: "URI", + URI: "spiffe://mycluster.example", + }, + }, + }, + wantErrors: []string{}, + }, + { + name: "invalid BackendTLSPolicyValidation with SubjectAltName type Hostname and empty Hostname field", + routeConfig: gatewayv1a3.BackendTLSPolicyValidation{ + CACertificateRefs: []v1beta1.LocalObjectReference{ + { + Group: "group", + Kind: "kind", + Name: "name", + }, + }, + Hostname: "foo.example.com", + SubjectAltNames: []gatewayv1a3.SubjectAltName{ + { + Type: "Hostname", + Hostname: "", + }, + }, + }, + wantErrors: []string{"SubjectAltName element must contain Hostname, if Type is set to Hostname"}, + }, + { + name: "invalid BackendTLSPolicyValidation with SubjectAltName type URI and non-empty Hostname field", + routeConfig: gatewayv1a3.BackendTLSPolicyValidation{ + CACertificateRefs: []v1beta1.LocalObjectReference{ + { + Group: "group", + Kind: "kind", + Name: "name", + }, + }, + Hostname: "foo.example.com", + SubjectAltNames: []gatewayv1a3.SubjectAltName{ + { + Type: "URI", + Hostname: "foo.example.com", + }, + }, + }, + wantErrors: []string{"SubjectAltName element must not contain Hostname, if Type is not set to Hostname"}, + }, + { + name: "invalid BackendTLSPolicyValidation with SubjectAltName type URI and empty URI field", + routeConfig: gatewayv1a3.BackendTLSPolicyValidation{ + CACertificateRefs: []v1beta1.LocalObjectReference{ + { + Group: "group", + Kind: "kind", + Name: "name", + }, + }, + Hostname: "foo.example.com", + SubjectAltNames: []gatewayv1a3.SubjectAltName{ + { + Type: "URI", + URI: "", + }, + }, + }, + wantErrors: []string{"SubjectAltName element must contain URI, if Type is set to URI"}, + }, + { + name: "invalid BackendTLSPolicyValidation with SubjectAltName type Hostname and non-empty URI field", + routeConfig: gatewayv1a3.BackendTLSPolicyValidation{ + CACertificateRefs: []v1beta1.LocalObjectReference{ + { + Group: "group", + Kind: "kind", + Name: "name", + }, + }, + Hostname: "foo.example.com", + SubjectAltNames: []gatewayv1a3.SubjectAltName{ + { + Type: "Hostname", + URI: "test", + }, + }, + }, + wantErrors: []string{"SubjectAltName element must not contain URI, if Type is not set to URI"}, + }, + { + name: "invalid BackendTLSPolicyValidation with SubjectAltName type Hostname and both Hostname and URI specified", + routeConfig: gatewayv1a3.BackendTLSPolicyValidation{ + CACertificateRefs: []v1beta1.LocalObjectReference{ + { + Group: "group", + Kind: "kind", + Name: "name", + }, + }, + Hostname: "foo.example.com", + SubjectAltNames: []gatewayv1a3.SubjectAltName{ + { + Type: "Hostname", + Hostname: "foo.example.com", + URI: "test", + }, + }, + }, + wantErrors: []string{"SubjectAltName element must not contain URI, if Type is not set to URI"}, + }, + { + name: "invalid BackendTLSPolicyValidation incorrect URI SAN", + routeConfig: gatewayv1a3.BackendTLSPolicyValidation{ + CACertificateRefs: []v1beta1.LocalObjectReference{ + { + Group: "group", + Kind: "kind", + Name: "name", + }, + }, + Hostname: "foo.example.com", + SubjectAltNames: []gatewayv1a3.SubjectAltName{ + { + Type: "URI", + URI: "foo.example.com", + }, + }, + }, + wantErrors: []string{"spec.validation.subjectAltNames[0].uri in body should match '^(([^:/?#]+):)(//([^/?#]*))([^?#]*)(\\?([^#]*))?(#(.*))?'"}, + }, } for _, tc := range tests {