Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v2alpha1 support for short hosts #1311

Merged
merged 27 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a4e7ea9
Short host validation
videlov Sep 19, 2024
028a98f
More validation
videlov Sep 19, 2024
7f98f1b
Default domain from non-kyma gateway
videlov Sep 19, 2024
6ed8290
State
videlov Sep 22, 2024
3cfb14c
State
videlov Sep 23, 2024
738da6b
integration test
videlov Sep 24, 2024
61dd290
Renamings and docs update
videlov Sep 24, 2024
fd39bf4
Update internal/validation/v2alpha1/hosts.go
videlov Sep 24, 2024
ab44d4e
Review naming suggestions
videlov Sep 24, 2024
01a51ec
Update error message
videlov Sep 24, 2024
c13f012
Cover case for Istio gateway with host definition "*."
videlov Sep 24, 2024
8255e4c
Fix
videlov Sep 24, 2024
c4348fe
Error on APIRule when Gateway not found
videlov Sep 24, 2024
15fce55
Fix integration test
videlov Sep 24, 2024
7fd5421
Update internal/validation/v2alpha1/hosts.go
videlov Sep 25, 2024
9cc0e93
Update docs/contributor/adr/0007-apirule-v2alpha1-api-proposal.md
videlov Sep 25, 2024
3d843bc
Update internal/helpers/hosts.go
videlov Sep 25, 2024
51c4ad4
Review adjustments
videlov Sep 25, 2024
d0802db
Fix lint
videlov Sep 25, 2024
4077c1f
Review suggestion re-correction
videlov Sep 25, 2024
dbe830e
Fix int test v2alpha1 status checks and adds error int scenario for s…
videlov Sep 25, 2024
e71efa2
Unwanted removal
videlov Sep 25, 2024
63d2259
Fix error message
videlov Sep 25, 2024
6aa8442
Revert v2alpha1 integration test use new status check func
videlov Sep 25, 2024
dd15309
Update docs/user/custom-resources/apirule/v2alpha1/04-10-apirule-cust…
videlov Sep 26, 2024
b06ed25
Update docs/user/custom-resources/apirule/v2alpha1/04-10-apirule-cust…
videlov Sep 26, 2024
4d0a37a
Fix v2alpha1 int tests with original function for applying
videlov Sep 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apis/gateway/v2alpha1/apirule_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,9 @@ type APIRuleSpec struct {
Timeout *Timeout `json:"timeout,omitempty"`
}

// Host is the URL of the exposed service.
// +kubebuilder:validation:MinLength=3
// Host is the URL of the exposed service. We support short names.
videlov marked this conversation as resolved.
Show resolved Hide resolved
// +kubebuilder:validation:MaxLength=255
// +kubebuilder:validation:XValidation:rule=`self.matches('^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$')`,message="Host is not Fully Qualified Domain Name"
// +kubebuilder:validation:XValidation:rule=`self.matches('^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*$')`,message="Host must be a short name or a fully qualified domain name"
videlov marked this conversation as resolved.
Show resolved Hide resolved
type Host string

// APIRuleStatus describes the observed state of ApiRule.
Expand Down
9 changes: 5 additions & 4 deletions config/crd/bases/gateway.kyma-project.io_apirules.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -363,13 +363,14 @@ spec:
hosts:
description: Specifies the URLs of the exposed service.
items:
description: Host is the URL of the exposed service.
description: Host is the URL of the exposed service. We support
short names.
maxLength: 255
minLength: 3
type: string
x-kubernetes-validations:
- message: Host is not Fully Qualified Domain Name
rule: self.matches('^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$')
- message: Host must be a short name or a fully qualified domain
name
rule: self.matches('^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*$')
maxItems: 1
minItems: 1
type: array
Expand Down
899 changes: 465 additions & 434 deletions controllers/gateway/api_controller_integration_test.go

Large diffs are not rendered by default.

38 changes: 26 additions & 12 deletions controllers/gateway/apirule_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ package gateway
import (
"context"
"fmt"
"strings"
"time"

"github.com/kyma-project/api-gateway/internal/dependencies"
"github.com/kyma-project/api-gateway/internal/processing/processors/migration"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"time"

gatewayv1beta1 "github.com/kyma-project/api-gateway/apis/gateway/v1beta1"
gatewayv2alpha1 "github.com/kyma-project/api-gateway/apis/gateway/v2alpha1"
Expand All @@ -44,6 +46,7 @@ import (

"github.com/go-logr/logr"
"github.com/kyma-project/api-gateway/internal/processing"
networkingv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1"
corev1 "k8s.io/api/core/v1"
apierrs "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
Expand Down Expand Up @@ -77,7 +80,7 @@ func (r *APIRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
l.Info("Starting reconciliation")
ctx = logr.NewContext(ctx, r.Log)

defaultDomainName, err := default_domain.GetDefaultDomainFromKymaGateway(ctx, r.Client)
defaultDomainName, err := default_domain.GetDomainFromKymaGateway(ctx, r.Client)
if err != nil && default_domain.HandleDefaultDomainError(l, err) {
return doneReconcileErrorRequeue(err, errorReconciliationPeriod)
}
Expand Down Expand Up @@ -117,11 +120,11 @@ func (r *APIRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
}

if r.isApiRuleConvertedFromV2alpha1(apiRule) {
return r.reconcileV2alpha1APIRule(ctx, l, apiRule, defaultDomainName)
return r.reconcileV2Alpha1APIRule(ctx, l, apiRule, defaultDomainName)
}

l.Info("Reconciling v1beta1 APIRule", "jwtHandler", r.Config.JWTHandler)
cmd := r.getV1beta1Reconciliation(&apiRule, defaultDomainName, &l)
cmd := r.getV1Beta1Reconciliation(&apiRule, defaultDomainName, &l)
if name, err := dependencies.APIRule().AreAvailable(ctx, r.Client); err != nil {
s, err := handleDependenciesError(name, err).V1beta1Status()
if err != nil {
Expand Down Expand Up @@ -157,7 +160,7 @@ func (r *APIRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
return r.updateStatus(ctx, l, &apiRule)
}

func (r *APIRuleReconciler) reconcileV2alpha1APIRule(ctx context.Context, l logr.Logger, apiRule gatewayv1beta1.APIRule, domain string) (ctrl.Result, error) {
func (r *APIRuleReconciler) reconcileV2Alpha1APIRule(ctx context.Context, l logr.Logger, apiRule gatewayv1beta1.APIRule, defaultDomainName string) (ctrl.Result, error) {
l.Info("Reconciling v2alpha1 APIRule")
toUpdate := apiRule.DeepCopy()
migrate, err := apiRuleNeedsMigration(ctx, r.Client, toUpdate)
Expand All @@ -179,8 +182,19 @@ func (r *APIRuleReconciler) reconcileV2alpha1APIRule(ctx context.Context, l logr
if err := rule.ConvertFrom(toUpdate); err != nil {
return doneReconcileErrorRequeue(err, r.OnErrorReconcilePeriod)
}
cmd := r.getv2alpha1Reconciliation(&apiRule, &rule,
domain, migrate, &l)

gatewayName := strings.Split(*rule.Spec.Gateway, "/")
gatewayNN := types.NamespacedName{
Namespace: gatewayName[0],
Name: gatewayName[1],
}
var gateway networkingv1beta1.Gateway
if err := r.Client.Get(ctx, gatewayNN, &gateway); err != nil {
l.Error(err, "Error while getting Gateway for APIRule", "not found", apierrs.IsNotFound(err))
return doneReconcileErrorRequeue(err, r.OnErrorReconcilePeriod)
}

cmd := r.getV2Alpha1Reconciliation(&apiRule, &rule, &gateway, defaultDomainName, migrate, &l)

if name, err := dependencies.APIRule().AreAvailable(ctx, r.Client); err != nil {
s, err := handleDependenciesError(name, err).V2alpha1Status()
Expand Down Expand Up @@ -256,9 +270,9 @@ func handleDependenciesError(name string, err error) controllers.Status {
}
}

func (r *APIRuleReconciler) getV1beta1Reconciliation(apiRule *gatewayv1beta1.APIRule, defaultDomain string, namespacedLogger *logr.Logger) processing.ReconciliationCommand {
func (r *APIRuleReconciler) getV1Beta1Reconciliation(apiRule *gatewayv1beta1.APIRule, defaultDomainName string, namespacedLogger *logr.Logger) processing.ReconciliationCommand {
config := r.ReconciliationConfig
config.DefaultDomainName = defaultDomain
config.DefaultDomainName = defaultDomainName
switch {
case r.Config.JWTHandler == helpers.JWT_HANDLER_ISTIO:
return istio.NewIstioReconciliation(apiRule, config, namespacedLogger)
Expand All @@ -267,11 +281,11 @@ func (r *APIRuleReconciler) getV1beta1Reconciliation(apiRule *gatewayv1beta1.API
}
}

func (r *APIRuleReconciler) getv2alpha1Reconciliation(apiRulev1beta1 *gatewayv1beta1.APIRule, apiRulev2alpha1 *gatewayv2alpha1.APIRule, defaultDomain string, needsMigration bool, namespacedLogger *logr.Logger) processing.ReconciliationCommand {
func (r *APIRuleReconciler) getV2Alpha1Reconciliation(apiRulev1beta1 *gatewayv1beta1.APIRule, apiRulev2alpha1 *gatewayv2alpha1.APIRule, gateway *networkingv1beta1.Gateway, defaultDomainName string, needsMigration bool, namespacedLogger *logr.Logger) processing.ReconciliationCommand {
config := r.ReconciliationConfig
config.DefaultDomainName = defaultDomain
config.DefaultDomainName = defaultDomainName
v2alpha1Validator := v2alpha1.NewAPIRuleValidator(apiRulev2alpha1)
return v2alpha1Processing.NewReconciliation(apiRulev2alpha1, apiRulev1beta1, v2alpha1Validator, config, namespacedLogger, needsMigration)
return v2alpha1Processing.NewReconciliation(apiRulev2alpha1, apiRulev1beta1, gateway, v2alpha1Validator, config, namespacedLogger, needsMigration)
}

// SetupWithManager sets up the controller with the Manager.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ This table lists all parameters of APIRule `v2alpha1` CRD together with their de
| **corsPolicy.allowCredentials** | **NO** | Specifies whether credentials are allowed in the **Access-Control-Allow-Credentials** CORS header. | None |
| **corsPolicy.exposeHeaders** | **NO** | Specifies headers exposed with the **Access-Control-Expose-Headers** CORS header. | None |
| **corsPolicy.maxAge** | **NO** | Specifies the maximum age of CORS policy cache. The value is provided in the **Access-Control-Max-Age** CORS header. | None |
| **hosts** | **YES** | Specifies the Service's communication address for inbound external traffic. It must be a fully qualified domain name in proper FQDN format: at least two domain labels with characters, numbers, or dashes. | FQDN format. |
| **hosts** | **YES** | Specifies the Service's communication address for inbound external traffic. It must be a short name or a valid fully qualified domain name (FQDN) in format: at least two domain labels with characters, numbers, or hypens. | Short name or FQDN format. |
videlov marked this conversation as resolved.
Show resolved Hide resolved
| **service.name** | **NO** | Specifies the name of the exposed Service. | None |
| **service.namespace** | **NO** | Specifies the namespace of the exposed Service. | None |
| **service.port** | **NO** | Specifies the communication port of the exposed Service. | None |
Expand Down
22 changes: 22 additions & 0 deletions internal/helpers/hosts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package helpers

import (
"regexp"
)

const (
fqdnMaxLength = 255
)

var (
regexFqdn = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$`)
regexShortName = regexp.MustCompile("^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
videlov marked this conversation as resolved.
Show resolved Hide resolved
)

func IsHostFqdn(host string) bool {
return len(host) <= fqdnMaxLength && regexFqdn.MatchString(host)
}

func IsHostShortName(host string) bool {
return regexShortName.MatchString(host)
}
103 changes: 103 additions & 0 deletions internal/helpers/hosts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package helpers

import (
"fmt"
"strings"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("IsHostFqdn", func() {
It("Should be false if host is empty", func() {
Expect(IsHostFqdn("")).To(BeFalse())
})

It("Should be true if host is a valid FQDN", func() {
Expect(IsHostFqdn("host.example.com")).To(BeTrue())
})

It("Should be false if host name has uppercase letters", func() {
Expect(IsHostFqdn("host.exaMple.com")).To(BeFalse())
})

It("Should be true if host name has 255 chars", func() {
Expect(IsHostFqdn(fmt.Sprintf("%s.%s.%s.%s.eee", strings.Repeat("a", 63), strings.Repeat("b", 63),
strings.Repeat("c", 63), strings.Repeat("d", 59)))).To(BeTrue())
})

It("Should be false if host name is longer than 255 chars", func() {
Expect(IsHostFqdn(fmt.Sprintf("%s.%s.%s.%s.eee", strings.Repeat("a", 63), strings.Repeat("b", 63),
strings.Repeat("c", 63), strings.Repeat("d", 60)))).To(BeFalse())
})

It("Should be false if any domain label is longer than 63 chars", func() {
Expect(IsHostFqdn(fmt.Sprintf("%s.com", strings.Repeat("a", 64)))).To(BeFalse())
Expect(IsHostFqdn(fmt.Sprintf("host.%s.com", strings.Repeat("a", 64)))).To(BeFalse())
Expect(IsHostFqdn(fmt.Sprintf("host.example.%s", strings.Repeat("a", 64)))).To(BeFalse())
})

It("Should be false if any domain label is empty", func() {
Expect(IsHostFqdn("host.")).To(BeFalse())
Expect(IsHostFqdn(".com")).To(BeFalse())
Expect(IsHostFqdn(".example.com")).To(BeFalse())
Expect(IsHostFqdn("host..com")).To(BeFalse())
Expect(IsHostFqdn("host.example.")).To(BeFalse())
})

It("Should be false if top level domain is too short", func() {
Expect(IsHostFqdn("host.example.c")).To(BeFalse())
})

It("Should be false if any domain label contain wrong characters", func() {
Expect(IsHostFqdn("*host.example.com")).To(BeFalse())
Expect(IsHostFqdn("ho*st.example.com")).To(BeFalse())
Expect(IsHostFqdn("host*.example.com")).To(BeFalse())
Expect(IsHostFqdn("host.*example.com")).To(BeFalse())
Expect(IsHostFqdn("host.exam*ple.com")).To(BeFalse())
Expect(IsHostFqdn("host.example*.com")).To(BeFalse())
Expect(IsHostFqdn("host.example.*com")).To(BeFalse())
Expect(IsHostFqdn("host.example.co*m")).To(BeFalse())
Expect(IsHostFqdn("host.example.com*")).To(BeFalse())
})

It("Should be false if any segment in host name starts or ends with hyphen", func() {
Expect(IsHostFqdn("-host.example.com")).To(BeFalse())
Expect(IsHostFqdn("host-.example.com")).To(BeFalse())
Expect(IsHostFqdn("host.-example.com")).To(BeFalse())
Expect(IsHostFqdn("host.example-.com")).To(BeFalse())
Expect(IsHostFqdn("host.example.-com")).To(BeFalse())
Expect(IsHostFqdn("host.example.com-")).To(BeFalse())
})
})

var _ = Describe("IsHostFqdn", func() {
It("Should be false if host is empty", func() {
Expect(IsHostShortName("")).To(BeFalse())
})

It("Should be true if host is a valid short name", func() {
Expect(IsHostShortName("short-host--name")).To(BeTrue())
})

It("Should be false if short host name has uppercase letters", func() {
Expect(IsHostShortName("sHort")).To(BeFalse())
})

It("Should be true if short host name has 1 char", func() {
Expect(IsHostShortName("a")).To(BeTrue())
})

It("Should be true if short host name has 63 chars", func() {
Expect(IsHostShortName(strings.Repeat("a", 63))).To(BeTrue())
})

It("Should be false if short host name is longer than 63 chars", func() {
Expect(IsHostShortName(strings.Repeat("a", 64))).To(BeFalse())
})

It("Should be false if short host name contains not allowed char", func() {
Expect(IsHostShortName("short-host.")).To(BeFalse())
Expect(IsHostShortName(".short-host")).To(BeFalse())
})
})
57 changes: 42 additions & 15 deletions internal/processing/default_domain/default_domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@ package default_domain

import (
"context"
"errors"
"fmt"
"strings"

"github.com/go-logr/logr"
"github.com/thoas/go-funk"
apiv1beta1 "istio.io/api/networking/v1beta1"
networkingv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1"
apierrs "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"regexp"
"sigs.k8s.io/controller-runtime/pkg/client"
"strings"
)

const (
gatewayNamespace = "kyma-system"
gatewayName = "kyma-gateway"

protocolHttps = "HTTPS"
kymaGatewayNamespace = "kyma-system"
kymaGatewayName = "kyma-gateway"
kymaGatewayProtocol = "HTTPS"
)

func GetHostWithDomain(host, defaultDomainName string) string {
Expand Down Expand Up @@ -50,34 +50,61 @@ func HandleDefaultDomainError(log logr.Logger, err error) (finishReconciliation
}
}

func GetDefaultDomainFromKymaGateway(ctx context.Context, k8sClient client.Client) (string, error) {
func GetDomainFromKymaGateway(ctx context.Context, k8sClient client.Client) (string, error) {
var gateway networkingv1beta1.Gateway
err := k8sClient.Get(ctx, types.NamespacedName{Namespace: gatewayNamespace, Name: gatewayName}, &gateway)
err := k8sClient.Get(ctx, types.NamespacedName{Namespace: kymaGatewayNamespace, Name: kymaGatewayName}, &gateway)
if err != nil {
return "", err
}

httpsServers := funk.Filter(gateway.Spec.GetServers(), func(g *apiv1beta1.Server) bool {
return g.Port != nil && strings.ToUpper(g.Port.Protocol) == protocolHttps
return g.Port != nil && strings.ToUpper(g.Port.Protocol) == kymaGatewayProtocol
}).([]*apiv1beta1.Server)

if len(httpsServers) != 1 {
return "", fmt.Errorf("could not get default domain, number of https servers was more than 1, num=%d", len(gateway.Spec.GetServers()))
return "", fmt.Errorf("gateway must have a single https server definition, num=%d", len(httpsServers))
}

if len(httpsServers[0].Hosts) != 1 {
return "", fmt.Errorf("could not get default domain, number of hosts in HTTPS server was different than default of 1, num=%d", len(gateway.Spec.GetServers()[0].Hosts))
return "", fmt.Errorf("gateway https server must have a single host definition, num=%d", len(httpsServers[0].Hosts))
}

if !strings.HasPrefix(httpsServers[0].Hosts[0], "*.") {
return "", fmt.Errorf(`gateway https server host %s does not start with the prefix "*."`, httpsServers[0].Hosts[0])
}

match, err := regexp.MatchString(`^\*\..+$`, httpsServers[0].Hosts[0])
return strings.TrimPrefix(httpsServers[0].Hosts[0], "*."), nil
videlov marked this conversation as resolved.
Show resolved Hide resolved
}

func GetDomainFromGateway(ctx context.Context, k8sClient client.Client, gatewayName, gatewayNamespace string) (string, error) {
var gateway networkingv1beta1.Gateway
err := k8sClient.Get(ctx, types.NamespacedName{Namespace: gatewayNamespace, Name: gatewayName}, &gateway)
if err != nil {
return "", err
}

if !match {
return "", fmt.Errorf(`host %s isn't a host with wildcard prefix "*."`, httpsServers[0].Hosts[0])
if !gatewayServersWithSameSingleHost(&gateway) {
videlov marked this conversation as resolved.
Show resolved Hide resolved
return "", errors.New("gateway must specify server(s) with the same single host")
}

if !strings.HasPrefix(gateway.Spec.Servers[0].Hosts[0], "*.") {
return "", fmt.Errorf(`gateway server host %s does not start with the prefix "*."`, gateway.Spec.Servers[0].Hosts[0])
}

return strings.TrimLeft(gateway.Spec.GetServers()[0].Hosts[0], "*."), nil
return strings.TrimPrefix(gateway.Spec.Servers[0].Hosts[0], "*."), nil
}

func gatewayServersWithSameSingleHost(gateway *networkingv1beta1.Gateway) bool {
host := ""
for _, server := range gateway.Spec.Servers {
if len(server.Hosts) > 1 {
return false
}
if host == "" {
host = server.Hosts[0]
} else if host != server.Hosts[0] {
return false
}
}
return host != ""
}
Loading
Loading