fix(kafka): support ZooKeeper broker discovery

This commit is contained in:
miracle authored and GitHub committed 2026-07-23 00:20:47 +08:00
1 parent 34448cba9d
commit 8adf802444
18 files changed
+977 -77

No files matched your search

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapServers";
import { normalizeKafkaBootstrapServers, parseKafkaBootstrapServers, resolveKafkaSecurityProtocol } from "@/lib/connection/kafkaBootstrapServers";
describe("Kafka bootstrap servers", () => {
it("keeps comma-separated bootstrap servers", () => {
@@ -14,11 +14,34 @@ describe("Kafka bootstrap servers", () => {
expect(normalizeKafkaBootstrapServers("[::1]:9092;[2001:db8::1]:9092")).toBe("[::1]:9092,[2001:db8::1]:9092");
});
it("rejects bootstrap servers with URL schemes", () => {
expect(() => normalizeKafkaBootstrapServers("PLAINTEXT://broker1:9092,broker2:9092")).toThrow("Kafka bootstrap servers must be host:port values without a URL scheme");
it("normalizes Kafka listener URIs and exposes the inferred security protocol", () => {
expect(parseKafkaBootstrapServers("PLAINTEXT://broker1:9092, broker2:9092")).toEqual({
bootstrapServers: "broker1:9092,broker2:9092",
inferredSecurityProtocol: "PLAINTEXT",
});
expect(parseKafkaBootstrapServers("SASL_SSL://secure-broker:9093")).toEqual({
bootstrapServers: "secure-broker:9093",
inferredSecurityProtocol: "SASL_SSL",
});
});
it("rejects bootstrap servers that declare conflicting security protocols", () => {
expect(() => normalizeKafkaBootstrapServers("SSL://broker1:9093,PLAINTEXT://broker2:9092")).toThrow("Kafka bootstrap servers must use one security protocol");
});
it("rejects unknown listener URI schemes", () => {
expect(() => normalizeKafkaBootstrapServers("INTERNAL://broker1:9092")).toThrow("Kafka bootstrap server protocol is invalid");
});
it("uses an inferred protocol only when security remains automatic", () => {
expect(resolveKafkaSecurityProtocol("", "SASL_SSL")).toBe("SASL_SSL");
expect(resolveKafkaSecurityProtocol("SSL", "SASL_SSL")).toBe("SSL");
expect(resolveKafkaSecurityProtocol("", undefined)).toBe("");
});
it("rejects invalid bootstrap server values", () => {
expect(() => normalizeKafkaBootstrapServers("broker1:9092/path,broker2:9092")).toThrow("Kafka bootstrap servers are invalid");
expect(() => normalizeKafkaBootstrapServers("broker1")).toThrow("Kafka bootstrap servers must be host:port values");
expect(() => normalizeKafkaBootstrapServers("broker1:70000")).toThrow("Kafka bootstrap servers are invalid");
});
});
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { buildMqKafkaConnectionExtra, mqKafkaConnectionTarget, normalizeMqKafkaZooKeeperServers, resolveMqKafkaConnectionSource } from "@/lib/connection/mqKafkaConnection";
describe("MQ Kafka connection", () => {
it("keeps existing configurations on the Bootstrap source", () => {
expect(resolveMqKafkaConnectionSource({ bootstrapServers: "broker-1:9092" })).toBe("bootstrap");
expect(resolveMqKafkaConnectionSource({})).toBe("bootstrap");
});
it("recognizes explicit and legacy ZooKeeper discovery configurations", () => {
expect(resolveMqKafkaConnectionSource({ connectionSource: "zookeeper", zookeeperServers: "zk-1:2181" })).toBe("zookeeper");
expect(resolveMqKafkaConnectionSource({ zookeeperServers: "zk-legacy:2181" })).toBe("zookeeper");
});
it("builds Bootstrap extra fields from listener URIs without persisting the scheme", () => {
expect(
buildMqKafkaConnectionExtra({
connectionSource: "bootstrap",
bootstrapServers: "SASL_SSL://broker-1:9093, broker-2:9093",
zookeeperServers: "ignored:2181",
securityProtocol: "",
}),
).toEqual({ bootstrapServers: "broker-1:9093,broker-2:9093", securityProtocol: "SASL_SSL" });
});
it("keeps an explicit security protocol ahead of the listener URI hint", () => {
expect(
buildMqKafkaConnectionExtra({
connectionSource: "bootstrap",
bootstrapServers: "PLAINTEXT://broker-1:9092",
zookeeperServers: "",
securityProtocol: "SSL",
}),
).toEqual({ bootstrapServers: "broker-1:9092", securityProtocol: "SSL" });
});
it("builds ZooKeeper discovery fields without a fake Bootstrap address", () => {
expect(
buildMqKafkaConnectionExtra({
connectionSource: "zookeeper",
bootstrapServers: "ignored:9092",
zookeeperServers: "zookeeper://zk-1:2181; zk-2:2181/kafka",
securityProtocol: "PLAINTEXT",
}),
).toEqual({
connectionSource: "zookeeper",
zookeeperServers: "zk-1:2181,zk-2:2181/kafka",
securityProtocol: "PLAINTEXT",
});
});
it("rejects malformed ZooKeeper discovery addresses", () => {
expect(() => normalizeMqKafkaZooKeeperServers("zk-1:not-a-port/kafka")).toThrow("Kafka ZooKeeper servers are invalid");
expect(() => normalizeMqKafkaZooKeeperServers("zk-1:70000/kafka")).toThrow("Kafka ZooKeeper servers are invalid");
});
it("maps the active source to the generic connection target", () => {
expect(
mqKafkaConnectionTarget({
connectionSource: "bootstrap",
bootstrapServers: "SSL://broker-1:9093,broker-2:9093",
zookeeperServers: "",
securityProtocol: "",
}),
).toEqual({ host: "broker-1", port: 9093, ssl: true });
expect(
mqKafkaConnectionTarget({
connectionSource: "zookeeper",
bootstrapServers: "",
zookeeperServers: "zk-1:2281,zk-2:2181/kafka",
securityProtocol: "SASL_SSL",
}),
).toEqual({ host: "zk-1", port: 2281, ssl: false });
});
});
@@ -1,4 +1,13 @@
const KAFKA_BOOTSTRAP_SERVER_SEPARATOR = /[\s,;,;]+/u;
const KAFKA_BOOTSTRAP_SERVER_SCHEME = /^([a-z][a-z0-9_-]*):\/\/(.+)$/iu;
const KAFKA_SECURITY_PROTOCOLS = new Set(["PLAINTEXT", "SSL", "SASL_PLAINTEXT", "SASL_SSL"] as const);
export type KafkaSecurityProtocol = "PLAINTEXT" | "SSL" | "SASL_PLAINTEXT" | "SASL_SSL";
export interface ParsedKafkaBootstrapServers {
bootstrapServers: string;
inferredSecurityProtocol?: KafkaSecurityProtocol;
}
function requireKafkaBootstrapServers(value: string): string {
const trimmed = value.trim();
@@ -6,28 +15,60 @@ function requireKafkaBootstrapServers(value: string): string {
return trimmed;
}
function normalizeKafkaBootstrapServer(server: string): string {
if (server.includes("://")) {
throw new Error("Kafka bootstrap servers must be host:port values without a URL scheme");
function normalizeKafkaBootstrapServer(server: string): { address: string; securityProtocol?: KafkaSecurityProtocol } {
const schemeMatch = server.match(KAFKA_BOOTSTRAP_SERVER_SCHEME);
let address = server;
let securityProtocol: KafkaSecurityProtocol | undefined;
if (schemeMatch) {
const protocol = schemeMatch[1].toUpperCase();
if (!KAFKA_SECURITY_PROTOCOLS.has(protocol as KafkaSecurityProtocol)) {
throw new Error("Kafka bootstrap server protocol is invalid");
}
securityProtocol = protocol as KafkaSecurityProtocol;
address = schemeMatch[2];
} else if (server.includes("://")) {
throw new Error("Kafka bootstrap server protocol is invalid");
}
let parsed: URL;
try {
parsed = new URL(`kafka://${server}`);
parsed = new URL(`kafka://${address}`);
} catch {
throw new Error("Kafka bootstrap servers are invalid");
}
if (!parsed.hostname || parsed.username || parsed.password || parsed.search || parsed.hash || (parsed.pathname && parsed.pathname !== "/")) {
throw new Error("Kafka bootstrap servers are invalid");
}
return server;
if (!parsed.port) {
throw new Error("Kafka bootstrap servers must be host:port values");
}
return { address, securityProtocol };
}
export function normalizeKafkaBootstrapServers(value: string): string {
const servers = requireKafkaBootstrapServers(value)
export function parseKafkaBootstrapServers(value: string): ParsedKafkaBootstrapServers {
const parsedServers = requireKafkaBootstrapServers(value)
.split(KAFKA_BOOTSTRAP_SERVER_SEPARATOR)
.map((server) => server.trim())
.filter(Boolean)
.map(normalizeKafkaBootstrapServer);
if (!servers.length) throw new Error("Kafka bootstrap servers are required");
return servers.join(",");
if (!parsedServers.length) throw new Error("Kafka bootstrap servers are required");
const protocols = new Set(parsedServers.map((server) => server.securityProtocol).filter((protocol): protocol is KafkaSecurityProtocol => !!protocol));
if (protocols.size > 1) {
throw new Error("Kafka bootstrap servers must use one security protocol");
}
const inferredSecurityProtocol = protocols.values().next().value;
return {
bootstrapServers: parsedServers.map((server) => server.address).join(","),
...(inferredSecurityProtocol ? { inferredSecurityProtocol } : {}),
};
}
export function normalizeKafkaBootstrapServers(value: string): string {
return parseKafkaBootstrapServers(value).bootstrapServers;
}
export function resolveKafkaSecurityProtocol(configured: string, inferred?: KafkaSecurityProtocol): string {
return configured.trim() || inferred || "";
}
@@ -0,0 +1,83 @@
import { parseKafkaBootstrapServers, resolveKafkaSecurityProtocol } from "@/lib/connection/kafkaBootstrapServers";
import { firstZooKeeperEndpoint, normalizeZooKeeperConnectString } from "@/lib/zookeeper/zookeeperConnection";
export type MqKafkaConnectionSource = "bootstrap" | "zookeeper";
export interface MqKafkaConnectionInput {
connectionSource: MqKafkaConnectionSource;
bootstrapServers: string;
zookeeperServers: string;
securityProtocol?: string;
}
export interface MqKafkaConnectionTarget {
host: string;
port: number;
ssl: boolean;
}
export function resolveMqKafkaConnectionSource(extra: Record<string, unknown>): MqKafkaConnectionSource {
if (extra.connectionSource === "zookeeper") return "zookeeper";
if (typeof extra.zookeeperServers === "string" && extra.zookeeperServers.trim() && !(typeof extra.bootstrapServers === "string" && extra.bootstrapServers.trim())) {
return "zookeeper";
}
return "bootstrap";
}
export function normalizeMqKafkaZooKeeperServers(value: string): string {
const normalized = normalizeZooKeeperConnectString(value.trim());
if (!normalized) throw new Error("Kafka ZooKeeper servers are required");
const chrootIndex = normalized.indexOf("/");
const ensemble = chrootIndex >= 0 ? normalized.slice(0, chrootIndex) : normalized;
const chroot = chrootIndex >= 0 ? normalized.slice(chrootIndex) : "";
if (chroot && (!chroot.startsWith("/") || chroot.includes("//"))) {
throw new Error("Kafka ZooKeeper servers are invalid");
}
for (const endpoint of ensemble.split(",")) {
const match = endpoint.match(/^(?:\[[^\]]+\]|[^:\s/?#]+):(\d+)$/u);
const port = match ? Number(match[1]) : Number.NaN;
if (!match || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Kafka ZooKeeper servers are invalid");
}
}
return normalized;
}
export function buildMqKafkaConnectionExtra(input: MqKafkaConnectionInput): Record<string, string> {
let extra: Record<string, string>;
let securityProtocol = input.securityProtocol?.trim() || "";
if (input.connectionSource === "zookeeper") {
extra = {
connectionSource: "zookeeper",
zookeeperServers: normalizeMqKafkaZooKeeperServers(input.zookeeperServers),
};
} else {
const parsed = parseKafkaBootstrapServers(input.bootstrapServers);
securityProtocol = resolveKafkaSecurityProtocol(securityProtocol, parsed.inferredSecurityProtocol);
extra = { bootstrapServers: parsed.bootstrapServers };
}
if (securityProtocol) extra.securityProtocol = securityProtocol;
return extra;
}
export function mqKafkaConnectionTarget(input: MqKafkaConnectionInput): MqKafkaConnectionTarget {
if (input.connectionSource === "zookeeper") {
const endpoint = firstZooKeeperEndpoint(normalizeMqKafkaZooKeeperServers(input.zookeeperServers));
if (!endpoint) throw new Error("Kafka ZooKeeper servers are required");
return { ...endpoint, ssl: false };
}
const parsed = parseKafkaBootstrapServers(input.bootstrapServers);
const first = parsed.bootstrapServers.split(",")[0];
const endpoint = new URL(`kafka://${first}`);
const securityProtocol = resolveKafkaSecurityProtocol(input.securityProtocol || "", parsed.inferredSecurityProtocol);
return {
host: endpoint.hostname,
port: Number(endpoint.port),
ssl: securityProtocol === "SSL" || securityProtocol === "SASL_SSL",
};
}