feat(distributed): 支持单主控多节点部署和停服迁移

复用现有 k3s 调度和 Job 生命周期,增加 worker 接入与批准、受保护节点身份、归档传输、持久迁移锁及活动 PVC 切换;同步管理员 API、CLI、面板和隔离规则。分布式模式默认关闭,保持单机兼容。

验证:Go 全量测试与 vet;面板 874 个测试、lint/build;Linux VM 安装器测试、清单服务端 dry-run、网络命名空间防火墙实测。A/B/C 三机 WireGuard、Velocity 和迁移验收仍待完成。
This commit is contained in:
Lemon-miaow committed 2026-10-01 19:48:37 +08:00
1 parent 26e817f2c9
commit adf563ffe3
77 files changed
+5224 -73

No files matched your search

@@ -6,7 +6,7 @@ import { CreateServerDialog } from "./CreateServerDialog";
import { humanizeError } from "@/lib/api";
import type { CreateServerRequest, WhitelistImage } from "@/lib/types";
const calls = vi.hoisted(() => ({ listImages: vi.fn(), createServer: vi.fn() }));
const calls = vi.hoisted(() => ({ listImages: vi.fn(), createServer: vi.fn(), nodes: vi.fn() }));
vi.mock("@/lib/api", async (importActual) => {
const actual = await importActual<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
@@ -258,3 +258,25 @@ describe("CreateServerDialog on reopening", () => {
expect(screen.queryByRole("alert")).toBeNull();
});
});
describe("distributed placement", () => {
it("requires an online approved worker and sends its name", async () => {
calls.nodes.mockResolvedValue([
{name:"b",role:"worker",ready:true,approved:true},
{name:"c",role:"worker",ready:false,approved:true},
{name:"a",role:"controller",ready:true,approved:true},
]);
const user = userEvent.setup();
render(<CreateServerDialog cfg={{...cfg,distributed:true}} onCreated={onCreated} />);
await user.click(screen.getByRole("button",{name:"New server"}));
await fillValid(user);
expect(create().disabled).toBe(true);
await user.click(screen.getByRole("combobox",{name:"Execution node"}));
expect(screen.queryByRole("option",{name:"c"})).toBeNull();
expect(screen.queryByRole("option",{name:"a"})).toBeNull();
await user.click(await screen.findByRole("option",{name:"b"}));
await user.click(create());
expect(sent().nodeName).toBe("b");
});
});
@@ -56,6 +56,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
// or retired since the page loaded shows up or drops out without a reload.
const images = useAsync(() => (open ? api.listImages() : Promise.resolve(null)), [open]);
const nodes = useAsync(() => (open && cfg.distributed ? api.nodes() : Promise.resolve(null)), [open, cfg.distributed]);
const workers = (nodes.data ?? []).filter((n) => n.role === "worker" && n.ready && n.approved);
const [form, setForm] = useState<CreateServerRequest>({
name: "",
subdomain: "",
@@ -89,6 +92,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
nameIssue === null &&
subdomainIssue === null &&
!!image &&
(!cfg.distributed || workers.some((n) => n.name === form.nodeName)) &&
!!form.memory &&
!!form.storage &&
!submitting;
@@ -216,6 +220,18 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
)}
</div>
{cfg.distributed && (
<div className="grid gap-2">
<Label htmlFor="cs-node">{t("node")}</Label>
<Select value={form.nodeName ?? ""} onValueChange={(v) => set("nodeName", v)}>
<SelectTrigger id="cs-node"><SelectValue placeholder={t("node_choose")} /></SelectTrigger>
<SelectContent>{workers.map((n) => <SelectItem key={n.name} value={n.name}>{n.name}</SelectItem>)}</SelectContent>
</Select>
{!!nodes.error && <InlineError message={humanizeError(nodes.error)} />}
{!nodes.loading && !nodes.error && workers.length === 0 && <p className="text-xs text-muted-foreground">{t("node_none")}</p>}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="create-server-memory">{t("create_server_memory")}</Label>
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MigrationDialog } from "./DistributedNodes";
const calls = vi.hoisted(() => ({ nodes: vi.fn(), migration: vi.fn(), retryMigration: vi.fn(), migrateServer: vi.fn() }));
vi.mock("@/lib/api", async (importActual) => {
const actual = await importActual<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
beforeEach(() => {
vi.clearAllMocks();
calls.nodes.mockResolvedValue([{ name: "c", role: "worker", ready: true, approved: true }]);
calls.migration.mockResolvedValue({ id: "persisted-op", state: "failed", sourceNode: "b", targetNode: "c", error: "source node is offline" });
calls.retryMigration.mockResolvedValue({ id: "persisted-op", state: "backing_up" });
});
describe("durable migration", () => {
it("loads the persisted operation on opening and retries its ID", async () => {
const user = userEvent.setup();
const changed = vi.fn();
render(<MigrationDialog name="survival" nodeName="b" stopped onChanged={changed} />);
await user.click(screen.getByRole("button", { name: "Migrate world" }));
await screen.findByText("source node is offline");
expect(calls.migration).toHaveBeenCalledWith("survival");
expect(screen.getByText("persisted-op")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "Try again" }));
await waitFor(() => expect(calls.retryMigration).toHaveBeenCalledWith("survival", "persisted-op"));
expect(changed).toHaveBeenCalled();
expect(calls.migrateServer).not.toHaveBeenCalled();
});
it("keeps retry disabled until the server is stopped", async () => {
const user = userEvent.setup();
render(<MigrationDialog name="survival" nodeName="b" stopped={false} onChanged={() => {}} />);
await user.click(screen.getByRole("button", { name: "Migrate world" }));
const retry = await screen.findByRole("button", { name: "Try again" });
expect((retry as HTMLButtonElement).disabled).toBe(true);
await user.click(retry);
expect(calls.retryMigration).not.toHaveBeenCalled();
});
});
+87
View File
@@ -0,0 +1,87 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { api, humanizeError } from "@/lib/api";
import { useAsync, usePolling } from "@/lib/hooks";
import { Button } from "@/components/ui/button";
import { InlineError } from "@/components/MessageLine";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
export function DistributedNodes() {
const { t } = useTranslation("servers");
const nodes = useAsync(api.nodes);
usePolling(nodes.reload, 10_000);
return (
<div className="rounded-lg border p-4 space-y-2">
<h2 className="font-medium">{t("nodes")}</h2>
{!!nodes.error && <InlineError message={humanizeError(nodes.error)} />}
<ul className="space-y-1 text-sm">{nodes.data?.map((n) => (
<li key={n.name} className="flex flex-wrap gap-3">
<code>{n.name}</code><span>{n.role || t("node_pending")}</span>
<span>{n.ready ? t("node_online") : t("node_offline")}</span>
<span>{n.approved ? t("node_approved") : t("node_pending")}</span>
<span className="text-muted-foreground">{n.architecture} · {n.addresses.join(", ")}</span>
</li>
))}</ul>
</div>
);
}
export function MigrationDialog({ name, nodeName, stopped, onChanged }: {
name: string; nodeName?: string; stopped: boolean; onChanged: () => void;
}) {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const [target, setTarget] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const nodes = useAsync(() => open ? api.nodes() : Promise.resolve(null), [open]);
const progress = useAsync(async () => {
if (!open) return null;
try { return await api.migration(name); }
catch (e) { if ((e as { status?: number }).status === 404) return null; throw e; }
}, [open, name]);
usePolling(() => { if (open) progress.reload(); }, 3_000);
const op = progress.data;
const active = !!op && op.state !== "succeeded" && op.state !== "failed";
const workers = (nodes.data ?? []).filter((n) => n.ready && n.approved && n.role === "worker" && n.name !== nodeName);
async function run(retry: boolean) {
setBusy(true); setError(null);
try {
if (retry && op) await api.retryMigration(name, op.id);
else await api.migrateServer(name, target);
progress.reload(); onChanged();
} catch (e) { setError(humanizeError(e)); }
finally { setBusy(false); }
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button size="sm" variant="outline">{t("migration")}</Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{t("migration")}</DialogTitle><DialogDescription>{t("migration_hint")}</DialogDescription></DialogHeader>
{nodeName && <p className="text-sm">{t("node")}: {nodeName}</p>}
{op && <div className="space-y-1 text-sm" role="status">
<p>{op.sourceNode} → {op.targetNode}</p>
<p>{t(`migration_state_${op.state}`, { defaultValue: op.state })}</p>
<code className="text-xs break-all">{op.id}</code>
{op.error && <InlineError message={op.error} />}
</div>}
{!!progress.error && <InlineError message={humanizeError(progress.error)} />}
{!!nodes.error && <InlineError message={humanizeError(nodes.error)} />}
<Label htmlFor={`target-${name}`}>{t("node_choose")}</Label>
<Select value={target} onValueChange={setTarget} disabled={active || busy || op?.state === "failed"}>
<SelectTrigger id={`target-${name}`}><SelectValue placeholder={t("node_choose")} /></SelectTrigger>
<SelectContent>{workers.map((n) => <SelectItem key={n.name} value={n.name}>{n.name}</SelectItem>)}</SelectContent>
</Select>
{!stopped && <p className="text-sm text-muted-foreground">{t("migration_stop")}</p>}
<InlineError message={error} />
{op?.state === "failed" ? (
<Button onClick={() => run(true)} disabled={!stopped || busy}>{t("common:try_again")}</Button>
) : (
<Button onClick={() => run(false)} disabled={!stopped || active || busy || progress.loading || !!progress.error || !workers.some((n) => n.name === target)}>{t("migration_start")}</Button>
)}
</DialogContent>
</Dialog>
);
}
+18 -1
View File
@@ -251,5 +251,22 @@
"retiring_cancel_admin_only": "An administrator asked for the deletion, so only an administrator can cancel it.",
"retiring_badge_release": "Given up",
"retiring_badge_delete": "Deleting",
"retiring_badge_hint": "Given up or being deleted; handled at the next daily reclaim run, and it can't be started until then."
"retiring_badge_hint": "Given up or being deleted; handled at the next daily reclaim run, and it can't be started until then.",
"nodes": "Execution nodes",
"node": "Execution node",
"node_choose": "Choose an approved node",
"node_none": "No online approved worker nodes",
"node_online": "Online",
"node_offline": "Offline",
"node_approved": "Approved",
"node_pending": "Pending approval",
"migration": "Migrate world",
"migration_hint": "The server stays stopped after migration. The source volume is retained; check the world before starting it.",
"migration_stop": "Stop the server and wait for the game process to exit first.",
"migration_start": "Start migration",
"migration_state_backing_up": "Archiving source world",
"migration_state_restoring": "Restoring and verifying target world",
"migration_state_switching": "Switching active volume",
"migration_state_succeeded": "Migration complete; start manually after inspection",
"migration_state_failed": "Migration failed; server remains stopped"
}
+18 -1
View File
@@ -250,5 +250,22 @@
"retiring_cancel_admin_only": "删除由管理员提出,只有管理员可以撤销。",
"retiring_badge_release": "等待回收",
"retiring_badge_delete": "等待删除",
"retiring_badge_hint": "已放弃或正在删除,下一次每日回收时处理;在此之前不能启动。"
"retiring_badge_hint": "已放弃或正在删除,下一次每日回收时处理;在此之前不能启动。",
"nodes": "执行节点",
"node": "执行位置",
"node_choose": "选择已批准的节点",
"node_none": "没有在线且已批准的执行节点",
"node_online": "在线",
"node_offline": "失联",
"node_approved": "已批准",
"node_pending": "待批准",
"migration": "停服迁移",
"migration_hint": "迁移完成后仍保持停服。源卷保留,请检查后手动启动。",
"migration_stop": "请先停服并等待游戏进程退出。",
"migration_start": "开始迁移",
"migration_state_backing_up": "正在归档源卷",
"migration_state_restoring": "正在恢复并校验目标卷",
"migration_state_switching": "正在切换活动卷",
"migration_state_succeeded": "迁移完成,等待手动启动",
"migration_state_failed": "迁移失败,服务器保持停服"
}
+9
View File
@@ -1,5 +1,7 @@
import type {
AccessResult,
ExecutionNode,
WorldMigration,
AllowlistEntry,
ApiError,
AutostartPolicy,
@@ -594,6 +596,13 @@ export const api = rejectingSync({
URL.revokeObjectURL(url);
},
nodes: () => request<{ nodes: ExecutionNode[] }>("GET", "/nodes").then((r) => r.nodes),
migration: (name: string) => request<WorldMigration>("GET", `/servers/${encodeURIComponent(name)}/migrations`),
migrateServer: (name: string, targetNode: string) =>
request<WorldMigration>("POST", `/servers/${encodeURIComponent(name)}/migrations`, { targetNode }),
retryMigration: (name: string, id: string) =>
request<WorldMigration>("POST", `/servers/${encodeURIComponent(name)}/migrations/${encodeURIComponent(id)}/retry`),
createServer: (req: CreateServerRequest) =>
request<{ name: string; subdomain: string; desiredState: string }>(
"POST",
+1
View File
@@ -33,6 +33,7 @@ describe("loadConfig", () => {
const cfg = await loadConfig();
expect(cfg).toEqual({
apiBase: "/api/v1",
distributed: false,
rootDomain: "mc.example",
panelHostname: undefined,
adminHostname: "op.console.mc.example",
+2
View File
@@ -16,6 +16,7 @@ export interface BuildInfo {
}
export interface RuntimeConfig {
distributed?: boolean;
apiBase: string;
rootDomain: string;
/** Player-console hostname (console.<root>), absent when unconfigured. */
@@ -79,6 +80,7 @@ export async function loadConfig(): Promise<RuntimeConfig> {
}
cached = {
apiBase: raw.apiBase ?? FALLBACK.apiBase,
distributed: raw.distributed === true,
rootDomain: raw.rootDomain,
panelHostname: raw.panelHostname,
adminHostname: raw.adminHostname,
+253
View File
@@ -4,6 +4,75 @@
*/
export interface paths {
"/api/v1/nodes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List execution nodes (administrator). */
get: operations["executionNodes"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/migrations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read durable migration progress. */
get: operations["latestWorldMigrationStatus"];
put?: never;
/** Migrate an already stopped world; persistent lock survives controller restart. */
post: operations["startWorldMigration"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/migrations/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read durable migration progress. */
get: operations["worldMigrationStatus"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/migrations/{id}/retry": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Retry a failed migration, retaining the source and stopped state. */
post: operations["retryWorldMigration"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/healthz": {
parameters: {
query?: never;
@@ -2810,8 +2879,42 @@ export interface components {
* @enum {string}
*/
Phase: "Unknown" | "Stopped" | "Starting" | "Running" | "Stopping" | "Failed";
ExecutionNode: {
name: string;
role: string;
ready: boolean;
approved: boolean;
addresses: string[];
architecture: string;
};
WorldMigration: {
id: string;
server: string;
/** @enum {string} */
state: "backing_up" | "restoring" | "switching" | "succeeded" | "failed";
stage: string;
sourceNode: string;
targetNode: string;
sourcePVC: string;
targetPVC: string;
backup: {
ref: string;
/** Format: int64 */
size: number;
sha256: string;
};
/** Format: date-time */
started: string;
/** Format: date-time */
updated: string;
switched: boolean;
attempt: number;
error?: string;
};
/** @description Status projection of one server (internal/api/cluster.go ServerInfo). */
ServerInfo: {
/** @description Execution node; legacy servers report the observed node. */
nodeName?: string;
name: string;
subdomain: string;
phase: components["schemas"]["Phase"];
@@ -3561,6 +3664,154 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
executionNodes: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Accepted operation or current state. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
nodes: components["schemas"]["ExecutionNode"][];
};
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
409: components["responses"]["Conflict"];
503: components["responses"]["ServiceUnavailable"];
};
};
latestWorldMigrationStatus: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Accepted operation or current state. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorldMigration"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
409: components["responses"]["Conflict"];
503: components["responses"]["ServiceUnavailable"];
};
};
startWorldMigration: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
targetNode: string;
};
};
};
responses: {
/** @description Accepted operation or current state. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorldMigration"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
409: components["responses"]["Conflict"];
503: components["responses"]["ServiceUnavailable"];
};
};
worldMigrationStatus: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Accepted operation or current state. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorldMigration"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
409: components["responses"]["Conflict"];
503: components["responses"]["ServiceUnavailable"];
};
};
retryWorldMigration: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Accepted operation or current state. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorldMigration"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
409: components["responses"]["Conflict"];
503: components["responses"]["ServiceUnavailable"];
};
};
healthz: {
parameters: {
query?: never;
@@ -3721,6 +3972,8 @@ export interface operations {
requestBody: {
content: {
"application/json": {
/** @description Required approved worker in distributed mode; administrator only. */
nodeName?: string;
name: string;
subdomain: string;
/** @description Trimmed; at most 64 characters, all visible ones or spaces (400 bad_display_name otherwise). */
+24
View File
@@ -59,6 +59,7 @@ export interface MyServerView {
* A caller who does not own the server gets the public subset, so everything
* past the counts may be absent. */
export interface ServerStatus {
nodeName?: string;
name: string;
subdomain: string;
phase: Phase;
@@ -340,6 +341,7 @@ export interface WhitelistImage {
/** CreateServerRequest is the §15 structured form — the ONLY create path. */
export interface CreateServerRequest {
nodeName?: string;
name: string;
subdomain: string;
displayName?: string;
@@ -716,3 +718,25 @@ export interface SessionView {
/** On the holder's own list only: the session this request came in on. */
current?: boolean;
}
export interface ExecutionNode {
name: string;
role: string;
ready: boolean;
approved: boolean;
addresses: string[];
architecture: string;
}
export interface WorldMigration {
id: string;
server: string;
state: string;
stage: string;
sourceNode: string;
targetNode: string;
sourcePVC: string;
targetPVC: string;
error?: string;
switched: boolean;
attempt: number;
}
+11
View File
@@ -34,6 +34,7 @@ import { PhaseBadge, PHASE_KEY, PHASE_COLOR, shownPhase, startFailure } from "@/
import { PowerButton } from "@/components/PowerButton";
import { Loading, ErrorState, EmptyState, RefreshError } from "@/components/States";
import { Pagination } from "@/components/Pagination";
import { DistributedNodes, MigrationDialog } from "@/components/DistributedNodes";
import { CreateServerDialog } from "@/components/CreateServerDialog";
import { StatCard } from "@/components/StatCard";
import { CopyAddress } from "@/components/CopyAddress";
@@ -106,6 +107,7 @@ function compareBy(key: SortKey): (a: UnifiedServer, b: UnifiedServer) => number
}
interface UnifiedServer {
nodeName?: string;
name: string;
subdomain: string;
/** The owner-chosen label; the list leads with it and keeps the name beside. */
@@ -163,6 +165,7 @@ export function ServersPage() {
if (isAdmin) {
return (data as FleetServer[]).map((s) => ({
name: s.name,
nodeName: s.nodeName,
displayName: s.displayName,
subdomain: s.subdomain,
phase: s.phase,
@@ -276,6 +279,8 @@ export function ServersPage() {
className="mb-6"
/>
{isAdmin && cfg?.distributed && <DistributedNodes />}
{showInitialLoading ? (
<Loading />
) : showInitialError ? (
@@ -508,6 +513,7 @@ function ServerActions({
className?: string;
}) {
const { t: ts } = useTranslation("servers");
const cfg = useConfig();
const [busy, setBusy] = useState<null | "claim">(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -543,6 +549,7 @@ function ServerActions({
return (
<div className={cn("flex flex-col items-end gap-1", className)}>
<div className="flex flex-wrap items-start justify-end gap-2 xl:flex-nowrap">
{isAdmin && cfg?.distributed && <MigrationDialog name={server.name} nodeName={server.nodeName} stopped={server.phase === "Stopped" && !server.ready && server.desiredState === "Stopped" && !server.retiring} onChanged={onChanged} />}
{claimable && (
<>
<Button
@@ -655,6 +662,10 @@ function PlayersLabel({ server }: { server: UnifiedServer }) {
}
function EndpointLabel({ server }: { server: UnifiedServer }) {
return <div className="space-y-1">{server.nodeName && <code className="text-xs">{server.nodeName}</code>}<EndpointAddress server={server} /></div>;
}
function EndpointAddress({ server }: { server: UnifiedServer }) {
const { t } = useTranslation("ops");
const endpoint = server.ready && server.endpointAddress ? server.endpointAddress : null;
if (!endpoint) {