Allow Hosts field to be set on an ingress config entry

- Validate that this cannot be set on a 'tcp' listener nor on a wildcard
service.
- Add Hosts field to api and test in consul config write CLI
- xds: Configure envoy with user-provided hosts from ingress gateways
This commit is contained in:
Chris Piraino 2020-04-23 10:06:19 -05:00
parent 837d2aa7d2
commit 210dda5682
17 changed files with 202 additions and 86 deletions

View File

@ -2542,6 +2542,7 @@ func (s *Store) ingressConfigGatewayServices(tx *memdb.Txn, gateway structs.Serv
Gateway: gateway,
Service: service.ToServiceID(),
GatewayKind: structs.ServiceKindIngressGateway,
Hosts: service.Hosts,
Port: listener.Port,
Protocol: listener.Protocol,
}

View File

@ -4997,6 +4997,7 @@ func TestStateStore_GatewayServices_Ingress(t *testing.T) {
require.Len(results, 2)
require.Equal("ingress1", results[0].Gateway.ID)
require.Equal("service1", results[0].Service.ID)
require.Len(results[0].Hosts, 1)
require.Equal(1111, results[0].Port)
require.Equal("ingress1", results[1].Gateway.ID)
require.Equal("service2", results[1].Service.ID)
@ -5218,6 +5219,7 @@ func setupIngressState(t *testing.T, s *Store) memdb.WatchSet {
Services: []structs.IngressService{
{
Name: "service1",
Hosts: []string{"test.example.com"},
},
},
},

View File

@ -1355,6 +1355,7 @@ func makeUpstream(g *structs.GatewayService, bindAddr string) structs.Upstream {
DestinationName: g.Service.ID,
DestinationNamespace: g.Service.NamespaceOrDefault(),
LocalBindPort: g.Port,
IngressHosts: g.Hosts,
// Pass the protocol that was configured on the ingress listener in order
// to force that protocol on the Envoy listener.
Config: map[string]interface{}{

View File

@ -5,6 +5,7 @@ import (
"strings"
"github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/lib"
)
// IngressGatewayConfigEntry manages the configuration for an ingress service
@ -51,6 +52,15 @@ type IngressService struct {
// protocol and means that the listener will forward traffic to all services.
Name string
// Hosts is a list of hostnames which should be associated to this service on
// the defined listener. Only allowed on layer 7 protocols, this will be used
// to route traffic to the service by matching the Host header of the HTTP
// request.
//
// This cannot be specified when using the wildcard specifier, "*", or when
// using a "tcp" listener.
Hosts []string
EnterpriseMeta `hcl:",squash" mapstructure:",squash"`
}
@ -109,18 +119,6 @@ func (e *IngressGatewayConfigEntry) Validate() error {
return fmt.Errorf("Protocol must be either 'http' or 'tcp', '%s' is an unsupported protocol.", listener.Protocol)
}
for _, s := range listener.Services {
if s.Name == WildcardSpecifier && listener.Protocol != "http" {
return fmt.Errorf("Wildcard service name is only valid for protocol = 'http' (listener on port %d)", listener.Port)
}
if s.Name == "" {
return fmt.Errorf("Service name cannot be blank (listener on port %d)", listener.Port)
}
if s.NamespaceOrDefault() == WildcardSpecifier {
return fmt.Errorf("Wildcard namespace is not supported for ingress services (listener on port %d)", listener.Port)
}
}
if len(listener.Services) == 0 {
return fmt.Errorf("No service declared for listener with port %d", listener.Port)
}
@ -130,6 +128,35 @@ func (e *IngressGatewayConfigEntry) Validate() error {
return fmt.Errorf("Multiple services per listener are only supported for protocol = 'http' (listener on port %d)",
listener.Port)
}
declaredHosts := make(map[string]bool)
for _, s := range listener.Services {
if listener.Protocol == "tcp" {
if s.Name == WildcardSpecifier {
return fmt.Errorf("Wildcard service name is only valid for protocol = 'http' (listener on port %d)", listener.Port)
}
if len(s.Hosts) != 0 {
return fmt.Errorf("Associating hosts to a service is not supported for the %s protocol (listener on port %d)", listener.Protocol, listener.Port)
}
}
if s.Name == "" {
return fmt.Errorf("Service name cannot be blank (listener on port %d)", listener.Port)
}
if s.Name == WildcardSpecifier && len(s.Hosts) != 0 {
return fmt.Errorf("Associating hosts to a wildcard service is not supported (listener on port %d)", listener.Port)
}
if s.NamespaceOrDefault() == WildcardSpecifier {
return fmt.Errorf("Wildcard namespace is not supported for ingress services (listener on port %d)", listener.Port)
}
// TODO(ingress): Validate Hosts are valid?
for _, h := range s.Hosts {
if declaredHosts[h] {
return fmt.Errorf("Hosts must be unique within a specific listener (listener on port %d)", listener.Port)
}
declaredHosts[h] = true
}
}
}
return nil
@ -296,6 +323,7 @@ type GatewayService struct {
GatewayKind ServiceKind
Port int
Protocol string
Hosts []string
CAFile string
CertFile string
KeyFile string
@ -312,6 +340,7 @@ func (g *GatewayService) IsSame(o *GatewayService) bool {
g.GatewayKind == o.GatewayKind &&
g.Port == o.Port &&
g.Protocol == o.Protocol &&
lib.StringSliceEqual(g.Hosts, o.Hosts) &&
g.CAFile == o.CAFile &&
g.CertFile == o.CertFile &&
g.KeyFile == o.KeyFile &&
@ -326,6 +355,8 @@ func (g *GatewayService) Clone() *GatewayService {
GatewayKind: g.GatewayKind,
Port: g.Port,
Protocol: g.Protocol,
// See https://github.com/go101/go101/wiki/How-to-efficiently-clone-a-slice%3F
Hosts: append(g.Hosts[:0:0], g.Hosts...),
CAFile: g.CAFile,
CertFile: g.CertFile,
KeyFile: g.KeyFile,

View File

@ -252,6 +252,70 @@ func TestIngressConfigEntry_Validate(t *testing.T) {
},
expectErr: "Protocol must be either 'http' or 'tcp', 'asdf' is an unsupported protocol.",
},
{
name: "hosts cannot be set on a tcp listener",
entry: IngressGatewayConfigEntry{
Kind: "ingress-gateway",
Name: "ingress-web",
Listeners: []IngressListener{
{
Port: 1111,
Protocol: "tcp",
Services: []IngressService{
{
Name: "db",
Hosts: []string{"db.example.com"},
},
},
},
},
},
expectErr: "Associating hosts to a service is not supported for the tcp protocol",
},
{
name: "hosts cannot be set on a wildcard specifier",
entry: IngressGatewayConfigEntry{
Kind: "ingress-gateway",
Name: "ingress-web",
Listeners: []IngressListener{
{
Port: 1111,
Protocol: "http",
Services: []IngressService{
{
Name: "*",
Hosts: []string{"db.example.com"},
},
},
},
},
},
expectErr: "Associating hosts to a wildcard service is not supported",
},
{
name: "hosts must be unique per listener",
entry: IngressGatewayConfigEntry{
Kind: "ingress-gateway",
Name: "ingress-web",
Listeners: []IngressListener{
{
Port: 1111,
Protocol: "http",
Services: []IngressService{
{
Name: "db",
Hosts: []string{"test.example.com"},
},
{
Name: "api",
Hosts: []string{"test.example.com"},
},
},
},
},
},
expectErr: "Hosts must be unique within a specific listener",
},
}
for _, test := range cases {

View File

@ -545,6 +545,7 @@ func TestDecodeConfigEntry(t *testing.T) {
services = [
{
name = "web"
hosts = ["test.example.com", "test2.example.com"]
},
{
name = "db"
@ -581,6 +582,7 @@ func TestDecodeConfigEntry(t *testing.T) {
Services = [
{
Name = "web"
Hosts = ["test.example.com", "test2.example.com"]
},
{
Name = "db"
@ -617,6 +619,7 @@ func TestDecodeConfigEntry(t *testing.T) {
Services: []IngressService{
IngressService{
Name: "web",
Hosts: []string{"test.example.com", "test2.example.com"},
},
IngressService{
Name: "db",

View File

@ -247,6 +247,10 @@ type Upstream struct {
// MeshGateway is the configuration for mesh gateway usage of this upstream
MeshGateway MeshGatewayConfig `json:",omitempty"`
// IngressHosts are a list of hosts that should route to this upstream from
// an ingress gateway
IngressHosts []string `json:"-" bexpr:"-"`
}
func (t *Upstream) UnmarshalJSON(data []byte) (err error) {

View File

@ -49,14 +49,14 @@ func routesFromSnapshotConnectProxy(cfgSnap *proxycfg.ConfigSnapshot) ([]proto.M
if chain == nil || chain.IsDefault() {
// TODO(rb): make this do the old school stuff too
} else {
virtualHost, err := makeUpstreamRouteForDiscoveryChain(upstreamID, chain, "*")
virtualHost, err := makeUpstreamRouteForDiscoveryChain(upstreamID, chain, []string{"*"})
if err != nil {
return nil, err
}
route := &envoy.RouteConfiguration{
Name: upstreamID,
VirtualHosts: []envoyroute.VirtualHost{*virtualHost},
VirtualHosts: []envoyroute.VirtualHost{virtualHost},
ValidateClusters: makeBoolValue(true),
}
resources = append(resources, route)
@ -91,21 +91,31 @@ func routesFromSnapshotIngressGateway(cfgSnap *proxycfg.ConfigSnapshot) ([]proto
for _, u := range upstreams {
upstreamID := u.Identifier()
chain := cfgSnap.IngressGateway.DiscoveryChain[upstreamID]
if chain != nil {
domain := fmt.Sprintf("%s.*", chain.ServiceName)
if chain == nil {
continue
}
var domains []string
switch {
case len(upstreams) == 1:
// Don't require a service prefix on the domain if there is only 1
// upstream. This makes it a smoother experience when only having a
// single service associated to a listener, which is probably a common
// case when demoing/testing
if len(upstreams) == 1 {
domain = "*"
domains = []string{"*"}
case len(u.IngressHosts) > 0:
// If a user has specified hosts, do not add the default
// "<service-name>.*" prefix
domains = u.IngressHosts
default:
domains = []string{fmt.Sprintf("%s.*", chain.ServiceName)}
}
virtualHost, err := makeUpstreamRouteForDiscoveryChain(upstreamID, chain, domain)
virtualHost, err := makeUpstreamRouteForDiscoveryChain(upstreamID, chain, domains)
if err != nil {
return nil, err
}
upstreamRoute.VirtualHosts = append(upstreamRoute.VirtualHosts, *virtualHost)
}
upstreamRoute.VirtualHosts = append(upstreamRoute.VirtualHosts, virtualHost)
}
result = append(result, upstreamRoute)
@ -117,8 +127,8 @@ func routesFromSnapshotIngressGateway(cfgSnap *proxycfg.ConfigSnapshot) ([]proto
func makeUpstreamRouteForDiscoveryChain(
routeName string,
chain *structs.CompiledDiscoveryChain,
serviceDomain string,
) (*envoyroute.VirtualHost, error) {
serviceDomains []string,
) (envoyroute.VirtualHost, error) {
var routes []envoyroute.Route
startNode := chain.Nodes[chain.StartNode]
@ -143,14 +153,14 @@ func makeUpstreamRouteForDiscoveryChain(
case structs.DiscoveryGraphNodeTypeSplitter:
routeAction, err = makeRouteActionForSplitter(nextNode.Splits, chain)
if err != nil {
return nil, err
return envoyroute.VirtualHost{}, err
}
case structs.DiscoveryGraphNodeTypeResolver:
routeAction = makeRouteActionForSingleCluster(nextNode.Resolver.Target, chain)
default:
return nil, fmt.Errorf("unexpected graph node after route %q", nextNode.Type)
return envoyroute.VirtualHost{}, fmt.Errorf("unexpected graph node after route %q", nextNode.Type)
}
// TODO(rb): Better help handle the envoy case where you need (prefix=/foo/,rewrite=/) and (exact=/foo,rewrite=/) to do a full rewrite
@ -197,7 +207,7 @@ func makeUpstreamRouteForDiscoveryChain(
case structs.DiscoveryGraphNodeTypeSplitter:
routeAction, err := makeRouteActionForSplitter(startNode.Splits, chain)
if err != nil {
return nil, err
return envoyroute.VirtualHost{}, err
}
defaultRoute := envoyroute.Route{
@ -221,9 +231,9 @@ func makeUpstreamRouteForDiscoveryChain(
panic("unknown first node in discovery chain of type: " + startNode.Type)
}
host := &envoyroute.VirtualHost{
host := envoyroute.VirtualHost{
Name: routeName,
Domains: []string{serviceDomain},
Domains: serviceDomains,
Routes: routes,
}

View File

@ -117,6 +117,7 @@ func TestRoutesFromSnapshot(t *testing.T) {
{
DestinationName: "foo",
LocalBindPort: 8080,
IngressHosts: []string{"test1.example.com", "test2.example.com"},
},
{
DestinationName: "bar",

View File

@ -47,7 +47,8 @@
{
"name": "foo",
"domains": [
"foo.*"
"test1.example.com",
"test2.example.com"
],
"routes": [
{

View File

@ -57,6 +57,15 @@ type IngressService struct {
// protocol and means that the listener will forward traffic to all services.
Name string
// Hosts is a list of hostnames which should be associated to this service on
// the defined listener. Only allowed on layer 7 protocols, this will be used
// to route traffic to the service by matching the Host header of the HTTP
// request.
//
// This cannot be specified when using the wildcard specifier, "*", or when
// using a "tcp" listener.
Hosts []string
// Namespace is the namespace where the service is located.
// Namespacing is a Consul Enterprise feature.
Namespace string `json:",omitempty"`

View File

@ -51,10 +51,11 @@ func TestAPI_ConfigEntries_IngressGateway(t *testing.T) {
ingress1.Listeners = []IngressListener{
{
Port: 2222,
Protocol: "tcp",
Protocol: "http",
Services: []IngressService{
{
Name: "asdf",
Hosts: []string{"test.example.com"},
},
},
},

View File

@ -1395,6 +1395,7 @@ func TestParseConfigEntry(t *testing.T) {
services = [
{
name = "web"
hosts = ["test.example.com"]
},
{
name = "db"
@ -1414,6 +1415,7 @@ func TestParseConfigEntry(t *testing.T) {
Services = [
{
Name = "web"
Hosts = ["test.example.com"]
},
{
Name = "db"
@ -1433,7 +1435,8 @@ func TestParseConfigEntry(t *testing.T) {
"protocol": "http",
"services": [
{
"name": "web"
"name": "web",
"hosts": ["test.example.com"]
},
{
"name": "db",
@ -1454,7 +1457,8 @@ func TestParseConfigEntry(t *testing.T) {
"Protocol": "http",
"Services": [
{
"Name": "web"
"Name": "web",
"Hosts": ["test.example.com"]
},
{
"Name": "db",
@ -1475,6 +1479,7 @@ func TestParseConfigEntry(t *testing.T) {
Services: []api.IngressService{
{
Name: "web",
Hosts: []string{"test.example.com"},
},
{
Name: "db",

View File

@ -31,28 +31,10 @@ load helpers
}
@test "ingress should be able to connect to s1 via configured path" {
run retry_default curl -s -f localhost:9999/s1/debug?env=dump
[ "$status" -eq 0 ]
GOT=$(echo "$output" | grep -E "^FORTIO_NAME=")
EXPECT_NAME="s1"
if [ "$GOT" != "FORTIO_NAME=${EXPECT_NAME}" ]; then
echo "expected name: $EXPECT_NAME, actual name: $GOT" 1>&2
return 1
fi
assert_expected_fortio_name s1 localhost 9999 /s1
}
@test "ingress should be able to connect to s2 via configured path" {
run retry_default curl -s -f localhost:9999/s2/debug?env=dump
[ "$status" -eq 0 ]
GOT=$(echo "$output" | grep -E "^FORTIO_NAME=")
EXPECT_NAME="s2"
if [ "$GOT" != "FORTIO_NAME=${EXPECT_NAME}" ]; then
echo "expected name: $EXPECT_NAME, actual name: $GOT" 1>&2
return 1
fi
assert_expected_fortio_name s2 localhost 9999 /s2
}

View File

@ -15,6 +15,16 @@ config_entries {
name = "*"
}
]
},
{
port = 9998
protocol = "http"
services = [
{
name = "s1"
hosts = ["test.example.com"]
}
]
}
]
},

View File

@ -31,28 +31,13 @@ load helpers
}
@test "ingress should be able to connect to s1 using Host header" {
run retry_default curl -H"Host: s1.example.consul" -s -f localhost:9999/debug?env=dump
[ "$status" -eq 0 ]
GOT=$(echo "$output" | grep -E "^FORTIO_NAME=")
EXPECT_NAME="s1"
if [ "$GOT" != "FORTIO_NAME=${EXPECT_NAME}" ]; then
echo "expected name: $EXPECT_NAME, actual name: $GOT" 1>&2
return 1
fi
assert_expected_fortio_name s1 s1.example.consul 9999
}
@test "ingress should be able to connect to s2 using Host header" {
run retry_default curl -H"Host: s2.example.consul" -s -f localhost:9999/debug?env=dump
[ "$status" -eq 0 ]
GOT=$(echo "$output" | grep -E "^FORTIO_NAME=")
EXPECT_NAME="s2"
if [ "$GOT" != "FORTIO_NAME=${EXPECT_NAME}" ]; then
echo "expected name: $EXPECT_NAME, actual name: $GOT" 1>&2
return 1
fi
assert_expected_fortio_name s2 s2.example.consul 9999
}
@test "ingress should be able to connect to s1 using a user-specified Host" {
assert_expected_fortio_name s1 test.example.com 9998
}

View File

@ -646,15 +646,21 @@ function set_ttl_check_state {
}
function get_upstream_fortio_name {
run retry_default curl -v -s -f localhost:5000/debug?env=dump
local HOST=$1
local PORT=$2
local PREFIX=$3
run retry_default curl -v -s -f -H"Host: ${HOST}" "localhost:${PORT}${PREFIX}/debug?env=dump"
[ "$status" == 0 ]
echo "$output" | grep -E "^FORTIO_NAME="
}
function assert_expected_fortio_name {
local EXPECT_NAME=$1
local HOST=${2:-"localhost"}
local PORT=${3:-5000}
local URL_PREFIX=${4:-""}
GOT=$(get_upstream_fortio_name)
GOT=$(get_upstream_fortio_name ${HOST} ${PORT} ${URL_PREFIX})
if [ "$GOT" != "FORTIO_NAME=${EXPECT_NAME}" ]; then
echo "expected name: $EXPECT_NAME, actual name: $GOT" 1>&2