diff --git a/docs/content/routing/services/index.md b/docs/content/routing/services/index.md index 5bc1fa05b..7f0a8bf4a 100644 --- a/docs/content/routing/services/index.md +++ b/docs/content/routing/services/index.md @@ -353,6 +353,53 @@ http: - url: "http://private-ip-server-2/" ``` +### Mirroring (service) + +The mirroring is able to mirror requests sent to a service to other services. + +This strategy can be defined only with [File](../../providers/file.md). + +```toml tab="TOML" +[http.services] + [http.services.mirroring] + [http.services.mirroring.mirroring] + service = "app" + [[http.services.mirroring.mirroring.mirrors]] + name = "mirror" + percent = 10 + + [http.services.app] + [http.services.app.loadBalancer] + [[http.services.appv1.loadBalancer.servers]] + url = "http://private-ip-server-1/" + + [http.services.mirror] + [http.services.mirror.loadBalancer] + [[http.services.mirror.loadBalancer.servers]] + url = "http://private-ip-server-2/" +``` + +```yaml tab="YAML" +http: + services: + mirroring: + mirroring: + service: app + mirrors: + - name: mirror + percent: 10 + + app: + loadBalancer: + servers: + - url: "http://private-ip-server-1/" + + mirror: + loadBalancer: + servers: + - url: "http://private-ip-server-2/" +``` + ## Configuring TCP Services ### General diff --git a/integration/fixtures/mirror.toml b/integration/fixtures/mirror.toml new file mode 100644 index 000000000..a5a07cce8 --- /dev/null +++ b/integration/fixtures/mirror.toml @@ -0,0 +1,44 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[api] + +[log] + level = "DEBUG" + +[entryPoints] + + [entryPoints.web] + address = ":8000" + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + [http.routers.router] + service = "mirror" + rule = "Path(`/whoami`)" + +[http.services] + [http.services.mirror.mirroring] + service = "service1" + [[http.services.mirror.mirroring.mirrors]] + name = "mirror1" + percent = 10 + [[http.services.mirror.mirroring.mirrors]] + name = "mirror2" + percent = 50 + + [http.services.service1.loadBalancer] + [[http.services.service1.loadBalancer.servers]] + url = "{{ .MainServer }}" + [http.services.mirror1.loadBalancer] + [[http.services.mirror1.loadBalancer.servers]] + url = "{{ .Mirror1Server }}" + [http.services.mirror2.loadBalancer] + [[http.services.mirror2.loadBalancer.servers]] + url = "{{ .Mirror2Server }}" + diff --git a/integration/simple_test.go b/integration/simple_test.go index 7105f2482..9b40b8b7c 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -2,6 +2,7 @@ package integration import ( "bytes" + "context" "encoding/json" "fmt" "io/ioutil" @@ -9,6 +10,7 @@ import ( "net/http/httptest" "os" "strings" + "sync/atomic" "syscall" "time" @@ -671,3 +673,111 @@ func (s *SimpleSuite) TestWRRSticky(c *check.C) { c.Assert(repartition[server1], checker.Equals, 4) c.Assert(repartition[server2], checker.Equals, 0) } + +func (s *SimpleSuite) TestMirror(c *check.C) { + var count, countMirror1, countMirror2 int32 + + main := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + atomic.AddInt32(&count, 1) + })) + + mirror1 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + atomic.AddInt32(&countMirror1, 1) + })) + + mirror2 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + atomic.AddInt32(&countMirror2, 1) + })) + + mainServer := main.URL + mirror1Server := mirror1.URL + mirror2Server := mirror2.URL + + file := s.adaptFile(c, "fixtures/mirror.toml", struct { + MainServer string + Mirror1Server string + Mirror2Server string + }{MainServer: mainServer, Mirror1Server: mirror1Server, Mirror2Server: mirror2Server}) + defer os.Remove(file) + + cmd, output := s.traefikCmd(withConfigFile(file)) + defer output(c) + + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + err = try.GetRequest("http://127.0.0.1:8080/api/http/services", 1000*time.Millisecond, try.BodyContains("mirror1", "mirror2", "service1")) + c.Assert(err, checker.IsNil) + + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil) + c.Assert(err, checker.IsNil) + for i := 0; i < 10; i++ { + response, err := http.DefaultClient.Do(req) + c.Assert(err, checker.IsNil) + c.Assert(response.StatusCode, checker.Equals, http.StatusOK) + } + + countTotal := atomic.LoadInt32(&count) + val1 := atomic.LoadInt32(&countMirror1) + val2 := atomic.LoadInt32(&countMirror2) + + c.Assert(countTotal, checker.Equals, int32(10)) + c.Assert(val1, checker.Equals, int32(1)) + c.Assert(val2, checker.Equals, int32(5)) +} + +func (s *SimpleSuite) TestMirrorCanceled(c *check.C) { + var count, countMirror1, countMirror2 int32 + + main := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + atomic.AddInt32(&count, 1) + time.Sleep(time.Second * 2) + })) + + mirror1 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + atomic.AddInt32(&countMirror1, 1) + })) + + mirror2 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + atomic.AddInt32(&countMirror2, 1) + })) + + mainServer := main.URL + mirror1Server := mirror1.URL + mirror2Server := mirror2.URL + + file := s.adaptFile(c, "fixtures/mirror.toml", struct { + MainServer string + Mirror1Server string + Mirror2Server string + }{MainServer: mainServer, Mirror1Server: mirror1Server, Mirror2Server: mirror2Server}) + defer os.Remove(file) + + cmd, output := s.traefikCmd(withConfigFile(file)) + defer output(c) + + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + err = try.GetRequest("http://127.0.0.1:8080/api/http/services", 1000*time.Millisecond, try.BodyContains("mirror1", "mirror2", "service1")) + c.Assert(err, checker.IsNil) + + for i := 0; i < 5; i++ { + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil) + c.Assert(err, checker.IsNil) + + newCtx, _ := context.WithTimeout(req.Context(), time.Second) + req = req.WithContext(newCtx) + http.DefaultClient.Do(req) + } + + countTotal := atomic.LoadInt32(&count) + val1 := atomic.LoadInt32(&countMirror1) + val2 := atomic.LoadInt32(&countMirror2) + + c.Assert(countTotal, checker.Equals, int32(5)) + c.Assert(val1, checker.Equals, int32(0)) + c.Assert(val2, checker.Equals, int32(0)) +} diff --git a/pkg/config/dynamic/http_config.go b/pkg/config/dynamic/http_config.go index 2b855d32f..7fc264018 100644 --- a/pkg/config/dynamic/http_config.go +++ b/pkg/config/dynamic/http_config.go @@ -21,6 +21,7 @@ type HTTPConfiguration struct { type Service struct { LoadBalancer *ServersLoadBalancer `json:"loadBalancer,omitempty" toml:"loadBalancer,omitempty" yaml:"loadBalancer,omitempty"` Weighted *WeightedRoundRobin `json:"weighted,omitempty" toml:"weighted,omitempty" yaml:"weighted,omitempty" label:"-"` + Mirroring *Mirroring `json:"mirroring,omitempty" toml:"mirroring,omitempty" yaml:"mirroring,omitempty" label:"-"` } // +k8s:deepcopy-gen=true @@ -46,6 +47,22 @@ type RouterTLSConfig struct { // +k8s:deepcopy-gen=true +// Mirroring holds the Mirroring configuration. +type Mirroring struct { + Service string `json:"service,omitempty" toml:"service,omitempty" yaml:"service,omitempty"` + Mirrors []MirrorService `json:"mirrors,omitempty" toml:"mirrors,omitempty" yaml:"mirrors,omitempty"` +} + +// +k8s:deepcopy-gen=true + +// MirrorService holds the MirrorService configuration. +type MirrorService struct { + Name string `json:"name,omitempty" toml:"name,omitempty" yaml:"name,omitempty"` + Percent int `json:"percent,omitempty" toml:"percent,omitempty" yaml:"percent,omitempty"` +} + +// +k8s:deepcopy-gen=true + // WeightedRoundRobin is a weighted round robin load-balancer of services. type WeightedRoundRobin struct { Services []WRRService `json:"services,omitempty" toml:"services,omitempty" yaml:"services,omitempty"` diff --git a/pkg/server/router/router_test.go b/pkg/server/router/router_test.go index 66b11487d..1ff4c43f4 100644 --- a/pkg/server/router/router_test.go +++ b/pkg/server/router/router_test.go @@ -306,7 +306,7 @@ func TestRouterManager_Get(t *testing.T) { Middlewares: test.middlewaresConfig, }, }) - serviceManager := service.NewManager(rtConf.Services, http.DefaultTransport, nil) + serviceManager := service.NewManager(rtConf.Services, http.DefaultTransport, nil, nil) middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager) responseModifierFactory := responsemodifiers.NewBuilder(rtConf.Middlewares) routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, responseModifierFactory) @@ -407,7 +407,7 @@ func TestAccessLog(t *testing.T) { Middlewares: test.middlewaresConfig, }, }) - serviceManager := service.NewManager(rtConf.Services, http.DefaultTransport, nil) + serviceManager := service.NewManager(rtConf.Services, http.DefaultTransport, nil, nil) middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager) responseModifierFactory := responsemodifiers.NewBuilder(rtConf.Middlewares) routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, responseModifierFactory) @@ -693,7 +693,7 @@ func TestRuntimeConfiguration(t *testing.T) { Middlewares: test.middlewareConfig, }, }) - serviceManager := service.NewManager(rtConf.Services, http.DefaultTransport, nil) + serviceManager := service.NewManager(rtConf.Services, http.DefaultTransport, nil, nil) middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager) responseModifierFactory := responsemodifiers.NewBuilder(map[string]*runtime.MiddlewareInfo{}) routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, responseModifierFactory) @@ -767,7 +767,7 @@ func BenchmarkRouterServe(b *testing.B) { Middlewares: map[string]*dynamic.Middleware{}, }, }) - serviceManager := service.NewManager(rtConf.Services, &staticTransport{res}, nil) + serviceManager := service.NewManager(rtConf.Services, &staticTransport{res}, nil, nil) middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager) responseModifierFactory := responsemodifiers.NewBuilder(rtConf.Middlewares) routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, responseModifierFactory) @@ -808,7 +808,7 @@ func BenchmarkService(b *testing.B) { Services: serviceConfig, }, }) - serviceManager := service.NewManager(rtConf.Services, &staticTransport{res}, nil) + serviceManager := service.NewManager(rtConf.Services, &staticTransport{res}, nil, nil) w := httptest.NewRecorder() req := testhelpers.MustNewRequest(http.MethodGet, "http://foo.bar/", nil) diff --git a/pkg/server/server_configuration.go b/pkg/server/server_configuration.go index 65a0521f4..cc9acc707 100644 --- a/pkg/server/server_configuration.go +++ b/pkg/server/server_configuration.go @@ -97,7 +97,7 @@ func (s *Server) createTCPRouters(ctx context.Context, configuration *runtime.Co // createHTTPHandlers returns, for the given configuration and entryPoints, the HTTP handlers for non-TLS connections, and for the TLS ones. the given configuration must not be nil. its fields will get mutated. func (s *Server) createHTTPHandlers(ctx context.Context, configuration *runtime.Configuration, entryPoints []string) (map[string]http.Handler, map[string]http.Handler) { - serviceManager := service.NewManager(configuration.Services, s.defaultRoundTripper, s.metricsRegistry) + serviceManager := service.NewManager(configuration.Services, s.defaultRoundTripper, s.metricsRegistry, s.routinesPool) middlewaresBuilder := middleware.NewBuilder(configuration.Middlewares, serviceManager) responseModifierFactory := responsemodifiers.NewBuilder(configuration.Middlewares) routerManager := router.NewManager(configuration, serviceManager, middlewaresBuilder, responseModifierFactory) diff --git a/pkg/server/service/loadbalancer/mirror/mirror.go b/pkg/server/service/loadbalancer/mirror/mirror.go new file mode 100644 index 000000000..593f20858 --- /dev/null +++ b/pkg/server/service/loadbalancer/mirror/mirror.go @@ -0,0 +1,104 @@ +package mirror + +import ( + "context" + "errors" + "net/http" + "sync" + + "github.com/containous/traefik/v2/pkg/safe" +) + +// Mirroring is an http.Handler that can mirror requests. +type Mirroring struct { + handler http.Handler + mirrorHandlers []*mirrorHandler + rw http.ResponseWriter + routinePool *safe.Pool + + lock sync.RWMutex + total uint64 +} + +// New returns a new instance of *Mirroring. +func New(handler http.Handler, pool *safe.Pool) *Mirroring { + return &Mirroring{ + routinePool: pool, + handler: handler, + rw: blackholeResponseWriter{}, + } +} + +func (m *Mirroring) inc() uint64 { + m.lock.Lock() + defer m.lock.Unlock() + m.total++ + return m.total +} + +type mirrorHandler struct { + http.Handler + percent int + + lock sync.RWMutex + count uint64 +} + +func (m *Mirroring) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + m.handler.ServeHTTP(rw, req) + + select { + case <-req.Context().Done(): + // No mirroring if request has been canceled during main handler ServeHTTP + return + default: + } + + m.routinePool.GoCtx(func(_ context.Context) { + total := m.inc() + for _, handler := range m.mirrorHandlers { + handler.lock.Lock() + if handler.count*100 < total*uint64(handler.percent) { + handler.count++ + handler.lock.Unlock() + // When a request served by m.handler is successful, req.Context will be cancelled, + // which would trigger a cancellation of the ongoing mirrored requests. + // Therefore, we give a new, non-cancellable context to each of the mirrored calls, + // so they can terminate by themselves. + handler.ServeHTTP(m.rw, req.WithContext(contextStopPropagation{req.Context()})) + } else { + handler.lock.Unlock() + } + } + }) +} + +// AddMirror adds an httpHandler to mirror to. +func (m *Mirroring) AddMirror(handler http.Handler, percent int) error { + if percent < 0 || percent >= 100 { + return errors.New("percent must be between 0 and 100") + } + m.mirrorHandlers = append(m.mirrorHandlers, &mirrorHandler{Handler: handler, percent: percent}) + return nil +} + +type blackholeResponseWriter struct{} + +func (b blackholeResponseWriter) Header() http.Header { + return http.Header{} +} + +func (b blackholeResponseWriter) Write(bytes []byte) (int, error) { + return len(bytes), nil +} + +func (b blackholeResponseWriter) WriteHeader(statusCode int) { +} + +type contextStopPropagation struct { + context.Context +} + +func (c contextStopPropagation) Done() <-chan struct{} { + return make(chan struct{}) +} diff --git a/pkg/server/service/loadbalancer/mirror/mirror_test.go b/pkg/server/service/loadbalancer/mirror/mirror_test.go new file mode 100644 index 000000000..fb2c516c5 --- /dev/null +++ b/pkg/server/service/loadbalancer/mirror/mirror_test.go @@ -0,0 +1,79 @@ +package mirror + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/containous/traefik/v2/pkg/safe" + "github.com/stretchr/testify/assert" +) + +func TestMirroringOn100(t *testing.T) { + var countMirror1, countMirror2 int32 + handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusOK) + }) + pool := safe.NewPool(context.Background()) + mirror := New(handler, pool) + err := mirror.AddMirror(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + atomic.AddInt32(&countMirror1, 1) + }), 10) + assert.NoError(t, err) + + err = mirror.AddMirror(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + atomic.AddInt32(&countMirror2, 1) + }), 50) + assert.NoError(t, err) + + for i := 0; i < 100; i++ { + mirror.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + } + + pool.Stop() + + val1 := atomic.LoadInt32(&countMirror1) + val2 := atomic.LoadInt32(&countMirror2) + assert.Equal(t, 10, int(val1)) + assert.Equal(t, 50, int(val2)) +} + +func TestMirroringOn10(t *testing.T) { + var countMirror1, countMirror2 int32 + handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusOK) + }) + pool := safe.NewPool(context.Background()) + mirror := New(handler, pool) + err := mirror.AddMirror(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + atomic.AddInt32(&countMirror1, 1) + }), 10) + assert.NoError(t, err) + + err = mirror.AddMirror(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + atomic.AddInt32(&countMirror2, 1) + }), 50) + assert.NoError(t, err) + + for i := 0; i < 10; i++ { + mirror.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + } + + pool.Stop() + + val1 := atomic.LoadInt32(&countMirror1) + val2 := atomic.LoadInt32(&countMirror2) + assert.Equal(t, 1, int(val1)) + assert.Equal(t, 5, int(val2)) +} + +func TestInvalidPercent(t *testing.T) { + mirror := New(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {}), safe.NewPool(context.Background())) + err := mirror.AddMirror(nil, -1) + assert.Error(t, err) + + err = mirror.AddMirror(nil, 101) + assert.Error(t, err) +} diff --git a/pkg/server/service/service.go b/pkg/server/service/service.go index 1ea363817..1595d0452 100644 --- a/pkg/server/service/service.go +++ b/pkg/server/service/service.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httputil" "net/url" + "reflect" "time" "github.com/containous/alice" @@ -19,8 +20,10 @@ import ( "github.com/containous/traefik/v2/pkg/middlewares/emptybackendhandler" metricsMiddle "github.com/containous/traefik/v2/pkg/middlewares/metrics" "github.com/containous/traefik/v2/pkg/middlewares/pipelining" + "github.com/containous/traefik/v2/pkg/safe" "github.com/containous/traefik/v2/pkg/server/cookie" "github.com/containous/traefik/v2/pkg/server/internal" + "github.com/containous/traefik/v2/pkg/server/service/loadbalancer/mirror" "github.com/containous/traefik/v2/pkg/server/service/loadbalancer/wrr" "github.com/vulcand/oxy/roundrobin" ) @@ -31,8 +34,9 @@ const ( ) // NewManager creates a new Manager -func NewManager(configs map[string]*runtime.ServiceInfo, defaultRoundTripper http.RoundTripper, metricsRegistry metrics.Registry) *Manager { +func NewManager(configs map[string]*runtime.ServiceInfo, defaultRoundTripper http.RoundTripper, metricsRegistry metrics.Registry, routinePool *safe.Pool) *Manager { return &Manager{ + routinePool: routinePool, metricsRegistry: metricsRegistry, bufferPool: newBufferPool(), defaultRoundTripper: defaultRoundTripper, @@ -43,6 +47,7 @@ func NewManager(configs map[string]*runtime.ServiceInfo, defaultRoundTripper htt // Manager The service manager type Manager struct { + routinePool *safe.Pool metricsRegistry metrics.Registry bufferPool httputil.BufferPool defaultRoundTripper http.RoundTripper @@ -62,7 +67,14 @@ func (m *Manager) BuildHTTP(rootCtx context.Context, serviceName string, respons return nil, fmt.Errorf("the service %q does not exist", serviceName) } - if conf.LoadBalancer != nil && conf.Weighted != nil { + value := reflect.ValueOf(*conf.Service) + var count int + for i := 0; i < value.NumField(); i++ { + if !value.Field(i).IsNil() { + count++ + } + } + if count > 1 { return nil, errors.New("cannot create service: multi-types service not supported, consider declaring two different pieces of service instead") } @@ -83,6 +95,13 @@ func (m *Manager) BuildHTTP(rootCtx context.Context, serviceName string, respons conf.AddError(err, true) return nil, err } + case conf.Mirroring != nil: + var err error + lb, err = m.getLoadBalancerMirrorServiceHandler(ctx, serviceName, conf.Mirroring, responseModifier) + if err != nil { + conf.AddError(err, true) + return nil, err + } default: sErr := fmt.Errorf("the service %q does not have any type defined", serviceName) conf.AddError(sErr, true) @@ -92,6 +111,27 @@ func (m *Manager) BuildHTTP(rootCtx context.Context, serviceName string, respons return lb, nil } +func (m *Manager) getLoadBalancerMirrorServiceHandler(ctx context.Context, serviceName string, config *dynamic.Mirroring, responseModifier func(*http.Response) error) (http.Handler, error) { + serviceHandler, err := m.BuildHTTP(ctx, config.Service, responseModifier) + if err != nil { + return nil, err + } + + handler := mirror.New(serviceHandler, m.routinePool) + for _, mirrorConfig := range config.Mirrors { + mirrorHandler, err := m.BuildHTTP(ctx, mirrorConfig.Name, responseModifier) + if err != nil { + return nil, err + } + + err = handler.AddMirror(mirrorHandler, mirrorConfig.Percent) + if err != nil { + return nil, err + } + } + return handler, nil +} + func (m *Manager) getLoadBalancerWRRServiceHandler(ctx context.Context, serviceName string, config *dynamic.WeightedRoundRobin, responseModifier func(*http.Response) error) (http.Handler, error) { // TODO Handle accesslog and metrics with multiple service name if config.Sticky != nil && config.Sticky.Cookie != nil { diff --git a/pkg/server/service/service_test.go b/pkg/server/service/service_test.go index e150d0cc7..c2bb707b7 100644 --- a/pkg/server/service/service_test.go +++ b/pkg/server/service/service_test.go @@ -80,7 +80,7 @@ func TestGetLoadBalancer(t *testing.T) { } func TestGetLoadBalancerServiceHandler(t *testing.T) { - sm := NewManager(nil, http.DefaultTransport, nil) + sm := NewManager(nil, http.DefaultTransport, nil, nil) server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-From", "first") @@ -332,7 +332,7 @@ func TestManager_Build(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - manager := NewManager(test.configs, http.DefaultTransport, nil) + manager := NewManager(test.configs, http.DefaultTransport, nil, nil) ctx := context.Background() if len(test.providerName) > 0 { @@ -345,4 +345,18 @@ func TestManager_Build(t *testing.T) { } } +func TestMultipleTypeOnBuildHTTP(t *testing.T) { + manager := NewManager(map[string]*runtime.ServiceInfo{ + "test@file": { + Service: &dynamic.Service{ + LoadBalancer: &dynamic.ServersLoadBalancer{}, + Weighted: &dynamic.WeightedRoundRobin{}, + }, + }, + }, http.DefaultTransport, nil, nil) + + _, err := manager.BuildHTTP(context.Background(), "test@file", nil) + assert.Error(t, err, "cannot create service: multi-types service not supported, consider declaring two different pieces of service instead") +} + // FIXME Add healthcheck tests