mirror of
https://github.com/status-im/consul.git
synced 2025-02-16 15:47:21 +00:00
added exported svc controller (#19589)
* added exported svc controller * added license headers
This commit is contained in:
parent
4d64ef0961
commit
005e1b9926
@ -20,6 +20,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/armon/go-metrics"
|
"github.com/armon/go-metrics"
|
||||||
|
"github.com/hashicorp/consul/internal/auth"
|
||||||
|
"github.com/hashicorp/consul/internal/mesh"
|
||||||
|
"github.com/hashicorp/consul/internal/multicluster"
|
||||||
"github.com/hashicorp/go-connlimit"
|
"github.com/hashicorp/go-connlimit"
|
||||||
"github.com/hashicorp/go-hclog"
|
"github.com/hashicorp/go-hclog"
|
||||||
"github.com/hashicorp/go-memdb"
|
"github.com/hashicorp/go-memdb"
|
||||||
@ -70,10 +73,8 @@ import (
|
|||||||
"github.com/hashicorp/consul/agent/rpc/peering"
|
"github.com/hashicorp/consul/agent/rpc/peering"
|
||||||
"github.com/hashicorp/consul/agent/structs"
|
"github.com/hashicorp/consul/agent/structs"
|
||||||
"github.com/hashicorp/consul/agent/token"
|
"github.com/hashicorp/consul/agent/token"
|
||||||
"github.com/hashicorp/consul/internal/auth"
|
|
||||||
"github.com/hashicorp/consul/internal/catalog"
|
"github.com/hashicorp/consul/internal/catalog"
|
||||||
"github.com/hashicorp/consul/internal/controller"
|
"github.com/hashicorp/consul/internal/controller"
|
||||||
"github.com/hashicorp/consul/internal/mesh"
|
|
||||||
proxysnapshot "github.com/hashicorp/consul/internal/mesh/proxy-snapshot"
|
proxysnapshot "github.com/hashicorp/consul/internal/mesh/proxy-snapshot"
|
||||||
"github.com/hashicorp/consul/internal/resource"
|
"github.com/hashicorp/consul/internal/resource"
|
||||||
"github.com/hashicorp/consul/internal/resource/demo"
|
"github.com/hashicorp/consul/internal/resource/demo"
|
||||||
@ -945,7 +946,7 @@ func (s *Server) registerControllers(deps Deps, proxyUpdater ProxyUpdater) error
|
|||||||
|
|
||||||
if s.useV2Resources {
|
if s.useV2Resources {
|
||||||
catalog.RegisterControllers(s.controllerManager, catalog.DefaultControllerDependencies())
|
catalog.RegisterControllers(s.controllerManager, catalog.DefaultControllerDependencies())
|
||||||
|
multicluster.RegisterControllers(s.controllerManager)
|
||||||
defaultAllow, err := s.config.ACLResolverSettings.IsDefaultAllow()
|
defaultAllow, err := s.config.ACLResolverSettings.IsDefaultAllow()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -43,4 +43,8 @@ flowchart TD
|
|||||||
mesh/v2beta1/proxystatetemplate --> mesh/v2beta1/computedproxyconfiguration
|
mesh/v2beta1/proxystatetemplate --> mesh/v2beta1/computedproxyconfiguration
|
||||||
mesh/v2beta1/proxystatetemplate --> mesh/v2beta1/computedroutes
|
mesh/v2beta1/proxystatetemplate --> mesh/v2beta1/computedroutes
|
||||||
mesh/v2beta1/tcproute
|
mesh/v2beta1/tcproute
|
||||||
|
multicluster/v2beta1/computedexportedservices --> catalog/v2beta1/service
|
||||||
|
multicluster/v2beta1/computedexportedservices --> multicluster/v2beta1/exportedservices
|
||||||
|
multicluster/v2beta1/computedexportedservices --> multicluster/v2beta1/namespaceexportedservices
|
||||||
|
multicluster/v2beta1/computedexportedservices --> multicluster/v2beta1/partitionexportedservices
|
||||||
```
|
```
|
@ -4,6 +4,8 @@
|
|||||||
package multicluster
|
package multicluster
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/hashicorp/consul/internal/controller"
|
||||||
|
"github.com/hashicorp/consul/internal/multicluster/internal/controllers"
|
||||||
"github.com/hashicorp/consul/internal/multicluster/internal/types"
|
"github.com/hashicorp/consul/internal/multicluster/internal/types"
|
||||||
"github.com/hashicorp/consul/internal/resource"
|
"github.com/hashicorp/consul/internal/resource"
|
||||||
)
|
)
|
||||||
@ -20,3 +22,9 @@ var (
|
|||||||
func RegisterTypes(r resource.Registry) {
|
func RegisterTypes(r resource.Registry) {
|
||||||
types.Register(r)
|
types.Register(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterControllers registers controllers for the multicluster types with
|
||||||
|
// the given controller Manager.
|
||||||
|
func RegisterControllers(mgr *controller.Manager) {
|
||||||
|
controllers.Register(mgr)
|
||||||
|
}
|
||||||
|
@ -0,0 +1,295 @@
|
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package exportedservices
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"google.golang.org/protobuf/types/known/anypb"
|
||||||
|
|
||||||
|
"github.com/hashicorp/consul/internal/controller"
|
||||||
|
"github.com/hashicorp/consul/internal/multicluster/internal/types"
|
||||||
|
"github.com/hashicorp/consul/internal/resource"
|
||||||
|
"github.com/hashicorp/consul/internal/storage"
|
||||||
|
pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1"
|
||||||
|
pbmulticluster "github.com/hashicorp/consul/proto-public/pbmulticluster/v2beta1"
|
||||||
|
"github.com/hashicorp/consul/proto-public/pbresource"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Controller() controller.Controller {
|
||||||
|
|
||||||
|
return controller.ForType(pbmulticluster.ComputedExportedServicesType).
|
||||||
|
WithWatch(pbmulticluster.ExportedServicesType, ReplaceTypeForComputedExportedServices()).
|
||||||
|
WithWatch(pbcatalog.ServiceType, ReplaceTypeForComputedExportedServices()).
|
||||||
|
WithWatch(pbmulticluster.NamespaceExportedServicesType, ReplaceTypeForComputedExportedServices()).
|
||||||
|
WithWatch(pbmulticluster.PartitionExportedServicesType, ReplaceTypeForComputedExportedServices()).
|
||||||
|
WithReconciler(&reconciler{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type reconciler struct{}
|
||||||
|
|
||||||
|
type serviceExports struct {
|
||||||
|
ref *pbresource.Reference
|
||||||
|
partitions map[string]struct{}
|
||||||
|
peers map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *reconciler) Reconcile(ctx context.Context, rt controller.Runtime, req controller.Request) error {
|
||||||
|
rt.Logger = rt.Logger.With("resource-id", req.ID)
|
||||||
|
rt.Logger.Trace("reconciling exported services")
|
||||||
|
exportedServices, err := resource.ListDecodedResource[*pbmulticluster.ExportedServices](ctx, rt.Client, &pbresource.ListRequest{
|
||||||
|
Tenancy: &pbresource.Tenancy{
|
||||||
|
Namespace: storage.Wildcard,
|
||||||
|
Partition: req.ID.Tenancy.Partition,
|
||||||
|
PeerName: resource.DefaultPeerName,
|
||||||
|
},
|
||||||
|
Type: pbmulticluster.ExportedServicesType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
rt.Logger.Error("error getting exported services", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
namespaceExportedServices, err := resource.ListDecodedResource[*pbmulticluster.NamespaceExportedServices](ctx, rt.Client, &pbresource.ListRequest{
|
||||||
|
Tenancy: &pbresource.Tenancy{
|
||||||
|
Namespace: storage.Wildcard,
|
||||||
|
Partition: req.ID.Tenancy.Partition,
|
||||||
|
PeerName: resource.DefaultPeerName,
|
||||||
|
},
|
||||||
|
Type: pbmulticluster.NamespaceExportedServicesType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
rt.Logger.Error("error getting namespace exported services", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
partitionedExportedServices, err := resource.ListDecodedResource[*pbmulticluster.PartitionExportedServices](ctx, rt.Client, &pbresource.ListRequest{
|
||||||
|
Tenancy: &pbresource.Tenancy{
|
||||||
|
Partition: req.ID.Tenancy.Partition,
|
||||||
|
PeerName: resource.DefaultPeerName,
|
||||||
|
},
|
||||||
|
Type: pbmulticluster.PartitionExportedServicesType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
rt.Logger.Error("error getting partitioned exported services", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
//TODO: we are listing more services than required. this should be optimized
|
||||||
|
services, err := resource.ListDecodedResource[*pbcatalog.Service](ctx, rt.Client, &pbresource.ListRequest{
|
||||||
|
Tenancy: &pbresource.Tenancy{
|
||||||
|
Namespace: storage.Wildcard,
|
||||||
|
Partition: req.ID.Tenancy.Partition,
|
||||||
|
PeerName: resource.DefaultPeerName,
|
||||||
|
},
|
||||||
|
Type: pbcatalog.ServiceType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
rt.Logger.Error("error getting services", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
builder := newExportedServicesBuilder()
|
||||||
|
|
||||||
|
svcs := make(map[resource.ReferenceKey]struct{}, len(services))
|
||||||
|
for _, svc := range services {
|
||||||
|
svcs[resource.NewReferenceKey(svc.Id)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, es := range exportedServices {
|
||||||
|
for _, svc := range es.Data.Services {
|
||||||
|
id := &pbresource.ID{
|
||||||
|
Type: pbcatalog.ServiceType,
|
||||||
|
Tenancy: es.Id.Tenancy,
|
||||||
|
Name: svc,
|
||||||
|
}
|
||||||
|
if _, ok := svcs[resource.NewReferenceKey(id)]; ok {
|
||||||
|
builder.track(id, es.Data.Consumers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, nes := range namespaceExportedServices {
|
||||||
|
for _, svc := range services {
|
||||||
|
if svc.Id.Tenancy.Namespace != nes.Id.Tenancy.Namespace {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
builder.track(svc.Id, nes.Data.Consumers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pes := range partitionedExportedServices {
|
||||||
|
for _, svc := range services {
|
||||||
|
builder.track(svc.Id, pes.Data.Consumers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oldComputedExportedService, err := getOldComputedExportedService(ctx, rt, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newComputedExportedService := builder.build()
|
||||||
|
|
||||||
|
if oldComputedExportedService.GetResource() != nil && newComputedExportedService == nil {
|
||||||
|
rt.Logger.Trace("deleting computed exported services")
|
||||||
|
_, err := rt.Client.Delete(ctx, &pbresource.DeleteRequest{
|
||||||
|
Id: oldComputedExportedService.GetResource().GetId(),
|
||||||
|
Version: oldComputedExportedService.GetResource().GetVersion(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
rt.Logger.Error("error deleting computed exported service", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if proto.Equal(newComputedExportedService, oldComputedExportedService.GetData()) {
|
||||||
|
rt.Logger.Trace("skip writing computed exported services")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
newComputedExportedServiceData, err := anypb.New(newComputedExportedService)
|
||||||
|
if err != nil {
|
||||||
|
rt.Logger.Error("error marshalling latest computed exported service", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rt.Logger.Trace("writing computed exported services")
|
||||||
|
_, err = rt.Client.Write(ctx, &pbresource.WriteRequest{
|
||||||
|
Resource: &pbresource.Resource{
|
||||||
|
Id: req.ID,
|
||||||
|
Owner: nil,
|
||||||
|
Data: newComputedExportedServiceData,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
rt.Logger.Error("error writing computed exported service", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReplaceTypeForComputedExportedServices() controller.DependencyMapper {
|
||||||
|
return func(_ context.Context, _ controller.Runtime, res *pbresource.Resource) ([]controller.Request, error) {
|
||||||
|
return []controller.Request{
|
||||||
|
{
|
||||||
|
ID: &pbresource.ID{
|
||||||
|
Type: pbmulticluster.ComputedExportedServicesType,
|
||||||
|
Tenancy: &pbresource.Tenancy{
|
||||||
|
Partition: res.Id.Tenancy.Partition,
|
||||||
|
},
|
||||||
|
Name: "global",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOldComputedExportedService(ctx context.Context, rt controller.Runtime, req controller.Request) (*resource.DecodedResource[*pbmulticluster.ComputedExportedServices], error) {
|
||||||
|
computedExpSvcID := &pbresource.ID{
|
||||||
|
Name: types.ComputedExportedServicesName,
|
||||||
|
Type: pbmulticluster.ComputedExportedServicesType,
|
||||||
|
Tenancy: &pbresource.Tenancy{
|
||||||
|
Partition: req.ID.Tenancy.Partition,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
computedExpSvcRes, err := resource.GetDecodedResource[*pbmulticluster.ComputedExportedServices](ctx, rt.Client, computedExpSvcID)
|
||||||
|
if err != nil {
|
||||||
|
rt.Logger.Error("error fetching computed exported service", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return computedExpSvcRes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exportedServicesBuilder struct {
|
||||||
|
data map[resource.ReferenceKey]*serviceExports
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExportedServicesBuilder() *exportedServicesBuilder {
|
||||||
|
return &exportedServicesBuilder{
|
||||||
|
data: make(map[resource.ReferenceKey]*serviceExports),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *exportedServicesBuilder) track(id *pbresource.ID, consumers []*pbmulticluster.ExportedServicesConsumer) error {
|
||||||
|
key := resource.NewReferenceKey(id)
|
||||||
|
exports, ok := b.data[key]
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
exports = &serviceExports{
|
||||||
|
ref: resource.Reference(id, ""),
|
||||||
|
partitions: make(map[string]struct{}),
|
||||||
|
peers: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
b.data[key] = exports
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range consumers {
|
||||||
|
switch v := c.ConsumerTenancy.(type) {
|
||||||
|
case *pbmulticluster.ExportedServicesConsumer_Peer:
|
||||||
|
exports.peers[v.Peer] = struct{}{}
|
||||||
|
case *pbmulticluster.ExportedServicesConsumer_Partition:
|
||||||
|
exports.partitions[v.Partition] = struct{}{}
|
||||||
|
case *pbmulticluster.ExportedServicesConsumer_SamenessGroup:
|
||||||
|
// TODO do we currently validate that sameness groups can't be set?
|
||||||
|
return fmt.Errorf("unexpected export to sameness group %q", v.SamenessGroup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *exportedServicesBuilder) build() *pbmulticluster.ComputedExportedServices {
|
||||||
|
if len(b.data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ces := &pbmulticluster.ComputedExportedServices{
|
||||||
|
Consumers: make([]*pbmulticluster.ComputedExportedService, 0, len(b.data)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, svc := range sortRefValue(b.data) {
|
||||||
|
consumers := make([]*pbmulticluster.ComputedExportedServicesConsumer, 0, len(svc.peers)+len(svc.partitions))
|
||||||
|
|
||||||
|
for _, peer := range sortKeys(svc.peers) {
|
||||||
|
consumers = append(consumers, &pbmulticluster.ComputedExportedServicesConsumer{
|
||||||
|
ConsumerTenancy: &pbmulticluster.ComputedExportedServicesConsumer_Peer{
|
||||||
|
Peer: peer,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, partition := range sortKeys(svc.partitions) {
|
||||||
|
consumers = append(consumers, &pbmulticluster.ComputedExportedServicesConsumer{
|
||||||
|
ConsumerTenancy: &pbmulticluster.ComputedExportedServicesConsumer_Partition{
|
||||||
|
Partition: partition,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ces.Consumers = append(ces.Consumers, &pbmulticluster.ComputedExportedService{
|
||||||
|
TargetRef: svc.ref,
|
||||||
|
Consumers: consumers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ces
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortKeys(m map[string]struct{}) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for key := range m {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortRefValue(m map[resource.ReferenceKey]*serviceExports) []*serviceExports {
|
||||||
|
vals := make([]*serviceExports, 0, len(m))
|
||||||
|
for _, val := range m {
|
||||||
|
vals = append(vals, val)
|
||||||
|
}
|
||||||
|
sort.Slice(vals, func(i, j int) bool {
|
||||||
|
return resource.ReferenceToString(vals[i].ref) < resource.ReferenceToString(vals[j].ref)
|
||||||
|
})
|
||||||
|
return vals
|
||||||
|
}
|
@ -0,0 +1,422 @@
|
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package exportedservices
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
svc "github.com/hashicorp/consul/agent/grpc-external/services/resource"
|
||||||
|
svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing"
|
||||||
|
"github.com/hashicorp/consul/agent/structs"
|
||||||
|
cat "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"
|
||||||
|
pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1"
|
||||||
|
pbmulticluster "github.com/hashicorp/consul/proto-public/pbmulticluster/v2beta1"
|
||||||
|
"github.com/hashicorp/consul/proto-public/pbresource"
|
||||||
|
"github.com/hashicorp/consul/proto/private/prototest"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"github.com/hashicorp/consul/sdk/testutil"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type controllerSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
ctx context.Context
|
||||||
|
client *rtest.Client
|
||||||
|
rt controller.Runtime
|
||||||
|
isEnterprise bool
|
||||||
|
reconciler *reconciler
|
||||||
|
tenancies []*pbresource.Tenancy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *controllerSuite) SetupTest() {
|
||||||
|
suite.ctx = testutil.TestContext(suite.T())
|
||||||
|
suite.tenancies = rtest.TestTenancies()
|
||||||
|
mockTenancyBridge := &svc.MockTenancyBridge{}
|
||||||
|
for _, tenancy := range suite.tenancies {
|
||||||
|
mockTenancyBridge.On("PartitionExists", tenancy.Partition).Return(true, nil)
|
||||||
|
mockTenancyBridge.On("IsPartitionMarkedForDeletion", tenancy.Partition).Return(false, nil)
|
||||||
|
mockTenancyBridge.On("NamespaceExists", tenancy.Partition, tenancy.Namespace).Return(true, nil)
|
||||||
|
mockTenancyBridge.On("IsNamespaceMarkedForDeletion", tenancy.Partition, tenancy.Namespace).Return(false, nil)
|
||||||
|
mockTenancyBridge.On("NamespaceExists", tenancy.Partition, "app").Return(true, nil)
|
||||||
|
mockTenancyBridge.On("IsNamespaceMarkedForDeletion", tenancy.Partition, "app").Return(false, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := svc.Config{
|
||||||
|
TenancyBridge: mockTenancyBridge,
|
||||||
|
}
|
||||||
|
client := svctest.RunResourceServiceWithConfig(suite.T(), config, types.Register, cat.RegisterTypes)
|
||||||
|
suite.client = rtest.NewClient(client)
|
||||||
|
suite.rt = controller.Runtime{
|
||||||
|
Client: suite.client,
|
||||||
|
Logger: testutil.Logger(suite.T()),
|
||||||
|
}
|
||||||
|
suite.reconciler = &reconciler{}
|
||||||
|
suite.isEnterprise = (structs.NodeEnterpriseMetaInDefaultPartition().PartitionOrEmpty() == "default")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *controllerSuite) TestReconcile() {
|
||||||
|
suite.runTestCaseWithTenancies(suite.reconcileTest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestController(t *testing.T) {
|
||||||
|
suite.Run(t, new(controllerSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 removeService(consumers []*pbmulticluster.ComputedExportedService, ref *pbresource.Reference) []*pbmulticluster.ComputedExportedService {
|
||||||
|
newConsumers := []*pbmulticluster.ComputedExportedService{}
|
||||||
|
for _, consumer := range consumers {
|
||||||
|
if !proto.Equal(consumer.TargetRef, ref) {
|
||||||
|
newConsumers = append(newConsumers, consumer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newConsumers
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *controllerSuite) getComputedExportedSvc(id *pbresource.ID) *pbmulticluster.ComputedExportedServices {
|
||||||
|
computedExportedService := suite.client.RequireResourceExists(suite.T(), id)
|
||||||
|
decodedComputedExportedService := rtest.MustDecode[*pbmulticluster.ComputedExportedServices](suite.T(), computedExportedService)
|
||||||
|
return decodedComputedExportedService.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
var svc0, svc2, svc3, svc4, svc5 *pbresource.Resource
|
||||||
|
|
||||||
|
func (suite *controllerSuite) reconcileTest(tenancy *pbresource.Tenancy) {
|
||||||
|
id := rtest.Resource(pbmulticluster.ComputedExportedServicesType, "global").WithTenancy(&pbresource.Tenancy{
|
||||||
|
Partition: tenancy.Partition,
|
||||||
|
}).ID()
|
||||||
|
require.NotNil(suite.T(), id)
|
||||||
|
|
||||||
|
rtest.Resource(pbcatalog.ServiceType, "svc1").
|
||||||
|
WithData(suite.T(), &pbcatalog.Service{
|
||||||
|
Ports: []*pbcatalog.ServicePort{
|
||||||
|
{TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP},
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
WithTenancy(tenancy).
|
||||||
|
Write(suite.T(), suite.client)
|
||||||
|
|
||||||
|
exportedSvcData := &pbmulticluster.ExportedServices{
|
||||||
|
Services: []string{"svc1", "svcx"},
|
||||||
|
Consumers: []*pbmulticluster.ExportedServicesConsumer{{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_Peer{
|
||||||
|
Peer: "peer-0",
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if suite.isEnterprise {
|
||||||
|
exportedSvcData.Consumers = append(exportedSvcData.Consumers, &pbmulticluster.ExportedServicesConsumer{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_Partition{
|
||||||
|
Partition: "part-0",
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
expSvc := rtest.Resource(pbmulticluster.ExportedServicesType, "expsvc").WithData(suite.T(), exportedSvcData).WithTenancy(tenancy).Write(suite.T(), suite.client)
|
||||||
|
require.NotNil(suite.T(), expSvc)
|
||||||
|
|
||||||
|
err := suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: id})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
actualComputedExportedService := suite.getComputedExportedSvc(id)
|
||||||
|
expectedComputedExportedService := getExpectation(tenancy, suite.isEnterprise, 0)
|
||||||
|
prototest.AssertDeepEqual(suite.T(), expectedComputedExportedService, actualComputedExportedService)
|
||||||
|
|
||||||
|
svc2 = rtest.Resource(pbcatalog.ServiceType, "svc2").
|
||||||
|
WithData(suite.T(), &pbcatalog.Service{
|
||||||
|
Ports: []*pbcatalog.ServicePort{
|
||||||
|
{TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP},
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
WithTenancy(tenancy).
|
||||||
|
Write(suite.T(), suite.client)
|
||||||
|
|
||||||
|
svc0 = rtest.Resource(pbcatalog.ServiceType, "svc0").
|
||||||
|
WithData(suite.T(), &pbcatalog.Service{
|
||||||
|
Ports: []*pbcatalog.ServicePort{
|
||||||
|
{TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP},
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
WithTenancy(&pbresource.Tenancy{
|
||||||
|
Partition: tenancy.Partition,
|
||||||
|
Namespace: "app",
|
||||||
|
}).
|
||||||
|
Write(suite.T(), suite.client)
|
||||||
|
|
||||||
|
exportedNamespaceSvcData := &pbmulticluster.NamespaceExportedServices{
|
||||||
|
Consumers: []*pbmulticluster.ExportedServicesConsumer{{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_Peer{
|
||||||
|
Peer: "peer-1",
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
nameexpSvc := rtest.Resource(pbmulticluster.NamespaceExportedServicesType, "namesvc").WithData(suite.T(), exportedNamespaceSvcData).WithTenancy(tenancy).Write(suite.T(), suite.client)
|
||||||
|
require.NotNil(suite.T(), nameexpSvc)
|
||||||
|
err = suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: id})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
actualComputedExportedService = suite.getComputedExportedSvc(id)
|
||||||
|
expectedComputedExportedService = getExpectation(tenancy, suite.isEnterprise, 1)
|
||||||
|
prototest.AssertDeepEqual(suite.T(), expectedComputedExportedService, actualComputedExportedService)
|
||||||
|
|
||||||
|
svc3 = rtest.Resource(pbcatalog.ServiceType, "svc3").
|
||||||
|
WithData(suite.T(), &pbcatalog.Service{
|
||||||
|
Ports: []*pbcatalog.ServicePort{
|
||||||
|
{TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP},
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
WithTenancy(tenancy).
|
||||||
|
Write(suite.T(), suite.client)
|
||||||
|
|
||||||
|
err = suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: id})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
actualComputedExportedService = suite.getComputedExportedSvc(id)
|
||||||
|
expectedComputedExportedService = getExpectation(tenancy, suite.isEnterprise, 2)
|
||||||
|
prototest.AssertDeepEqual(suite.T(), expectedComputedExportedService, actualComputedExportedService)
|
||||||
|
|
||||||
|
suite.client.MustDelete(suite.T(), svc3.Id)
|
||||||
|
err = suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: id})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
actualComputedExportedService = suite.getComputedExportedSvc(id)
|
||||||
|
expectedComputedExportedService = getExpectation(tenancy, suite.isEnterprise, 3)
|
||||||
|
prototest.AssertDeepEqual(suite.T(), expectedComputedExportedService, actualComputedExportedService)
|
||||||
|
|
||||||
|
partitionedExportedSvcData := &pbmulticluster.PartitionExportedServices{
|
||||||
|
Consumers: []*pbmulticluster.ExportedServicesConsumer{{ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_Peer{
|
||||||
|
Peer: "peer-1",
|
||||||
|
}}, {ConsumerTenancy: &pbmulticluster.ExportedServicesConsumer_Peer{
|
||||||
|
Peer: "peer-2",
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
partexpSvc := rtest.Resource(pbmulticluster.PartitionExportedServicesType, "partsvc").WithData(suite.T(), partitionedExportedSvcData).WithTenancy(&pbresource.Tenancy{Partition: tenancy.Partition}).Write(suite.T(), suite.client)
|
||||||
|
require.NotNil(suite.T(), partexpSvc)
|
||||||
|
|
||||||
|
err = suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: id})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
actualComputedExportedService = suite.getComputedExportedSvc(id)
|
||||||
|
expectedComputedExportedService = getExpectation(tenancy, suite.isEnterprise, 4)
|
||||||
|
prototest.AssertDeepEqual(suite.T(), expectedComputedExportedService, actualComputedExportedService)
|
||||||
|
|
||||||
|
svc4 = rtest.Resource(pbcatalog.ServiceType, "svc4").
|
||||||
|
WithData(suite.T(), &pbcatalog.Service{
|
||||||
|
Ports: []*pbcatalog.ServicePort{
|
||||||
|
{TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP},
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
WithTenancy(&pbresource.Tenancy{
|
||||||
|
Partition: tenancy.Partition,
|
||||||
|
Namespace: "app",
|
||||||
|
}).
|
||||||
|
Write(suite.T(), suite.client)
|
||||||
|
|
||||||
|
err = suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: id})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
actualComputedExportedService = suite.getComputedExportedSvc(id)
|
||||||
|
expectedComputedExportedService = getExpectation(tenancy, suite.isEnterprise, 5)
|
||||||
|
prototest.AssertDeepEqual(suite.T(), expectedComputedExportedService, actualComputedExportedService)
|
||||||
|
|
||||||
|
suite.client.MustDelete(suite.T(), svc4.Id)
|
||||||
|
err = suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: id})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
actualComputedExportedService = suite.getComputedExportedSvc(id)
|
||||||
|
expectedComputedExportedService = getExpectation(tenancy, suite.isEnterprise, 6)
|
||||||
|
prototest.AssertDeepEqual(suite.T(), expectedComputedExportedService, actualComputedExportedService)
|
||||||
|
|
||||||
|
svc5 = rtest.Resource(pbcatalog.ServiceType, "svc5").
|
||||||
|
WithData(suite.T(), &pbcatalog.Service{
|
||||||
|
Ports: []*pbcatalog.ServicePort{
|
||||||
|
{TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP},
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
WithTenancy(tenancy).
|
||||||
|
Write(suite.T(), suite.client)
|
||||||
|
|
||||||
|
err = suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: id})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
actualComputedExportedService = suite.getComputedExportedSvc(id)
|
||||||
|
expectedComputedExportedService = getExpectation(tenancy, suite.isEnterprise, 7)
|
||||||
|
prototest.AssertDeepEqual(suite.T(), expectedComputedExportedService, actualComputedExportedService)
|
||||||
|
|
||||||
|
suite.client.MustDelete(suite.T(), partexpSvc.Id)
|
||||||
|
err = suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: id})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
actualComputedExportedService = suite.getComputedExportedSvc(id)
|
||||||
|
expectedComputedExportedService = getExpectation(tenancy, suite.isEnterprise, 8)
|
||||||
|
|
||||||
|
prototest.AssertDeepEqual(suite.T(), expectedComputedExportedService, actualComputedExportedService)
|
||||||
|
|
||||||
|
suite.client.MustDelete(suite.T(), nameexpSvc.Id)
|
||||||
|
err = suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: id})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
actualComputedExportedService = suite.getComputedExportedSvc(id)
|
||||||
|
|
||||||
|
expectedComputedExportedService = getExpectation(tenancy, suite.isEnterprise, 9)
|
||||||
|
prototest.AssertDeepEqual(suite.T(), expectedComputedExportedService, actualComputedExportedService)
|
||||||
|
|
||||||
|
suite.client.MustDelete(suite.T(), expSvc.Id)
|
||||||
|
err = suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: id})
|
||||||
|
require.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
suite.client.RequireResourceNotFound(suite.T(), id)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func getExpectation(tenancy *pbresource.Tenancy, isEnterprise bool, testCase int) *pbmulticluster.ComputedExportedServices {
|
||||||
|
makeCES := func(consumers ...*pbmulticluster.ComputedExportedService) *pbmulticluster.ComputedExportedServices {
|
||||||
|
return &pbmulticluster.ComputedExportedServices{
|
||||||
|
Consumers: consumers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
makeConsumer := func(ref *pbresource.Reference, consumers ...*pbmulticluster.ComputedExportedServicesConsumer) *pbmulticluster.ComputedExportedService {
|
||||||
|
var actual []*pbmulticluster.ComputedExportedServicesConsumer
|
||||||
|
for _, c := range consumers {
|
||||||
|
_, ok := c.ConsumerTenancy.(*pbmulticluster.ComputedExportedServicesConsumer_Partition)
|
||||||
|
if (isEnterprise && ok) || !ok {
|
||||||
|
actual = append(actual, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pbmulticluster.ComputedExportedService{
|
||||||
|
TargetRef: ref,
|
||||||
|
Consumers: actual,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svc0Ref := &pbresource.Reference{
|
||||||
|
Type: pbcatalog.ServiceType,
|
||||||
|
Tenancy: &pbresource.Tenancy{
|
||||||
|
Partition: tenancy.Partition,
|
||||||
|
Namespace: "app",
|
||||||
|
PeerName: resource.DefaultPeerName,
|
||||||
|
},
|
||||||
|
Name: "svc0",
|
||||||
|
}
|
||||||
|
svc1Ref := &pbresource.Reference{
|
||||||
|
Type: pbcatalog.ServiceType,
|
||||||
|
Tenancy: tenancy,
|
||||||
|
Name: "svc1",
|
||||||
|
}
|
||||||
|
svc2Ref := &pbresource.Reference{
|
||||||
|
Type: pbcatalog.ServiceType,
|
||||||
|
Tenancy: tenancy,
|
||||||
|
Name: "svc2",
|
||||||
|
}
|
||||||
|
svc3Ref := &pbresource.Reference{
|
||||||
|
Type: pbcatalog.ServiceType,
|
||||||
|
Tenancy: tenancy,
|
||||||
|
Name: "svc3",
|
||||||
|
}
|
||||||
|
svc4Ref := &pbresource.Reference{
|
||||||
|
Type: pbcatalog.ServiceType,
|
||||||
|
Tenancy: &pbresource.Tenancy{
|
||||||
|
Partition: tenancy.Partition,
|
||||||
|
Namespace: "app",
|
||||||
|
PeerName: resource.DefaultPeerName,
|
||||||
|
},
|
||||||
|
Name: "svc4",
|
||||||
|
}
|
||||||
|
svc5Ref := &pbresource.Reference{
|
||||||
|
Type: pbcatalog.ServiceType,
|
||||||
|
Tenancy: tenancy,
|
||||||
|
Name: "svc5",
|
||||||
|
}
|
||||||
|
|
||||||
|
peer0Consumer := &pbmulticluster.ComputedExportedServicesConsumer{
|
||||||
|
ConsumerTenancy: &pbmulticluster.ComputedExportedServicesConsumer_Peer{
|
||||||
|
Peer: "peer-0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
peer1Consumer := &pbmulticluster.ComputedExportedServicesConsumer{
|
||||||
|
ConsumerTenancy: &pbmulticluster.ComputedExportedServicesConsumer_Peer{
|
||||||
|
Peer: "peer-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
peer2Consumer := &pbmulticluster.ComputedExportedServicesConsumer{
|
||||||
|
ConsumerTenancy: &pbmulticluster.ComputedExportedServicesConsumer_Peer{
|
||||||
|
Peer: "peer-2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
part0Consumer := &pbmulticluster.ComputedExportedServicesConsumer{
|
||||||
|
ConsumerTenancy: &pbmulticluster.ComputedExportedServicesConsumer_Partition{
|
||||||
|
Partition: "part-0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
switch testCase {
|
||||||
|
case 0:
|
||||||
|
return makeCES(makeConsumer(svc1Ref, peer0Consumer, part0Consumer))
|
||||||
|
case 1:
|
||||||
|
return makeCES(
|
||||||
|
makeConsumer(svc1Ref, peer0Consumer, peer1Consumer, part0Consumer),
|
||||||
|
makeConsumer(svc2Ref, peer1Consumer),
|
||||||
|
)
|
||||||
|
case 2:
|
||||||
|
return makeCES(
|
||||||
|
makeConsumer(svc1Ref, peer0Consumer, peer1Consumer, part0Consumer),
|
||||||
|
makeConsumer(svc2Ref, peer1Consumer),
|
||||||
|
makeConsumer(svc3Ref, peer1Consumer),
|
||||||
|
)
|
||||||
|
case 3:
|
||||||
|
return makeCES(
|
||||||
|
makeConsumer(svc1Ref, peer0Consumer, peer1Consumer, part0Consumer),
|
||||||
|
makeConsumer(svc2Ref, peer1Consumer),
|
||||||
|
)
|
||||||
|
case 4:
|
||||||
|
return makeCES(
|
||||||
|
makeConsumer(svc0Ref, peer1Consumer, peer2Consumer),
|
||||||
|
makeConsumer(svc1Ref, peer0Consumer, peer1Consumer, peer2Consumer, part0Consumer),
|
||||||
|
makeConsumer(svc2Ref, peer1Consumer, peer2Consumer),
|
||||||
|
)
|
||||||
|
case 5:
|
||||||
|
return makeCES(
|
||||||
|
makeConsumer(svc0Ref, peer1Consumer, peer2Consumer),
|
||||||
|
makeConsumer(svc4Ref, peer1Consumer, peer2Consumer),
|
||||||
|
makeConsumer(svc1Ref, peer0Consumer, peer1Consumer, peer2Consumer, part0Consumer),
|
||||||
|
makeConsumer(svc2Ref, peer1Consumer, peer2Consumer),
|
||||||
|
)
|
||||||
|
case 6:
|
||||||
|
return makeCES(
|
||||||
|
makeConsumer(svc0Ref, peer1Consumer, peer2Consumer),
|
||||||
|
makeConsumer(svc1Ref, peer0Consumer, peer1Consumer, peer2Consumer, part0Consumer),
|
||||||
|
makeConsumer(svc2Ref, peer1Consumer, peer2Consumer),
|
||||||
|
)
|
||||||
|
case 7:
|
||||||
|
return makeCES(
|
||||||
|
makeConsumer(svc0Ref, peer1Consumer, peer2Consumer),
|
||||||
|
makeConsumer(svc1Ref, peer0Consumer, peer1Consumer, peer2Consumer, part0Consumer),
|
||||||
|
makeConsumer(svc2Ref, peer1Consumer, peer2Consumer),
|
||||||
|
makeConsumer(svc5Ref, peer1Consumer, peer2Consumer),
|
||||||
|
)
|
||||||
|
case 8:
|
||||||
|
return makeCES(
|
||||||
|
makeConsumer(svc1Ref, peer0Consumer, peer1Consumer, part0Consumer),
|
||||||
|
makeConsumer(svc2Ref, peer1Consumer),
|
||||||
|
makeConsumer(svc5Ref, peer1Consumer),
|
||||||
|
)
|
||||||
|
case 9:
|
||||||
|
return makeCES(
|
||||||
|
makeConsumer(svc1Ref, peer0Consumer, part0Consumer),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
13
internal/multicluster/internal/controllers/register.go
Normal file
13
internal/multicluster/internal/controllers/register.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/hashicorp/consul/internal/controller"
|
||||||
|
"github.com/hashicorp/consul/internal/multicluster/internal/controllers/exportedservices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Register(mgr *controller.Manager) {
|
||||||
|
mgr.Register(exportedservices.Controller())
|
||||||
|
}
|
@ -70,3 +70,20 @@ func GetDecodedResource[T proto.Message](ctx context.Context, client pbresource.
|
|||||||
}
|
}
|
||||||
return Decode[T](rsp.Resource)
|
return Decode[T](rsp.Resource)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListDecodedResource[T proto.Message](ctx context.Context, client pbresource.ResourceServiceClient, req *pbresource.ListRequest) ([]*DecodedResource[T], error) {
|
||||||
|
rsp, err := client.List(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]*DecodedResource[T], len(rsp.Resources))
|
||||||
|
for idx, rsc := range rsp.Resources {
|
||||||
|
d, err := Decode[T](rsc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results[idx] = d
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user