mirror of https://github.com/status-im/consul.git
[CE] Test tenancies for exported-services config manager (#20678)
Sync controller tests from ENT
This commit is contained in:
parent
3f3477cdd3
commit
53afd8f4c5
|
@ -6,8 +6,11 @@ package v1compat
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
|
"golang.org/x/exp/maps"
|
||||||
|
|
||||||
"github.com/hashicorp/consul/acl"
|
"github.com/hashicorp/consul/acl"
|
||||||
"github.com/hashicorp/consul/agent/structs"
|
"github.com/hashicorp/consul/agent/structs"
|
||||||
"github.com/hashicorp/consul/internal/controller"
|
"github.com/hashicorp/consul/internal/controller"
|
||||||
|
@ -25,6 +28,7 @@ const (
|
||||||
controllerMetaKey = "managed-by-controller"
|
controllerMetaKey = "managed-by-controller"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:generate mockery --name AggregatedConfig --inpackage --with-expecter --filename mock_AggregatedConfig.go
|
||||||
type AggregatedConfig interface {
|
type AggregatedConfig interface {
|
||||||
Start(context.Context)
|
Start(context.Context)
|
||||||
GetExportedServicesConfigEntry(context.Context, string, *acl.EnterpriseMeta) (*structs.ExportedServicesConfigEntry, error)
|
GetExportedServicesConfigEntry(context.Context, string, *acl.EnterpriseMeta) (*structs.ExportedServicesConfigEntry, error)
|
||||||
|
@ -87,7 +91,9 @@ func (r *reconciler) Reconcile(ctx context.Context, rt controller.Runtime, req c
|
||||||
entMeta.OverridePartition(req.ID.Tenancy.Partition)
|
entMeta.OverridePartition(req.ID.Tenancy.Partition)
|
||||||
existing, err := r.config.GetExportedServicesConfigEntry(ctx, req.ID.Tenancy.Partition, entMeta)
|
existing, err := r.config.GetExportedServicesConfigEntry(ctx, req.ID.Tenancy.Partition, entMeta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rt.Logger.Error("error getting exported service config entry", "error", err)
|
// When we can't read the existing exported-services we purposely allow
|
||||||
|
// reconciler to continue so we can still write a new one
|
||||||
|
rt.Logger.Warn("error getting exported service config entry but continuing reconcile", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing != nil && existing.Meta["managed-by-controller"] != ControllerName {
|
if existing != nil && existing.Meta["managed-by-controller"] != ControllerName {
|
||||||
|
@ -117,7 +123,6 @@ func (r *reconciler) Reconcile(ctx context.Context, rt controller.Runtime, req c
|
||||||
},
|
},
|
||||||
index.IndexQueryOptions{Prefix: true},
|
index.IndexQueryOptions{Prefix: true},
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rt.Logger.Error("error retrieving partition exported services", "error", err)
|
rt.Logger.Error("error retrieving partition exported services", "error", err)
|
||||||
return err
|
return err
|
||||||
|
@ -133,9 +138,8 @@ func (r *reconciler) Reconcile(ctx context.Context, rt controller.Runtime, req c
|
||||||
},
|
},
|
||||||
index.IndexQueryOptions{Prefix: true},
|
index.IndexQueryOptions{Prefix: true},
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rt.Logger.Error("error retrieving namespace exported service", "error", err)
|
rt.Logger.Error("error retrieving namespace exported services", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,7 +153,6 @@ func (r *reconciler) Reconcile(ctx context.Context, rt controller.Runtime, req c
|
||||||
},
|
},
|
||||||
index.IndexQueryOptions{Prefix: true},
|
index.IndexQueryOptions{Prefix: true},
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rt.Logger.Error("error retrieving exported services", "error", err)
|
rt.Logger.Error("error retrieving exported services", "error", err)
|
||||||
return err
|
return err
|
||||||
|
@ -243,24 +246,24 @@ func (c *exportConsumers) addConsumers(consumers []*pbmulticluster.ExportedServi
|
||||||
func (c *exportConsumers) configEntryConsumers() []structs.ServiceConsumer {
|
func (c *exportConsumers) configEntryConsumers() []structs.ServiceConsumer {
|
||||||
consumers := make([]structs.ServiceConsumer, 0, len(c.partitions)+len(c.peers)+len(c.samenessGroups))
|
consumers := make([]structs.ServiceConsumer, 0, len(c.partitions)+len(c.peers)+len(c.samenessGroups))
|
||||||
|
|
||||||
partitions := keys(c.partitions)
|
partitions := maps.Keys(c.partitions)
|
||||||
sort.Strings(partitions)
|
slices.Sort(partitions)
|
||||||
for _, consumer := range partitions {
|
for _, consumer := range partitions {
|
||||||
consumers = append(consumers, structs.ServiceConsumer{
|
consumers = append(consumers, structs.ServiceConsumer{
|
||||||
Partition: consumer,
|
Partition: consumer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
peers := keys(c.peers)
|
peers := maps.Keys(c.peers)
|
||||||
sort.Strings(peers)
|
slices.Sort(peers)
|
||||||
for _, consumer := range peers {
|
for _, consumer := range peers {
|
||||||
consumers = append(consumers, structs.ServiceConsumer{
|
consumers = append(consumers, structs.ServiceConsumer{
|
||||||
Peer: consumer,
|
Peer: consumer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
samenessGroups := keys(c.samenessGroups)
|
samenessGroups := maps.Keys(c.samenessGroups)
|
||||||
sort.Strings(samenessGroups)
|
slices.Sort(samenessGroups)
|
||||||
for _, consumer := range samenessGroups {
|
for _, consumer := range samenessGroups {
|
||||||
consumers = append(consumers, structs.ServiceConsumer{
|
consumers = append(consumers, structs.ServiceConsumer{
|
||||||
SamenessGroup: consumer,
|
SamenessGroup: consumer,
|
||||||
|
@ -316,8 +319,8 @@ func (t *exportTracker) allExports() []structs.ExportedService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
namespaces := keys(t.namespaces)
|
namespaces := maps.Keys(t.namespaces)
|
||||||
sort.Strings(namespaces)
|
slices.Sort(namespaces)
|
||||||
for _, ns := range namespaces {
|
for _, ns := range namespaces {
|
||||||
exports = append(exports, structs.ExportedService{
|
exports = append(exports, structs.ExportedService{
|
||||||
Name: "*",
|
Name: "*",
|
||||||
|
@ -326,7 +329,7 @@ func (t *exportTracker) allExports() []structs.ExportedService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
services := keys(t.services)
|
services := maps.Keys(t.services)
|
||||||
sort.Slice(services, func(i, j int) bool {
|
sort.Slice(services, func(i, j int) bool {
|
||||||
// the partitions must already be equal because we are only
|
// the partitions must already be equal because we are only
|
||||||
// looking at resource exports for a single partition.
|
// looking at resource exports for a single partition.
|
||||||
|
|
|
@ -0,0 +1,428 @@
|
||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package v1compat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
|
||||||
|
"github.com/hashicorp/consul/acl"
|
||||||
|
svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing"
|
||||||
|
"github.com/hashicorp/consul/agent/structs"
|
||||||
|
"github.com/hashicorp/consul/internal/catalog"
|
||||||
|
"github.com/hashicorp/consul/internal/controller"
|
||||||
|
"github.com/hashicorp/consul/internal/multicluster/internal/types"
|
||||||
|
"github.com/hashicorp/consul/internal/resource"
|
||||||
|
rtest "github.com/hashicorp/consul/internal/resource/resourcetest"
|
||||||
|
pbmulticluster "github.com/hashicorp/consul/proto-public/pbmulticluster/v2"
|
||||||
|
"github.com/hashicorp/consul/proto-public/pbresource"
|
||||||
|
"github.com/hashicorp/consul/sdk/testutil"
|
||||||
|
"github.com/hashicorp/consul/version/versiontest"
|
||||||
|
)
|
||||||
|
|
||||||
|
type controllerSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
ctx context.Context
|
||||||
|
ctl *controller.TestController
|
||||||
|
isEnterprise bool
|
||||||
|
tenancies []*pbresource.Tenancy
|
||||||
|
config *MockAggregatedConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *controllerSuite) SetupTest() {
|
||||||
|
suite.tenancies = rtest.TestTenancies()
|
||||||
|
suite.isEnterprise = versiontest.IsEnterprise()
|
||||||
|
suite.ctx = testutil.TestContext(suite.T())
|
||||||
|
client := svctest.NewResourceServiceBuilder().
|
||||||
|
WithRegisterFns(types.Register, catalog.RegisterTypes).
|
||||||
|
WithTenancies(suite.tenancies...).
|
||||||
|
Run(suite.T())
|
||||||
|
|
||||||
|
suite.config = NewMockAggregatedConfig(suite.T())
|
||||||
|
suite.config.EXPECT().EventChannel().Return(make(chan controller.Event))
|
||||||
|
suite.ctl = controller.NewTestController(
|
||||||
|
Controller(suite.config),
|
||||||
|
client,
|
||||||
|
).WithLogger(testutil.Logger(suite.T()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that we do nothing if V1 exports have not been replicated to v2 resources compatible with this controller
|
||||||
|
func (suite *controllerSuite) TestReconcile_V1ExportsExist() {
|
||||||
|
incompatibleConfig := &structs.ExportedServicesConfigEntry{
|
||||||
|
Name: "v1Legacy",
|
||||||
|
Meta: map[string]string{controllerMetaKey: "foo-controller"},
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.runTestCaseWithTenancies(func(tenancy *pbresource.Tenancy) {
|
||||||
|
entMeta := acl.DefaultEnterpriseMeta()
|
||||||
|
entMeta.OverridePartition(tenancy.Partition)
|
||||||
|
|
||||||
|
suite.config.EXPECT().
|
||||||
|
GetExportedServicesConfigEntry(suite.ctx, tenancy.Partition, entMeta).
|
||||||
|
Return(incompatibleConfig, nil)
|
||||||
|
|
||||||
|
resID := &pbresource.ID{
|
||||||
|
Type: pbmulticluster.ComputedExportedServicesType,
|
||||||
|
Tenancy: &pbresource.Tenancy{Partition: tenancy.Partition},
|
||||||
|
Name: types.ComputedExportedServicesName,
|
||||||
|
}
|
||||||
|
err := suite.ctl.Reconcile(suite.ctx, controller.Request{ID: resID})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that we do not stop reconciler even when we fail to retrieve the config entry
|
||||||
|
func (suite *controllerSuite) TestReconcile_GetExportedServicesConfigEntry_Error() {
|
||||||
|
suite.runTestCaseWithTenancies(func(tenancy *pbresource.Tenancy) {
|
||||||
|
entMeta := acl.DefaultEnterpriseMeta()
|
||||||
|
entMeta.OverridePartition(tenancy.Partition)
|
||||||
|
|
||||||
|
suite.config.EXPECT().
|
||||||
|
GetExportedServicesConfigEntry(suite.ctx, tenancy.Partition, entMeta).
|
||||||
|
Return(nil, fmt.Errorf("failed to retrieve config entry"))
|
||||||
|
|
||||||
|
resID := &pbresource.ID{
|
||||||
|
Type: pbmulticluster.ComputedExportedServicesType,
|
||||||
|
Tenancy: &pbresource.Tenancy{Partition: tenancy.Partition},
|
||||||
|
Name: types.ComputedExportedServicesName,
|
||||||
|
}
|
||||||
|
err := suite.ctl.Reconcile(suite.ctx, controller.Request{ID: resID})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete config entry for case where resources aren't found
|
||||||
|
func (suite *controllerSuite) TestReconcile_DeleteConfig_MissingResources() {
|
||||||
|
suite.runTestCaseWithTenancies(func(tenancy *pbresource.Tenancy) {
|
||||||
|
entMeta := acl.DefaultEnterpriseMeta()
|
||||||
|
entMeta.OverridePartition(tenancy.Partition)
|
||||||
|
|
||||||
|
configEntry := &structs.ExportedServicesConfigEntry{
|
||||||
|
// v1 exported-services config entries must have a Name that is the partitions name
|
||||||
|
Name: tenancy.Partition,
|
||||||
|
Meta: map[string]string{
|
||||||
|
controllerMetaKey: ControllerName,
|
||||||
|
},
|
||||||
|
EnterpriseMeta: acl.NewEnterpriseMetaWithPartition(tenancy.Partition, ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.config.EXPECT().GetExportedServicesConfigEntry(suite.ctx, tenancy.Partition, entMeta).Return(configEntry, nil)
|
||||||
|
suite.config.EXPECT().DeleteExportedServicesConfigEntry(suite.ctx, tenancy.Partition, entMeta).Return(nil)
|
||||||
|
|
||||||
|
resID := &pbresource.ID{
|
||||||
|
Type: pbmulticluster.ComputedExportedServicesType,
|
||||||
|
Tenancy: &pbresource.Tenancy{Partition: tenancy.Partition},
|
||||||
|
Name: types.ComputedExportedServicesName,
|
||||||
|
}
|
||||||
|
err := suite.ctl.Reconcile(suite.ctx, controller.Request{ID: resID})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *controllerSuite) TestReconcile_NewExport_PartitionExport() {
|
||||||
|
if !suite.isEnterprise {
|
||||||
|
suite.T().Skip("this test should only run against the enterprise build")
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.runTestCaseWithTenancies(func(tenancy *pbresource.Tenancy) {
|
||||||
|
entMeta := acl.DefaultEnterpriseMeta()
|
||||||
|
entMeta.OverridePartition(tenancy.Partition)
|
||||||
|
|
||||||
|
// used as a return value for GetExportedServicesConfigEntry
|
||||||
|
existingCE := &structs.ExportedServicesConfigEntry{
|
||||||
|
// v1 exported-services config entries must have a Name that is the partitions name
|
||||||
|
Name: tenancy.Partition,
|
||||||
|
Meta: map[string]string{
|
||||||
|
controllerMetaKey: ControllerName,
|
||||||
|
},
|
||||||
|
EnterpriseMeta: acl.NewEnterpriseMetaWithPartition(tenancy.Partition, ""),
|
||||||
|
}
|
||||||
|
suite.config.EXPECT().GetExportedServicesConfigEntry(suite.ctx, tenancy.Partition, entMeta).Return(existingCE, nil)
|
||||||
|
|
||||||
|
// expected config entry to be written by reconcile
|
||||||
|
expectedCE := &structs.ExportedServicesConfigEntry{
|
||||||
|
Name: tenancy.Partition,
|
||||||
|
Services: []structs.ExportedService{
|
||||||
|
{
|
||||||
|
Name: "s1",
|
||||||
|
Namespace: resource.DefaultNamespaceName,
|
||||||
|
Consumers: []structs.ServiceConsumer{
|
||||||
|
{
|
||||||
|
Partition: "p1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Meta: map[string]string{
|
||||||
|
controllerMetaKey: ControllerName,
|
||||||
|
},
|
||||||
|
EnterpriseMeta: *entMeta,
|
||||||
|
}
|
||||||
|
suite.config.EXPECT().WriteExportedServicesConfigEntry(suite.ctx, expectedCE).Return(nil)
|
||||||
|
|
||||||
|
name := "s1"
|
||||||
|
expSv := &pbmulticluster.ExportedServices{
|
||||||
|
Services: []string{name},
|
||||||
|
Consumers: []*pbmulticluster.ExportedServicesConsumer{
|
||||||
|
{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_Partition{Partition: "p1"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rtest.Resource(pbmulticluster.ExportedServicesType, "exported-svcs").
|
||||||
|
WithData(suite.T(), expSv).
|
||||||
|
WithTenancy(&pbresource.Tenancy{Partition: tenancy.Partition}).
|
||||||
|
Write(suite.T(), suite.ctl.Runtime().Client)
|
||||||
|
cesID := &pbresource.ID{
|
||||||
|
Type: pbmulticluster.ComputedExportedServicesType,
|
||||||
|
Tenancy: &pbresource.Tenancy{Partition: tenancy.Partition},
|
||||||
|
Name: types.ComputedExportedServicesName,
|
||||||
|
}
|
||||||
|
err := suite.ctl.Reconcile(suite.ctx, controller.Request{ID: cesID})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *controllerSuite) TestReconcile_NewExport_PeerExport() {
|
||||||
|
if !suite.isEnterprise {
|
||||||
|
suite.T().Skip("this test should only run against the enterprise build")
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.runTestCaseWithTenancies(func(tenancy *pbresource.Tenancy) {
|
||||||
|
entMeta := acl.DefaultEnterpriseMeta()
|
||||||
|
entMeta.OverridePartition(tenancy.Partition)
|
||||||
|
|
||||||
|
// used as a return value for GetExportedServicesConfigEntry
|
||||||
|
existingCE := &structs.ExportedServicesConfigEntry{
|
||||||
|
// v1 exported-services config entries must have a Name that is the partitions name
|
||||||
|
Name: tenancy.Partition,
|
||||||
|
Meta: map[string]string{
|
||||||
|
controllerMetaKey: ControllerName,
|
||||||
|
},
|
||||||
|
EnterpriseMeta: acl.NewEnterpriseMetaWithPartition(tenancy.Partition, ""),
|
||||||
|
}
|
||||||
|
suite.config.EXPECT().GetExportedServicesConfigEntry(suite.ctx, tenancy.Partition, entMeta).Return(existingCE, nil)
|
||||||
|
|
||||||
|
// expected config entry to be written by reconcile
|
||||||
|
expectedCE := &structs.ExportedServicesConfigEntry{
|
||||||
|
Name: tenancy.Partition,
|
||||||
|
Services: []structs.ExportedService{
|
||||||
|
{
|
||||||
|
Name: "s1",
|
||||||
|
Namespace: resource.DefaultNamespaceName,
|
||||||
|
Consumers: []structs.ServiceConsumer{
|
||||||
|
{
|
||||||
|
Peer: "peer1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Meta: map[string]string{
|
||||||
|
controllerMetaKey: ControllerName,
|
||||||
|
},
|
||||||
|
EnterpriseMeta: *entMeta,
|
||||||
|
}
|
||||||
|
suite.config.EXPECT().WriteExportedServicesConfigEntry(suite.ctx, expectedCE).Return(nil)
|
||||||
|
|
||||||
|
name := "s1"
|
||||||
|
expSv := &pbmulticluster.ExportedServices{
|
||||||
|
Services: []string{name},
|
||||||
|
Consumers: []*pbmulticluster.ExportedServicesConsumer{
|
||||||
|
{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_Peer{Peer: "peer1"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rtest.Resource(pbmulticluster.ExportedServicesType, "exported-svcs").
|
||||||
|
WithData(suite.T(), expSv).
|
||||||
|
WithTenancy(&pbresource.Tenancy{Partition: tenancy.Partition}).
|
||||||
|
Write(suite.T(), suite.ctl.Runtime().Client)
|
||||||
|
cesID := &pbresource.ID{
|
||||||
|
Type: pbmulticluster.ComputedExportedServicesType,
|
||||||
|
Tenancy: &pbresource.Tenancy{Partition: tenancy.Partition},
|
||||||
|
Name: types.ComputedExportedServicesName,
|
||||||
|
}
|
||||||
|
err := suite.ctl.Reconcile(suite.ctx, controller.Request{ID: cesID})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *controllerSuite) TestReconcile_NewExport_SamenessGroupsExport() {
|
||||||
|
if !suite.isEnterprise {
|
||||||
|
suite.T().Skip("this test should only run against the enterprise build")
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.runTestCaseWithTenancies(func(tenancy *pbresource.Tenancy) {
|
||||||
|
entMeta := acl.DefaultEnterpriseMeta()
|
||||||
|
entMeta.OverridePartition(tenancy.Partition)
|
||||||
|
|
||||||
|
// used as a return value for GetExportedServicesConfigEntry
|
||||||
|
existingCE := &structs.ExportedServicesConfigEntry{
|
||||||
|
// v1 exported-services config entries must have a Name that is the partitions name
|
||||||
|
Name: tenancy.Partition,
|
||||||
|
Meta: map[string]string{
|
||||||
|
controllerMetaKey: ControllerName,
|
||||||
|
},
|
||||||
|
EnterpriseMeta: acl.NewEnterpriseMetaWithPartition(tenancy.Partition, ""),
|
||||||
|
}
|
||||||
|
suite.config.EXPECT().GetExportedServicesConfigEntry(suite.ctx, tenancy.Partition, entMeta).Return(existingCE, nil)
|
||||||
|
|
||||||
|
// expected config entry to be written by reconcile
|
||||||
|
expectedCE := &structs.ExportedServicesConfigEntry{
|
||||||
|
Name: tenancy.Partition,
|
||||||
|
Services: []structs.ExportedService{
|
||||||
|
{
|
||||||
|
Name: "s1",
|
||||||
|
Namespace: resource.DefaultNamespaceName,
|
||||||
|
Consumers: []structs.ServiceConsumer{
|
||||||
|
{
|
||||||
|
SamenessGroup: "sg1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Meta: map[string]string{
|
||||||
|
controllerMetaKey: ControllerName,
|
||||||
|
},
|
||||||
|
EnterpriseMeta: *entMeta,
|
||||||
|
}
|
||||||
|
suite.config.EXPECT().WriteExportedServicesConfigEntry(suite.ctx, expectedCE).Return(nil)
|
||||||
|
|
||||||
|
name := "s1"
|
||||||
|
expSv := &pbmulticluster.ExportedServices{
|
||||||
|
Services: []string{name},
|
||||||
|
Consumers: []*pbmulticluster.ExportedServicesConsumer{
|
||||||
|
{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_SamenessGroup{SamenessGroup: "sg1"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rtest.Resource(pbmulticluster.ExportedServicesType, "exported-svcs").
|
||||||
|
WithData(suite.T(), expSv).
|
||||||
|
WithTenancy(&pbresource.Tenancy{Partition: tenancy.Partition}).
|
||||||
|
Write(suite.T(), suite.ctl.Runtime().Client)
|
||||||
|
cesID := &pbresource.ID{
|
||||||
|
Type: pbmulticluster.ComputedExportedServicesType,
|
||||||
|
Tenancy: &pbresource.Tenancy{Partition: tenancy.Partition},
|
||||||
|
Name: types.ComputedExportedServicesName,
|
||||||
|
}
|
||||||
|
err := suite.ctl.Reconcile(suite.ctx, controller.Request{ID: cesID})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *controllerSuite) TestReconcile_MultipleExports() {
|
||||||
|
if !suite.isEnterprise {
|
||||||
|
suite.T().Skip("this test should only run against the enterprise build")
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.runTestCaseWithTenancies(func(tenancy *pbresource.Tenancy) {
|
||||||
|
entMeta := acl.DefaultEnterpriseMeta()
|
||||||
|
entMeta.OverridePartition(tenancy.Partition)
|
||||||
|
configCE := &structs.ExportedServicesConfigEntry{
|
||||||
|
// v1 exported-services config entries must have a Name that is the partitions name
|
||||||
|
Name: tenancy.Partition,
|
||||||
|
Meta: map[string]string{
|
||||||
|
controllerMetaKey: ControllerName,
|
||||||
|
},
|
||||||
|
EnterpriseMeta: acl.NewEnterpriseMetaWithPartition(tenancy.Partition, ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.config.EXPECT().
|
||||||
|
GetExportedServicesConfigEntry(suite.ctx, tenancy.Partition, entMeta).
|
||||||
|
Return(configCE, nil)
|
||||||
|
|
||||||
|
expSv1 := &pbmulticluster.ExportedServices{
|
||||||
|
Services: []string{"s1"},
|
||||||
|
Consumers: []*pbmulticluster.ExportedServicesConsumer{
|
||||||
|
{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_Partition{Partition: "p1"}},
|
||||||
|
{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_Partition{Partition: "p4"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expSv2 := &pbmulticluster.ExportedServices{
|
||||||
|
Services: []string{"s2"},
|
||||||
|
Consumers: []*pbmulticluster.ExportedServicesConsumer{
|
||||||
|
{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_Partition{Partition: "p2"}},
|
||||||
|
{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_Peer{Peer: "peer1"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expSv3 := &pbmulticluster.ExportedServices{
|
||||||
|
Services: []string{"s1", "s3"},
|
||||||
|
Consumers: []*pbmulticluster.ExportedServicesConsumer{
|
||||||
|
{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_Partition{Partition: "p3"}},
|
||||||
|
{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_SamenessGroup{SamenessGroup: "sg1"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, s := range []*pbmulticluster.ExportedServices{expSv1, expSv2, expSv3} {
|
||||||
|
rtest.Resource(pbmulticluster.ExportedServicesType, fmt.Sprintf("exported-svcs-%d", i)).
|
||||||
|
WithData(suite.T(), s).
|
||||||
|
WithTenancy(&pbresource.Tenancy{Partition: tenancy.Partition}).
|
||||||
|
Write(suite.T(), suite.ctl.Runtime().Client)
|
||||||
|
}
|
||||||
|
|
||||||
|
cesID := &pbresource.ID{
|
||||||
|
Type: pbmulticluster.ComputedExportedServicesType,
|
||||||
|
Tenancy: &pbresource.Tenancy{Partition: tenancy.Partition},
|
||||||
|
Name: types.ComputedExportedServicesName,
|
||||||
|
}
|
||||||
|
|
||||||
|
// expected computed config entry to be written by reconcile
|
||||||
|
computedConfigEntry := &structs.ExportedServicesConfigEntry{
|
||||||
|
Name: tenancy.Partition,
|
||||||
|
Meta: map[string]string{
|
||||||
|
controllerMetaKey: ControllerName,
|
||||||
|
},
|
||||||
|
Services: []structs.ExportedService{
|
||||||
|
{
|
||||||
|
Name: "s3",
|
||||||
|
Namespace: resource.DefaultNamespaceName,
|
||||||
|
Consumers: []structs.ServiceConsumer{
|
||||||
|
{Partition: "p3"},
|
||||||
|
{SamenessGroup: "sg1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "s2",
|
||||||
|
Namespace: resource.DefaultNamespaceName,
|
||||||
|
Consumers: []structs.ServiceConsumer{
|
||||||
|
{Partition: "p2"},
|
||||||
|
{Peer: "peer1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "s1",
|
||||||
|
Namespace: resource.DefaultNamespaceName,
|
||||||
|
Consumers: []structs.ServiceConsumer{
|
||||||
|
{Partition: "p1"},
|
||||||
|
{Partition: "p3"},
|
||||||
|
{Partition: "p4"},
|
||||||
|
{SamenessGroup: "sg1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EnterpriseMeta: acl.NewEnterpriseMetaWithPartition(tenancy.Partition, resource.DefaultNamespaceName),
|
||||||
|
}
|
||||||
|
suite.config.EXPECT().
|
||||||
|
WriteExportedServicesConfigEntry(suite.ctx, computedConfigEntry).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
|
err := suite.ctl.Reconcile(suite.ctx, controller.Request{ID: cesID})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *controllerSuite) runTestCaseWithTenancies(testFunc func(*pbresource.Tenancy)) {
|
||||||
|
for _, tenancy := range suite.tenancies {
|
||||||
|
suite.Run(suite.appendTenancyInfo(tenancy), func() {
|
||||||
|
testFunc(tenancy)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *controllerSuite) appendTenancyInfo(tenancy *pbresource.Tenancy) string {
|
||||||
|
return fmt.Sprintf("%s_Namespace_%s_Partition", tenancy.Namespace, tenancy.Partition)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestController(t *testing.T) {
|
||||||
|
suite.Run(t, new(controllerSuite))
|
||||||
|
}
|
|
@ -0,0 +1,262 @@
|
||||||
|
// Code generated by mockery v2.20.0. DO NOT EDIT.
|
||||||
|
|
||||||
|
package v1compat
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
|
||||||
|
acl "github.com/hashicorp/consul/acl"
|
||||||
|
|
||||||
|
controller "github.com/hashicorp/consul/internal/controller"
|
||||||
|
|
||||||
|
mock "github.com/stretchr/testify/mock"
|
||||||
|
|
||||||
|
structs "github.com/hashicorp/consul/agent/structs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockAggregatedConfig is an autogenerated mock type for the AggregatedConfig type
|
||||||
|
type MockAggregatedConfig struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockAggregatedConfig_Expecter struct {
|
||||||
|
mock *mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_m *MockAggregatedConfig) EXPECT() *MockAggregatedConfig_Expecter {
|
||||||
|
return &MockAggregatedConfig_Expecter{mock: &_m.Mock}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteExportedServicesConfigEntry provides a mock function with given fields: _a0, _a1, _a2
|
||||||
|
func (_m *MockAggregatedConfig) DeleteExportedServicesConfigEntry(_a0 context.Context, _a1 string, _a2 *acl.EnterpriseMeta) error {
|
||||||
|
ret := _m.Called(_a0, _a1, _a2)
|
||||||
|
|
||||||
|
var r0 error
|
||||||
|
if rf, ok := ret.Get(0).(func(context.Context, string, *acl.EnterpriseMeta) error); ok {
|
||||||
|
r0 = rf(_a0, _a1, _a2)
|
||||||
|
} else {
|
||||||
|
r0 = ret.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockAggregatedConfig_DeleteExportedServicesConfigEntry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteExportedServicesConfigEntry'
|
||||||
|
type MockAggregatedConfig_DeleteExportedServicesConfigEntry_Call struct {
|
||||||
|
*mock.Call
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteExportedServicesConfigEntry is a helper method to define mock.On call
|
||||||
|
// - _a0 context.Context
|
||||||
|
// - _a1 string
|
||||||
|
// - _a2 *acl.EnterpriseMeta
|
||||||
|
func (_e *MockAggregatedConfig_Expecter) DeleteExportedServicesConfigEntry(_a0 interface{}, _a1 interface{}, _a2 interface{}) *MockAggregatedConfig_DeleteExportedServicesConfigEntry_Call {
|
||||||
|
return &MockAggregatedConfig_DeleteExportedServicesConfigEntry_Call{Call: _e.mock.On("DeleteExportedServicesConfigEntry", _a0, _a1, _a2)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_DeleteExportedServicesConfigEntry_Call) Run(run func(_a0 context.Context, _a1 string, _a2 *acl.EnterpriseMeta)) *MockAggregatedConfig_DeleteExportedServicesConfigEntry_Call {
|
||||||
|
_c.Call.Run(func(args mock.Arguments) {
|
||||||
|
run(args[0].(context.Context), args[1].(string), args[2].(*acl.EnterpriseMeta))
|
||||||
|
})
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_DeleteExportedServicesConfigEntry_Call) Return(_a0 error) *MockAggregatedConfig_DeleteExportedServicesConfigEntry_Call {
|
||||||
|
_c.Call.Return(_a0)
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_DeleteExportedServicesConfigEntry_Call) RunAndReturn(run func(context.Context, string, *acl.EnterpriseMeta) error) *MockAggregatedConfig_DeleteExportedServicesConfigEntry_Call {
|
||||||
|
_c.Call.Return(run)
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventChannel provides a mock function with given fields:
|
||||||
|
func (_m *MockAggregatedConfig) EventChannel() chan controller.Event {
|
||||||
|
ret := _m.Called()
|
||||||
|
|
||||||
|
var r0 chan controller.Event
|
||||||
|
if rf, ok := ret.Get(0).(func() chan controller.Event); ok {
|
||||||
|
r0 = rf()
|
||||||
|
} else {
|
||||||
|
if ret.Get(0) != nil {
|
||||||
|
r0 = ret.Get(0).(chan controller.Event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockAggregatedConfig_EventChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EventChannel'
|
||||||
|
type MockAggregatedConfig_EventChannel_Call struct {
|
||||||
|
*mock.Call
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventChannel is a helper method to define mock.On call
|
||||||
|
func (_e *MockAggregatedConfig_Expecter) EventChannel() *MockAggregatedConfig_EventChannel_Call {
|
||||||
|
return &MockAggregatedConfig_EventChannel_Call{Call: _e.mock.On("EventChannel")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_EventChannel_Call) Run(run func()) *MockAggregatedConfig_EventChannel_Call {
|
||||||
|
_c.Call.Run(func(args mock.Arguments) {
|
||||||
|
run()
|
||||||
|
})
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_EventChannel_Call) Return(_a0 chan controller.Event) *MockAggregatedConfig_EventChannel_Call {
|
||||||
|
_c.Call.Return(_a0)
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_EventChannel_Call) RunAndReturn(run func() chan controller.Event) *MockAggregatedConfig_EventChannel_Call {
|
||||||
|
_c.Call.Return(run)
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExportedServicesConfigEntry provides a mock function with given fields: _a0, _a1, _a2
|
||||||
|
func (_m *MockAggregatedConfig) GetExportedServicesConfigEntry(_a0 context.Context, _a1 string, _a2 *acl.EnterpriseMeta) (*structs.ExportedServicesConfigEntry, error) {
|
||||||
|
ret := _m.Called(_a0, _a1, _a2)
|
||||||
|
|
||||||
|
var r0 *structs.ExportedServicesConfigEntry
|
||||||
|
var r1 error
|
||||||
|
if rf, ok := ret.Get(0).(func(context.Context, string, *acl.EnterpriseMeta) (*structs.ExportedServicesConfigEntry, error)); ok {
|
||||||
|
return rf(_a0, _a1, _a2)
|
||||||
|
}
|
||||||
|
if rf, ok := ret.Get(0).(func(context.Context, string, *acl.EnterpriseMeta) *structs.ExportedServicesConfigEntry); ok {
|
||||||
|
r0 = rf(_a0, _a1, _a2)
|
||||||
|
} else {
|
||||||
|
if ret.Get(0) != nil {
|
||||||
|
r0 = ret.Get(0).(*structs.ExportedServicesConfigEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rf, ok := ret.Get(1).(func(context.Context, string, *acl.EnterpriseMeta) error); ok {
|
||||||
|
r1 = rf(_a0, _a1, _a2)
|
||||||
|
} else {
|
||||||
|
r1 = ret.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0, r1
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockAggregatedConfig_GetExportedServicesConfigEntry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExportedServicesConfigEntry'
|
||||||
|
type MockAggregatedConfig_GetExportedServicesConfigEntry_Call struct {
|
||||||
|
*mock.Call
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExportedServicesConfigEntry is a helper method to define mock.On call
|
||||||
|
// - _a0 context.Context
|
||||||
|
// - _a1 string
|
||||||
|
// - _a2 *acl.EnterpriseMeta
|
||||||
|
func (_e *MockAggregatedConfig_Expecter) GetExportedServicesConfigEntry(_a0 interface{}, _a1 interface{}, _a2 interface{}) *MockAggregatedConfig_GetExportedServicesConfigEntry_Call {
|
||||||
|
return &MockAggregatedConfig_GetExportedServicesConfigEntry_Call{Call: _e.mock.On("GetExportedServicesConfigEntry", _a0, _a1, _a2)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_GetExportedServicesConfigEntry_Call) Run(run func(_a0 context.Context, _a1 string, _a2 *acl.EnterpriseMeta)) *MockAggregatedConfig_GetExportedServicesConfigEntry_Call {
|
||||||
|
_c.Call.Run(func(args mock.Arguments) {
|
||||||
|
run(args[0].(context.Context), args[1].(string), args[2].(*acl.EnterpriseMeta))
|
||||||
|
})
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_GetExportedServicesConfigEntry_Call) Return(_a0 *structs.ExportedServicesConfigEntry, _a1 error) *MockAggregatedConfig_GetExportedServicesConfigEntry_Call {
|
||||||
|
_c.Call.Return(_a0, _a1)
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_GetExportedServicesConfigEntry_Call) RunAndReturn(run func(context.Context, string, *acl.EnterpriseMeta) (*structs.ExportedServicesConfigEntry, error)) *MockAggregatedConfig_GetExportedServicesConfigEntry_Call {
|
||||||
|
_c.Call.Return(run)
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start provides a mock function with given fields: _a0
|
||||||
|
func (_m *MockAggregatedConfig) Start(_a0 context.Context) {
|
||||||
|
_m.Called(_a0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockAggregatedConfig_Start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Start'
|
||||||
|
type MockAggregatedConfig_Start_Call struct {
|
||||||
|
*mock.Call
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start is a helper method to define mock.On call
|
||||||
|
// - _a0 context.Context
|
||||||
|
func (_e *MockAggregatedConfig_Expecter) Start(_a0 interface{}) *MockAggregatedConfig_Start_Call {
|
||||||
|
return &MockAggregatedConfig_Start_Call{Call: _e.mock.On("Start", _a0)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_Start_Call) Run(run func(_a0 context.Context)) *MockAggregatedConfig_Start_Call {
|
||||||
|
_c.Call.Run(func(args mock.Arguments) {
|
||||||
|
run(args[0].(context.Context))
|
||||||
|
})
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_Start_Call) Return() *MockAggregatedConfig_Start_Call {
|
||||||
|
_c.Call.Return()
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_Start_Call) RunAndReturn(run func(context.Context)) *MockAggregatedConfig_Start_Call {
|
||||||
|
_c.Call.Return(run)
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteExportedServicesConfigEntry provides a mock function with given fields: _a0, _a1
|
||||||
|
func (_m *MockAggregatedConfig) WriteExportedServicesConfigEntry(_a0 context.Context, _a1 *structs.ExportedServicesConfigEntry) error {
|
||||||
|
ret := _m.Called(_a0, _a1)
|
||||||
|
|
||||||
|
var r0 error
|
||||||
|
if rf, ok := ret.Get(0).(func(context.Context, *structs.ExportedServicesConfigEntry) error); ok {
|
||||||
|
r0 = rf(_a0, _a1)
|
||||||
|
} else {
|
||||||
|
r0 = ret.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockAggregatedConfig_WriteExportedServicesConfigEntry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteExportedServicesConfigEntry'
|
||||||
|
type MockAggregatedConfig_WriteExportedServicesConfigEntry_Call struct {
|
||||||
|
*mock.Call
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteExportedServicesConfigEntry is a helper method to define mock.On call
|
||||||
|
// - _a0 context.Context
|
||||||
|
// - _a1 *structs.ExportedServicesConfigEntry
|
||||||
|
func (_e *MockAggregatedConfig_Expecter) WriteExportedServicesConfigEntry(_a0 interface{}, _a1 interface{}) *MockAggregatedConfig_WriteExportedServicesConfigEntry_Call {
|
||||||
|
return &MockAggregatedConfig_WriteExportedServicesConfigEntry_Call{Call: _e.mock.On("WriteExportedServicesConfigEntry", _a0, _a1)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_WriteExportedServicesConfigEntry_Call) Run(run func(_a0 context.Context, _a1 *structs.ExportedServicesConfigEntry)) *MockAggregatedConfig_WriteExportedServicesConfigEntry_Call {
|
||||||
|
_c.Call.Run(func(args mock.Arguments) {
|
||||||
|
run(args[0].(context.Context), args[1].(*structs.ExportedServicesConfigEntry))
|
||||||
|
})
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_WriteExportedServicesConfigEntry_Call) Return(_a0 error) *MockAggregatedConfig_WriteExportedServicesConfigEntry_Call {
|
||||||
|
_c.Call.Return(_a0)
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_c *MockAggregatedConfig_WriteExportedServicesConfigEntry_Call) RunAndReturn(run func(context.Context, *structs.ExportedServicesConfigEntry) error) *MockAggregatedConfig_WriteExportedServicesConfigEntry_Call {
|
||||||
|
_c.Call.Return(run)
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockConstructorTestingTNewMockAggregatedConfig interface {
|
||||||
|
mock.TestingT
|
||||||
|
Cleanup(func())
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockAggregatedConfig creates a new instance of MockAggregatedConfig. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||||
|
func NewMockAggregatedConfig(t mockConstructorTestingTNewMockAggregatedConfig) *MockAggregatedConfig {
|
||||||
|
mock := &MockAggregatedConfig{}
|
||||||
|
mock.Mock.Test(t)
|
||||||
|
|
||||||
|
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||||
|
|
||||||
|
return mock
|
||||||
|
}
|
Loading…
Reference in New Issue