feat: add d601 k3s readonly observability
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
formatFailure,
|
||||
runD601K3sReadonlyObservability
|
||||
} from "./src/d601-k3s-readonly-observability.mjs";
|
||||
|
||||
try {
|
||||
process.exitCode = await runD601K3sReadonlyObservability(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stdout.write(`${JSON.stringify(formatFailure(error), null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { access, mkdir, stat, writeFile } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const namespace = "hwlab-dev";
|
||||
const defaultReportPath = "reports/d601-k3s-readonly-observability.json";
|
||||
const issue = "pikasTech/HWLAB#46";
|
||||
const supports = ["#7", "#33", "#36", "#38", "#39", "#42"].map((id) => `pikasTech/HWLAB${id}`);
|
||||
const forbiddenActions = [
|
||||
"prod-access",
|
||||
"secret-material-read",
|
||||
"cluster-mutation",
|
||||
"runtime-restart",
|
||||
"heavy-e2e",
|
||||
"master-server-heavy-check"
|
||||
];
|
||||
const kubeconfigCandidatePaths = [
|
||||
"~/.kube/config",
|
||||
"/etc/rancher/k3s/k3s.yaml",
|
||||
"/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
|
||||
];
|
||||
const sshEnvVars = [
|
||||
"HWLAB_D601_SSH_HOST",
|
||||
"HWLAB_D601_SSH_TARGET",
|
||||
"HWLAB_D601_MAINTENANCE_HOST",
|
||||
"D601_SSH_HOST",
|
||||
"HWLAB_MAINTENANCE_SSH"
|
||||
];
|
||||
const blockerTypes = new Set(["environment_blocker", "observability_blocker", "safety_blocker"]);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
reportPath: defaultReportPath,
|
||||
timeoutMs: 5000,
|
||||
writeReport: true,
|
||||
pretty: false,
|
||||
failOnBlocked: false
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--report") {
|
||||
args.reportPath = argv[++index];
|
||||
} else if (arg === "--timeout-ms") {
|
||||
args.timeoutMs = Number.parseInt(argv[++index], 10);
|
||||
} else if (arg === "--no-write") {
|
||||
args.writeReport = false;
|
||||
} else if (arg === "--pretty") {
|
||||
args.pretty = true;
|
||||
} else if (arg === "--fail-on-blocked") {
|
||||
args.failOnBlocked = true;
|
||||
} else if (arg === "--help") {
|
||||
args.help = true;
|
||||
} else {
|
||||
throw new Error(`unknown argument ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(Number.isInteger(args.timeoutMs) && args.timeoutMs > 0, "--timeout-ms must be positive");
|
||||
return args;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage: node scripts/d601-k3s-readonly-observability.mjs [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --report <path> Write report path",
|
||||
" --timeout-ms <ms> Per-command timeout",
|
||||
" --no-write Print only, do not write report",
|
||||
" --pretty Pretty-print the full report",
|
||||
" --fail-on-blocked Exit 2 when conclusion is blocked",
|
||||
" --help Show this help"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function redacted(value) {
|
||||
return String(value)
|
||||
.replace(/(bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1[REDACTED]")
|
||||
.replace(/((?:token|password|client-key-data|client-certificate-data|certificate-authority-data)\s*[:=]\s*)\S+/giu, "$1[REDACTED]")
|
||||
.replace(/(authorization:\s*)\S+/giu, "$1[REDACTED]")
|
||||
.slice(0, 4000);
|
||||
}
|
||||
|
||||
function oneLine(value) {
|
||||
return redacted(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function expandHome(filePath) {
|
||||
return filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath;
|
||||
}
|
||||
|
||||
function displayPath(filePath) {
|
||||
const home = os.homedir();
|
||||
return filePath.startsWith(`${home}${path.sep}`) ? `~/${filePath.slice(home.length + 1)}` : filePath;
|
||||
}
|
||||
|
||||
function commandText(command, args) {
|
||||
return [command, ...args].join(" ");
|
||||
}
|
||||
|
||||
async function run(command, args = [], options = {}) {
|
||||
try {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd: repoRoot,
|
||||
timeout: options.timeoutMs ?? 5000,
|
||||
maxBuffer: 1024 * 1024
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
command: commandText(command, args),
|
||||
exitCode: 0,
|
||||
stdout: redacted(result.stdout.trim()),
|
||||
stderr: redacted(result.stderr.trim())
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
command: commandText(command, args),
|
||||
exitCode: typeof error.code === "number" ? error.code : 1,
|
||||
stdout: redacted(String(error.stdout ?? "").trim()),
|
||||
stderr: redacted(String(error.stderr ?? "").trim()),
|
||||
error: oneLine(error.message)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function findExecutable(name) {
|
||||
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
const candidate = path.join(dir, name);
|
||||
try {
|
||||
await access(candidate, fsConstants.X_OK);
|
||||
return candidate;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fileProbe(filePath) {
|
||||
const absolutePath = expandHome(filePath);
|
||||
try {
|
||||
const info = await stat(absolutePath);
|
||||
await access(absolutePath, fsConstants.R_OK);
|
||||
return {
|
||||
path: displayPath(absolutePath),
|
||||
exists: true,
|
||||
readable: true,
|
||||
type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other",
|
||||
sizeBytes: info.isFile() ? info.size : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
path: displayPath(absolutePath),
|
||||
exists: error?.code !== "ENOENT",
|
||||
readable: false,
|
||||
error: error?.code ?? "UNKNOWN"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function collectBinary(name, versionArgs) {
|
||||
const executablePath = await findExecutable(name);
|
||||
const result = {
|
||||
name,
|
||||
available: executablePath !== null,
|
||||
path: executablePath ? displayPath(executablePath) : null
|
||||
};
|
||||
if (executablePath && versionArgs) {
|
||||
const version = await run(name, versionArgs, { timeoutMs: 5000 });
|
||||
result.version = {
|
||||
ok: version.ok,
|
||||
command: version.command,
|
||||
output: oneLine(version.stdout || version.stderr || version.error)
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function collectBinaries() {
|
||||
const entries = await Promise.all([
|
||||
collectBinary("kubectl", ["version", "--client=true"]),
|
||||
collectBinary("k3s", ["--version"]),
|
||||
collectBinary("k3sctl", ["version"]),
|
||||
collectBinary("ssh", ["-V"])
|
||||
]);
|
||||
return Object.fromEntries(entries.map((entry) => [entry.name, entry]));
|
||||
}
|
||||
|
||||
function kubeconfigEnvPaths() {
|
||||
const raw = process.env.KUBECONFIG;
|
||||
if (!raw) return [];
|
||||
return raw.split(path.delimiter).filter(Boolean);
|
||||
}
|
||||
|
||||
async function collectKubeconfigPaths() {
|
||||
const envPaths = kubeconfigEnvPaths();
|
||||
const candidates = [
|
||||
...envPaths.map((item) => ({ source: "KUBECONFIG", path: item })),
|
||||
...kubeconfigCandidatePaths.map((item) => ({ source: "default", path: item }))
|
||||
];
|
||||
const seen = new Set();
|
||||
const uniqueCandidates = candidates.filter((item) => {
|
||||
const key = expandHome(item.path);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return Promise.all(uniqueCandidates.map(async (item) => ({
|
||||
source: item.source,
|
||||
...(await fileProbe(item.path))
|
||||
})));
|
||||
}
|
||||
|
||||
async function collectSshBridge(binaries) {
|
||||
const env = sshEnvVars.map((name) => ({
|
||||
name,
|
||||
set: Object.hasOwn(process.env, name),
|
||||
valueRedacted: Object.hasOwn(process.env, name) ? "[REDACTED]" : null
|
||||
}));
|
||||
const paths = await Promise.all(["~/.ssh", "~/.ssh/config", "~/.ssh/known_hosts"].map(fileProbe));
|
||||
return {
|
||||
sshBinaryAvailable: binaries.ssh.available,
|
||||
env,
|
||||
paths,
|
||||
configured: binaries.ssh.available && (
|
||||
env.some((item) => item.set) ||
|
||||
paths.some((item) => item.path.endsWith("/.ssh/config") && item.readable)
|
||||
),
|
||||
note: "SSH bridge discovery checks only binary, environment variable presence, and file metadata; it does not read SSH config or private keys."
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonProbe(probe) {
|
||||
if (!probe.ok || !probe.stdout) return null;
|
||||
try {
|
||||
return JSON.parse(probe.stdout);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function summarizePods(document) {
|
||||
const pods = document?.items ?? [];
|
||||
return {
|
||||
count: pods.length,
|
||||
items: pods.map((pod) => {
|
||||
const statuses = pod.status?.containerStatuses ?? [];
|
||||
return {
|
||||
name: pod.metadata?.name,
|
||||
phase: pod.status?.phase,
|
||||
readyContainers: statuses.filter((item) => item.ready).length,
|
||||
totalContainers: statuses.length,
|
||||
restartCount: statuses.reduce((total, item) => total + (item.restartCount ?? 0), 0),
|
||||
nodeName: pod.spec?.nodeName ?? null
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeServices(document) {
|
||||
const services = document?.items ?? [];
|
||||
return {
|
||||
count: services.length,
|
||||
items: services.map((service) => ({
|
||||
name: service.metadata?.name,
|
||||
type: service.spec?.type,
|
||||
clusterIP: service.spec?.clusterIP ?? null,
|
||||
ports: (service.spec?.ports ?? []).map((port) => ({
|
||||
name: port.name ?? null,
|
||||
port: port.port,
|
||||
targetPort: port.targetPort,
|
||||
protocol: port.protocol
|
||||
}))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeConfigMaps(document) {
|
||||
const configMaps = document?.items ?? [];
|
||||
return {
|
||||
count: configMaps.length,
|
||||
items: configMaps.map((configMap) => ({
|
||||
name: configMap.metadata?.name,
|
||||
keys: Object.keys(configMap.data ?? {}).sort()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function readonlyExecutor(name, command, prefixArgs = []) {
|
||||
return { name, command, prefixArgs };
|
||||
}
|
||||
|
||||
async function runExecutorProbe(executor, args, timeoutMs) {
|
||||
return run(executor.command, [...executor.prefixArgs, ...args], { timeoutMs });
|
||||
}
|
||||
|
||||
function authAllowed(probe) {
|
||||
return probe.ok && probe.stdout.trim().toLowerCase() === "yes";
|
||||
}
|
||||
|
||||
function compactProbe(probe, { includeStdout = true } = {}) {
|
||||
return {
|
||||
ok: probe.ok,
|
||||
command: probe.command,
|
||||
exitCode: probe.exitCode,
|
||||
stdout: includeStdout && probe.ok && probe.stdout.length <= 200 ? probe.stdout : undefined,
|
||||
stderr: probe.stderr ? oneLine(probe.stderr) : undefined,
|
||||
error: probe.error
|
||||
};
|
||||
}
|
||||
|
||||
async function collectClusterForExecutor(executor, timeoutMs) {
|
||||
const version = await runExecutorProbe(executor, ["version", "--client=true"], timeoutMs);
|
||||
const context = await runExecutorProbe(executor, ["config", "current-context"], timeoutMs);
|
||||
const namespaceProbe = await runExecutorProbe(executor, ["get", "namespace", namespace, "-o", "json"], timeoutMs);
|
||||
const auth = {};
|
||||
for (const resource of ["pods", "services", "configmaps"]) {
|
||||
auth[resource] = await runExecutorProbe(executor, ["auth", "can-i", "get", resource, "-n", namespace], timeoutMs);
|
||||
}
|
||||
|
||||
const podsProbe = await runExecutorProbe(executor, ["-n", namespace, "get", "pods", "-o", "json"], timeoutMs);
|
||||
const servicesProbe = await runExecutorProbe(executor, ["-n", namespace, "get", "services", "-o", "json"], timeoutMs);
|
||||
const configMapsProbe = await runExecutorProbe(executor, ["-n", namespace, "get", "configmaps", "-o", "json"], timeoutMs);
|
||||
const podsJson = parseJsonProbe(podsProbe);
|
||||
const servicesJson = parseJsonProbe(servicesProbe);
|
||||
const configMapsJson = parseJsonProbe(configMapsProbe);
|
||||
const ready = Boolean(namespaceProbe.ok && podsJson && servicesJson && configMapsJson);
|
||||
|
||||
return {
|
||||
executor: executor.name,
|
||||
ready,
|
||||
probes: {
|
||||
clientVersion: compactProbe(version),
|
||||
currentContext: compactProbe(context),
|
||||
namespace: compactProbe(namespaceProbe, { includeStdout: false }),
|
||||
auth: Object.fromEntries(Object.entries(auth).map(([resource, probe]) => [
|
||||
resource,
|
||||
{
|
||||
allowed: authAllowed(probe),
|
||||
...compactProbe(probe)
|
||||
}
|
||||
])),
|
||||
pods: compactProbe(podsProbe, { includeStdout: false }),
|
||||
services: compactProbe(servicesProbe, { includeStdout: false }),
|
||||
configMaps: compactProbe(configMapsProbe, { includeStdout: false })
|
||||
},
|
||||
summaries: ready ? {
|
||||
pods: summarizePods(podsJson),
|
||||
services: summarizeServices(servicesJson),
|
||||
configMaps: summarizeConfigMaps(configMapsJson)
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
async function collectClusterObservability(binaries, timeoutMs) {
|
||||
const executors = [];
|
||||
if (binaries.kubectl.available) {
|
||||
executors.push(readonlyExecutor("kubectl", "kubectl"));
|
||||
}
|
||||
if (binaries.k3s.available) {
|
||||
executors.push(readonlyExecutor("k3s-kubectl", "k3s", ["kubectl"]));
|
||||
}
|
||||
const results = [];
|
||||
for (const executor of executors) {
|
||||
results.push(await collectClusterForExecutor(executor, timeoutMs));
|
||||
}
|
||||
return {
|
||||
namespace,
|
||||
attemptedExecutors: results.map((item) => item.executor),
|
||||
readable: results.some((item) => item.ready),
|
||||
executors: results
|
||||
};
|
||||
}
|
||||
|
||||
function addBlocker(blockers, blocker) {
|
||||
assert.ok(blockerTypes.has(blocker.type), `unknown blocker type ${blocker.type}`);
|
||||
const key = `${blocker.type}:${blocker.scope}`;
|
||||
if (!blockers.some((item) => `${item.type}:${item.scope}` === key)) {
|
||||
blockers.push({ status: "open", ...blocker });
|
||||
}
|
||||
}
|
||||
|
||||
function buildBlockers({ binaries, kubeconfig, sshBridge, cluster }) {
|
||||
const blockers = [];
|
||||
const hasDirectClient = binaries.kubectl.available || binaries.k3s.available;
|
||||
if (!hasDirectClient) {
|
||||
addBlocker(blockers, {
|
||||
type: "environment_blocker",
|
||||
scope: "d601-k3s-client-binary",
|
||||
summary: "Neither kubectl nor k3s is installed in this runner PATH.",
|
||||
nextTask: "Install kubectl in the D601 Code Queue runner image, or mount an approved k3s kubectl client path for read-only hwlab-dev observation."
|
||||
});
|
||||
}
|
||||
if (!kubeconfig.some((item) => item.readable)) {
|
||||
addBlocker(blockers, {
|
||||
type: "environment_blocker",
|
||||
scope: "d601-kubeconfig-path",
|
||||
summary: "No readable KUBECONFIG/default k3s kubeconfig path was found by metadata checks.",
|
||||
nextTask: "Mount a read-only kubeconfig for hwlab-dev, or document the approved k3s local kubeconfig path without exposing token material."
|
||||
});
|
||||
}
|
||||
if (!sshBridge.configured) {
|
||||
addBlocker(blockers, {
|
||||
type: "environment_blocker",
|
||||
scope: "d601-maintenance-ssh-bridge",
|
||||
summary: "No SSH maintenance bridge was detected from ssh binary plus known environment variables or ~/.ssh/config metadata.",
|
||||
nextTask: "Provide a documented read-only maintenance bridge variable or config path if kubectl cannot be mounted directly in the runner."
|
||||
});
|
||||
}
|
||||
if (hasDirectClient && !cluster.readable) {
|
||||
const attempted = cluster.attemptedExecutors.length > 0 ? cluster.attemptedExecutors.join(", ") : "none";
|
||||
addBlocker(blockers, {
|
||||
type: "observability_blocker",
|
||||
scope: "hwlab-dev-readonly-rbac",
|
||||
summary: `Read-only cluster probes did not return pods, services, and ConfigMaps for ${namespace}; attempted executors: ${attempted}.`,
|
||||
nextTask: "Grant or mount read-only access that can get namespace, pods, services, and ConfigMaps in hwlab-dev, then rerun this preflight."
|
||||
});
|
||||
}
|
||||
return blockers;
|
||||
}
|
||||
|
||||
function runnerImageFollowUp(blockers) {
|
||||
const scopes = new Set(blockers.map((blocker) => blocker.scope));
|
||||
const needed = scopes.has("d601-k3s-client-binary") || scopes.has("d601-kubeconfig-path");
|
||||
return {
|
||||
needed,
|
||||
suggestedUniDeskIssue: needed
|
||||
? "Code Queue runner image should include kubectl and a documented read-only hwlab-dev kubeconfig mount/permission path; keep Secret resources and token material inaccessible to reports."
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
async function writeReport(report, reportPath) {
|
||||
const absoluteReportPath = path.join(repoRoot, reportPath);
|
||||
await mkdir(path.dirname(absoluteReportPath), { recursive: true });
|
||||
await writeFile(absoluteReportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function buildReport(args) {
|
||||
const binaries = await collectBinaries();
|
||||
const [kubeconfig, sshBridge, cluster] = await Promise.all([
|
||||
collectKubeconfigPaths(),
|
||||
collectSshBridge(binaries),
|
||||
collectClusterObservability(binaries, args.timeoutMs)
|
||||
]);
|
||||
const blockers = buildBlockers({ binaries, kubeconfig, sshBridge, cluster });
|
||||
const conclusion = cluster.readable && blockers.length === 0 ? "ready" : "blocked";
|
||||
return {
|
||||
$schema: "https://hwlab.pikastech.local/schemas/d601-k3s-readonly-observability-report.schema.json",
|
||||
$id: "https://hwlab.pikastech.local/reports/d601-k3s-readonly-observability.json",
|
||||
reportVersion: "v1",
|
||||
reportKind: "d601-k3s-readonly-observability",
|
||||
issue,
|
||||
supports,
|
||||
generatedAt: new Date().toISOString(),
|
||||
mode: "read-only",
|
||||
devOnly: true,
|
||||
prodDisabled: true,
|
||||
namespace,
|
||||
runtimeTarget: {
|
||||
runner: "D601 Code Queue",
|
||||
cluster: "D601 k3s",
|
||||
namespace,
|
||||
resources: ["pods", "services", "configmaps"]
|
||||
},
|
||||
safety: {
|
||||
noProd: true,
|
||||
noSecretsPrinted: true,
|
||||
noSecretResourcesRead: true,
|
||||
noClusterMutation: true,
|
||||
noRuntimeRestart: true,
|
||||
noHeavyE2E: true
|
||||
},
|
||||
forbiddenActions,
|
||||
validationCommands: [
|
||||
"node --check scripts/d601-k3s-readonly-observability.mjs",
|
||||
"node --check scripts/src/d601-k3s-readonly-observability.mjs",
|
||||
"node scripts/d601-k3s-readonly-observability.mjs"
|
||||
],
|
||||
conclusion,
|
||||
environment: {
|
||||
binaries,
|
||||
kubeconfig,
|
||||
sshBridge
|
||||
},
|
||||
cluster,
|
||||
blockers,
|
||||
runnerImageFollowUp: runnerImageFollowUp(blockers),
|
||||
notes: "This report uses command availability checks, file metadata checks, kubectl/k3s kubectl read-only get probes, and auth can-i checks only. It does not print kubeconfig contents, token material, Secret resources, ConfigMap values, SSH config contents, or private keys."
|
||||
};
|
||||
}
|
||||
|
||||
function printSummary(args, report) {
|
||||
const summary = {
|
||||
issue,
|
||||
reportKind: report.reportKind,
|
||||
conclusion: report.conclusion,
|
||||
namespace: report.namespace,
|
||||
report: args.writeReport ? args.reportPath : null,
|
||||
readable: report.cluster.readable,
|
||||
attemptedExecutors: report.cluster.attemptedExecutors,
|
||||
blockers: report.blockers.map((blocker) => ({
|
||||
type: blocker.type,
|
||||
scope: blocker.scope,
|
||||
nextTask: blocker.nextTask
|
||||
}))
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(args.pretty ? report : summary, null, args.pretty ? 2 : 0)}\n`);
|
||||
}
|
||||
|
||||
export function formatFailure(error) {
|
||||
return {
|
||||
issue,
|
||||
reportKind: "d601-k3s-readonly-observability",
|
||||
conclusion: "blocked",
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
|
||||
export async function runD601K3sReadonlyObservability(argv) {
|
||||
const args = parseArgs(argv);
|
||||
if (args.help) {
|
||||
process.stdout.write(`${usage()}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const report = await buildReport(args);
|
||||
if (args.writeReport) {
|
||||
await writeReport(report, args.reportPath);
|
||||
}
|
||||
printSummary(args, report);
|
||||
if (report.conclusion === "blocked" && args.failOnBlocked) {
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user