mirror of
https://github.com/zalando/postgres-operator.git
synced 2026-10-07 01:41:21 +02:00
Merge branch 'master' into fix/set-password-encryption-default-to-scram-sha-256
This commit is contained in:
+18
-14
@@ -31,6 +31,7 @@ type Resources struct {
|
||||
PodLabelWaitTimeout time.Duration `name:"pod_label_wait_timeout" default:"10m"`
|
||||
PodDeletionWaitTimeout time.Duration `name:"pod_deletion_wait_timeout" default:"10m"`
|
||||
PodTerminateGracePeriod time.Duration `name:"pod_terminate_grace_period" default:"5m"`
|
||||
LivenessProbe *v1.Probe `name:"-"`
|
||||
SpiloRunAsUser *int64 `name:"spilo_runasuser"`
|
||||
SpiloRunAsGroup *int64 `name:"spilo_runasgroup"`
|
||||
SpiloFSGroup *int64 `name:"spilo_fsgroup"`
|
||||
@@ -63,9 +64,10 @@ type Resources struct {
|
||||
NodeReadinessLabelMerge string `name:"node_readiness_label_merge" default:"OR"`
|
||||
ShmVolume *bool `name:"enable_shm_volume" default:"true"`
|
||||
|
||||
MaxInstances int32 `name:"max_instances" default:"-1"`
|
||||
MinInstances int32 `name:"min_instances" default:"-1"`
|
||||
IgnoreInstanceLimitsAnnotationKey string `name:"ignore_instance_limits_annotation_key"`
|
||||
MaxInstances int32 `name:"max_instances" default:"-1"`
|
||||
MinInstances int32 `name:"min_instances" default:"-1"`
|
||||
IgnoreInstanceLimitsAnnotationKey string `name:"ignore_instance_limits_annotation_key"`
|
||||
IgnoreResourcesLimitsAnnotationKey string `name:"ignore_resources_limits_annotation_key"`
|
||||
}
|
||||
|
||||
type InfrastructureRole struct {
|
||||
@@ -127,7 +129,7 @@ type Scalyr struct {
|
||||
// LogicalBackup defines configuration for logical backup
|
||||
type LogicalBackup struct {
|
||||
LogicalBackupSchedule string `name:"logical_backup_schedule" default:"30 00 * * *"`
|
||||
LogicalBackupDockerImage string `name:"logical_backup_docker_image" default:"ghcr.io/zalando/postgres-operator/logical-backup:v1.14.0"`
|
||||
LogicalBackupDockerImage string `name:"logical_backup_docker_image" default:"ghcr.io/zalando/postgres-operator/logical-backup:v1.15.1"`
|
||||
LogicalBackupProvider string `name:"logical_backup_provider" default:"s3"`
|
||||
LogicalBackupAzureStorageAccountName string `name:"logical_backup_azure_storage_account_name" default:""`
|
||||
LogicalBackupAzureStorageContainer string `name:"logical_backup_azure_storage_container" default:""`
|
||||
@@ -154,7 +156,7 @@ type ConnectionPooler struct {
|
||||
NumberOfInstances *int32 `name:"connection_pooler_number_of_instances" default:"2"`
|
||||
Schema string `name:"connection_pooler_schema" default:"pooler"`
|
||||
User string `name:"connection_pooler_user" default:"pooler"`
|
||||
Image string `name:"connection_pooler_image" default:"registry.opensource.zalan.do/acid/pgbouncer"`
|
||||
Image string `name:"connection_pooler_image" default:"ghcr.io/zalando/postgres-operator/pgbouncer:latest"`
|
||||
Mode string `name:"connection_pooler_mode" default:"transaction"`
|
||||
MaxDBConnections *int32 `name:"connection_pooler_max_db_connections" default:"60"`
|
||||
ConnectionPoolerDefaultCPURequest string `name:"connection_pooler_default_cpu_request"`
|
||||
@@ -172,13 +174,15 @@ type Config struct {
|
||||
LogicalBackup
|
||||
ConnectionPooler
|
||||
|
||||
WatchedNamespace string `name:"watched_namespace"` // special values: "*" means 'watch all namespaces', the empty string "" means 'watch a namespace where operator is deployed to'
|
||||
KubernetesUseConfigMaps bool `name:"kubernetes_use_configmaps" default:"false"`
|
||||
EtcdHost string `name:"etcd_host" default:""` // special values: the empty string "" means Patroni will use K8s as a DCS
|
||||
DockerImage string `name:"docker_image" default:"ghcr.io/zalando/spilo-17:4.0-p2"`
|
||||
SidecarImages map[string]string `name:"sidecar_docker_images"` // deprecated in favour of SidecarContainers
|
||||
SidecarContainers []v1.Container `name:"sidecars"`
|
||||
PodServiceAccountName string `name:"pod_service_account_name" default:"postgres-pod"`
|
||||
WatchedNamespace string `name:"watched_namespace"` // special values: "*" means 'watch all namespaces', the empty string "" means 'watch a namespace where operator is deployed to'
|
||||
KubernetesUseConfigMaps bool `name:"kubernetes_use_configmaps" default:"false"`
|
||||
EtcdHost string `name:"etcd_host" default:""` // special values: the empty string "" means Patroni will use K8s as a DCS
|
||||
EnableMaintenanceWindows *bool `name:"enable_maintenance_windows" default:"true"`
|
||||
MaintenanceWindows []string `name:"maintenance_windows"`
|
||||
DockerImage string `name:"docker_image" default:"ghcr.io/zalando/spilo-18:4.1-p1"`
|
||||
SidecarImages map[string]string `name:"sidecar_docker_images"` // deprecated in favour of SidecarContainers
|
||||
SidecarContainers []v1.Container `name:"sidecars"`
|
||||
PodServiceAccountName string `name:"pod_service_account_name" default:"postgres-pod"`
|
||||
// value of this string must be valid JSON or YAML; see initPodServiceAccount
|
||||
PodServiceAccountDefinition string `name:"pod_service_account_definition" default:""`
|
||||
PodServiceAccountRoleBindingDefinition string `name:"pod_service_account_role_binding_definition" default:""`
|
||||
@@ -246,8 +250,8 @@ type Config struct {
|
||||
EnableTeamIdClusternamePrefix bool `name:"enable_team_id_clustername_prefix" default:"false"`
|
||||
MajorVersionUpgradeMode string `name:"major_version_upgrade_mode" default:"manual"`
|
||||
MajorVersionUpgradeTeamAllowList []string `name:"major_version_upgrade_team_allow_list" default:""`
|
||||
MinimalMajorVersion string `name:"minimal_major_version" default:"13"`
|
||||
TargetMajorVersion string `name:"target_major_version" default:"17"`
|
||||
MinimalMajorVersion string `name:"minimal_major_version" default:"14"`
|
||||
TargetMajorVersion string `name:"target_major_version" default:"18"`
|
||||
PatroniAPICheckInterval time.Duration `name:"patroni_api_check_interval" default:"1s"`
|
||||
PatroniAPICheckTimeout time.Duration `name:"patroni_api_check_timeout" default:"5s"`
|
||||
EnablePatroniFailsafeMode *bool `name:"enable_patroni_failsafe_mode" default:"false"`
|
||||
|
||||
@@ -2,10 +2,19 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Set OPERATOR_NAMESPACE to avoid log.Fatal in GetOperatorNamespace
|
||||
// when running tests outside a Kubernetes pod
|
||||
os.Setenv("OPERATOR_NAMESPACE", "default")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
var getMapPairsFromStringTest = []struct {
|
||||
in string
|
||||
expected []string
|
||||
@@ -29,3 +38,300 @@ func TestGetMapPairsFromString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func int32Ptr(i int32) *int32 {
|
||||
return &i
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
var validateTests = []struct {
|
||||
description string
|
||||
cfg Config
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
description: "valid config",
|
||||
cfg: Config{
|
||||
Resources: Resources{
|
||||
MinInstances: 1,
|
||||
MaxInstances: 5,
|
||||
},
|
||||
Auth: Auth{
|
||||
SuperUsername: "postgres",
|
||||
},
|
||||
ConnectionPooler: ConnectionPooler{
|
||||
NumberOfInstances: int32Ptr(2),
|
||||
User: "pooler",
|
||||
},
|
||||
Workers: 4,
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
description: "min instances greater than max instances",
|
||||
cfg: Config{
|
||||
Resources: Resources{
|
||||
MinInstances: 10,
|
||||
MaxInstances: 5,
|
||||
},
|
||||
Auth: Auth{
|
||||
SuperUsername: "postgres",
|
||||
},
|
||||
ConnectionPooler: ConnectionPooler{
|
||||
NumberOfInstances: int32Ptr(2),
|
||||
User: "pooler",
|
||||
},
|
||||
Workers: 4,
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "minimum number of instances",
|
||||
},
|
||||
{
|
||||
description: "workers set to zero",
|
||||
cfg: Config{
|
||||
Resources: Resources{
|
||||
MinInstances: 1,
|
||||
MaxInstances: 5,
|
||||
},
|
||||
Auth: Auth{
|
||||
SuperUsername: "postgres",
|
||||
},
|
||||
ConnectionPooler: ConnectionPooler{
|
||||
NumberOfInstances: int32Ptr(2),
|
||||
User: "pooler",
|
||||
},
|
||||
Workers: 0,
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "number of workers should be higher than 0",
|
||||
},
|
||||
{
|
||||
description: "connection pooler instances below minimum",
|
||||
cfg: Config{
|
||||
Resources: Resources{
|
||||
MinInstances: 1,
|
||||
MaxInstances: 5,
|
||||
},
|
||||
Auth: Auth{
|
||||
SuperUsername: "postgres",
|
||||
},
|
||||
ConnectionPooler: ConnectionPooler{
|
||||
NumberOfInstances: int32Ptr(0),
|
||||
User: "pooler",
|
||||
},
|
||||
Workers: 4,
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "number of connection pooler instances",
|
||||
},
|
||||
{
|
||||
description: "connection pooler user same as super user",
|
||||
cfg: Config{
|
||||
Resources: Resources{
|
||||
MinInstances: 1,
|
||||
MaxInstances: 5,
|
||||
},
|
||||
Auth: Auth{
|
||||
SuperUsername: "postgres",
|
||||
},
|
||||
ConnectionPooler: ConnectionPooler{
|
||||
NumberOfInstances: int32Ptr(2),
|
||||
User: "postgres",
|
||||
},
|
||||
Workers: 4,
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "connection pool user is not allowed to be the same as super user",
|
||||
},
|
||||
{
|
||||
description: "min and max instances both negative (disabled)",
|
||||
cfg: Config{
|
||||
Resources: Resources{
|
||||
MinInstances: -1,
|
||||
MaxInstances: -1,
|
||||
},
|
||||
Auth: Auth{
|
||||
SuperUsername: "postgres",
|
||||
},
|
||||
ConnectionPooler: ConnectionPooler{
|
||||
NumberOfInstances: int32Ptr(2),
|
||||
User: "pooler",
|
||||
},
|
||||
Workers: 4,
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
for _, tt := range validateTests {
|
||||
t.Run(tt.description, func(t *testing.T) {
|
||||
err := validate(&tt.cfg)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.errorMsg)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Errorf("expected error containing %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var newFromMapTests = []struct {
|
||||
description string
|
||||
input map[string]string
|
||||
expectPanic bool
|
||||
panicMsg string
|
||||
validateFunc func(t *testing.T, cfg *Config)
|
||||
}{
|
||||
{
|
||||
description: "empty map uses defaults",
|
||||
input: map[string]string{},
|
||||
expectPanic: false,
|
||||
validateFunc: func(t *testing.T, cfg *Config) {
|
||||
if cfg.Workers != 8 {
|
||||
t.Errorf("expected default Workers=8, got %d", cfg.Workers)
|
||||
}
|
||||
if cfg.SuperUsername != "postgres" {
|
||||
t.Errorf("expected default SuperUsername=postgres, got %s", cfg.SuperUsername)
|
||||
}
|
||||
if cfg.ReplicationUsername != "standby" {
|
||||
t.Errorf("expected default ReplicationUsername=standby, got %s", cfg.ReplicationUsername)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "custom values override defaults",
|
||||
input: map[string]string{
|
||||
"workers": "16",
|
||||
"super_username": "admin",
|
||||
},
|
||||
expectPanic: false,
|
||||
validateFunc: func(t *testing.T, cfg *Config) {
|
||||
if cfg.Workers != 16 {
|
||||
t.Errorf("expected Workers=16, got %d", cfg.Workers)
|
||||
}
|
||||
if cfg.SuperUsername != "admin" {
|
||||
t.Errorf("expected SuperUsername=admin, got %s", cfg.SuperUsername)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "duration parsing",
|
||||
input: map[string]string{
|
||||
"ready_wait_interval": "10s",
|
||||
"ready_wait_timeout": "1m",
|
||||
},
|
||||
expectPanic: false,
|
||||
validateFunc: func(t *testing.T, cfg *Config) {
|
||||
if cfg.ReadyWaitInterval.Seconds() != 10 {
|
||||
t.Errorf("expected ReadyWaitInterval=10s, got %v", cfg.ReadyWaitInterval)
|
||||
}
|
||||
if cfg.ReadyWaitTimeout.Minutes() != 1 {
|
||||
t.Errorf("expected ReadyWaitTimeout=1m, got %v", cfg.ReadyWaitTimeout)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "boolean parsing",
|
||||
input: map[string]string{
|
||||
"enable_teams_api": "false",
|
||||
"debug_logging": "false",
|
||||
},
|
||||
expectPanic: false,
|
||||
validateFunc: func(t *testing.T, cfg *Config) {
|
||||
if cfg.EnableTeamsAPI != false {
|
||||
t.Errorf("expected EnableTeamsAPI=false, got %v", cfg.EnableTeamsAPI)
|
||||
}
|
||||
if cfg.DebugLogging != false {
|
||||
t.Errorf("expected DebugLogging=false, got %v", cfg.DebugLogging)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "map parsing",
|
||||
input: map[string]string{
|
||||
"cluster_labels": "app:myapp,env:prod",
|
||||
},
|
||||
expectPanic: false,
|
||||
validateFunc: func(t *testing.T, cfg *Config) {
|
||||
if cfg.ClusterLabels["app"] != "myapp" {
|
||||
t.Errorf("expected ClusterLabels[app]=myapp, got %s", cfg.ClusterLabels["app"])
|
||||
}
|
||||
if cfg.ClusterLabels["env"] != "prod" {
|
||||
t.Errorf("expected ClusterLabels[env]=prod, got %s", cfg.ClusterLabels["env"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "slice parsing",
|
||||
input: map[string]string{
|
||||
"inherited_labels": "label1,label2,label3",
|
||||
},
|
||||
expectPanic: false,
|
||||
validateFunc: func(t *testing.T, cfg *Config) {
|
||||
expected := []string{"label1", "label2", "label3"}
|
||||
if !reflect.DeepEqual(cfg.InheritedLabels, expected) {
|
||||
t.Errorf("expected InheritedLabels=%v, got %v", expected, cfg.InheritedLabels)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "invalid workers triggers validation panic",
|
||||
input: map[string]string{
|
||||
"workers": "0",
|
||||
},
|
||||
expectPanic: true,
|
||||
panicMsg: "number of workers should be higher than 0",
|
||||
},
|
||||
{
|
||||
description: "invalid integer causes panic",
|
||||
input: map[string]string{
|
||||
"workers": "invalid",
|
||||
},
|
||||
expectPanic: true,
|
||||
panicMsg: "invalid syntax",
|
||||
},
|
||||
}
|
||||
|
||||
func TestNewFromMap(t *testing.T) {
|
||||
for _, tt := range newFromMapTests {
|
||||
t.Run(tt.description, func(t *testing.T) {
|
||||
if tt.expectPanic {
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Errorf("expected panic with message containing %q, but no panic occurred", tt.panicMsg)
|
||||
return
|
||||
}
|
||||
errMsg := fmt.Sprintf("%v", r)
|
||||
if !strings.Contains(errMsg, tt.panicMsg) {
|
||||
t.Errorf("expected panic message containing %q, got %q", tt.panicMsg, errMsg)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
cfg := NewFromMap(tt.input)
|
||||
|
||||
if tt.expectPanic {
|
||||
t.Errorf("expected panic but NewFromMap returned successfully")
|
||||
return
|
||||
}
|
||||
|
||||
if tt.validateFunc != nil {
|
||||
tt.validateFunc(t, cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package constants
|
||||
// Names and values in Kubernetes annotation for services, statefulsets and volumes
|
||||
const (
|
||||
ZalandoDNSNameAnnotation = "external-dns.alpha.kubernetes.io/hostname"
|
||||
ElbTimeoutAnnotationName = "service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout"
|
||||
ElbTimeoutAnnotationValue = "3600"
|
||||
KubeIAmAnnotation = "iam.amazonaws.com/role"
|
||||
VolumeStorateProvisionerAnnotation = "pv.kubernetes.io/provisioned-by"
|
||||
PostgresqlControllerAnnotationKey = "acid.zalan.do/controller"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package httpclient
|
||||
|
||||
//go:generate mockgen -package mocks -destination=../../../mocks/$GOFILE -source=$GOFILE -build_flags=-mod=vendor
|
||||
//go:generate go tool mockgen -package mocks -destination=../../../mocks/$GOFILE -source=$GOFILE
|
||||
|
||||
import "net/http"
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ type KubernetesClient struct {
|
||||
zalandov1.FabricEventStreamsGetter
|
||||
|
||||
RESTClient rest.Interface
|
||||
Clientset *kubernetes.Clientset
|
||||
AcidV1ClientSet *zalandoclient.Clientset
|
||||
Zalandov1ClientSet *zalandoclient.Clientset
|
||||
}
|
||||
@@ -148,6 +149,7 @@ func NewFromConfig(cfg *rest.Config) (KubernetesClient, error) {
|
||||
return kubeClient, fmt.Errorf("could not get clientset: %v", err)
|
||||
}
|
||||
|
||||
kubeClient.Clientset = client
|
||||
kubeClient.PodsGetter = client.CoreV1()
|
||||
kubeClient.ServicesGetter = client.CoreV1()
|
||||
kubeClient.EndpointsGetter = client.CoreV1()
|
||||
@@ -191,24 +193,8 @@ func NewFromConfig(cfg *rest.Config) (KubernetesClient, error) {
|
||||
}
|
||||
|
||||
// SetPostgresCRDStatus of Postgres cluster
|
||||
func (client *KubernetesClient) SetPostgresCRDStatus(clusterName spec.NamespacedName, status string) (*apiacidv1.Postgresql, error) {
|
||||
var pg *apiacidv1.Postgresql
|
||||
var pgStatus apiacidv1.PostgresStatus
|
||||
pgStatus.PostgresClusterStatus = status
|
||||
|
||||
patch, err := json.Marshal(struct {
|
||||
PgStatus interface{} `json:"status"`
|
||||
}{&pgStatus})
|
||||
|
||||
if err != nil {
|
||||
return pg, fmt.Errorf("could not marshal status: %v", err)
|
||||
}
|
||||
|
||||
// we cannot do a full scale update here without fetching the previous manifest (as the resourceVersion may differ),
|
||||
// however, we could do patch without it. In the future, once /status subresource is there (starting Kubernetes 1.11)
|
||||
// we should take advantage of it.
|
||||
pg, err = client.PostgresqlsGetter.Postgresqls(clusterName.Namespace).Patch(
|
||||
context.TODO(), clusterName.Name, types.MergePatchType, patch, metav1.PatchOptions{}, "status")
|
||||
func (client *KubernetesClient) SetPostgresCRDStatus(clusterName spec.NamespacedName, pg *apiacidv1.Postgresql) (*apiacidv1.Postgresql, error) {
|
||||
pg, err := client.PostgresqlsGetter.Postgresqls(clusterName.Namespace).UpdateStatus(context.TODO(), pg, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
return pg, fmt.Errorf("could not update status: %v", err)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ func (strategy DefaultUserSyncStrategy) ProduceSyncRequests(dbUsers spec.PgUserM
|
||||
if newUser.Deleted {
|
||||
continue
|
||||
}
|
||||
// when the secret of the user could not be created or updated skip any database actions
|
||||
if newUser.Degraded {
|
||||
continue
|
||||
}
|
||||
dbUser, exists := dbUsers[name]
|
||||
if !exists {
|
||||
reqs = append(reqs, spec.PgSyncUserRequest{Kind: spec.PGSyncUserAdd, User: newUser})
|
||||
|
||||
@@ -88,12 +88,13 @@ func (r *EBSVolumeResizer) DescribeVolumes(volumeIds []string) ([]VolumeProperti
|
||||
}
|
||||
|
||||
for _, v := range volumeOutput.Volumes {
|
||||
if *v.VolumeType == "gp3" {
|
||||
switch *v.VolumeType {
|
||||
case "gp3":
|
||||
p = append(p, VolumeProperties{VolumeID: *v.VolumeId, Size: *v.Size, VolumeType: *v.VolumeType, Iops: *v.Iops, Throughput: *v.Throughput})
|
||||
} else if *v.VolumeType == "gp2" {
|
||||
case "gp2":
|
||||
p = append(p, VolumeProperties{VolumeID: *v.VolumeId, Size: *v.Size, VolumeType: *v.VolumeType})
|
||||
} else {
|
||||
return nil, fmt.Errorf("Discovered unexpected volume type %s %s", *v.VolumeId, *v.VolumeType)
|
||||
default:
|
||||
return nil, fmt.Errorf("discovered unexpected volume type %s %s", *v.VolumeId, *v.VolumeType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package volumes
|
||||
|
||||
//go:generate mockgen -package mocks -destination=../../../mocks/$GOFILE -source=$GOFILE -build_flags=-mod=vendor
|
||||
//go:generate go tool mockgen -package mocks -destination=../../../mocks/$GOFILE -source=$GOFILE
|
||||
|
||||
import v1 "k8s.io/api/core/v1"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user