feat: extend main-server artifact consumers
This commit is contained in:
@@ -69,7 +69,7 @@ const defaultOptions: ArtifactRegistryOptions = {
|
||||
sourceRepo: "https://github.com/pikasTech/unidesk",
|
||||
deployRef: null,
|
||||
};
|
||||
const supportedArtifactConsumerServices = ["backend-core", "baidu-netdisk", "decision-center", "frontend"] as const;
|
||||
const supportedArtifactConsumerServices = ["backend-core", "baidu-netdisk", "code-queue-mgr", "decision-center", "frontend", "oa-event-flow", "project-manager", "todo-note"] as const;
|
||||
type SupportedArtifactConsumerService = typeof supportedArtifactConsumerServices[number];
|
||||
const legacyDeployBackendCoreDisabled = true;
|
||||
|
||||
@@ -80,6 +80,10 @@ interface ArtifactConsumerSpec {
|
||||
registryRepository: string;
|
||||
dockerfile: string;
|
||||
targets: Partial<Record<ArtifactDeployEnvironment, ArtifactConsumerTarget>>;
|
||||
prodLiveApply: "enabled" | "supervisor-only" | "unsupported";
|
||||
prodLiveBlockReason?: string;
|
||||
runtimeVerification?: "strict" | "blocked";
|
||||
runtimeVerificationBlockReason?: string;
|
||||
}
|
||||
|
||||
interface ArtifactConsumerTarget {
|
||||
@@ -113,6 +117,7 @@ const artifactConsumerSpecs: Record<string, ArtifactConsumerSpec> = {
|
||||
kind: "compose",
|
||||
registryRepository: "unidesk/backend-core",
|
||||
dockerfile: "src/components/backend-core/Dockerfile",
|
||||
prodLiveApply: "enabled",
|
||||
targets: {
|
||||
prod: {
|
||||
targetImage: "unidesk-backend-core",
|
||||
@@ -134,7 +139,20 @@ const artifactConsumerSpecs: Record<string, ArtifactConsumerSpec> = {
|
||||
kind: "compose",
|
||||
registryRepository: "unidesk/baidu-netdisk",
|
||||
dockerfile: "src/components/microservices/baidu-netdisk/Dockerfile",
|
||||
prodLiveApply: "enabled",
|
||||
targets: {
|
||||
dev: {
|
||||
targetImage: "baidu-netdisk",
|
||||
targetCommitImage: (commit: string) => `baidu-netdisk:${commit}`,
|
||||
deployRef: "deploy.json#environments.dev.services.baidu-netdisk",
|
||||
compose: {
|
||||
serviceName: "baidu-netdisk",
|
||||
containerName: "baidu-netdisk-backend",
|
||||
deployEnvPrefix: "UNIDESK_BAIDU_NETDISK_DEPLOY",
|
||||
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4244/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
|
||||
requireHealthCommit: true,
|
||||
},
|
||||
},
|
||||
prod: {
|
||||
targetImage: "baidu-netdisk",
|
||||
targetCommitImage: (commit: string) => `baidu-netdisk:${commit}`,
|
||||
@@ -149,12 +167,48 @@ const artifactConsumerSpecs: Record<string, ArtifactConsumerSpec> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"code-queue-mgr": {
|
||||
serviceId: "code-queue-mgr",
|
||||
environment: "prod",
|
||||
kind: "compose",
|
||||
registryRepository: "unidesk/code-queue-mgr",
|
||||
dockerfile: "src/components/microservices/code-queue-mgr/Dockerfile",
|
||||
prodLiveApply: "supervisor-only",
|
||||
prodLiveBlockReason: "code-queue-mgr is the main-server Code Queue control-plane sidecar; live production apply requires explicit supervisor confirmation.",
|
||||
targets: {
|
||||
dev: {
|
||||
targetImage: "code-queue-mgr",
|
||||
targetCommitImage: (commit: string) => `code-queue-mgr:${commit}`,
|
||||
deployRef: "deploy.json#environments.dev.services.code-queue-mgr",
|
||||
compose: {
|
||||
serviceName: "code-queue-mgr",
|
||||
containerName: "code-queue-mgr-backend",
|
||||
deployEnvPrefix: "UNIDESK_CODE_QUEUE_MGR_DEPLOY",
|
||||
healthProbeCommand: "code-queue-mgr --print-health",
|
||||
requireHealthCommit: true,
|
||||
},
|
||||
},
|
||||
prod: {
|
||||
targetImage: "code-queue-mgr",
|
||||
targetCommitImage: (commit: string) => `code-queue-mgr:${commit}`,
|
||||
deployRef: "deploy.json#environments.prod.services.code-queue-mgr",
|
||||
compose: {
|
||||
serviceName: "code-queue-mgr",
|
||||
containerName: "code-queue-mgr-backend",
|
||||
deployEnvPrefix: "UNIDESK_CODE_QUEUE_MGR_DEPLOY",
|
||||
healthProbeCommand: "code-queue-mgr --print-health",
|
||||
requireHealthCommit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"decision-center": {
|
||||
serviceId: "decision-center",
|
||||
environment: "prod",
|
||||
kind: "d601-k3s",
|
||||
registryRepository: "unidesk/decision-center",
|
||||
dockerfile: "src/components/microservices/decision-center/Dockerfile",
|
||||
prodLiveApply: "enabled",
|
||||
targets: {
|
||||
dev: {
|
||||
targetImage: "unidesk-decision-center:dev",
|
||||
@@ -193,6 +247,7 @@ const artifactConsumerSpecs: Record<string, ArtifactConsumerSpec> = {
|
||||
kind: "compose",
|
||||
registryRepository: "unidesk/frontend",
|
||||
dockerfile: "src/components/frontend/Dockerfile",
|
||||
prodLiveApply: "enabled",
|
||||
targets: {
|
||||
prod: {
|
||||
targetImage: "unidesk-frontend",
|
||||
@@ -208,12 +263,118 @@ const artifactConsumerSpecs: Record<string, ArtifactConsumerSpec> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"oa-event-flow": {
|
||||
serviceId: "oa-event-flow",
|
||||
environment: "prod",
|
||||
kind: "compose",
|
||||
registryRepository: "unidesk/oa-event-flow",
|
||||
dockerfile: "src/components/microservices/oa-event-flow/Dockerfile",
|
||||
prodLiveApply: "enabled",
|
||||
targets: {
|
||||
dev: {
|
||||
targetImage: "oa-event-flow",
|
||||
targetCommitImage: (commit: string) => `oa-event-flow:${commit}`,
|
||||
deployRef: "deploy.json#environments.dev.services.oa-event-flow",
|
||||
compose: {
|
||||
serviceName: "oa-event-flow",
|
||||
containerName: "oa-event-flow-backend",
|
||||
deployEnvPrefix: "UNIDESK_OA_EVENT_FLOW_DEPLOY",
|
||||
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4255/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
|
||||
requireHealthCommit: true,
|
||||
},
|
||||
},
|
||||
prod: {
|
||||
targetImage: "oa-event-flow",
|
||||
targetCommitImage: (commit: string) => `oa-event-flow:${commit}`,
|
||||
deployRef: "deploy.json#environments.prod.services.oa-event-flow",
|
||||
compose: {
|
||||
serviceName: "oa-event-flow",
|
||||
containerName: "oa-event-flow-backend",
|
||||
deployEnvPrefix: "UNIDESK_OA_EVENT_FLOW_DEPLOY",
|
||||
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4255/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
|
||||
requireHealthCommit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"project-manager": {
|
||||
serviceId: "project-manager",
|
||||
environment: "prod",
|
||||
kind: "compose",
|
||||
registryRepository: "unidesk/project-manager",
|
||||
dockerfile: "src/components/microservices/project-manager/Dockerfile",
|
||||
prodLiveApply: "enabled",
|
||||
targets: {
|
||||
dev: {
|
||||
targetImage: "project-manager",
|
||||
targetCommitImage: (commit: string) => `project-manager:${commit}`,
|
||||
deployRef: "deploy.json#environments.dev.services.project-manager",
|
||||
compose: {
|
||||
serviceName: "project-manager",
|
||||
containerName: "project-manager-backend",
|
||||
deployEnvPrefix: "UNIDESK_PROJECT_MANAGER_DEPLOY",
|
||||
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4233/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
|
||||
requireHealthCommit: true,
|
||||
},
|
||||
},
|
||||
prod: {
|
||||
targetImage: "project-manager",
|
||||
targetCommitImage: (commit: string) => `project-manager:${commit}`,
|
||||
deployRef: "deploy.json#environments.prod.services.project-manager",
|
||||
compose: {
|
||||
serviceName: "project-manager",
|
||||
containerName: "project-manager-backend",
|
||||
deployEnvPrefix: "UNIDESK_PROJECT_MANAGER_DEPLOY",
|
||||
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4233/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
|
||||
requireHealthCommit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"todo-note": {
|
||||
serviceId: "todo-note",
|
||||
environment: "prod",
|
||||
kind: "compose",
|
||||
registryRepository: "unidesk/todo-note",
|
||||
dockerfile: "Dockerfile",
|
||||
prodLiveApply: "unsupported",
|
||||
prodLiveBlockReason: "todo-note source is external to this repository and the current checked-in contract cannot prove /api/health deploy.commit/deploy.requestedCommit support.",
|
||||
runtimeVerification: "blocked",
|
||||
runtimeVerificationBlockReason: "todo-note source is external to this repository and the current checked-in contract cannot prove /api/health deploy.commit/deploy.requestedCommit support.",
|
||||
targets: {
|
||||
dev: {
|
||||
targetImage: "todo-note",
|
||||
targetCommitImage: (commit: string) => `todo-note:${commit}`,
|
||||
deployRef: "deploy.json#environments.dev.services.todo-note",
|
||||
compose: {
|
||||
serviceName: "todo-note",
|
||||
containerName: "todo-note-backend",
|
||||
deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY",
|
||||
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4211/api/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
|
||||
requireHealthCommit: true,
|
||||
},
|
||||
},
|
||||
prod: {
|
||||
targetImage: "todo-note",
|
||||
targetCommitImage: (commit: string) => `todo-note:${commit}`,
|
||||
deployRef: "deploy.json#environments.prod.services.todo-note",
|
||||
compose: {
|
||||
serviceName: "todo-note",
|
||||
containerName: "todo-note-backend",
|
||||
deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY",
|
||||
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4211/api/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
|
||||
requireHealthCommit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"dev:frontend": {
|
||||
serviceId: "frontend",
|
||||
environment: "dev",
|
||||
kind: "d601-k3s",
|
||||
registryRepository: "unidesk/frontend",
|
||||
dockerfile: "src/components/frontend/Dockerfile",
|
||||
prodLiveApply: "enabled",
|
||||
targets: {
|
||||
dev: {
|
||||
targetImage: "unidesk-frontend:dev",
|
||||
@@ -649,6 +810,58 @@ function unsupportedEnvironment(spec: ArtifactConsumerSpec, options: ArtifactReg
|
||||
};
|
||||
}
|
||||
|
||||
function artifactConsumerLiveBlock(spec: ArtifactConsumerSpec, options: ArtifactRegistryOptions): Record<string, unknown> | null {
|
||||
const environment = options.environment ?? "prod";
|
||||
if (spec.runtimeVerification === "blocked") {
|
||||
return {
|
||||
ok: false,
|
||||
supported: false,
|
||||
liveApplyAllowed: false,
|
||||
error: "runtime-verification-blocked",
|
||||
serviceId: spec.serviceId,
|
||||
environment,
|
||||
providerId: options.providerId,
|
||||
reason: spec.runtimeVerificationBlockReason ?? `${spec.serviceId} does not yet satisfy strict runtime deploy commit verification.`,
|
||||
requiredBeforeLiveApply: [
|
||||
"runtime Compose env injects deploy commit/requestedCommit metadata",
|
||||
"service health reports deploy.commit and deploy.requestedCommit for strict verification",
|
||||
],
|
||||
policy: "artifact CD must not accept a healthy old service or silently fall back to legacy rebuild paths",
|
||||
};
|
||||
}
|
||||
if (environment !== "prod" || spec.prodLiveApply === "enabled") return null;
|
||||
if (spec.prodLiveApply === "supervisor-only") {
|
||||
return {
|
||||
ok: false,
|
||||
supported: true,
|
||||
liveApplyAllowed: false,
|
||||
error: "supervisor-confirmation-required",
|
||||
serviceId: spec.serviceId,
|
||||
environment,
|
||||
providerId: options.providerId,
|
||||
reason: spec.prodLiveBlockReason ?? `${spec.serviceId} production artifact apply requires supervisor confirmation.`,
|
||||
dryRunCommandShape: `bun scripts/cli.ts artifact-registry deploy-service --env prod --service ${spec.serviceId} --commit <full-sha> --dry-run`,
|
||||
policy: "worker automation must not perform live production apply for this infrastructure control-plane service",
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
supported: false,
|
||||
liveApplyAllowed: false,
|
||||
error: "artifact-consumer-blocked",
|
||||
serviceId: spec.serviceId,
|
||||
environment,
|
||||
providerId: options.providerId,
|
||||
reason: spec.prodLiveBlockReason ?? `${spec.serviceId} does not yet satisfy the artifact consumer runtime verification contract.`,
|
||||
requiredBeforeLiveApply: [
|
||||
"CI can publish a commit-pinned image with matching service id, source commit, and Dockerfile labels",
|
||||
"runtime Compose env injects deploy commit/requestedCommit metadata",
|
||||
"service health reports deploy.commit and deploy.requestedCommit for strict verification",
|
||||
],
|
||||
policy: "do not silently fall back to server rebuild, dirty worktrees, mutable tags, or source builds on the runtime target",
|
||||
};
|
||||
}
|
||||
|
||||
function artifactImageRef(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, commit: string): string {
|
||||
return `127.0.0.1:${options.port}/${spec.registryRepository}:${commit}`;
|
||||
}
|
||||
@@ -1040,7 +1253,9 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec:
|
||||
"cat \"$health_json\"",
|
||||
...(target.compose.requireHealthCommit ? [
|
||||
"health_commit=$(python3 -c 'import json,sys; print(((json.load(open(sys.argv[1])).get(\"deploy\") or {}).get(\"commit\") or \"\"))' \"$health_json\")",
|
||||
"health_requested_commit=$(python3 -c 'import json,sys; print(((json.load(open(sys.argv[1])).get(\"deploy\") or {}).get(\"requestedCommit\") or \"\"))' \"$health_json\")",
|
||||
`test "$health_commit" = ${shellQuote(commit)}`,
|
||||
`test "$health_requested_commit" = ${shellQuote(commit)}`,
|
||||
] : []),
|
||||
].join("\n");
|
||||
const deploy = runCommand(["bash", "-lc", composeLockScript(upScript)], repoRoot, { timeoutMs: Math.max(options.timeoutMs, 300_000) });
|
||||
@@ -1072,8 +1287,10 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec:
|
||||
running: running.stdout.trim(),
|
||||
validation: {
|
||||
liveCommit: commit,
|
||||
liveRequestedCommit: target.compose.requireHealthCommit ? commit : "not-required",
|
||||
imageLabelCommit: commit,
|
||||
serviceHealthCommit: target.compose.requireHealthCommit ? commit : "not-required",
|
||||
serviceHealthRequestedCommit: target.compose.requireHealthCommit ? commit : "not-required",
|
||||
healthyOldVersionAccepted: false,
|
||||
},
|
||||
rollback: {
|
||||
@@ -1125,12 +1342,14 @@ function legacyDeployBackendCoreResult(options: ArtifactRegistryOptions): Record
|
||||
|
||||
function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget, commit: string): Record<string, unknown> {
|
||||
const environment = options.environment ?? "prod";
|
||||
const verificationBlocked = spec.runtimeVerification === "blocked";
|
||||
const sourceImage = artifactImageRef(options, spec, commit);
|
||||
const common = {
|
||||
ok: true,
|
||||
supported: true,
|
||||
ok: !verificationBlocked,
|
||||
supported: !verificationBlocked,
|
||||
dryRun: true,
|
||||
mutation: false,
|
||||
error: verificationBlocked ? "runtime-verification-blocked" : undefined,
|
||||
environment,
|
||||
providerId: options.providerId,
|
||||
serviceId: spec.serviceId,
|
||||
@@ -1148,6 +1367,11 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti
|
||||
url: `http://127.0.0.1:${options.port}/v2/${spec.registryRepository}/manifests/${commit}`,
|
||||
},
|
||||
boundary: `${environment} CD is artifact-consumer only: verify commit-pinned registry image, pull/import, deploy, then verify live commit/image/health; it never builds source on the runtime target`,
|
||||
liveApply: {
|
||||
policy: spec.prodLiveApply,
|
||||
allowed: !verificationBlocked && (environment !== "prod" || spec.prodLiveApply === "enabled"),
|
||||
reason: spec.runtimeVerificationBlockReason ?? spec.prodLiveBlockReason ?? null,
|
||||
},
|
||||
};
|
||||
if (spec.kind === "compose") {
|
||||
if (target.compose === undefined) throw new Error(`${spec.serviceId} missing compose artifact consumer config`);
|
||||
@@ -1166,7 +1390,9 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti
|
||||
"D601 registry /v2 manifest exists for the commit tag",
|
||||
"loaded image labels match service id, source commit, and Dockerfile",
|
||||
"running Compose container image label matches the requested commit",
|
||||
target.compose.requireHealthCommit
|
||||
verificationBlocked
|
||||
? `blocked: ${spec.runtimeVerificationBlockReason}`
|
||||
: target.compose.requireHealthCommit
|
||||
? `${spec.serviceId} /health succeeds and reports deploy.commit matching the artifact commit`
|
||||
: `${spec.serviceId} /health succeeds for the recreated container`,
|
||||
],
|
||||
@@ -1430,6 +1656,8 @@ async function deployServiceNow(options: ArtifactRegistryOptions): Promise<Recor
|
||||
const target = artifactConsumerTarget(spec, options.environment);
|
||||
if (target === null) return unsupportedEnvironment(spec, options);
|
||||
if (options.dryRun) return dryRunArtifactConsumerPlan(options, spec, target, options.commit);
|
||||
const liveBlock = artifactConsumerLiveBlock(spec, options);
|
||||
if (liveBlock !== null) return liveBlock;
|
||||
if (spec.kind === "compose") return deployComposeArtifactNow(options, spec, target);
|
||||
return deployD601K3sArtifactNow(options, spec, target);
|
||||
}
|
||||
@@ -1442,6 +1670,8 @@ function deployServiceJob(args: string[], options: ArtifactRegistryOptions): Rec
|
||||
const target = artifactConsumerTarget(spec, options.environment);
|
||||
if (target === null) return unsupportedEnvironment(spec, options);
|
||||
if (options.dryRun) return dryRunArtifactConsumerPlan(options, spec, target, options.commit);
|
||||
const liveBlock = artifactConsumerLiveBlock(spec, options);
|
||||
if (liveBlock !== null) return liveBlock;
|
||||
const runArgs = args.includes("--run-now") ? args : [...args, "--run-now"];
|
||||
const command = [process.execPath, rootPath("scripts", "cli.ts"), "artifact-registry", ...runArgs];
|
||||
const job = startJob("artifact_registry_service_cd", command, `Pull and deploy ${options.environment ?? "prod"} ${options.serviceId} artifact ${options.commit} from D601 registry`);
|
||||
@@ -1478,6 +1708,10 @@ function localHelp(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts artifact-registry deploy-service --service frontend --env dev --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
|
||||
"bun scripts/cli.ts artifact-registry deploy-service --env dev --service decision-center --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
|
||||
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service decision-center --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
|
||||
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service project-manager --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
|
||||
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service oa-event-flow --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
|
||||
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service code-queue-mgr --commit <full-sha> --dry-run [--provider-id D601]",
|
||||
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service todo-note --commit <full-sha> --dry-run [--provider-id D601]",
|
||||
],
|
||||
firstStage: "install now writes the rendered systemd/Compose/config files and starts the registry",
|
||||
artifactConsumers: {
|
||||
@@ -1489,10 +1723,17 @@ function localHelp(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts deploy apply --env prod --service baidu-netdisk",
|
||||
"bun scripts/cli.ts deploy apply --env prod --service frontend",
|
||||
"bun scripts/cli.ts deploy apply --env prod --service decision-center",
|
||||
"bun scripts/cli.ts deploy apply --env prod --service project-manager",
|
||||
"bun scripts/cli.ts deploy apply --env prod --service oa-event-flow",
|
||||
"bun scripts/cli.ts deploy apply --env prod --service code-queue-mgr --dry-run",
|
||||
],
|
||||
devCommands: [
|
||||
"bun scripts/cli.ts deploy apply --env dev --service frontend",
|
||||
"bun scripts/cli.ts deploy apply --env dev --service decision-center",
|
||||
"bun scripts/cli.ts deploy apply --env dev --service project-manager --dry-run",
|
||||
"bun scripts/cli.ts deploy apply --env dev --service oa-event-flow --dry-run",
|
||||
"bun scripts/cli.ts deploy apply --env dev --service code-queue-mgr --dry-run",
|
||||
"bun scripts/cli.ts deploy apply --env dev --service todo-note --dry-run",
|
||||
],
|
||||
rollbackShape: "rerun the same artifact consumer with a previous commit-pinned image",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user