feat(nacos): global content replacement with persistent rollback

This commit is contained in:
xiaochouwei authored and GitHub committed 2026-09-22 16:12:18 +08:00
1 parent cf36425b0a
commit 7ade5d2b6e
26 files changed
+2706 -13

No files matched your search

@@ -0,0 +1,194 @@
import { describe, expect, it, vi } from "vitest";
import type { NacosConfigItem, NacosConfigUpsert } from "@/types/nacos";
import { applyNacosContentReplacePlan, buildNacosContentReplacePlan, rollbackNacosContentReplace } from "../nacosContentReplace";
function config(overrides: Partial<NacosConfigItem> = {}): NacosConfigItem {
return {
namespace: "public",
group: "DEFAULT_GROUP",
dataId: "application.yaml",
content: "url: mysql-old:3306\nreplica: mysql-old:3306",
configType: "yaml",
md5: "before-md5",
...overrides,
};
}
describe("buildNacosContentReplacePlan", () => {
it("builds one global plan across namespaces, groups, and data IDs", () => {
const plan = buildNacosContentReplacePlan(
[config(), config({ namespace: "tenant-a", group: "orders", dataId: "orders.yaml", content: "dsn=mysql-old:3306/orders", md5: "orders-md5" }), config({ namespace: "tenant-a", group: "billing", dataId: "billing.properties", content: "host=mysql-new", md5: "billing-md5" })],
"mysql-old:3306",
"mysql-new:3306",
);
expect(plan.items).toHaveLength(2);
expect(plan.totalReplacements).toBe(3);
expect(plan.items.map((item) => `${item.namespace}/${item.group}/${item.dataId}`)).toEqual(["public/DEFAULT_GROUP/application.yaml", "tenant-a/orders/orders.yaml"]);
expect(plan.items[0].afterContent).toBe("url: mysql-new:3306\nreplica: mysql-new:3306");
});
it("deduplicates search matches for the same configuration", () => {
const item = config();
const plan = buildNacosContentReplacePlan([item, { ...item }], "mysql-old", "mysql-new");
expect(plan.items).toHaveLength(1);
expect(plan.totalReplacements).toBe(2);
});
it("rejects an empty search value and a no-op replacement", () => {
expect(() => buildNacosContentReplacePlan([config()], "", "mysql-new")).toThrow(/required/i);
expect(() => buildNacosContentReplacePlan([config()], "same", "same")).toThrow(/different/i);
});
});
describe("applyNacosContentReplacePlan", () => {
it("awaits durable checkpoints and stops further publishing if a checkpoint fails", async () => {
const plan = buildNacosContentReplacePlan([config(), config({ dataId: "other.yaml" })], "mysql-old", "mysql-new");
const publish = vi.fn();
const onBeforeItem = vi.fn().mockResolvedValue(undefined);
const onItemResult = vi.fn().mockRejectedValue(new Error("history write failed"));
let reads = 0;
await expect(
applyNacosContentReplacePlan(plan, {
getConfig: async () => config({ content: ++reads === 1 ? plan.items[0].beforeContent : plan.items[0].afterContent }),
publishConfig: publish,
onBeforeItem,
onItemResult,
}),
).rejects.toThrow("history write failed");
expect(onBeforeItem).toHaveBeenCalledTimes(1);
expect(onItemResult).toHaveBeenCalledWith(expect.objectContaining({ status: "replaced" }));
expect(publish).toHaveBeenCalledTimes(1);
});
it("does not publish when the pre-item checkpoint cannot be saved", async () => {
const publish = vi.fn();
await expect(
applyNacosContentReplacePlan(buildNacosContentReplacePlan([config()], "mysql-old", "mysql-new"), {
getConfig: async () => config(),
publishConfig: publish,
onBeforeItem: async () => {
throw new Error("storage unavailable");
},
}),
).rejects.toThrow("storage unavailable");
expect(publish).not.toHaveBeenCalled();
});
it("publishes every current item with CAS metadata and verifies the result", async () => {
const plan = buildNacosContentReplacePlan([config(), config({ namespace: "tenant-a", dataId: "orders.yaml", content: "mysql-old", md5: "orders-before" })], "mysql-old", "mysql-new");
const current = new Map(plan.items.map((item) => [item.key, config({ namespace: item.namespace, group: item.group, dataId: item.dataId, content: item.beforeContent, md5: item.expectedMd5 })]));
const publish = vi.fn(async (request: NacosConfigUpsert) => {
const key = `${request.namespace || ""}\u0000${request.group}\u0000${request.dataId}`;
current.set(key, config({ ...request, md5: `after-${request.dataId}` }));
});
const report = await applyNacosContentReplacePlan(plan, {
getConfig: async (key) => current.get(`${key.namespace || ""}\u0000${key.group}\u0000${key.dataId}`)!,
publishConfig: publish,
});
expect(report.replaced).toBe(2);
expect(report.conflicts).toBe(0);
expect(report.failed).toBe(0);
expect(publish).toHaveBeenCalledTimes(2);
expect(publish.mock.calls[0][0]).toMatchObject({ content: "url: mysql-new:3306\nreplica: mysql-new:3306", casMd5: "before-md5" });
expect(report.items.every((item) => item.status === "replaced" && item.appliedMd5)).toBe(true);
});
it("skips a configuration changed after preview", async () => {
const plan = buildNacosContentReplacePlan([config()], "mysql-old", "mysql-new");
const publish = vi.fn();
const report = await applyNacosContentReplacePlan(plan, {
getConfig: async () => config({ content: "changed elsewhere", md5: "other-md5" }),
publishConfig: publish,
});
expect(report.conflicts).toBe(1);
expect(report.replaced).toBe(0);
expect(publish).not.toHaveBeenCalled();
});
it("reports a failed verification when the server does not retain the replacement", async () => {
const plan = buildNacosContentReplacePlan([config()], "mysql-old", "mysql-new");
let reads = 0;
const report = await applyNacosContentReplacePlan(plan, {
getConfig: async () => {
reads += 1;
return reads === 1 ? config() : config({ md5: "after-md5" });
},
publishConfig: vi.fn().mockResolvedValue(undefined),
});
expect(report.failed).toBe(1);
expect(report.items[0].message).toMatch(/verification/i);
});
it("reports a CAS rejection as a conflict instead of a generic failure", async () => {
const plan = buildNacosContentReplacePlan([config()], "mysql-old", "mysql-new");
const report = await applyNacosContentReplacePlan(plan, {
getConfig: async () => config(),
publishConfig: vi.fn().mockRejectedValue(new Error("Nacos publish rejected by CAS validation")),
});
expect(report.conflicts).toBe(1);
expect(report.failed).toBe(0);
expect(report.items[0].status).toBe("conflict");
});
});
describe("rollbackNacosContentReplace", () => {
it("restores successfully replaced configurations with CAS protection", async () => {
const plan = buildNacosContentReplacePlan([config()], "mysql-old", "mysql-new");
const applied = config({ content: plan.items[0].afterContent, md5: "after-md5" });
const publish = vi.fn().mockResolvedValue(undefined);
let reads = 0;
const report = await rollbackNacosContentReplace(
{
...plan,
items: [{ ...plan.items[0], status: "replaced", appliedMd5: "after-md5" }],
replaced: 1,
conflicts: 0,
failed: 0,
cancelled: false,
},
{
getConfig: async () => {
reads += 1;
return reads === 1 ? applied : config();
},
publishConfig: publish,
},
);
expect(report.restored).toBe(1);
expect(publish).toHaveBeenCalledWith(expect.objectContaining({ content: config().content, casMd5: "after-md5" }));
});
it("does not overwrite a configuration changed after replacement", async () => {
const plan = buildNacosContentReplacePlan([config()], "mysql-old", "mysql-new");
const publish = vi.fn();
const report = await rollbackNacosContentReplace(
{
...plan,
items: [{ ...plan.items[0], status: "replaced", appliedMd5: "after-md5" }],
replaced: 1,
conflicts: 0,
failed: 0,
cancelled: false,
},
{
getConfig: async () => config({ content: "newer change", md5: "newer-md5" }),
publishConfig: publish,
},
);
expect(report.conflicts).toBe(1);
expect(publish).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildNacosContentReplacePlan } from "../nacosContentReplace";
import { applyWithNacosHistory, rollbackFromNacosHistory, nacosHistoryTarget, withNacosHistoryLock } from "../nacosReplaceHistory";
import type { NacosReplaceHistoryEntry } from "../nacosReplaceHistory";
import type { NacosConfigItem, NacosConfigUpsert } from "@/types/nacos";
const storage = vi.hoisted(() => ({ saveNacosReplaceHistory: vi.fn(), getNacosReplaceHistory: vi.fn() }));
vi.mock("../nacosReplaceHistoryStorage", () => storage);
const saved = new Map<string, NacosReplaceHistoryEntry>();
const original: NacosConfigItem = { namespace: "public", group: "test", dataId: "one", content: "mysql-old", md5: "before" };
const target = "test-endpoint";
let current: NacosConfigItem;
const backend = {
getConfig: vi.fn(async () => ({ ...current })),
publishConfig: vi.fn(async (req: NacosConfigUpsert) => {
current = { ...current, ...req, md5: req.content === original.content ? "before" : "after" };
}),
};
beforeEach(() => {
vi.clearAllMocks();
saved.clear();
current = { ...original };
storage.saveNacosReplaceHistory.mockImplementation(async (entry: NacosReplaceHistoryEntry) => {
saved.set(entry.id, structuredClone(entry));
});
storage.getNacosReplaceHistory.mockImplementation(async (id: string) => structuredClone(saved.get(id)));
});
function apply() {
return applyWithNacosHistory("main", target, { scope: "allNamespaces", namespace: "public", group: "", dataId: "" }, buildNacosContentReplacePlan([original], "mysql-old", "mysql-new"), backend);
}
describe("durable Nacos batch journal", () => {
it("saves snapshots before publishing and rolls back a reloaded historical batch", async () => {
backend.publishConfig.mockImplementationOnce(async (req) => {
const persisted = [...saved.values()][0];
expect(persisted.plan.items[0].beforeContent).toBe("mysql-old");
expect(persisted.inFlight).toEqual({ key: persisted.plan.items[0].key, phase: "apply" });
current = { ...current, ...req, md5: "after" };
});
const entry = await apply();
expect(saved.get(entry.id)?.state).toBe("completed");
const restored = await rollbackFromNacosHistory(entry.id, "main", target, backend);
expect(restored.rollback?.restored).toBe(1);
expect(current.content).toBe("mysql-old");
await rollbackFromNacosHistory(entry.id, "main", target, backend);
expect(backend.publishConfig).toHaveBeenCalledTimes(2);
});
it("does not publish when initial backup storage fails", async () => {
storage.saveNacosReplaceHistory.mockRejectedValueOnce(new Error("quota"));
await expect(apply()).rejects.toThrow("quota");
expect(backend.publishConfig).not.toHaveBeenCalled();
});
it("preserves an uncertain write and never treats it as a confirmed rollback candidate", async () => {
storage.saveNacosReplaceHistory
.mockImplementationOnce(async (entry) => {
saved.set(entry.id, structuredClone(entry));
})
.mockImplementationOnce(async (entry) => {
saved.set(entry.id, structuredClone(entry));
})
.mockRejectedValueOnce(new Error("quota"));
await expect(apply()).rejects.toThrow("quota");
const entry = [...saved.values()][0];
expect(entry.inFlight?.phase).toBe("apply");
await rollbackFromNacosHistory(entry.id, "main", target, backend);
expect(backend.publishConfig).toHaveBeenCalledTimes(1);
});
it("rejects another connection or changed target before any rollback reads/writes", async () => {
const entry = await apply();
backend.getConfig.mockClear();
backend.publishConfig.mockClear();
await expect(rollbackFromNacosHistory(entry.id, "other", target, backend)).rejects.toThrow(/target/i);
await expect(rollbackFromNacosHistory(entry.id, "main", "changed", backend)).rejects.toThrow(/target/i);
expect(backend.getConfig).not.toHaveBeenCalled();
expect(backend.publishConfig).not.toHaveBeenCalled();
});
it("records rollback conflicts without overwriting subsequent edits and permits retry", async () => {
const entry = await apply();
current = { ...current, content: "someone else's change", md5: "other" };
const conflict = await rollbackFromNacosHistory(entry.id, "main", target, backend);
expect(conflict.rollback?.conflicts).toBe(1);
expect(backend.publishConfig).toHaveBeenCalledTimes(1);
current = { ...current, content: "mysql-new", md5: "after" };
expect((await rollbackFromNacosHistory(entry.id, "main", target, backend)).rollback?.restored).toBe(1);
});
it("stops a rollback when its durable checkpoint fails", async () => {
const entry = await apply();
backend.publishConfig.mockClear();
storage.saveNacosReplaceHistory.mockRejectedValueOnce(new Error("quota"));
await expect(rollbackFromNacosHistory(entry.id, "main", target, backend)).rejects.toThrow("quota");
expect(backend.publishConfig).not.toHaveBeenCalled();
});
it("retains uncertain items across partial rollback retries without republishing already restored items", async () => {
const configs = ["one", "two", "three"].map((dataId) => ({ ...original, dataId }));
const rows = new Map(configs.map((item) => [item.dataId, item]));
const live = {
getConfig: async (key: { dataId: string }) => ({ ...rows.get(key.dataId)! }),
publishConfig: vi.fn(async (req: NacosConfigUpsert) => {
rows.set(req.dataId, { ...rows.get(req.dataId)!, ...req, md5: req.content === "mysql-old" ? "before" : "after" });
}),
};
const entry = await applyWithNacosHistory("main", target, { scope: "allNamespaces", namespace: "public", group: "", dataId: "" }, buildNacosContentReplacePlan(configs, "mysql-old", "mysql-new"), live);
let failOnce = true;
storage.saveNacosReplaceHistory.mockImplementation(async (candidate) => {
if (failOnce && candidate.state === "rollingBack" && candidate.rollback?.items.length === 2 && !candidate.inFlight) {
failOnce = false;
throw new Error("quota");
}
saved.set(candidate.id, structuredClone(candidate));
});
await expect(rollbackFromNacosHistory(entry.id, "main", target, live)).rejects.toThrow("quota");
expect(saved.get(entry.id)?.rollback?.restored).toBe(1);
expect(saved.get(entry.id)?.inFlight?.key).toBe(entry.plan.items[1].key);
const retried = await rollbackFromNacosHistory(entry.id, "main", target, live);
expect(retried.rollback?.restored).toBe(2);
expect(retried.uncertainItems).toEqual([{ key: entry.plan.items[1].key, phase: "rollback" }]);
await rollbackFromNacosHistory(entry.id, "main", target, live);
expect(live.publishConfig).toHaveBeenCalledTimes(6);
});
it("binds target identity to endpoints but excludes credentials", () => {
const connection = { id: "main", host: "localhost", port: 8848, external_config: { serverAddr: "http://localhost:8848", contextPath: "/nacos", auth: { password: "secret" } } };
const identity = nacosHistoryTarget(connection);
expect(identity).not.toContain("secret");
expect(nacosHistoryTarget({ ...connection, external_config: { ...connection.external_config, serverAddr: "http://other:8848" } })).not.toBe(identity);
});
it("rejects an overlapping operation across tabs", async () => {
vi.stubGlobal("navigator", { locks: { request: async (_name: string, _options: unknown, cb: (lock: null) => unknown) => cb(null) } });
const action = vi.fn();
await expect(withNacosHistoryLock("main", action)).rejects.toThrow(/busy/i);
expect(action).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
});
@@ -0,0 +1,128 @@
import { webcrypto } from "node:crypto";
import { IDBFactory, IDBObjectStore } from "fake-indexeddb";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { saveNacosReplaceHistory, getNacosReplaceHistory, listNacosReplaceHistory, deleteNacosReplaceHistory } from "../nacosReplaceHistoryStorage";
import { buildNacosContentReplacePlan } from "../nacosContentReplace";
import type { NacosReplaceHistoryEntry } from "../nacosReplaceHistory";
const plan = buildNacosContentReplacePlan([{ namespace: "", group: "test", dataId: "one", content: "secret-test-old" }], "secret-test-old", "secret-test-new");
function entry(id = "one", connectionId = "main", createdAt = 1): NacosReplaceHistoryEntry {
return {
version: 1,
id,
connectionId,
target: "local",
createdAt,
updatedAt: createdAt,
scope: { scope: "allNamespaces", namespace: "", group: "", dataId: "" },
state: "completed",
plan,
report: { ...plan, items: [{ ...plan.items[0], status: "replaced" }], replaced: 1, conflicts: 0, failed: 0, cancelled: false },
};
}
function value<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function open() {
return value(indexedDB.open("dbx-nacos-replace-history", 1));
}
async function stored() {
const db = await open();
const row = await value(db.transaction("entries").objectStore("entries").get("one"));
const key = await value(db.transaction("keys").objectStore("keys").get("encryption-key"));
db.close();
return { row, key };
}
beforeEach(() => {
vi.stubGlobal("indexedDB", new IDBFactory());
vi.stubGlobal("crypto", webcrypto);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("encrypted local Nacos history storage", () => {
it("round trips snapshots with a durable non-extractable key and no plaintext values", async () => {
await saveNacosReplaceHistory(entry());
expect(await getNacosReplaceHistory("one", "main")).toEqual(JSON.parse(JSON.stringify(entry())));
const { row, key } = await stored();
expect(row.data).toBeInstanceOf(ArrayBuffer);
expect(new TextDecoder().decode(row.data)).not.toContain("secret-test");
expect(JSON.stringify(row)).not.toContain("secret-test");
expect(key.extractable).toBe(false);
await expect(crypto.subtle.exportKey("raw", key)).rejects.toThrow();
});
it("lists only the current connection, sorts newest first, updates one row and deletes only that connection's record", async () => {
expect(await listNacosReplaceHistory("main")).toEqual([]);
await saveNacosReplaceHistory(entry());
await saveNacosReplaceHistory(entry("two", "main", 2));
await saveNacosReplaceHistory(entry("foreign", "other", 3));
await saveNacosReplaceHistory({ ...entry(), state: "rollbackCompleted" });
expect((await listNacosReplaceHistory("main")).map((item) => item.id)).toEqual(["two", "one"]);
expect((await getNacosReplaceHistory("one", "main"))?.state).toBe("rollbackCompleted");
expect(await getNacosReplaceHistory("one", "other")).toBeUndefined();
expect(await getNacosReplaceHistory("missing", "main")).toBeUndefined();
await deleteNacosReplaceHistory("one", "other");
expect(await getNacosReplaceHistory("one", "main")).toBeDefined();
await deleteNacosReplaceHistory("one", "main");
await deleteNacosReplaceHistory("missing", "main");
expect((await listNacosReplaceHistory("main")).map((item) => item.id)).toEqual(["two"]);
});
it("atomically initializes one shared key during concurrent first saves", async () => {
await Promise.all(Array.from({ length: 8 }, (_, i) => saveNacosReplaceHistory(entry(String(i)))));
expect(await listNacosReplaceHistory("main")).toHaveLength(8);
});
it("rejects tampered ciphertext rather than returning untrusted snapshots", async () => {
await saveNacosReplaceHistory(entry());
const { row } = await stored();
new Uint8Array(row.data)[0] ^= 1;
const db = await open();
await value(db.transaction("entries", "readwrite").objectStore("entries").put(row));
db.close();
await expect(getNacosReplaceHistory("one", "main")).rejects.toThrow("nacos-history-storage-failed");
await expect(listNacosReplaceHistory("main")).rejects.toThrow("nacos-history-storage-failed");
});
it.each([{ version: 2 }, { id: "changed" }, { connectionId: "other" }, { plan: {} }, { report: {} }])("validates authenticated record identity and version (%j)", async (change) => {
await saveNacosReplaceHistory(entry());
const { row, key } = await stored();
row.data = await crypto.subtle.encrypt({ name: "AES-GCM", iv: row.iv, additionalData: new TextEncoder().encode("one\u0000main") }, key, new TextEncoder().encode(JSON.stringify({ ...entry(), ...change })));
const db = await open();
await value(db.transaction("entries", "readwrite").objectStore("entries").put(row));
db.close();
await expect(getNacosReplaceHistory("one", "main")).rejects.toThrow("nacos-history-storage-failed");
});
it("never replaces a lost key when encrypted backups already exist", async () => {
await saveNacosReplaceHistory(entry());
const db = await open();
await value(db.transaction("keys", "readwrite").objectStore("keys").delete("encryption-key"));
db.close();
await expect(getNacosReplaceHistory("one", "main")).rejects.toThrow("nacos-history-storage-failed");
await expect(saveNacosReplaceHistory(entry("two"))).rejects.toThrow("nacos-history-storage-failed");
});
it("fails closed without IndexedDB or Web Crypto and never falls back to localStorage", async () => {
const setItem = vi.fn();
vi.stubGlobal("localStorage", { setItem });
vi.stubGlobal("indexedDB", undefined);
await expect(saveNacosReplaceHistory(entry())).rejects.toThrow("nacos-history-storage-unavailable");
vi.stubGlobal("indexedDB", new IDBFactory());
vi.stubGlobal("crypto", {});
await expect(saveNacosReplaceHistory(entry())).rejects.toThrow("nacos-history-storage-unavailable");
expect(setItem).not.toHaveBeenCalled();
});
it("reports quota/transaction errors rather than silently losing snapshots", async () => {
await saveNacosReplaceHistory(entry());
vi.spyOn(IDBObjectStore.prototype, "put").mockImplementationOnce(() => {
throw new DOMException("quota", "QuotaExceededError");
});
await expect(saveNacosReplaceHistory(entry("two"))).rejects.toThrow("nacos-history-storage-failed");
expect(await getNacosReplaceHistory("one", "main")).toBeDefined();
});
it("reports a database open error", async () => {
await value(indexedDB.open("dbx-nacos-replace-history", 2)).then((db) => db.close());
await expect(listNacosReplaceHistory("main")).rejects.toThrow("nacos-history-storage-failed");
});
});
@@ -0,0 +1,256 @@
import type { NacosConfigItem, NacosConfigKey, NacosConfigUpsert } from "@/types/nacos";
export type NacosContentReplaceItemStatus = "replaced" | "conflict" | "failed";
export interface NacosContentReplacePlanItem {
key: string;
namespace: string;
group: string;
dataId: string;
beforeContent: string;
afterContent: string;
replacements: number;
expectedMd5?: string;
configType?: string;
appName?: string;
desc?: string;
tags?: string;
}
export interface NacosContentReplacePlan {
search: string;
replacement: string;
totalReplacements: number;
items: NacosContentReplacePlanItem[];
}
export interface NacosContentReplaceResultItem extends NacosContentReplacePlanItem {
status: NacosContentReplaceItemStatus;
message?: string;
appliedMd5?: string;
}
export interface NacosContentReplaceReport extends Omit<NacosContentReplacePlan, "items"> {
items: NacosContentReplaceResultItem[];
replaced: number;
conflicts: number;
failed: number;
cancelled: boolean;
}
export interface NacosContentRollbackReport {
restored: number;
conflicts: number;
failed: number;
items: Array<{
key: string;
namespace: string;
group: string;
dataId: string;
status: "restored" | "conflict" | "failed";
message?: string;
}>;
}
export interface NacosContentReplaceApi {
getConfig(key: NacosConfigKey): Promise<NacosConfigItem>;
publishConfig(request: NacosConfigUpsert): Promise<void>;
}
export interface NacosContentReplaceExecutionOptions extends NacosContentReplaceApi {
signal?: AbortSignal;
onProgress?: (completed: number, total: number) => void;
onBeforeItem?: (item: NacosContentReplacePlanItem) => Promise<void>;
onItemResult?: (item: NacosContentReplaceResultItem) => Promise<void>;
}
export interface NacosContentRollbackOptions extends NacosContentReplaceApi {
onBeforeItem?: (item: NacosContentReplaceResultItem) => Promise<void>;
onItemResult?: (item: NacosContentRollbackReport["items"][number]) => Promise<void>;
}
export function nacosConfigIdentity(config: Pick<NacosConfigItem, "namespace" | "group" | "dataId">): string {
return `${config.namespace || ""}\u0000${config.group}\u0000${config.dataId}`;
}
function countLiteralOccurrences(content: string, search: string): number {
let count = 0;
let offset = 0;
while (true) {
const match = content.indexOf(search, offset);
if (match < 0) return count;
count += 1;
offset = match + search.length;
}
}
function validateReplaceInput(search: string, replacement: string) {
if (!search) throw new Error("Nacos content replacement search value is required");
if (search.length > 1024) throw new Error("Nacos content replacement search value exceeds the 1024 character limit");
if (search === replacement) throw new Error("Nacos content replacement values must be different");
}
export function buildNacosContentReplacePlan(configs: readonly NacosConfigItem[], search: string, replacement: string): NacosContentReplacePlan {
validateReplaceInput(search, replacement);
const seen = new Set<string>();
const items: NacosContentReplacePlanItem[] = [];
let totalReplacements = 0;
for (const config of configs) {
const key = nacosConfigIdentity(config);
if (seen.has(key)) continue;
seen.add(key);
const beforeContent = config.content ?? "";
const replacements = countLiteralOccurrences(beforeContent, search);
if (replacements === 0) continue;
totalReplacements += replacements;
items.push({
key,
namespace: config.namespace || "",
group: config.group,
dataId: config.dataId,
beforeContent,
afterContent: beforeContent.split(search).join(replacement),
replacements,
expectedMd5: config.md5,
configType: config.configType,
appName: config.appName,
desc: config.desc,
tags: config.tags,
});
}
return { search, replacement, totalReplacements, items };
}
function configKey(item: NacosContentReplacePlanItem): NacosConfigKey & { namespace: string } {
return { namespace: item.namespace, group: item.group, dataId: item.dataId };
}
function configMatchesPreview(item: NacosContentReplacePlanItem, current: NacosConfigItem): boolean {
if (current.content !== item.beforeContent) return false;
return !item.expectedMd5 || !current.md5 || current.md5 === item.expectedMd5;
}
function configMatchesApplied(item: NacosContentReplaceResultItem, current: NacosConfigItem): boolean {
if (current.content !== item.afterContent) return false;
return !item.appliedMd5 || !current.md5 || current.md5 === item.appliedMd5;
}
function replacementUpsert(item: NacosContentReplacePlanItem, currentMd5?: string): NacosConfigUpsert {
return {
namespace: item.namespace,
group: item.group,
dataId: item.dataId,
content: item.afterContent,
configType: item.configType,
appName: item.appName,
desc: item.desc,
tags: item.tags,
casMd5: currentMd5,
};
}
function rollbackUpsert(item: NacosContentReplaceResultItem, currentMd5?: string): NacosConfigUpsert {
return {
namespace: item.namespace,
group: item.group,
dataId: item.dataId,
content: item.beforeContent,
configType: item.configType,
appName: item.appName,
desc: item.desc,
tags: item.tags,
casMd5: currentMd5,
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export async function applyNacosContentReplacePlan(plan: NacosContentReplacePlan, options: NacosContentReplaceExecutionOptions): Promise<NacosContentReplaceReport> {
const resultItems: NacosContentReplaceResultItem[] = [];
let replaced = 0;
let conflicts = 0;
let failed = 0;
for (const item of plan.items) {
if (options.signal?.aborted) break;
await options.onBeforeItem?.(item);
try {
const current = await options.getConfig(configKey(item));
if (!configMatchesPreview(item, current)) {
conflicts += 1;
resultItems.push({ ...item, status: "conflict", message: "Configuration changed after preview" });
continue;
}
await options.publishConfig(replacementUpsert(item, current.md5));
const verified = await options.getConfig(configKey(item));
if (verified.content !== item.afterContent) {
failed += 1;
resultItems.push({ ...item, status: "failed", message: "Published content verification failed" });
continue;
}
replaced += 1;
resultItems.push({ ...item, status: "replaced", appliedMd5: verified.md5 });
} catch (error) {
const message = errorMessage(error);
if (/\bcas\b|casmd5/i.test(message)) {
conflicts += 1;
resultItems.push({ ...item, status: "conflict", message });
} else {
failed += 1;
resultItems.push({ ...item, status: "failed", message });
}
} finally {
// Journal errors must escape the API-error handler and stop subsequent writes.
await options.onItemResult?.(resultItems[resultItems.length - 1]);
options.onProgress?.(resultItems.length, plan.items.length);
}
}
return {
...plan,
items: resultItems,
replaced,
conflicts,
failed,
cancelled: options.signal?.aborted === true,
};
}
export async function rollbackNacosContentReplace(report: NacosContentReplaceReport, options: NacosContentRollbackOptions): Promise<NacosContentRollbackReport> {
const items: NacosContentRollbackReport["items"] = [];
let restored = 0;
let conflicts = 0;
let failed = 0;
for (const item of report.items.filter((candidate) => candidate.status === "replaced")) {
await options.onBeforeItem?.(item);
try {
const current = await options.getConfig(configKey(item));
if (!configMatchesApplied(item, current)) {
conflicts += 1;
items.push({ ...configKey(item), key: item.key, status: "conflict", message: "Configuration changed after replacement" });
continue;
}
await options.publishConfig(rollbackUpsert(item, current.md5));
const verified = await options.getConfig(configKey(item));
if (verified.content !== item.beforeContent) {
failed += 1;
items.push({ ...configKey(item), key: item.key, status: "failed", message: "Rollback verification failed" });
continue;
}
restored += 1;
items.push({ ...configKey(item), key: item.key, status: "restored" });
} catch (error) {
failed += 1;
items.push({ ...configKey(item), key: item.key, status: "failed", message: errorMessage(error) });
} finally {
await options.onItemResult?.(items[items.length - 1]);
}
}
return { restored, conflicts, failed, items };
}
@@ -0,0 +1,122 @@
import { uuid } from "@/lib/common/utils";
import type { ConnectionConfig } from "@/types/database";
import type { NacosAdminConfig, NacosNamespaceScope } from "@/types/nacos";
import { applyNacosContentReplacePlan, rollbackNacosContentReplace, type NacosContentReplaceApi, type NacosContentReplacePlan, type NacosContentReplaceReport, type NacosContentRollbackReport } from "./nacosContentReplace";
import { getNacosReplaceHistory, saveNacosReplaceHistory } from "./nacosReplaceHistoryStorage";
export interface NacosReplaceHistoryEntry {
version: 1;
id: string;
connectionId: string;
target: string;
createdAt: number;
updatedAt: number;
scope: { scope: NacosNamespaceScope; namespace: string; group: string; dataId: string };
state: "applying" | "completed" | "rollingBack" | "rollbackCompleted";
plan: NacosContentReplacePlan;
report: NacosContentReplaceReport;
rollback?: NacosContentRollbackReport;
inFlight?: { key: string; phase: "apply" | "rollback" };
uncertainItems?: Array<{ key: string; phase: "apply" | "rollback" }>;
}
export function nacosHistoryTarget(connection: Pick<ConnectionConfig, "id" | "host" | "port" | "external_config">): string {
const config = connection.external_config as NacosAdminConfig | undefined;
// Do not include authentication or transport secrets in the identity.
return JSON.stringify([connection.host, connection.port, config?.serverAddr ?? "", config?.contextPath ?? "/nacos", config?.implementation ?? "nacos", config?.rnacosConsoleAddr ?? ""]);
}
export async function withNacosHistoryLock<T>(connectionId: string, action: () => Promise<T>): Promise<T> {
if (!globalThis.navigator?.locks) throw new Error("nacos-history-storage-unavailable");
return navigator.locks.request(`dbx-nacos-replace:${connectionId}`, { ifAvailable: true }, async (lock) => {
if (!lock) throw new Error("nacos-history-busy");
return action();
});
}
function updateCounts(entry: NacosReplaceHistoryEntry) {
entry.report.replaced = entry.report.items.filter((item) => item.status === "replaced").length;
entry.report.conflicts = entry.report.items.filter((item) => item.status === "conflict").length;
entry.report.failed = entry.report.items.filter((item) => item.status === "failed").length;
if (entry.rollback) {
entry.rollback.restored = entry.rollback.items.filter((item) => item.status === "restored").length;
entry.rollback.conflicts = entry.rollback.items.filter((item) => item.status === "conflict").length;
entry.rollback.failed = entry.rollback.items.filter((item) => item.status === "failed").length;
}
entry.updatedAt = Date.now();
}
export function nacosHistoryRollbackCandidates(entry: NacosReplaceHistoryEntry) {
const restored = new Set(entry.rollback?.items.filter((item) => item.status === "restored").map((item) => item.key));
const uncertain = new Set(entry.uncertainItems?.map((item) => item.key));
if (entry.inFlight) uncertain.add(entry.inFlight.key);
return entry.report.items.filter((item) => item.status === "replaced" && !restored.has(item.key) && !uncertain.has(item.key));
}
export async function applyWithNacosHistory(connectionId: string, target: string, scope: NacosReplaceHistoryEntry["scope"], plan: NacosContentReplacePlan, api: NacosContentReplaceApi): Promise<NacosReplaceHistoryEntry> {
const entry: NacosReplaceHistoryEntry = {
version: 1,
id: uuid(),
connectionId,
target,
scope,
createdAt: Date.now(),
updatedAt: Date.now(),
state: "applying",
plan,
report: { search: plan.search, replacement: plan.replacement, totalReplacements: plan.totalReplacements, items: [], replaced: 0, conflicts: 0, failed: 0, cancelled: false },
};
await saveNacosReplaceHistory(entry);
entry.report = await applyNacosContentReplacePlan(plan, {
...api,
onBeforeItem: async (item) => {
entry.inFlight = { key: item.key, phase: "apply" };
await saveNacosReplaceHistory(entry);
},
onItemResult: async (item) => {
entry.report.items.push(item);
delete entry.inFlight;
updateCounts(entry);
await saveNacosReplaceHistory(entry);
},
});
entry.state = "completed";
updateCounts(entry);
await saveNacosReplaceHistory(entry);
return entry;
}
export async function rollbackFromNacosHistory(id: string, connectionId: string, target: string, api: NacosContentReplaceApi): Promise<NacosReplaceHistoryEntry> {
const entry = await getNacosReplaceHistory(id, connectionId);
if (!entry || entry.connectionId !== connectionId || entry.target !== target) throw new Error("nacos-history-target-mismatch");
const candidates = nacosHistoryRollbackCandidates(entry);
if (!candidates.length) return entry;
entry.state = "rollingBack";
entry.rollback ??= { restored: 0, conflicts: 0, failed: 0, items: [] };
if (entry.inFlight) {
entry.uncertainItems = [...(entry.uncertainItems ?? []), entry.inFlight];
delete entry.inFlight;
}
await saveNacosReplaceHistory(entry);
await rollbackNacosContentReplace(
{ ...entry.report, items: candidates },
{
...api,
onBeforeItem: async (item) => {
entry.inFlight = { key: item.key, phase: "rollback" };
await saveNacosReplaceHistory(entry);
},
onItemResult: async (item) => {
entry.rollback!.items = entry.rollback!.items.filter((previous) => previous.key !== item.key);
entry.rollback!.items.push(item);
delete entry.inFlight;
updateCounts(entry);
await saveNacosReplaceHistory(entry);
},
},
);
entry.state = entry.uncertainItems?.length ? "rollingBack" : "rollbackCompleted";
updateCounts(entry);
await saveNacosReplaceHistory(entry);
return entry;
}
@@ -0,0 +1,135 @@
import type { NacosReplaceHistoryEntry } from "./nacosReplaceHistory";
const DB_NAME = "dbx-nacos-replace-history";
const KEY_ID = "encryption-key";
interface EncryptedRow {
id: string;
connectionId: string;
createdAt: number;
iv: Uint8Array<ArrayBuffer>;
data: ArrayBuffer;
}
function requestValue<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(new Error("nacos-history-storage-failed"));
});
}
function transactionDone(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = transaction.onerror = () => reject(new Error("nacos-history-storage-failed"));
});
}
async function openDatabase(): Promise<IDBDatabase> {
if (!globalThis.indexedDB || !globalThis.crypto?.subtle) throw new Error("nacos-history-storage-unavailable");
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore("keys");
const entries = db.createObjectStore("entries", { keyPath: "id" });
entries.createIndex("connectionId", "connectionId");
};
request.onblocked = () => request.onerror?.(new Event("error"));
const db = await requestValue(request);
db.onversionchange = () => db.close();
return db;
}
async function encryptionKey(db: IDBDatabase): Promise<CryptoKey> {
const existing = await requestValue<CryptoKey | undefined>(db.transaction("keys").objectStore("keys").get(KEY_ID));
if (existing) return existing;
const candidate = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const tx = db.transaction(["keys", "entries"], "readwrite");
const done = transactionDone(tx);
let key: CryptoKey = candidate;
const lookup = tx.objectStore("keys").get(KEY_ID);
lookup.onsuccess = () => {
if (lookup.result) {
key = lookup.result;
return;
}
const count = tx.objectStore("entries").count();
count.onsuccess = () => {
if (count.result) tx.abort();
else tx.objectStore("keys").add(candidate, KEY_ID);
};
};
await done;
return key;
}
function additionalData(row: Pick<EncryptedRow, "id" | "connectionId">) {
return new TextEncoder().encode(`${row.id}\u0000${row.connectionId}`);
}
async function decryptRow(row: EncryptedRow, key: CryptoKey): Promise<NacosReplaceHistoryEntry> {
try {
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: row.iv, additionalData: additionalData(row) }, key, row.data);
const entry = JSON.parse(new TextDecoder().decode(plain)) as NacosReplaceHistoryEntry;
if (entry.version !== 1 || entry.id !== row.id || entry.connectionId !== row.connectionId || !Array.isArray(entry.plan?.items) || !Array.isArray(entry.report?.items)) throw new Error();
return entry;
} catch {
throw new Error("nacos-history-storage-failed");
}
}
export async function saveNacosReplaceHistory(entry: NacosReplaceHistoryEntry): Promise<void> {
const db = await openDatabase();
try {
const key = await encryptionKey(db);
const row: EncryptedRow = { id: entry.id, connectionId: entry.connectionId, createdAt: entry.createdAt, iv: crypto.getRandomValues(new Uint8Array(12)), data: new ArrayBuffer(0) };
row.data = await crypto.subtle.encrypt({ name: "AES-GCM", iv: row.iv, additionalData: additionalData(row) }, key, new TextEncoder().encode(JSON.stringify(entry)));
const tx = db.transaction("entries", "readwrite");
const done = transactionDone(tx);
tx.objectStore("entries").put(row);
await done;
} catch {
throw new Error("nacos-history-storage-failed");
} finally {
db.close();
}
}
export async function listNacosReplaceHistory(connectionId: string): Promise<NacosReplaceHistoryEntry[]> {
const db = await openDatabase();
try {
const rows = await requestValue<EncryptedRow[]>(db.transaction("entries").objectStore("entries").index("connectionId").getAll(connectionId));
if (!rows.length) return [];
const key = await encryptionKey(db);
const entries = await Promise.all(rows.map((row) => decryptRow(row, key)));
return entries.sort((a, b) => b.createdAt - a.createdAt);
} finally {
db.close();
}
}
export async function getNacosReplaceHistory(id: string, connectionId: string): Promise<NacosReplaceHistoryEntry | undefined> {
const db = await openDatabase();
try {
const row = await requestValue<EncryptedRow | undefined>(db.transaction("entries").objectStore("entries").get(id));
if (!row || row.connectionId !== connectionId) return undefined;
return await decryptRow(row, await encryptionKey(db));
} finally {
db.close();
}
}
export async function deleteNacosReplaceHistory(id: string, connectionId: string): Promise<void> {
const db = await openDatabase();
try {
const tx = db.transaction("entries", "readwrite");
const done = transactionDone(tx);
const store = tx.objectStore("entries");
const lookup = store.get(id);
lookup.onsuccess = () => {
if (lookup.result?.connectionId === connectionId) store.delete(id);
};
await done;
} finally {
db.close();
}
}