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

@@ -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();
});
});