Skip to content

Commit

Permalink
Refactor envoy package (#154)
Browse files Browse the repository at this point in the history
  • Loading branch information
Kyle Hodgetts authored Dec 21, 2021
1 parent 228db39 commit 25474f8
Show file tree
Hide file tree
Showing 16 changed files with 1,826 additions and 626 deletions.
9 changes: 7 additions & 2 deletions controllers/config_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,18 @@ var (
func (c *KubeEnvoyConfigManager) UpdateConfiguration(ctx context.Context) error {

l := configManagerLogger

// acquiring this lock is required so that no potentially conflicting updates would happen at the same time
// this probably should be done on a per-envoy basis but as we have a static config for now this will do
c.m.Lock()
defer c.m.Unlock()

l.Info("Started updating configuration")
defer l.Info("Finished updating configuration")

parser := spec.NewParser(nil)
envoyConfig := config.New()

// fetch all APIs and Static Routes to rebuild Envoy configuration
l.Info("Getting APIs")
var apis gateway.APIList
Expand All @@ -87,11 +90,12 @@ func (c *KubeEnvoyConfigManager) UpdateConfiguration(ctx context.Context) error
return fmt.Errorf("failed to validate options: %w", err)
}

if err = envoyConfig.UpdateConfigFromAPIOpts(opts, apiSpec); err != nil {
if err = UpdateConfigFromAPIOpts(envoyConfig, opts, apiSpec); err != nil {
return fmt.Errorf("failed to generate config: %w", err)
}
l.Info("API route configuration processed", "api", api)
}

l.Info("Succesfully processed APIs")
l.Info("Getting Static Routes")
var staticRoutes gateway.StaticRouteList
Expand All @@ -105,10 +109,11 @@ func (c *KubeEnvoyConfigManager) UpdateConfiguration(ctx context.Context) error
return fmt.Errorf("failed to generate options from the static route config: %w", err)
}

if err := envoyConfig.UpdateConfigFromOpts(opts); err != nil {
if err := UpdateConfigFromOpts(envoyConfig, opts); err != nil {
return fmt.Errorf("failed to generate config: %w", err)
}
}

l.Info("Succesfully processed Static Routes")

l.Info("Processing EnvoyFleet configuration")
Expand Down
353 changes: 353 additions & 0 deletions controllers/parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
package controllers

import (
"fmt"
"strings"

route "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
envoytypematcher "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
"github.com/getkin/kin-openapi/openapi3"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/wrapperspb"

"github.com/kubeshop/kusk-gateway/envoy/config"
"github.com/kubeshop/kusk-gateway/envoy/types"
"github.com/kubeshop/kusk-gateway/options"
)

/* This is the copy of https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/route_matching to remind how Envoy matches the route.
When Envoy matches a route, it uses the following procedure:
The HTTP request’s Host or :authority header is matched to a virtual host.
Each route entry in the virtual host is checked, in order. If there is a match, the route is used and no further route checks are made.
Independently, each virtual cluster in the virtual host is checked, in order. If there is a match, the virtual cluster is used and no further virtual cluster checks are made.
From the istio issue tracker:
The virtual hosts order does not influence the domain matching order
It is the domain matters
Domain search order:
1. Exact domain names: www.foo.com.
2. Suffix domain wildcards: *.foo.com or *-bar.foo.com.
3. Prefix domain wildcards: foo.* or foo-*.
4. Special wildcard * matching any domain.
*/

// UpdateConfigFromAPIOpts updates Envoy configuration from OpenAPI spec and x-kusk options
func UpdateConfigFromAPIOpts(envoyConfiguration *config.EnvoyConfiguration, opts *options.Options, spec *openapi3.T) error {
for _, vhost := range opts.Hosts {
vh := types.NewVirtualHost(string(vhost))
envoyConfiguration.AddVirtualHost(vh)
}

// Iterate on all paths and build routes
// The overriding works in the following way:
// 1. For each path we get SubOptions from the opts map and merge in top level SubOpts
// 2. For each method we get SubOptions for that method from the opts map and merge in path SubOpts
for path, pathItem := range spec.Paths {
// x-kusk options per operation (http method)
for method, operation := range pathItem.Operations() {

finalOpts := opts.OperationFinalSubOptions[method+path]
if finalOpts.Disabled != nil && *finalOpts.Disabled {
continue
}

routePath := path
if finalOpts.Path != nil {
routePath = generateRoutePath(finalOpts.Path.Prefix, path)
}

corsPolicy, err := generateCORSPolicy(finalOpts.CORS)
if err != nil {
return err
}

rt := &route.Route{
Name: generateRouteName(routePath, method),
Match: generateRouteMatch(
routePath,
method,
extractParams(operation.Parameters),
corsPolicy,
),
}

// This block creates redirect route
// We either create the redirect or the route with proxy to upstream
// Redirect takes a precedence.
if finalOpts.Redirect != nil {
routeRedirect, err := generateRedirect(finalOpts.Redirect)
if err != nil {
return err
}

rt.Action = routeRedirect
} else {
upstreamHostname, upstreamPort := getUpstreamHost(finalOpts.Upstream)
clusterName := generateClusterName(upstreamHostname, upstreamPort)
if !envoyConfiguration.ClusterExist(clusterName) {
envoyConfiguration.AddCluster(clusterName, upstreamHostname, upstreamPort)
}

var rewriteOpts *options.RewriteRegex
if opts.Path != nil {
rewriteOpts = &opts.Path.Rewrite
}
routeRoute, err := generateRoute(
clusterName,
corsPolicy,
rewriteOpts,
opts.QoS,
)
if err != nil {
return err
}

rt.Action = routeRoute
}

for _, vh := range envoyConfiguration.GetVirtualHosts() {
if err := envoyConfiguration.AddRouteToVHost(vh.Name, rt); err != nil {
return fmt.Errorf("failure adding the route: %w", err)
}
}
}
}

return nil
}

// extract Params returns a map mapping the name of a paths parameter to its schema
// where the schema elements we care about are its type and enum if its defined
func extractParams(parameters openapi3.Parameters) map[string]types.ParamSchema {
params := map[string]types.ParamSchema{}

for _, parameter := range parameters {
// Prevent populating map with empty parameter names
if parameter.Value != nil && parameter.Value.Name != "" {
params[parameter.Value.Name] = types.ParamSchema{}

// Extract the schema if it's not nil and assign the map value
if parameter.Value.Schema != nil && parameter.Value.Schema.Value != nil {
schemaValue := parameter.Value.Schema.Value

// It is acceptable for Type and / or Enum to have their zero value
// It means the user has not defined it, and we will construct the regex path accordingly
params[fmt.Sprintf("{%s}", parameter.Value.Name)] = types.ParamSchema{
Type: schemaValue.Type,
Enum: schemaValue.Enum,
}
}
}
}

return params
}

// UpdateConfigFromOpts updates Envoy configuration from Options only
func UpdateConfigFromOpts(envoyConfiguration *config.EnvoyConfiguration, opts *options.StaticOptions) error {
for _, vhost := range opts.Hosts {
vh := types.NewVirtualHost(string(vhost))
envoyConfiguration.AddVirtualHost(vh)
}

// Iterate on all paths and build routes
for path, methods := range opts.Paths {
for method, methodOpts := range methods {
strMethod := string(method)

routePath := generateRoutePath("", path)

corsPolicy, err := generateCORSPolicy(methodOpts.CORS)
if err != nil {
return err
}

// routeMatcher defines how we match a route by the provided path and the headers
rt := &route.Route{
Name: generateRouteName(routePath, strMethod),
Match: generateRouteMatch(
routePath,
string(method),
nil,
corsPolicy,
),
}

if methodOpts.Redirect != nil {
// Generating Redirect
routeRedirect, err := generateRedirect(methodOpts.Redirect)
if err != nil {
return err
}

rt.Action = routeRedirect
} else {
upstreamHostname, upstreamPort := getUpstreamHost(methodOpts.Upstream)
clusterName := generateClusterName(upstreamHostname, upstreamPort)
if !envoyConfiguration.ClusterExist(clusterName) {
envoyConfiguration.AddCluster(clusterName, upstreamHostname, upstreamPort)
}

var rewriteOpts *options.RewriteRegex
if methodOpts.Path != nil {
rewriteOpts = &methodOpts.Path.Rewrite
}
routeRoute, err := generateRoute(
clusterName,
corsPolicy,
rewriteOpts,
methodOpts.QoS,
)
if err != nil {
return err
}

rt.Action = routeRoute
}

for _, vh := range envoyConfiguration.GetVirtualHosts() {
if err := envoyConfiguration.AddRouteToVHost(vh.Name, rt); err != nil {
return fmt.Errorf("failure adding the route: %w", err)
}
}
}
}

return nil
}

func generateRouteMatch(path string, method string, pathParameters map[string]types.ParamSchema, corsPolicy *route.CorsPolicy) *route.RouteMatch {
headerMatcherConfig := []*route.HeaderMatcher{
types.GetHeaderMatcherConfig([]string{strings.ToUpper(method)}, corsPolicy != nil),
}

routeMatcherBuilder := types.NewRouteMatcherBuilder(path, pathParameters)
return routeMatcherBuilder.GetRouteMatcher(headerMatcherConfig)
}

func generateRedirect(redirectOpts *options.RedirectOptions) (*route.Route_Redirect, error) {
if redirectOpts == nil {
return nil, nil
}

redirect, err := types.NewRouteRedirectBuilder().
HostRedirect(redirectOpts.HostRedirect).
PortRedirect(redirectOpts.PortRedirect).
SchemeRedirect(redirectOpts.SchemeRedirect).
RegexRedirect(redirectOpts.RewriteRegex.Pattern, redirectOpts.RewriteRegex.Substitution).
PathRedirect(redirectOpts.PathRedirect).
ResponseCode(redirectOpts.ResponseCode).
StripQuery(redirectOpts.StripQuery).
ValidateAndReturn()

if err != nil {
return nil, err
}

return redirect, nil
}

func generateCORSPolicy(corsOpts *options.CORSOptions) (*route.CorsPolicy, error) {
if corsOpts == nil {
return nil, nil
}

return types.GenerateCORSPolicy(
corsOpts.Origins,
corsOpts.Methods,
corsOpts.Headers,
corsOpts.ExposeHeaders,
corsOpts.MaxAge,
corsOpts.Credentials,
)
}

func getUpstreamHost(upstreamOpts *options.UpstreamOptions) (hostname string, port uint32) {
if upstreamOpts.Service != nil {
return fmt.Sprintf("%s.%s.svc.cluster.local.", upstreamOpts.Service.Name, upstreamOpts.Service.Namespace), upstreamOpts.Service.Port
}
return upstreamOpts.Host.Hostname, upstreamOpts.Host.Port
}

// each cluster can be uniquely identified by dns name + port (i.e. canonical Host, which is hostname:port)
func generateClusterName(name string, port uint32) string {
return fmt.Sprintf("%s-%d", name, port)
}

// Can be moved to operationID, but generally we just need unique string
func generateRouteName(path string, method string) string {
return fmt.Sprintf("%s-%s", path, strings.ToUpper(method))
}

func generateRoutePath(prefix, path string) string {
if prefix == "" {
return path
}

// Avoids path joins (removes // in e.g. /path//subpath, or //subpath)
return fmt.Sprintf(`%s/%s`, strings.TrimSuffix(prefix, "/"), strings.TrimPrefix(path, "/"))
}

func generateRoute(
clusterName string,
corsPolicy *route.CorsPolicy,
rewriteRegex *options.RewriteRegex,
QoS *options.QoSOptions,
) (*route.Route_Route, error) {

var rewritePathRegex *envoytypematcher.RegexMatchAndSubstitute
if rewriteRegex != nil {
rewritePathRegex = types.GenerateRewriteRegex(rewriteRegex.Pattern, rewriteRegex.Substitution)
}

var (
requestTimeout, requestIdleTimeout int64 = 0, 0
retries uint32 = 0
)
if QoS != nil {
retries = QoS.Retries
requestTimeout = int64(QoS.RequestTimeout)
requestIdleTimeout = int64(QoS.IdleTimeout)
}

routeRoute := &route.Route_Route{
Route: &route.RouteAction{
ClusterSpecifier: &route.RouteAction_Cluster{
Cluster: clusterName,
},
},
}

if corsPolicy != nil {
routeRoute.Route.Cors = corsPolicy
}
if rewritePathRegex != nil {
routeRoute.Route.RegexRewrite = rewritePathRegex
}

if requestTimeout != 0 {
routeRoute.Route.Timeout = &durationpb.Duration{Seconds: requestTimeout}
}
if requestIdleTimeout != 0 {
routeRoute.Route.IdleTimeout = &durationpb.Duration{Seconds: requestIdleTimeout}
}

if retries != 0 {
routeRoute.Route.RetryPolicy = &route.RetryPolicy{
RetryOn: "5xx",
NumRetries: &wrapperspb.UInt32Value{Value: retries},
}
}

if err := routeRoute.Route.Validate(); err != nil {
return nil, fmt.Errorf("incorrect Route Action: %w", err)
}

return routeRoute, nil
}
Loading

0 comments on commit 25474f8

Please sign in to comment.