mirror of https://github.com/status-im/consul.git
feat: add endpoint struct to ServiceConfigEntry
This commit is contained in:
parent
876f3bb971
commit
147fd96d97
|
@ -1,7 +1,9 @@
|
||||||
package structs
|
package structs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -103,6 +105,7 @@ type ServiceConfigEntry struct {
|
||||||
Expose ExposeConfig `json:",omitempty"`
|
Expose ExposeConfig `json:",omitempty"`
|
||||||
ExternalSNI string `json:",omitempty" alias:"external_sni"`
|
ExternalSNI string `json:",omitempty" alias:"external_sni"`
|
||||||
UpstreamConfig *UpstreamConfiguration `json:",omitempty" alias:"upstream_config"`
|
UpstreamConfig *UpstreamConfiguration `json:",omitempty" alias:"upstream_config"`
|
||||||
|
Endpoint *EndpointConfig `json:",omitempty"`
|
||||||
|
|
||||||
Meta map[string]string `json:",omitempty"`
|
Meta map[string]string `json:",omitempty"`
|
||||||
acl.EnterpriseMeta `hcl:",squash" mapstructure:",squash"`
|
acl.EnterpriseMeta `hcl:",squash" mapstructure:",squash"`
|
||||||
|
@ -175,6 +178,12 @@ func (e *ServiceConfigEntry) Validate() error {
|
||||||
|
|
||||||
validationErr := validateConfigEntryMeta(e.Meta)
|
validationErr := validateConfigEntryMeta(e.Meta)
|
||||||
|
|
||||||
|
// External endpoints are invalid with an existing service's upstream configuration
|
||||||
|
if e.UpstreamConfig != nil && e.Endpoint != nil {
|
||||||
|
validationErr = multierror.Append(validationErr, errors.New("UpstreamConfig and Endpoint are mutually exclusive for service defaults"))
|
||||||
|
return validationErr
|
||||||
|
}
|
||||||
|
|
||||||
if e.UpstreamConfig != nil {
|
if e.UpstreamConfig != nil {
|
||||||
for _, override := range e.UpstreamConfig.Overrides {
|
for _, override := range e.UpstreamConfig.Overrides {
|
||||||
err := override.ValidateWithName()
|
err := override.ValidateWithName()
|
||||||
|
@ -190,9 +199,61 @@ func (e *ServiceConfigEntry) Validate() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if e.Endpoint != nil {
|
||||||
|
if err := validateEndpointAddress(e.Endpoint.Address); err != nil {
|
||||||
|
validationErr = multierror.Append(validationErr, fmt.Errorf("Endpoint address is invalid %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Endpoint.Port < 1 || e.Endpoint.Port > 65535 {
|
||||||
|
validationErr = multierror.Append(validationErr, fmt.Errorf("Invalid Port number %d", e.Endpoint.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If either client cert config file was specified then the CA file, client cert, and key file must be specified
|
||||||
|
// Specifying only a CAFile is allowed for one-way TLS
|
||||||
|
if (e.Endpoint.CertFile != "" || e.Endpoint.KeyFile != "") &&
|
||||||
|
!(e.Endpoint.CAFile != "" && e.Endpoint.CertFile != "" && e.Endpoint.KeyFile != "") {
|
||||||
|
validationErr = multierror.Append(validationErr, errors.New("Endpoint must have a CertFile, CAFile, and KeyFile specified for TLS origination"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return validationErr
|
return validationErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateEndpointAddress(address string) error {
|
||||||
|
var valid bool
|
||||||
|
|
||||||
|
ip := net.ParseIP(address)
|
||||||
|
valid = ip != nil
|
||||||
|
|
||||||
|
_, _, err := net.ParseCIDR(address)
|
||||||
|
valid = valid || err == nil
|
||||||
|
|
||||||
|
// Since we don't know if this will be a TLS connection, setting tlsEnabled to false will be more permissive with wildcards
|
||||||
|
err = validateHost(false, address)
|
||||||
|
valid = valid || err == nil
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return fmt.Errorf("Could not validate address %s as an IP, CIDR block or Hostname", address)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ServiceConfigEntry) Warnings() []string {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
warnings := make([]string, 0)
|
||||||
|
|
||||||
|
if e.Endpoint != nil {
|
||||||
|
if (e.Endpoint.CAFile != "" || e.Endpoint.CertFile != "" || e.Endpoint.KeyFile != "") && e.Endpoint.SNI == "" {
|
||||||
|
warning := fmt.Sprintf("TLS is configured but SNI is not set for the endpoint. Enabling SNI is strongly recommended when using TLS.")
|
||||||
|
warnings = append(warnings, warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
func (e *ServiceConfigEntry) CanRead(authz acl.Authorizer) error {
|
func (e *ServiceConfigEntry) CanRead(authz acl.Authorizer) error {
|
||||||
var authzContext acl.AuthorizerContext
|
var authzContext acl.AuthorizerContext
|
||||||
e.FillAuthzContext(&authzContext)
|
e.FillAuthzContext(&authzContext)
|
||||||
|
@ -253,6 +314,30 @@ func (c *UpstreamConfiguration) Clone() *UpstreamConfiguration {
|
||||||
return &c2
|
return &c2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EndpointConfig represents a virtual service, i.e. one that is external to Consul
|
||||||
|
type EndpointConfig struct {
|
||||||
|
// Address of the endpoint; hostname, IP, or CIDR
|
||||||
|
Address string `json:",omitempty"`
|
||||||
|
|
||||||
|
// Port allowed within this endpoint
|
||||||
|
Port int `json:",omitempty"`
|
||||||
|
|
||||||
|
// CAFile is the optional path to a CA certificate to use for TLS connections
|
||||||
|
// from the gateway to the linked service
|
||||||
|
CAFile string `json:",omitempty" alias:"ca_file"`
|
||||||
|
|
||||||
|
// CertFile is the optional path to a client certificate to use for TLS connections
|
||||||
|
// from the gateway to the linked service
|
||||||
|
CertFile string `json:",omitempty" alias:"cert_file"`
|
||||||
|
|
||||||
|
// KeyFile is the optional path to a private key to use for TLS connections
|
||||||
|
// from the gateway to the linked service
|
||||||
|
KeyFile string `json:",omitempty" alias:"key_file"`
|
||||||
|
|
||||||
|
// SNI is the optional name to specify during the TLS handshake with a linked service.
|
||||||
|
SNI string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ProxyConfigEntry is the top-level struct for global proxy configuration defaults.
|
// ProxyConfigEntry is the top-level struct for global proxy configuration defaults.
|
||||||
type ProxyConfigEntry struct {
|
type ProxyConfigEntry struct {
|
||||||
Kind string
|
Kind string
|
||||||
|
|
|
@ -427,6 +427,48 @@ func TestDecodeConfigEntry(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "service-defaults-with-endpoint",
|
||||||
|
snake: `
|
||||||
|
kind = "service-defaults"
|
||||||
|
name = "external"
|
||||||
|
protocol = "tcp"
|
||||||
|
endpoint {
|
||||||
|
address = "1.2.3.4/24"
|
||||||
|
port = 8080
|
||||||
|
ca_file = "ca.pem"
|
||||||
|
cert_file = "cert.pem"
|
||||||
|
key_file = "key.pem"
|
||||||
|
sni = "external.com"
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
camel: `
|
||||||
|
Kind = "service-defaults"
|
||||||
|
Name = "external"
|
||||||
|
Protocol = "tcp"
|
||||||
|
Endpoint {
|
||||||
|
Address = "1.2.3.4/24"
|
||||||
|
Port = 8080
|
||||||
|
CAFile = "ca.pem"
|
||||||
|
CertFile = "cert.pem"
|
||||||
|
KeyFile = "key.pem"
|
||||||
|
SNI = "external.com"
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
expect: &ServiceConfigEntry{
|
||||||
|
Kind: "service-defaults",
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "1.2.3.4/24",
|
||||||
|
Port: 8080,
|
||||||
|
CAFile: "ca.pem",
|
||||||
|
CertFile: "cert.pem",
|
||||||
|
KeyFile: "key.pem",
|
||||||
|
SNI: "external.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "service-router: kitchen sink",
|
name: "service-router: kitchen sink",
|
||||||
snake: `
|
snake: `
|
||||||
|
@ -2391,6 +2433,195 @@ func TestServiceConfigEntry(t *testing.T) {
|
||||||
EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(),
|
EnterpriseMeta: *DefaultEnterpriseMetaInDefaultPartition(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"validate: missing endpoint address": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "",
|
||||||
|
Port: 443,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validateErr: "Could not validate address",
|
||||||
|
},
|
||||||
|
"validate: endpoint ipv4 address": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "1.2.3.4",
|
||||||
|
Port: 443,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"validate: endpoint ipv4 CIDR address": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "10.0.0.1/16",
|
||||||
|
Port: 8080,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"validate: endpoint ipv6 address": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "2001:0db8:0000:8a2e:0370:7334:1234:5678",
|
||||||
|
Port: 443,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"valid endpoint shortened ipv6 address": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "2001:db8::8a2e:370:7334",
|
||||||
|
Port: 443,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"validate: endpoint ipv6 CIDR address": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "2001:db8::8a2e:370:7334/64",
|
||||||
|
Port: 443,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"validate: invalid endpoint port": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "2001:db8::8a2e:370:7334/64",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validateErr: "Invalid Port number",
|
||||||
|
},
|
||||||
|
"validate: not all TLS options provided-1": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "2001:db8::8a2e:370:7334/64",
|
||||||
|
Port: 443,
|
||||||
|
CertFile: "client.crt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validateErr: "must have a CertFile, CAFile, and KeyFile",
|
||||||
|
},
|
||||||
|
"validate: not all TLS options provided-2": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "2001:db8::8a2e:370:7334/64",
|
||||||
|
Port: 443,
|
||||||
|
KeyFile: "tls.key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validateErr: "must have a CertFile, CAFile, and KeyFile",
|
||||||
|
},
|
||||||
|
"validate: all TLS options provided": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "2001:db8::8a2e:370:7334/64",
|
||||||
|
Port: 443,
|
||||||
|
CAFile: "ca.crt",
|
||||||
|
CertFile: "client.crt",
|
||||||
|
KeyFile: "tls.key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"validate: only providing ca file is allowed": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "2001:db8::8a2e:370:7334/64",
|
||||||
|
Port: 443,
|
||||||
|
CAFile: "ca.crt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"validate: wildcard is allowed for hostname": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "*.external.com",
|
||||||
|
Port: 443,
|
||||||
|
CAFile: "ca.crt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"validate: hostname": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "api.external.com",
|
||||||
|
Port: 443,
|
||||||
|
CAFile: "ca.crt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"validate: invalid hostname 1": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "*external.com",
|
||||||
|
Port: 443,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validateErr: "Could not validate address",
|
||||||
|
},
|
||||||
|
"validate: invalid hostname 2": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "..hello.",
|
||||||
|
Port: 443,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validateErr: "Could not validate address",
|
||||||
|
},
|
||||||
|
"validate: all web traffic allowed": {
|
||||||
|
entry: &ServiceConfigEntry{
|
||||||
|
Kind: ServiceDefaults,
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "http",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "*",
|
||||||
|
Port: 443,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
testConfigEntryNormalizeAndValidate(t, cases)
|
testConfigEntryNormalizeAndValidate(t, cases)
|
||||||
}
|
}
|
||||||
|
|
|
@ -179,6 +179,30 @@ type UpstreamConfig struct {
|
||||||
MeshGateway MeshGatewayConfig `json:",omitempty" alias:"mesh_gateway" `
|
MeshGateway MeshGatewayConfig `json:",omitempty" alias:"mesh_gateway" `
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EndpointConfig represents a virtual service, i.e. one that is external to Consul
|
||||||
|
type EndpointConfig struct {
|
||||||
|
// Address of the endpoint; hostname, IP, or CIDR
|
||||||
|
Address string `json:",omitempty"`
|
||||||
|
|
||||||
|
// Port allowed within this endpoint
|
||||||
|
Port int `json:",omitempty"`
|
||||||
|
|
||||||
|
// CAFile is the optional path to a CA certificate to use for TLS connections
|
||||||
|
// from the gateway to the linked service
|
||||||
|
CAFile string `json:",omitempty" alias:"ca_file"`
|
||||||
|
|
||||||
|
// CertFile is the optional path to a client certificate to use for TLS connections
|
||||||
|
// from the gateway to the linked service
|
||||||
|
CertFile string `json:",omitempty" alias:"cert_file"`
|
||||||
|
|
||||||
|
// KeyFile is the optional path to a private key to use for TLS connections
|
||||||
|
// from the gateway to the linked service
|
||||||
|
KeyFile string `json:",omitempty" alias:"key_file"`
|
||||||
|
|
||||||
|
// SNI is the optional name to specify during the TLS handshake with a linked service.
|
||||||
|
SNI string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type PassiveHealthCheck struct {
|
type PassiveHealthCheck struct {
|
||||||
// Interval between health check analysis sweeps. Each sweep may remove
|
// Interval between health check analysis sweeps. Each sweep may remove
|
||||||
// hosts or return hosts to the pool.
|
// hosts or return hosts to the pool.
|
||||||
|
@ -220,7 +244,7 @@ type ServiceConfigEntry struct {
|
||||||
Expose ExposeConfig `json:",omitempty"`
|
Expose ExposeConfig `json:",omitempty"`
|
||||||
ExternalSNI string `json:",omitempty" alias:"external_sni"`
|
ExternalSNI string `json:",omitempty" alias:"external_sni"`
|
||||||
UpstreamConfig *UpstreamConfiguration `json:",omitempty" alias:"upstream_config"`
|
UpstreamConfig *UpstreamConfiguration `json:",omitempty" alias:"upstream_config"`
|
||||||
|
Endpoint *EndpointConfig `json:",omitempty"`
|
||||||
Meta map[string]string `json:",omitempty"`
|
Meta map[string]string `json:",omitempty"`
|
||||||
CreateIndex uint64
|
CreateIndex uint64
|
||||||
ModifyIndex uint64
|
ModifyIndex uint64
|
||||||
|
|
|
@ -106,10 +106,16 @@ func TestAPI_ConfigEntries(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endpoint := &EndpointConfig{
|
||||||
|
Address: "my.example.com",
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
service2 := &ServiceConfigEntry{
|
service2 := &ServiceConfigEntry{
|
||||||
Kind: ServiceDefaults,
|
Kind: ServiceDefaults,
|
||||||
Name: "bar",
|
Name: "bar",
|
||||||
Protocol: "tcp",
|
Protocol: "tcp",
|
||||||
|
Endpoint: endpoint,
|
||||||
}
|
}
|
||||||
|
|
||||||
// set it
|
// set it
|
||||||
|
@ -185,6 +191,7 @@ func TestAPI_ConfigEntries(t *testing.T) {
|
||||||
require.Equal(t, service2.Kind, readService.Kind)
|
require.Equal(t, service2.Kind, readService.Kind)
|
||||||
require.Equal(t, service2.Name, readService.Name)
|
require.Equal(t, service2.Name, readService.Name)
|
||||||
require.Equal(t, service2.Protocol, readService.Protocol)
|
require.Equal(t, service2.Protocol, readService.Protocol)
|
||||||
|
require.Equal(t, endpoint, readService.Endpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -516,6 +523,37 @@ func TestDecodeConfigEntry(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "service-defaults-endpoint",
|
||||||
|
body: `
|
||||||
|
{
|
||||||
|
"Kind": "service-defaults",
|
||||||
|
"Name": "external",
|
||||||
|
"Protocol": "http",
|
||||||
|
"Endpoint": {
|
||||||
|
"Address": "1.2.3.4/24",
|
||||||
|
"Port": 443,
|
||||||
|
"CAFile": "ca.pem",
|
||||||
|
"CertFile": "crt.pem",
|
||||||
|
"KeyFile": "key.pem",
|
||||||
|
"SNI": "external.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
expect: &ServiceConfigEntry{
|
||||||
|
Kind: "service-defaults",
|
||||||
|
Name: "external",
|
||||||
|
Protocol: "http",
|
||||||
|
Endpoint: &EndpointConfig{
|
||||||
|
Address: "1.2.3.4/24",
|
||||||
|
Port: 443,
|
||||||
|
CAFile: "ca.pem",
|
||||||
|
CertFile: "crt.pem",
|
||||||
|
KeyFile: "key.pem",
|
||||||
|
SNI: "external.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "service-router: kitchen sink",
|
name: "service-router: kitchen sink",
|
||||||
body: `
|
body: `
|
||||||
|
|
Loading…
Reference in New Issue