Store GKE dashboard parameters in catalog
Signed-off-by: Tomasz Szuba <tszuba@box.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-gcp': patch
|
||||
---
|
||||
|
||||
Allow integration with kubernetes dashboard
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': patch
|
||||
'@backstage/plugin-kubernetes-common': patch
|
||||
---
|
||||
|
||||
Allow storing dashboard parameters for kubernetes in catalog
|
||||
@@ -48,6 +48,7 @@
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/backend-tasks": "workspace:^",
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/plugin-catalog-node": "workspace:^",
|
||||
"@backstage/plugin-kubernetes-common": "workspace:^",
|
||||
|
||||
@@ -16,11 +16,6 @@
|
||||
|
||||
import { GkeEntityProvider } from './GkeEntityProvider';
|
||||
import { TaskRunner } from '@backstage/backend-tasks';
|
||||
import {
|
||||
ANNOTATION_KUBERNETES_API_SERVER,
|
||||
ANNOTATION_KUBERNETES_API_SERVER_CA,
|
||||
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
|
||||
} from '@backstage/plugin-kubernetes-common';
|
||||
import * as container from '@google-cloud/container';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
@@ -55,7 +50,10 @@ describe('GkeEntityProvider', () => {
|
||||
providers: {
|
||||
gcp: {
|
||||
gke: {
|
||||
parents: ['parent1', 'parent2'],
|
||||
parents: [
|
||||
'projects/parent1/locations/-',
|
||||
'projects/parent2/locations/some-other-location',
|
||||
],
|
||||
schedule: {
|
||||
frequency: {
|
||||
minutes: 3,
|
||||
@@ -77,7 +75,7 @@ describe('GkeEntityProvider', () => {
|
||||
|
||||
it('should return clusters as Resources', async () => {
|
||||
clusterManagerClientMock.listClusters.mockImplementation(req => {
|
||||
if (req.parent === 'parent1') {
|
||||
if (req.parent === 'projects/parent1/locations/-') {
|
||||
return [
|
||||
{
|
||||
clusters: [
|
||||
@@ -93,7 +91,9 @@ describe('GkeEntityProvider', () => {
|
||||
],
|
||||
},
|
||||
];
|
||||
} else if (req.parent === 'parent2') {
|
||||
} else if (
|
||||
req.parent === 'projects/parent2/locations/some-other-location'
|
||||
) {
|
||||
return [
|
||||
{
|
||||
clusters: [
|
||||
@@ -114,58 +114,7 @@ describe('GkeEntityProvider', () => {
|
||||
throw new Error(`unexpected parent ${req.parent}`);
|
||||
});
|
||||
await gkeEntityProvider.refresh();
|
||||
expect(connectionMock.applyMutation).toHaveBeenCalledWith({
|
||||
type: 'full',
|
||||
entities: [
|
||||
{
|
||||
locationKey: 'gcp-gke:some-location',
|
||||
entity: {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Resource',
|
||||
metadata: {
|
||||
annotations: {
|
||||
[ANNOTATION_KUBERNETES_API_SERVER]: 'https://127.0.0.1',
|
||||
[ANNOTATION_KUBERNETES_API_SERVER_CA]: 'abcdefg',
|
||||
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google',
|
||||
'backstage.io/managed-by-location': 'gcp-gke:some-location',
|
||||
'backstage.io/managed-by-origin-location':
|
||||
'gcp-gke:some-location',
|
||||
},
|
||||
name: 'some-cluster',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'kubernetes-cluster',
|
||||
owner: 'unknown',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
locationKey: 'gcp-gke:some-other-location',
|
||||
entity: {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Resource',
|
||||
metadata: {
|
||||
annotations: {
|
||||
[ANNOTATION_KUBERNETES_API_SERVER]: 'https://127.0.0.1',
|
||||
[ANNOTATION_KUBERNETES_API_SERVER_CA]: '',
|
||||
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google',
|
||||
'backstage.io/managed-by-location':
|
||||
'gcp-gke:some-other-location',
|
||||
'backstage.io/managed-by-origin-location':
|
||||
'gcp-gke:some-other-location',
|
||||
},
|
||||
name: 'some-other-cluster',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'kubernetes-cluster',
|
||||
owner: 'unknown',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(connectionMock.applyMutation).toMatchSnapshot();
|
||||
});
|
||||
|
||||
const ignoredPartialClustersTests: [
|
||||
@@ -223,7 +172,7 @@ describe('GkeEntityProvider', () => {
|
||||
'ignore cluster - %s',
|
||||
async (_name, ignoredCluster) => {
|
||||
clusterManagerClientMock.listClusters.mockImplementation(req => {
|
||||
if (req.parent === 'parent1') {
|
||||
if (req.parent === 'projects/parent1/locations/-') {
|
||||
return [ignoredCluster];
|
||||
}
|
||||
return [
|
||||
|
||||
@@ -29,9 +29,15 @@ import {
|
||||
ANNOTATION_KUBERNETES_API_SERVER,
|
||||
ANNOTATION_KUBERNETES_API_SERVER_CA,
|
||||
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
|
||||
ANNOTATION_KUBERNETES_DASHBOARD_APP,
|
||||
ANNOTATION_KUBERNETES_DASHBOARD_PARAMETERS,
|
||||
} from '@backstage/plugin-kubernetes-common';
|
||||
import { Config } from '@backstage/config';
|
||||
import { SchedulerService } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
ANNOTATION_LOCATION,
|
||||
ANNOTATION_ORIGIN_LOCATION,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
/**
|
||||
* Catalog provider to ingest GKE clusters
|
||||
@@ -120,10 +126,16 @@ export class GkeEntityProvider implements EntityProvider {
|
||||
|
||||
private clusterToResource(
|
||||
cluster: container.protos.google.container.v1.ICluster,
|
||||
project: string,
|
||||
): DeferredEntity | undefined {
|
||||
const location = `${this.getProviderName()}:${cluster.location}`;
|
||||
|
||||
if (!cluster.name || !cluster.selfLink || !location || !cluster.endpoint) {
|
||||
if (
|
||||
!cluster.name ||
|
||||
!cluster.selfLink ||
|
||||
!cluster.endpoint ||
|
||||
!cluster.location
|
||||
) {
|
||||
this.logger.warn(
|
||||
`ignoring partial cluster, one of name=${cluster.name}, endpoint=${cluster.endpoint}, selfLink=${cluster.selfLink} or location=${cluster.location} is missing`,
|
||||
);
|
||||
@@ -142,8 +154,14 @@ export class GkeEntityProvider implements EntityProvider {
|
||||
[ANNOTATION_KUBERNETES_API_SERVER_CA]:
|
||||
cluster.masterAuth?.clusterCaCertificate || '',
|
||||
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google',
|
||||
'backstage.io/managed-by-location': location,
|
||||
'backstage.io/managed-by-origin-location': location,
|
||||
[ANNOTATION_KUBERNETES_DASHBOARD_APP]: 'gke',
|
||||
[ANNOTATION_LOCATION]: location,
|
||||
[ANNOTATION_ORIGIN_LOCATION]: location,
|
||||
[ANNOTATION_KUBERNETES_DASHBOARD_PARAMETERS]: JSON.stringify({
|
||||
projectId: project,
|
||||
region: cluster.location,
|
||||
clusterName: cluster.name,
|
||||
}),
|
||||
},
|
||||
name: cluster.name,
|
||||
namespace: 'default',
|
||||
@@ -172,18 +190,22 @@ export class GkeEntityProvider implements EntityProvider {
|
||||
};
|
||||
}
|
||||
|
||||
private async getClusters(): Promise<
|
||||
container.protos.google.container.v1.ICluster[]
|
||||
> {
|
||||
private async getClusters(): Promise<DeferredEntity[]> {
|
||||
const clusters = await Promise.all(
|
||||
this.gkeParents.map(async parent => {
|
||||
const project = parent.split('/')[1];
|
||||
const request = {
|
||||
parent: parent,
|
||||
};
|
||||
const [response] = await this.clusterManagerClient.listClusters(
|
||||
request,
|
||||
);
|
||||
return response.clusters?.filter(this.filterOutUndefinedCluster) ?? [];
|
||||
return (
|
||||
response.clusters
|
||||
?.filter(this.filterOutUndefinedCluster)
|
||||
.map(c => this.clusterToResource(c, project))
|
||||
.filter(this.filterOutUndefinedDeferredEntity) ?? []
|
||||
);
|
||||
}),
|
||||
);
|
||||
return clusters.flat();
|
||||
@@ -196,18 +218,14 @@ export class GkeEntityProvider implements EntityProvider {
|
||||
|
||||
this.logger.info('Discovering GKE clusters');
|
||||
|
||||
let clusters: container.protos.google.container.v1.ICluster[];
|
||||
let resources: DeferredEntity[];
|
||||
|
||||
try {
|
||||
clusters = await this.getClusters();
|
||||
resources = await this.getClusters();
|
||||
} catch (e) {
|
||||
this.logger.error('error fetching GKE clusters', e);
|
||||
return;
|
||||
}
|
||||
const resources =
|
||||
clusters
|
||||
.map(c => this.clusterToResource(c))
|
||||
.filter(this.filterOutUndefinedDeferredEntity) ?? [];
|
||||
|
||||
this.logger.info(
|
||||
`Ingesting GKE clusters [${resources
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`GkeEntityProvider should return clusters as Resources 1`] = `
|
||||
[MockFunction] {
|
||||
"calls": [
|
||||
[
|
||||
{
|
||||
"entities": [
|
||||
{
|
||||
"entity": {
|
||||
"apiVersion": "backstage.io/v1alpha1",
|
||||
"kind": "Resource",
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"backstage.io/managed-by-location": "gcp-gke:some-location",
|
||||
"backstage.io/managed-by-origin-location": "gcp-gke:some-location",
|
||||
"kubernetes.io/api-server": "https://127.0.0.1",
|
||||
"kubernetes.io/api-server-certificate-authority": "abcdefg",
|
||||
"kubernetes.io/auth-provider": "google",
|
||||
"kubernetes.io/dashboard-app": "gke",
|
||||
"kubernetes.io/dashboard-parameters": "{"projectId":"parent1","region":"some-location","clusterName":"some-cluster"}",
|
||||
},
|
||||
"name": "some-cluster",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": {
|
||||
"owner": "unknown",
|
||||
"type": "kubernetes-cluster",
|
||||
},
|
||||
},
|
||||
"locationKey": "gcp-gke:some-location",
|
||||
},
|
||||
{
|
||||
"entity": {
|
||||
"apiVersion": "backstage.io/v1alpha1",
|
||||
"kind": "Resource",
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"backstage.io/managed-by-location": "gcp-gke:some-other-location",
|
||||
"backstage.io/managed-by-origin-location": "gcp-gke:some-other-location",
|
||||
"kubernetes.io/api-server": "https://127.0.0.1",
|
||||
"kubernetes.io/api-server-certificate-authority": "",
|
||||
"kubernetes.io/auth-provider": "google",
|
||||
"kubernetes.io/dashboard-app": "gke",
|
||||
"kubernetes.io/dashboard-parameters": "{"projectId":"parent2","region":"some-other-location","clusterName":"some-other-cluster"}",
|
||||
},
|
||||
"name": "some-other-cluster",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": {
|
||||
"owner": "unknown",
|
||||
"type": "kubernetes-cluster",
|
||||
},
|
||||
},
|
||||
"locationKey": "gcp-gke:some-other-location",
|
||||
},
|
||||
],
|
||||
"type": "full",
|
||||
},
|
||||
],
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"type": "return",
|
||||
"value": undefined,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from '@backstage/plugin-kubernetes-common';
|
||||
import { CatalogClusterLocator } from './CatalogClusterLocator';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { ClusterDetails } from '../types/types';
|
||||
|
||||
const mockCatalogApi = {
|
||||
getEntityByRef: jest.fn(),
|
||||
@@ -93,25 +92,7 @@ describe('CatalogClusterLocator', () => {
|
||||
const result = await clusterSupplier.getClusters();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toStrictEqual<ClusterDetails>({
|
||||
name: 'owned',
|
||||
url: 'https://apiserver.com',
|
||||
caData: 'caData',
|
||||
authMetadata: {
|
||||
'kubernetes.io/api-server': 'https://apiserver.com',
|
||||
'kubernetes.io/api-server-certificate-authority': 'caData',
|
||||
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'oidc',
|
||||
[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google',
|
||||
'kubernetes.io/skip-metrics-lookup': 'true',
|
||||
'kubernetes.io/skip-tls-verify': 'true',
|
||||
'kubernetes.io/dashboard-url': 'my-url',
|
||||
'kubernetes.io/dashboard-app': 'my-app',
|
||||
},
|
||||
skipMetricsLookup: true,
|
||||
skipTLSVerify: true,
|
||||
dashboardUrl: 'my-url',
|
||||
dashboardApp: 'my-app',
|
||||
});
|
||||
expect(result[0]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('returns the aws cluster details provided by annotations', async () => {
|
||||
@@ -120,24 +101,6 @@ describe('CatalogClusterLocator', () => {
|
||||
const result = await clusterSupplier.getClusters();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1]).toStrictEqual<ClusterDetails>({
|
||||
name: 'owned',
|
||||
url: 'https://apiserver.com',
|
||||
caData: 'caData',
|
||||
authMetadata: {
|
||||
'kubernetes.io/api-server': 'https://apiserver.com',
|
||||
'kubernetes.io/api-server-certificate-authority': 'caData',
|
||||
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws',
|
||||
[ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: 'my-role',
|
||||
[ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: 'my-id',
|
||||
[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google',
|
||||
'kubernetes.io/dashboard-url': 'my-url',
|
||||
'kubernetes.io/dashboard-app': 'my-app',
|
||||
},
|
||||
skipMetricsLookup: false,
|
||||
skipTLSVerify: false,
|
||||
dashboardUrl: 'my-url',
|
||||
dashboardApp: 'my-app',
|
||||
});
|
||||
expect(result[1]).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,13 @@ import {
|
||||
ANNOTATION_KUBERNETES_SKIP_TLS_VERIFY,
|
||||
ANNOTATION_KUBERNETES_DASHBOARD_URL,
|
||||
ANNOTATION_KUBERNETES_DASHBOARD_APP,
|
||||
ANNOTATION_KUBERNETES_DASHBOARD_PARAMETERS,
|
||||
} from '@backstage/plugin-kubernetes-common';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
|
||||
function isObject(obj: unknown): obj is JsonObject {
|
||||
return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
|
||||
}
|
||||
|
||||
export class CatalogClusterLocator implements KubernetesClustersSupplier {
|
||||
private catalogClient: CatalogApi;
|
||||
@@ -54,27 +60,38 @@ export class CatalogClusterLocator implements KubernetesClustersSupplier {
|
||||
filter: [filter],
|
||||
});
|
||||
return clusters.items.map(entity => {
|
||||
const annotations = entity.metadata.annotations!;
|
||||
const clusterDetails: ClusterDetails = {
|
||||
name: entity.metadata.name,
|
||||
url: entity.metadata.annotations![ANNOTATION_KUBERNETES_API_SERVER]!,
|
||||
authMetadata: entity.metadata.annotations!,
|
||||
caData:
|
||||
entity.metadata.annotations![ANNOTATION_KUBERNETES_API_SERVER_CA]!,
|
||||
url: annotations[ANNOTATION_KUBERNETES_API_SERVER],
|
||||
authMetadata: annotations,
|
||||
caData: annotations[ANNOTATION_KUBERNETES_API_SERVER_CA],
|
||||
skipMetricsLookup:
|
||||
entity.metadata.annotations![
|
||||
ANNOTATION_KUBERNETES_SKIP_METRICS_LOOKUP
|
||||
]! === 'true',
|
||||
annotations[ANNOTATION_KUBERNETES_SKIP_METRICS_LOOKUP] === 'true',
|
||||
skipTLSVerify:
|
||||
entity.metadata.annotations![
|
||||
ANNOTATION_KUBERNETES_SKIP_TLS_VERIFY
|
||||
]! === 'true',
|
||||
dashboardUrl:
|
||||
entity.metadata.annotations![ANNOTATION_KUBERNETES_DASHBOARD_URL]!,
|
||||
dashboardApp:
|
||||
entity.metadata.annotations![ANNOTATION_KUBERNETES_DASHBOARD_APP]!,
|
||||
annotations[ANNOTATION_KUBERNETES_SKIP_TLS_VERIFY] === 'true',
|
||||
dashboardUrl: annotations[ANNOTATION_KUBERNETES_DASHBOARD_URL],
|
||||
dashboardApp: annotations[ANNOTATION_KUBERNETES_DASHBOARD_APP],
|
||||
dashboardParameters: this.getDashboardParameters(annotations),
|
||||
};
|
||||
|
||||
return clusterDetails;
|
||||
});
|
||||
}
|
||||
|
||||
private getDashboardParameters(
|
||||
annotations: Record<string, string>,
|
||||
): JsonObject | undefined {
|
||||
const dashboardParamsString =
|
||||
annotations[ANNOTATION_KUBERNETES_DASHBOARD_PARAMETERS];
|
||||
if (dashboardParamsString) {
|
||||
try {
|
||||
const dashboardParams = JSON.parse(dashboardParamsString);
|
||||
return isObject(dashboardParams) ? dashboardParams : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CatalogClusterLocator returns the aws cluster details provided by annotations 1`] = `
|
||||
{
|
||||
"authMetadata": {
|
||||
"kubernetes.io/api-server": "https://apiserver.com",
|
||||
"kubernetes.io/api-server-certificate-authority": "caData",
|
||||
"kubernetes.io/auth-provider": "aws",
|
||||
"kubernetes.io/aws-assume-role": "my-role",
|
||||
"kubernetes.io/aws-external-id": "my-id",
|
||||
"kubernetes.io/dashboard-app": "my-app",
|
||||
"kubernetes.io/dashboard-url": "my-url",
|
||||
"kubernetes.io/oidc-token-provider": "google",
|
||||
},
|
||||
"caData": "caData",
|
||||
"dashboardApp": "my-app",
|
||||
"dashboardParameters": undefined,
|
||||
"dashboardUrl": "my-url",
|
||||
"name": "owned",
|
||||
"skipMetricsLookup": false,
|
||||
"skipTLSVerify": false,
|
||||
"url": "https://apiserver.com",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`CatalogClusterLocator returns the cluster details provided by annotations 1`] = `
|
||||
{
|
||||
"authMetadata": {
|
||||
"kubernetes.io/api-server": "https://apiserver.com",
|
||||
"kubernetes.io/api-server-certificate-authority": "caData",
|
||||
"kubernetes.io/auth-provider": "oidc",
|
||||
"kubernetes.io/dashboard-app": "my-app",
|
||||
"kubernetes.io/dashboard-url": "my-url",
|
||||
"kubernetes.io/oidc-token-provider": "google",
|
||||
"kubernetes.io/skip-metrics-lookup": "true",
|
||||
"kubernetes.io/skip-tls-verify": "true",
|
||||
},
|
||||
"caData": "caData",
|
||||
"dashboardApp": "my-app",
|
||||
"dashboardParameters": undefined,
|
||||
"dashboardUrl": "my-url",
|
||||
"name": "owned",
|
||||
"skipMetricsLookup": true,
|
||||
"skipTLSVerify": true,
|
||||
"url": "https://apiserver.com",
|
||||
}
|
||||
`;
|
||||
@@ -46,6 +46,10 @@ export const ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID =
|
||||
export const ANNOTATION_KUBERNETES_DASHBOARD_APP =
|
||||
'kubernetes.io/dashboard-app';
|
||||
|
||||
// @public
|
||||
export const ANNOTATION_KUBERNETES_DASHBOARD_PARAMETERS =
|
||||
'kubernetes.io/dashboard-parameters';
|
||||
|
||||
// @public
|
||||
export const ANNOTATION_KUBERNETES_DASHBOARD_URL =
|
||||
'kubernetes.io/dashboard-url';
|
||||
|
||||
@@ -77,6 +77,13 @@ export const ANNOTATION_KUBERNETES_DASHBOARD_URL =
|
||||
export const ANNOTATION_KUBERNETES_DASHBOARD_APP =
|
||||
'kubernetes.io/dashboard-app';
|
||||
|
||||
/**
|
||||
* Annotation for specifying the dashboard app parameters for a Kubernetes cluster.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const ANNOTATION_KUBERNETES_DASHBOARD_PARAMETERS =
|
||||
'kubernetes.io/dashboard-parameters';
|
||||
/**
|
||||
* Annotation for specifying the assume role use to authenticate with AWS.
|
||||
*
|
||||
|
||||
@@ -5432,6 +5432,7 @@ __metadata:
|
||||
"@backstage/backend-plugin-api": "workspace:^"
|
||||
"@backstage/backend-tasks": "workspace:^"
|
||||
"@backstage/backend-test-utils": "workspace:^"
|
||||
"@backstage/catalog-model": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/plugin-catalog-node": "workspace:^"
|
||||
|
||||
Reference in New Issue
Block a user