Merge pull request #2237 from pikasTech/fix/1148-d518-secret-plane-selector
fix: scope v03 secret-plane smoke to D601
This commit is contained in:
@@ -8,6 +8,8 @@ metadata:
|
|||||||
secretPlane:
|
secretPlane:
|
||||||
enabled: true
|
enabled: true
|
||||||
issue: pikasTech/HWLAB#2234
|
issue: pikasTech/HWLAB#2234
|
||||||
|
enabledOnNodes:
|
||||||
|
- D601
|
||||||
valuesPrinted: false
|
valuesPrinted: false
|
||||||
store:
|
store:
|
||||||
kind: ClusterSecretStore
|
kind: ClusterSecretStore
|
||||||
|
|||||||
@@ -1292,6 +1292,7 @@ function mergeEnvMaps(...values) {
|
|||||||
|
|
||||||
function runtimeNodeIdForProfile(deploy, profile, fallback = "node") {
|
function runtimeNodeIdForProfile(deploy, profile, fallback = "node") {
|
||||||
if (!isRuntimeLane(profile)) return fallback;
|
if (!isRuntimeLane(profile)) return fallback;
|
||||||
|
if (typeof fallback === "string" && fallback.trim() && fallback !== "node") return fallback;
|
||||||
const nodeId = deploy?.lanes?.[profile]?.node;
|
const nodeId = deploy?.lanes?.[profile]?.node;
|
||||||
return typeof nodeId === "string" && nodeId.length > 0 ? nodeId : fallback;
|
return typeof nodeId === "string" && nodeId.length > 0 ? nodeId : fallback;
|
||||||
}
|
}
|
||||||
@@ -1357,7 +1358,7 @@ function upsertV02MetricsPort(service) {
|
|||||||
service.spec.ports.push({ name: "metrics", port: 9100, targetPort: "metrics" });
|
service.spec.ports.push({ name: "metrics", port: 9100, targetPort: "metrics" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch = defaultBranch, gitReadUrl, registryPrefix, runtimeEndpoint, webEndpoint, profile = "dev", useDeployImages = false, metricsSidecarSha256 = null }) {
|
function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch = defaultBranch, gitReadUrl, registryPrefix, runtimeEndpoint, webEndpoint, profile = "dev", nodeId = "node", useDeployImages = false, metricsSidecarSha256 = null }) {
|
||||||
const result = cloneJson(workloads);
|
const result = cloneJson(workloads);
|
||||||
const deployServices = deployServicesForProfile(deploy, profile);
|
const deployServices = deployServicesForProfile(deploy, profile);
|
||||||
const namespace = namespaceNameForProfile(profile);
|
const namespace = namespaceNameForProfile(profile);
|
||||||
@@ -1486,7 +1487,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch =
|
|||||||
applyServiceShutdownDrainLifecycle(container, deployService.shutdownDrain);
|
applyServiceShutdownDrainLifecycle(container, deployService.shutdownDrain);
|
||||||
upsertEnv(container.env, "HWLAB_M3_IO_CONTROL_ENABLED", "false");
|
upsertEnv(container.env, "HWLAB_M3_IO_CONTROL_ENABLED", "false");
|
||||||
upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_REPO_URL", gitReadUrl ?? defaultRuntimeLaneGitReadUrl);
|
upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_REPO_URL", gitReadUrl ?? defaultRuntimeLaneGitReadUrl);
|
||||||
upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID", runtimeNodeIdForProfile(deploy, profile));
|
upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID", runtimeNodeIdForProfile(deploy, profile, nodeId));
|
||||||
upsertEnv(container.env, "HWLAB_OPENFGA_MODE", "enforce");
|
upsertEnv(container.env, "HWLAB_OPENFGA_MODE", "enforce");
|
||||||
upsertEnv(container.env, "HWLAB_OPENFGA_API_URL", `http://hwlab-openfga.${namespace}.svc.cluster.local:8080`);
|
upsertEnv(container.env, "HWLAB_OPENFGA_API_URL", `http://hwlab-openfga.${namespace}.svc.cluster.local:8080`);
|
||||||
upsertEnvEntry(container.env, deployEnvEntry("HWLAB_OPENFGA_AUTHN_TOKEN", `secretRef:hwlab-${profile}-openfga/authn-preshared-key`, namespace, profile));
|
upsertEnvEntry(container.env, deployEnvEntry("HWLAB_OPENFGA_AUTHN_TOKEN", `secretRef:hwlab-${profile}-openfga/authn-preshared-key`, namespace, profile));
|
||||||
@@ -5521,7 +5522,7 @@ function runtimeConfigMapsManifest({ configMaps, namespace, labels, annotations
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runtimeSecretPlaneConfigForProfile(deploy, profile) {
|
async function runtimeSecretPlaneConfigForProfile(deploy, profile, args = {}) {
|
||||||
if (!isRuntimeLane(profile)) return null;
|
if (!isRuntimeLane(profile)) return null;
|
||||||
const laneConfig = deploy?.lanes?.[profile];
|
const laneConfig = deploy?.lanes?.[profile];
|
||||||
const secretPlane = laneConfig?.secretPlaneRef
|
const secretPlane = laneConfig?.secretPlaneRef
|
||||||
@@ -5529,9 +5530,39 @@ async function runtimeSecretPlaneConfigForProfile(deploy, profile) {
|
|||||||
: laneConfig?.secretPlane;
|
: laneConfig?.secretPlane;
|
||||||
if (!secretPlane || typeof secretPlane !== "object" || Array.isArray(secretPlane)) return null;
|
if (!secretPlane || typeof secretPlane !== "object" || Array.isArray(secretPlane)) return null;
|
||||||
if (secretPlane.enabled !== true) return null;
|
if (secretPlane.enabled !== true) return null;
|
||||||
|
if (!runtimeSecretPlaneEnabledForNode(secretPlane, args)) return null;
|
||||||
return cloneJson(secretPlane);
|
return cloneJson(secretPlane);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runtimeSecretPlaneEnabledForNode(secretPlane, args = {}) {
|
||||||
|
const enabledOnNodes = normalizeSecretPlaneNodeList(secretPlane.enabledOnNodes, "secretPlane.enabledOnNodes");
|
||||||
|
if (enabledOnNodes.length === 0) return true;
|
||||||
|
const nodeId = effectiveSecretPlaneNodeId(args);
|
||||||
|
assert.ok(nodeId, "secretPlane.enabledOnNodes requires --node or a node-scoped --gitops-root");
|
||||||
|
return enabledOnNodes.includes(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSecretPlaneNodeList(value, label) {
|
||||||
|
if (value === undefined) return [];
|
||||||
|
assert.ok(Array.isArray(value), `${label} must be an array when set`);
|
||||||
|
return value.map((item, index) => {
|
||||||
|
assert.equal(typeof item, "string", `${label}[${index}] must be a string`);
|
||||||
|
const nodeId = item.trim().toUpperCase();
|
||||||
|
assert.ok(/^[A-Z0-9][A-Z0-9-]*$/u.test(nodeId), `${label}[${index}] must be a node id`);
|
||||||
|
return nodeId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveSecretPlaneNodeId(args = {}) {
|
||||||
|
return gitopsRootNodeId(args.gitopsRoot) ?? String(args.nodeId ?? "").trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitopsRootNodeId(gitopsRoot) {
|
||||||
|
const normalized = String(gitopsRoot ?? "").replaceAll("\\", "/").replace(/\/+$/u, "");
|
||||||
|
const match = normalized.match(/(?:^|\/)deploy\/gitops\/node\/([^/]+)$/u);
|
||||||
|
return match?.[1] ? match[1].trim().toUpperCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
function runtimeSecretPlaneManifest({ config, namespace, labels, annotations }) {
|
function runtimeSecretPlaneManifest({ config, namespace, labels, annotations }) {
|
||||||
const store = config?.store;
|
const store = config?.store;
|
||||||
assert.ok(store && typeof store === "object" && !Array.isArray(store), "secretPlane.store must be an object");
|
assert.ok(store && typeof store === "object" && !Array.isArray(store), "secretPlane.store must be an object");
|
||||||
@@ -5744,7 +5775,7 @@ async function plannedFiles(args) {
|
|||||||
const externalPostgres = externalPostgresConfigForLane(deploy, profile);
|
const externalPostgres = externalPostgresConfigForLane(deploy, profile);
|
||||||
const workbenchRedis = workbenchRuntimeRedisConfigForProfile(deploy, profile);
|
const workbenchRedis = workbenchRuntimeRedisConfigForProfile(deploy, profile);
|
||||||
const runtimeConfigMaps = runtimeConfigMapsForProfile(deploy, profile);
|
const runtimeConfigMaps = runtimeConfigMapsForProfile(deploy, profile);
|
||||||
const runtimeSecretPlane = await runtimeSecretPlaneConfigForProfile(deploy, profile);
|
const runtimeSecretPlane = await runtimeSecretPlaneConfigForProfile(deploy, profile, args);
|
||||||
const profileLabels = { ...baseLabels, "hwlab.pikastech.local/environment": runtimeLabelForProfile(profile), "hwlab.pikastech.local/profile": runtimeLabelForProfile(profile) };
|
const profileLabels = { ...baseLabels, "hwlab.pikastech.local/environment": runtimeLabelForProfile(profile), "hwlab.pikastech.local/profile": runtimeLabelForProfile(profile) };
|
||||||
const runtimeNamespace = cloneJson(namespaceTemplate);
|
const runtimeNamespace = cloneJson(namespaceTemplate);
|
||||||
runtimeNamespace.metadata.name = namespace;
|
runtimeNamespace.metadata.name = namespace;
|
||||||
@@ -5759,7 +5790,7 @@ async function plannedFiles(args) {
|
|||||||
putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile));
|
putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile));
|
||||||
if (runtimeConfigMaps.length > 0) putJson(`${runtimePath}/configmaps.yaml`, runtimeConfigMapsManifest({ configMaps: runtimeConfigMaps, namespace, labels: profileLabels, annotations }));
|
if (runtimeConfigMaps.length > 0) putJson(`${runtimePath}/configmaps.yaml`, runtimeConfigMapsManifest({ configMaps: runtimeConfigMaps, namespace, labels: profileLabels, annotations }));
|
||||||
if (runtimeSecretPlane) putJson(`${runtimePath}/external-secrets.yaml`, runtimeSecretPlaneManifest({ config: runtimeSecretPlane, namespace, labels: profileLabels, annotations }));
|
if (runtimeSecretPlane) putJson(`${runtimePath}/external-secrets.yaml`, runtimeSecretPlaneManifest({ config: runtimeSecretPlane, namespace, labels: profileLabels, annotations }));
|
||||||
putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, sourceBranch: args.sourceBranch, gitReadUrl: args.gitReadUrl, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
|
putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, sourceBranch: args.sourceBranch, gitReadUrl: args.gitReadUrl, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, nodeId: args.nodeId, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
|
||||||
putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, sourceBranch: args.sourceBranch, sourceRepo: args.sourceRepo, gitReadUrl: args.gitReadUrl, deploy, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
|
putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, sourceBranch: args.sourceBranch, sourceRepo: args.sourceRepo, gitReadUrl: args.gitReadUrl, deploy, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
|
||||||
if (isRuntimeLane(profile) && externalPostgres) putJson(`${runtimePath}/external-postgres.yaml`, externalPostgresManifest({ profile, config: externalPostgres, source }));
|
if (isRuntimeLane(profile) && externalPostgres) putJson(`${runtimePath}/external-postgres.yaml`, externalPostgresManifest({ profile, config: externalPostgres, source }));
|
||||||
if (isRuntimeLane(profile) && !externalPostgres) putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ profile, migrationSql, source, image: runtimePostgresImageForProfile(deploy, profile) }));
|
if (isRuntimeLane(profile) && !externalPostgres) putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ profile, migrationSql, source, image: runtimePostgresImageForProfile(deploy, profile) }));
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ test("v03 render keeps node identity as data instead of generated structure", as
|
|||||||
"scripts/run-bun.mjs",
|
"scripts/run-bun.mjs",
|
||||||
"scripts/gitops-render.mjs",
|
"scripts/gitops-render.mjs",
|
||||||
"--lane", "v03",
|
"--lane", "v03",
|
||||||
"--node", "G14",
|
"--node", "D601",
|
||||||
"--catalog-path", staleCatalogPath,
|
"--catalog-path", staleCatalogPath,
|
||||||
"--image-tag-mode", "full",
|
"--image-tag-mode", "full",
|
||||||
"--source-branch", "v0.3",
|
"--source-branch", "v0.3",
|
||||||
@@ -630,10 +630,82 @@ test("v03 render keeps node identity as data instead of generated structure", as
|
|||||||
.flatMap((container) => container.env ?? [])
|
.flatMap((container) => container.env ?? [])
|
||||||
.filter((entry) => entry.name === "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID");
|
.filter((entry) => entry.name === "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID");
|
||||||
assert.ok(agentRunNodeEnv.length > 0, "expected AgentRun provider id env to carry configured node id");
|
assert.ok(agentRunNodeEnv.length > 0, "expected AgentRun provider id env to carry configured node id");
|
||||||
assert.deepEqual([...new Set(agentRunNodeEnv.map((entry) => entry.value))], ["G14"]);
|
assert.deepEqual([...new Set(agentRunNodeEnv.map((entry) => entry.value))], ["D601"]);
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
await rm(outDir, { recursive: true, force: true });
|
await rm(outDir, { recursive: true, force: true });
|
||||||
await rm(scratchDir, { recursive: true, force: true });
|
await rm(scratchDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("v03 render skips D601 secret-plane smoke on D518 gitops root", async () => {
|
||||||
|
const outDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-v03-d518-render-test-"));
|
||||||
|
const scratchDir = path.join(".tmp", `gitops-render-v03-d518-${process.pid}-${Date.now()}`);
|
||||||
|
const staleCatalogPath = path.join(scratchDir, "artifact-catalog.v03.json");
|
||||||
|
try {
|
||||||
|
const staleRevision = "0".repeat(40);
|
||||||
|
await mkdir(scratchDir, { recursive: true });
|
||||||
|
await writeFile(staleCatalogPath, JSON.stringify({
|
||||||
|
catalogVersion: "v1",
|
||||||
|
kind: "hwlab-artifact-catalog",
|
||||||
|
environment: "v03",
|
||||||
|
profile: "v03",
|
||||||
|
namespace: "hwlab-v03",
|
||||||
|
commitId: staleRevision.slice(0, 12),
|
||||||
|
artifactState: "ready",
|
||||||
|
services: [{
|
||||||
|
serviceId: "hwlab-cloud-api",
|
||||||
|
runtimeMode: "env-reuse-git-mirror-checkout",
|
||||||
|
envReuse: true,
|
||||||
|
sourceCommitId: staleRevision,
|
||||||
|
commitId: staleRevision,
|
||||||
|
image: "127.0.0.1:5000/hwlab/hwlab-cloud-api-env:env-stale",
|
||||||
|
imageTag: "env-stale",
|
||||||
|
digest: `sha256:${"1".repeat(64)}`,
|
||||||
|
environmentImage: "127.0.0.1:5000/hwlab/hwlab-cloud-api-env:env-stale",
|
||||||
|
environmentDigest: `sha256:${"1".repeat(64)}`,
|
||||||
|
environmentInputHash: "e".repeat(64),
|
||||||
|
codeInputHash: "c".repeat(64),
|
||||||
|
bootRepo: "git@github.com:pikasTech/HWLAB.git",
|
||||||
|
bootCommit: staleRevision,
|
||||||
|
bootSh: "deploy/runtime/boot/hwlab-cloud-api.sh"
|
||||||
|
}]
|
||||||
|
}, null, 2));
|
||||||
|
const render = spawnSync(process.execPath, [
|
||||||
|
"scripts/run-bun.mjs",
|
||||||
|
"scripts/gitops-render.mjs",
|
||||||
|
"--lane", "v03",
|
||||||
|
"--catalog-path", staleCatalogPath,
|
||||||
|
"--image-tag-mode", "full",
|
||||||
|
"--source-branch", "v0.3",
|
||||||
|
"--gitops-branch", "v0.3-gitops",
|
||||||
|
"--gitops-root", "deploy/gitops/node/d518",
|
||||||
|
"--out", outDir,
|
||||||
|
"--source-revision", "HEAD"
|
||||||
|
], { cwd: process.cwd(), encoding: "utf8" });
|
||||||
|
assert.equal(render.status, 0, render.stderr || render.stdout);
|
||||||
|
|
||||||
|
const generatedFiles = await collectGeneratedFiles(outDir);
|
||||||
|
const generatedPaths = generatedFiles.map((filePath) => path.relative(outDir, filePath));
|
||||||
|
assert.ok(generatedPaths.includes("runtime-v03/kustomization.yaml"));
|
||||||
|
assert.ok(generatedPaths.includes("runtime-v03/workloads.yaml"));
|
||||||
|
assert.equal(generatedPaths.includes("runtime-v03/external-secrets.yaml"), false);
|
||||||
|
const kustomization = await readFile(path.join(outDir, "runtime-v03", "kustomization.yaml"), "utf8");
|
||||||
|
assert.doesNotMatch(kustomization, /external-secrets\.yaml/u);
|
||||||
|
|
||||||
|
const workloadsJson = JSON.parse(await readFile(path.join(outDir, "runtime-v03", "workloads.yaml"), "utf8"));
|
||||||
|
const cloudApi = (workloadsJson.items ?? []).find((item) => item.kind === "Deployment" && item.metadata?.name === "hwlab-cloud-api");
|
||||||
|
const cloudApiContainer = collectContainersFromItem(cloudApi).find((container) => container.name === "hwlab-cloud-api");
|
||||||
|
const cloudApiEnvEntries = new Map((cloudApiContainer?.env ?? []).map((entry) => [entry.name, entry]));
|
||||||
|
assert.deepEqual(cloudApiEnvEntries.get("HWLAB_SECRET_PLANE_SMOKE")?.valueFrom?.secretKeyRef, { name: "hwlab-secret-plane-smoke", key: "password", optional: true });
|
||||||
|
const agentRunNodeEnv = (workloadsJson.items ?? [])
|
||||||
|
.flatMap((item) => collectContainersFromItem(item))
|
||||||
|
.flatMap((container) => container.env ?? [])
|
||||||
|
.filter((entry) => entry.name === "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID");
|
||||||
|
assert.ok(agentRunNodeEnv.length > 0, "expected D518 node id to be inferred from gitops root");
|
||||||
|
assert.deepEqual([...new Set(agentRunNodeEnv.map((entry) => entry.value))], ["D518"]);
|
||||||
|
} finally {
|
||||||
|
await rm(outDir, { recursive: true, force: true });
|
||||||
|
await rm(scratchDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ export function applyRuntimeLaneDeployConfig(args: GitOpsRenderArgs, deploy: Dep
|
|||||||
if (!isRuntimeLane(args.lane)) return args;
|
if (!isRuntimeLane(args.lane)) return args;
|
||||||
const result: GitOpsRenderArgs = { ...args };
|
const result: GitOpsRenderArgs = { ...args };
|
||||||
const laneConfig = runtimeLaneConfig(deploy, args.lane);
|
const laneConfig = runtimeLaneConfig(deploy, args.lane);
|
||||||
const nodeId = nodeIdForRuntimeLane(deploy, args.lane, defaults);
|
const configuredNodeId = nodeIdForRuntimeLane(deploy, args.lane, defaults);
|
||||||
|
const rootNodeId = nodeIdFromGitopsRoot(result.gitopsRoot);
|
||||||
|
const nodeId = result.nodeId && result.nodeId !== defaultNodeId ? result.nodeId : rootNodeId ?? configuredNodeId;
|
||||||
const node = nodeConfig(deploy, nodeId);
|
const node = nodeConfig(deploy, nodeId);
|
||||||
const versionName = versionNameForRuntimeLane(args.lane);
|
const versionName = versionNameForRuntimeLane(args.lane);
|
||||||
const defaultEndpointOptions: { host?: string; v02Endpoint?: string } = {};
|
const defaultEndpointOptions: { host?: string; v02Endpoint?: string } = {};
|
||||||
@@ -172,6 +174,13 @@ export function applyRuntimeLaneDeployConfig(args: GitOpsRenderArgs, deploy: Dep
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nodeIdFromGitopsRoot(gitopsRoot: unknown): string | undefined {
|
||||||
|
if (typeof gitopsRoot !== "string") return undefined;
|
||||||
|
const normalized = normalizeRepoPath(gitopsRoot);
|
||||||
|
const match = normalized.match(/(?:^|\/)deploy\/gitops\/node\/([^/]+)$/u);
|
||||||
|
return match?.[1] ? match[1].toUpperCase() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function runtimeLaneMirrorSpecs(deploy: DeployConfig, defaults: RuntimeLaneDefaults = {}): RuntimeLaneMirrorSpec[] {
|
export function runtimeLaneMirrorSpecs(deploy: DeployConfig, defaults: RuntimeLaneDefaults = {}): RuntimeLaneMirrorSpec[] {
|
||||||
return Object.entries(deploy.lanes ?? {})
|
return Object.entries(deploy.lanes ?? {})
|
||||||
.filter(([lane, config]) => isRuntimeLane(lane) && isObject(config))
|
.filter(([lane, config]) => isRuntimeLane(lane) && isObject(config))
|
||||||
|
|||||||
Reference in New Issue
Block a user