mirror of
https://github.com/k8sgpt-ai/k8sgpt.git
synced 2025-04-27 11:11:31 +00:00
feat: integration refactor (#684)
* feat: more significant refactor Signed-off-by: Alex Jones <alexsimonjones@gmail.com> * feat: more significant refactor Signed-off-by: Alex Jones <alexsimonjones@gmail.com> * feat: reworked the integration activate/deactivation Signed-off-by: Alex Jones <alexsimonjones@gmail.com> * chore: updated schema for list integrations Signed-off-by: Alex Jones <alexsimonjones@gmail.com> * fix: error with incorrect error being swallowed Signed-off-by: Alex Jones <alexsimonjones@gmail.com> * feat: added namespace check Signed-off-by: Alex Jones <alexsimonjones@gmail.com> * chore: fixed issue with namespace and skip install validation Signed-off-by: Alex Jones <alexsimonjones@gmail.com> --------- Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
This commit is contained in:
parent
ddeff9fae4
commit
69fe2db8ac
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
|
.idea
|
||||||
__debug*
|
__debug*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
k8sgpt*
|
k8sgpt*
|
||||||
|
4
go.mod
4
go.mod
@ -24,8 +24,8 @@ require (
|
|||||||
require github.com/adrg/xdg v0.4.0
|
require github.com/adrg/xdg v0.4.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
buf.build/gen/go/k8sgpt-ai/k8sgpt/grpc/go v1.3.0-20230919114723-34e017906403.1
|
buf.build/gen/go/k8sgpt-ai/k8sgpt/grpc/go v1.3.0-20230927080702-a2be8a73637d.1
|
||||||
buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go v1.31.0-20230919114723-34e017906403.1
|
buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go v1.31.0-20230927080702-a2be8a73637d.1
|
||||||
github.com/aws/aws-sdk-go v1.45.16
|
github.com/aws/aws-sdk-go v1.45.16
|
||||||
github.com/cohere-ai/cohere-go v0.2.0
|
github.com/cohere-ai/cohere-go v0.2.0
|
||||||
)
|
)
|
||||||
|
10
go.sum
10
go.sum
@ -1,8 +1,8 @@
|
|||||||
buf.build/gen/go/k8sgpt-ai/k8sgpt/grpc/go v1.3.0-20230919114723-34e017906403.1 h1:OMpJ48yTsJ12DDJlhpNXTZOfNEfkrcAwGqgSvL1vg7U=
|
buf.build/gen/go/k8sgpt-ai/k8sgpt/grpc/go v1.3.0-20230927080702-a2be8a73637d.1 h1:uXlT8FiRD+JL0qzZJ0m5Zmw5HpKyDFs204y27zuT7RA=
|
||||||
buf.build/gen/go/k8sgpt-ai/k8sgpt/grpc/go v1.3.0-20230919114723-34e017906403.1/go.mod h1:cc42fuhIhL3qTsCrT4dK0kZ5u6hm02WJraREmSVZHmA=
|
buf.build/gen/go/k8sgpt-ai/k8sgpt/grpc/go v1.3.0-20230927080702-a2be8a73637d.1/go.mod h1:p9CUiOwgt2bvcr0goNK7NgMfButIVGhKnv8cyWW7FOM=
|
||||||
buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go v1.28.1-20230919114723-34e017906403.4/go.mod h1:i/s4ALHwKvjA1oGNKpoHg0FpEOTbufoOm/NdTE6YQAE=
|
buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go v1.28.1-20230927080702-a2be8a73637d.4/go.mod h1:i/s4ALHwKvjA1oGNKpoHg0FpEOTbufoOm/NdTE6YQAE=
|
||||||
buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go v1.31.0-20230919114723-34e017906403.1 h1:rn//G20ZMgHwnfl7shj5zmpDgzS8aZsoVkeJ7+fMkfo=
|
buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go v1.31.0-20230927080702-a2be8a73637d.1 h1:Snnz9mUZNxMFpd+l5m1zaVdIVAplVmdFxYVn5/f4UoI=
|
||||||
buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go v1.31.0-20230919114723-34e017906403.1/go.mod h1:gtnk2yAUexdY5nTuUg0SH5WCCGvpKzr7pd3Xbi7MWjE=
|
buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go v1.31.0-20230927080702-a2be8a73637d.1/go.mod h1:gtnk2yAUexdY5nTuUg0SH5WCCGvpKzr7pd3Xbi7MWjE=
|
||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||||
|
9
pkg/cache/cache.go
vendored
9
pkg/cache/cache.go
vendored
@ -1,7 +1,8 @@
|
|||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
@ -63,17 +64,17 @@ func RemoveRemoteCache(bucketName string) error {
|
|||||||
var cacheInfo CacheProvider
|
var cacheInfo CacheProvider
|
||||||
err := viper.UnmarshalKey("cache", &cacheInfo)
|
err := viper.UnmarshalKey("cache", &cacheInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return status.Error(codes.Internal, "cache unmarshal")
|
||||||
}
|
}
|
||||||
if cacheInfo.BucketName == "" {
|
if cacheInfo.BucketName == "" {
|
||||||
return errors.New("Error: no cache is configured")
|
return status.Error(codes.Internal, "no cache configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheInfo = CacheProvider{}
|
cacheInfo = CacheProvider{}
|
||||||
viper.Set("cache", cacheInfo)
|
viper.Set("cache", cacheInfo)
|
||||||
err = viper.WriteConfig()
|
err = viper.WriteConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return status.Error(codes.Internal, "unable to write config")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -32,6 +32,8 @@ type IIntegration interface {
|
|||||||
AddAnalyzer(*map[string]common.IAnalyzer)
|
AddAnalyzer(*map[string]common.IAnalyzer)
|
||||||
|
|
||||||
GetAnalyzerName() []string
|
GetAnalyzerName() []string
|
||||||
|
// An integration must keep record of its deployed namespace (if not using --no-install)
|
||||||
|
GetNamespace() (string, error)
|
||||||
|
|
||||||
OwnsAnalyzer(string) bool
|
OwnsAnalyzer(string) bool
|
||||||
|
|
||||||
@ -86,7 +88,6 @@ func (*Integration) Activate(name string, namespace string, activeFilters []stri
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mergedFilters := activeFilters
|
mergedFilters := activeFilters
|
||||||
mergedFilters = append(mergedFilters, integrations[name].GetAnalyzerName()...)
|
mergedFilters = append(mergedFilters, integrations[name].GetAnalyzerName()...)
|
||||||
uniqueFilters, _ := util.RemoveDuplicates(mergedFilters)
|
uniqueFilters, _ := util.RemoveDuplicates(mergedFilters)
|
||||||
|
@ -16,6 +16,8 @@ package trivy
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
|
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
|
||||||
helmclient "github.com/mittwald/go-helm-client"
|
helmclient "github.com/mittwald/go-helm-client"
|
||||||
@ -51,6 +53,20 @@ func (t *Trivy) GetAnalyzerName() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This doesnt work
|
||||||
|
func (t *Trivy) GetNamespace() (string, error) {
|
||||||
|
releases, err := t.helm.ListDeployedReleases()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, rel := range releases {
|
||||||
|
if rel.Name == ReleaseName {
|
||||||
|
return rel.Namespace, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", status.Error(codes.NotFound, "trivy release not found")
|
||||||
|
}
|
||||||
|
|
||||||
func (t *Trivy) OwnsAnalyzer(analyzer string) bool {
|
func (t *Trivy) OwnsAnalyzer(analyzer string) bool {
|
||||||
|
|
||||||
for _, a := range t.GetAnalyzerName() {
|
for _, a := range t.GetAnalyzerName() {
|
||||||
@ -67,7 +83,6 @@ func (t *Trivy) Deploy(namespace string) error {
|
|||||||
Name: RepoShortName,
|
Name: RepoShortName,
|
||||||
URL: Repo,
|
URL: Repo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a chart-repository to the client.
|
// Add a chart-repository to the client.
|
||||||
if err := t.helm.AddOrUpdateChartRepo(chartRepo); err != nil {
|
if err := t.helm.AddOrUpdateChartRepo(chartRepo); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
@ -16,7 +16,7 @@ grpcurl -plaintext -d '{"namespace": "k8sgpt", "explain" : "true"}' localhost:80
|
|||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
grpcurl -plaintext localhost:8080 schema.v1.ServerService/ListIntegrations
|
grpcurl -plaintext localhost:8080 schema.v1.ServerService/ListIntegrations
|
||||||
{
|
{
|
||||||
"integrations": [
|
"integrations": [
|
||||||
"trivy"
|
"trivy"
|
||||||
@ -24,3 +24,7 @@ grpcurl -plaintext localhost:8080 schema.v1.ServerService/ListIntegrations
|
|||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
grpcurl -plaintext -d '{"integrations":{"trivy":{"enabled":"true","namespace":"default","skipInstall":"false"}}}' localhost:8080 schema.v1.ServerService/AddConfig
|
||||||
|
```
|
||||||
|
@ -1,64 +1,45 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
|
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
|
||||||
"github.com/k8sgpt-ai/k8sgpt/pkg/analyzer"
|
"context"
|
||||||
"github.com/k8sgpt-ai/k8sgpt/pkg/cache"
|
"github.com/k8sgpt-ai/k8sgpt/pkg/cache"
|
||||||
"github.com/k8sgpt-ai/k8sgpt/pkg/integration"
|
"google.golang.org/grpc/codes"
|
||||||
"github.com/spf13/viper"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *handler) AddConfig(ctx context.Context, i *schemav1.AddConfigRequest) (*schemav1.AddConfigResponse, error,
|
func (h *handler) AddConfig(ctx context.Context, i *schemav1.AddConfigRequest) (*schemav1.AddConfigResponse, error,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
if i.Integrations != nil {
|
resp, err := h.syncIntegration(ctx, i)
|
||||||
coreFilters, _, _ := analyzer.ListFilters()
|
if err != nil {
|
||||||
// Update filters
|
return resp, err
|
||||||
activeFilters := viper.GetStringSlice("active_filters")
|
|
||||||
if len(activeFilters) == 0 {
|
|
||||||
activeFilters = coreFilters
|
|
||||||
}
|
|
||||||
integration := integration.NewIntegration()
|
|
||||||
|
|
||||||
if i.Integrations.Trivy != nil {
|
|
||||||
// Enable/Disable Trivy
|
|
||||||
var err = integration.Activate("trivy", i.Integrations.Trivy.Namespace,
|
|
||||||
activeFilters, i.Integrations.Trivy.SkipInstall)
|
|
||||||
return &schemav1.AddConfigResponse{
|
|
||||||
Status: "",
|
|
||||||
}, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if i.Cache != nil {
|
if i.Cache != nil {
|
||||||
// Remote cache
|
// Remote cache
|
||||||
if i.Cache.BucketName == "" || i.Cache.Region == "" {
|
if i.Cache.BucketName == "" || i.Cache.Region == "" {
|
||||||
return &schemav1.AddConfigResponse{}, errors.New("BucketName & Region are required")
|
return resp, status.Error(codes.InvalidArgument, "cache arguments")
|
||||||
}
|
}
|
||||||
|
|
||||||
err := cache.AddRemoteCache(i.Cache.BucketName, i.Cache.Region)
|
err := cache.AddRemoteCache(i.Cache.BucketName, i.Cache.Region)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &schemav1.AddConfigResponse{
|
return resp, err
|
||||||
Status: err.Error(),
|
|
||||||
}, err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &schemav1.AddConfigResponse{
|
return resp, nil
|
||||||
Status: "Configuration updated.",
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) RemoveConfig(ctx context.Context, i *schemav1.RemoveConfigRequest) (*schemav1.RemoveConfigResponse, error,
|
func (h *handler) RemoveConfig(ctx context.Context, i *schemav1.RemoveConfigRequest) (*schemav1.RemoveConfigResponse, error,
|
||||||
) {
|
) {
|
||||||
err := cache.RemoveRemoteCache(i.Cache.BucketName)
|
err := cache.RemoveRemoteCache(i.Cache.BucketName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &schemav1.RemoveConfigResponse{
|
return &schemav1.RemoveConfigResponse{}, err
|
||||||
Status: err.Error(),
|
|
||||||
}, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove any integrations is a TBD as it would be nice to make this more granular
|
||||||
|
// Currently integrations can be removed in the AddConfig sync
|
||||||
|
|
||||||
return &schemav1.RemoveConfigResponse{
|
return &schemav1.RemoveConfigResponse{
|
||||||
Status: "Successfully removed the remote cache",
|
Status: "Successfully removed the remote cache",
|
||||||
}, nil
|
}, nil
|
||||||
|
@ -1,24 +1,144 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
|
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/k8sgpt-ai/k8sgpt/pkg/analyzer"
|
||||||
"github.com/k8sgpt-ai/k8sgpt/pkg/integration"
|
"github.com/k8sgpt-ai/k8sgpt/pkg/integration"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
trivyName = "trivy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syncIntegration is aware of the following events
|
||||||
|
// A new integration added
|
||||||
|
// An integration removed from the Integration block
|
||||||
|
func (h *handler) syncIntegration(ctx context.Context,
|
||||||
|
i *schemav1.AddConfigRequest) (*schemav1.AddConfigResponse, error,
|
||||||
|
) {
|
||||||
|
response := &schemav1.AddConfigResponse{}
|
||||||
|
integrationProvider := integration.NewIntegration()
|
||||||
|
if i.Integrations == nil {
|
||||||
|
// If there are locally activate integrations, disable them
|
||||||
|
err := h.deactivateAllIntegrations(integrationProvider)
|
||||||
|
if err != nil {
|
||||||
|
return response, status.Error(codes.NotFound, "deactivation error")
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
coreFilters, _, _ := analyzer.ListFilters()
|
||||||
|
// Update filters
|
||||||
|
activeFilters := viper.GetStringSlice("active_filters")
|
||||||
|
if len(activeFilters) == 0 {
|
||||||
|
activeFilters = coreFilters
|
||||||
|
}
|
||||||
|
var err error = status.Error(codes.OK, "")
|
||||||
|
deactivateFunc := func(integrationRef integration.IIntegration) error {
|
||||||
|
namespace, err := integrationRef.GetNamespace()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = integrationProvider.Deactivate(trivyName, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return status.Error(codes.NotFound, "integration already deactivated")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
integrationRef, err := integrationProvider.Get(trivyName)
|
||||||
|
if err != nil {
|
||||||
|
return response, status.Error(codes.NotFound, "provider get failure")
|
||||||
|
}
|
||||||
|
if i.Integrations.Trivy != nil {
|
||||||
|
switch i.Integrations.Trivy.Enabled {
|
||||||
|
case true:
|
||||||
|
if b, err := integrationProvider.IsActivate(trivyName); err != nil {
|
||||||
|
return response, status.Error(codes.Internal, "integration activation error")
|
||||||
|
} else {
|
||||||
|
if !b {
|
||||||
|
err := integrationProvider.Activate(trivyName, i.Integrations.Trivy.Namespace,
|
||||||
|
activeFilters, i.Integrations.Trivy.SkipInstall)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return response, status.Error(codes.AlreadyExists, "integration already active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case false:
|
||||||
|
err = deactivateFunc(integrationRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// This break is included purely for static analysis to pass
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If Trivy has been removed, disable it
|
||||||
|
err = deactivateFunc(integrationRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, err
|
||||||
|
}
|
||||||
|
|
||||||
func (*handler) ListIntegrations(ctx context.Context, req *schemav1.ListIntegrationsRequest) (*schemav1.ListIntegrationsResponse, error) {
|
func (*handler) ListIntegrations(ctx context.Context, req *schemav1.ListIntegrationsRequest) (*schemav1.ListIntegrationsResponse, error) {
|
||||||
|
|
||||||
integrationProvider := integration.NewIntegration()
|
integrationProvider := integration.NewIntegration()
|
||||||
integrations := integrationProvider.List()
|
// Update the requester with the status of Trivy
|
||||||
resp := &schemav1.ListIntegrationsResponse{
|
trivy, err := integrationProvider.Get(trivyName)
|
||||||
Integrations: make([]string, 0),
|
active := trivy.IsActivate()
|
||||||
|
var skipInstall bool
|
||||||
|
var namespace string = ""
|
||||||
|
if active {
|
||||||
|
namespace, err = trivy.GetNamespace()
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, "namespace not found")
|
||||||
|
}
|
||||||
|
if namespace == "" {
|
||||||
|
skipInstall = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, "trivy integration")
|
||||||
|
}
|
||||||
|
resp := &schemav1.ListIntegrationsResponse{
|
||||||
|
Trivy: &schemav1.Trivy{
|
||||||
|
Enabled: active,
|
||||||
|
Namespace: namespace,
|
||||||
|
SkipInstall: skipInstall,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*handler) deactivateAllIntegrations(integrationProvider *integration.Integration) error {
|
||||||
|
integrations := integrationProvider.List()
|
||||||
for _, i := range integrations {
|
for _, i := range integrations {
|
||||||
b, _ := integrationProvider.IsActivate(i)
|
b, _ := integrationProvider.IsActivate(i)
|
||||||
if b {
|
if b {
|
||||||
resp.Integrations = append(resp.Integrations, i)
|
in, err := integrationProvider.Get(i)
|
||||||
|
namespace, err := in.GetNamespace()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if namespace != "" {
|
||||||
|
integrationProvider.Deactivate(i, namespace)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Skipping deactivation of %s, not installed\n", i)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resp, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user