From 2d4e38d197347c58e6c4aeb56745cfcc0e48edd2 Mon Sep 17 00:00:00 2001 From: Oleksandr Hrab Date: Fri, 21 Aug 2026 14:47:29 +0300 Subject: [PATCH] fix(cli): emit namespace-relative static-nodes hostnames Static-nodes entries embedded the deployment's own namespace, so a network restored into a differently named namespace kept pointing every enode at the original deployment: consensus never resumed and RPC nodes waited forever on a dependency that would not appear (BC/DR finding I-9, PRD-13096). - --static-node-namespace is deprecated and ignored; hostnames stay relative (besu-validators-0.besu-validators) and resolve through the pod DNS search list, which is identical at deploy time and namespace-agnostic on restore. - A cluster-scoped --static-node-domain (svc.*, *.cluster.local, including namespace-smuggling forms like network.svc.cluster.local) is dropped with a warning; it only resolves alongside a namespace segment. Non-cluster suffixes are still appended. - New --static-node-fqdn opts back into the fully qualified form. It requires --static-node-namespace and warns that the namespace is pinned. Hostname resolution runs before key generation so unusable combinations fail fast, and warnings go to stderr to keep screen output consumable. Claude-Session: https://claude.ai/code/session_01FXPrQuEEnF32SBEFwEuEp8 --- README.md | 5 +- .../bootstrap/bootstrap.command.test.ts | 163 ++++++++++++++++++ .../commands/bootstrap/bootstrap.command.ts | 82 ++++++++- 3 files changed, 242 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d65aa66..b797086 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ Usage: network-bootstrapper generate [options] Generate node identities, configure consensus, and emit a Besu genesis. Options: - --static-node-domain DNS suffix appended to validator peer hostnames for static-nodes entries. - --static-node-namespace Namespace segment inserted between service name and domain for static-nodes entries. + --static-node-domain DNS suffix appended to validator peer hostnames for static-nodes entries. Cluster-scoped suffixes are dropped unless --static-node-fqdn is set. + --static-node-namespace Deprecated and ignored unless --static-node-fqdn is set: namespace segment inserted between service name and domain for static-nodes entries. --static-node-service-name Headless Service name used when constructing static-nodes hostnames. --static-node-pod-prefix StatefulSet prefix used when constructing validator pod hostnames. --rpc-node-service-name Headless Service name used when constructing RPC static-nodes hostnames. @@ -50,6 +50,7 @@ Options: -o, --outputType Output target (screen, file, kubernetes). (default: "screen") --static-node-port P2P port used for static-nodes enode URIs. (default: 30303) --static-node-discovery-port Discovery port used for static-nodes enode URIs. (default: 30303) + --static-node-fqdn Embed --static-node-namespace (and any cluster-scoped --static-node-domain) in static-nodes hostnames. (default: disabled) --consensus Consensus algorithm (IBFTv2, QBFT). (default: QBFT) --chain-id Chain ID for the genesis config. (default: random between 40000 and 50000) --seconds-per-block Block time in seconds. (default: 2) diff --git a/src/cli/commands/bootstrap/bootstrap.command.test.ts b/src/cli/commands/bootstrap/bootstrap.command.test.ts index ac2c032..c183c64 100644 --- a/src/cli/commands/bootstrap/bootstrap.command.test.ts +++ b/src/cli/commands/bootstrap/bootstrap.command.test.ts @@ -449,6 +449,9 @@ describe("CLI command bootstrap", () => { Promise.resolve({} satisfies Record), loadAbis: () => Promise.resolve([]), loadSubgraphHash: () => Promise.resolve(SAMPLE_SUBGRAPH_HASH), + warn: (_message: string) => { + // Warnings are asserted in the dedicated static-nodes tests. + }, outputResult: async (type, payload) => { await realOutputResult(type, payload); }, @@ -520,6 +523,9 @@ describe("CLI command bootstrap", () => { Promise.resolve({} satisfies Record), loadAbis: () => Promise.resolve([]), loadSubgraphHash: () => Promise.resolve(SAMPLE_SUBGRAPH_HASH), + warn: (_message: string) => { + // Warnings are asserted in the dedicated static-nodes tests. + }, outputResult: (_type, payload) => { capturedPayload = payload; return Promise.resolve(); @@ -538,6 +544,7 @@ describe("CLI command bootstrap", () => { "svc.cluster.local", "--static-node-namespace", "network", + "--static-node-fqdn", "--static-node-port", "40000", "--static-node-discovery-port", @@ -621,6 +628,9 @@ describe("CLI command bootstrap", () => { Promise.resolve({} satisfies Record), loadAbis: () => Promise.resolve([]), loadSubgraphHash: () => Promise.resolve(SAMPLE_SUBGRAPH_HASH), + warn: (_message: string) => { + // Warnings are asserted in the dedicated static-nodes tests. + }, outputResult: (_type, payload) => { capturedPayload = payload; return Promise.resolve(); @@ -633,6 +643,7 @@ describe("CLI command bootstrap", () => { rpcNodes: 0, staticNodeDomain: "svc.cluster.local", staticNodeNamespace: "network", + staticNodeFqdn: true, staticNodePort: CUSTOM_STATIC_NODE_PORT, staticNodeDiscoveryPort: 0, }, @@ -650,6 +661,158 @@ describe("CLI command bootstrap", () => { ]); }); + const createStaticNodeDeps = ( + onPayload: (payload: OutputPayload) => void, + warnings: string[] + ): BootstrapDependencies => ({ + factory: createFactoryStub(), + promptForCount: (_label, provided, defaultValue) => + Promise.resolve(provided ?? defaultValue), + promptForGenesis: (_service, { faucetAddress }) => + Promise.resolve({ + algorithm: ALGORITHM.qbft, + config: { + chainId: 77, + faucetWalletAddress: faucetAddress, + gasLimit: "0x1", + secondsPerBlock: 2, + }, + genesis: { config: {}, extraData: "0xextra" } as any, + }), + promptForText: passthroughTextPrompt, + service: {} as any, + loadAllocations: () => + Promise.resolve({} satisfies Record), + loadAbis: () => Promise.resolve([]), + loadSubgraphHash: () => Promise.resolve(SAMPLE_SUBGRAPH_HASH), + warn: (message: string) => { + warnings.push(message); + }, + outputResult: (_type, payload) => { + onPayload(payload); + return Promise.resolve(); + }, + }); + + test("runBootstrap drops the namespace and cluster-scoped domain by default", async () => { + let capturedPayload: OutputPayload | undefined; + const warnings: string[] = []; + + await runBootstrap( + { + validators: 1, + rpcNodes: 1, + staticNodeDomain: "svc.cluster.local", + staticNodeNamespace: "network", + }, + createStaticNodeDeps((payload) => { + capturedPayload = payload; + }, warnings) + ); + + expect(capturedPayload?.staticNodes).toEqual([ + expectedStaticNodeUri(1), + expectedRpcStaticNodeUri(2, 0), + ]); + expect( + warnings.some((message) => + message.includes("--static-node-namespace is deprecated") + ) + ).toBe(true); + expect( + warnings.some((message) => message.includes("is cluster-scoped")) + ).toBe(true); + }); + + test("runBootstrap keeps a namespace-agnostic domain suffix", async () => { + let capturedPayload: OutputPayload | undefined; + const warnings: string[] = []; + + await runBootstrap( + { + validators: 1, + rpcNodes: 0, + staticNodeDomain: "example.com", + }, + createStaticNodeDeps((payload) => { + capturedPayload = payload; + }, warnings) + ); + + expect(capturedPayload?.staticNodes).toEqual([ + expectedStaticNodeUri(1, "example.com"), + ]); + expect(warnings).toEqual([]); + }); + + test("runBootstrap drops a domain that smuggles the namespace in", async () => { + let capturedPayload: OutputPayload | undefined; + const warnings: string[] = []; + + await runBootstrap( + { + validators: 1, + rpcNodes: 0, + staticNodeDomain: "network.svc.cluster.local", + }, + createStaticNodeDeps((payload) => { + capturedPayload = payload; + }, warnings) + ); + + expect(capturedPayload?.staticNodes).toEqual([expectedStaticNodeUri(1)]); + expect(warnings).toHaveLength(1); + }); + + test("runBootstrap rejects --static-node-fqdn without a namespace", async () => { + const warnings: string[] = []; + + await expect( + runBootstrap( + { + validators: 1, + rpcNodes: 0, + staticNodeDomain: "svc.cluster.local", + staticNodeFqdn: true, + }, + createStaticNodeDeps(() => { + // No payload is emitted; the call is expected to reject. + }, warnings) + ) + ).rejects.toThrow("--static-node-fqdn requires --static-node-namespace"); + expect(warnings).toEqual([]); + }); + + test("runBootstrap warns when the fully qualified form is opted into", async () => { + let capturedPayload: OutputPayload | undefined; + const warnings: string[] = []; + + await runBootstrap( + { + validators: 1, + rpcNodes: 0, + staticNodeDomain: "svc.cluster.local", + staticNodeNamespace: "network", + staticNodeFqdn: true, + }, + createStaticNodeDeps((payload) => { + capturedPayload = payload; + }, warnings) + ); + + expect(capturedPayload?.staticNodes).toEqual([ + expectedStaticNodeUri( + 1, + "svc.cluster.local", + DEFAULT_STATIC_NODE_PORT, + DEFAULT_STATIC_NODE_PORT, + "network" + ), + ]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("--static-node-fqdn embeds namespace"); + }); + test("runBootstrap bypasses genesis prompts when CLI overrides provided", async () => { const factory = createFactoryStub(); const validatorOverride = 1; diff --git a/src/cli/commands/bootstrap/bootstrap.command.ts b/src/cli/commands/bootstrap/bootstrap.command.ts index 1462ca4..8f7bc31 100644 --- a/src/cli/commands/bootstrap/bootstrap.command.ts +++ b/src/cli/commands/bootstrap/bootstrap.command.ts @@ -48,6 +48,7 @@ type CliOptions = { secondsPerBlock?: number; staticNodeDomain?: string; staticNodeNamespace?: string; + staticNodeFqdn?: boolean; staticNodePort?: number; staticNodeDiscoveryPort?: number; staticNodeServiceName?: string; @@ -69,6 +70,7 @@ type BootstrapDependencies = { loadAllocations: typeof loadAllocations; loadAbis: typeof loadAbis; loadSubgraphHash: typeof loadSubgraphHash; + warn?: (message: string) => void; outputResult: (type: OutputType, payload: OutputPayload) => Promise; }; @@ -87,6 +89,8 @@ const { } = ARTIFACT_DEFAULTS; const OUTPUT_CHOICES: OutputType[] = ["screen", "file", "kubernetes"]; const LEADING_DOT_REGEX = /^\./u; +// `svc` and `cluster.local` suffixes only resolve alongside a namespace segment. +const CLUSTER_SCOPED_DOMAIN_REGEX = /(^|\.)(svc|cluster\.local)(\.|$)/u; const UNCOMPRESSED_PUBLIC_KEY_PREFIX = "04"; const UNCOMPRESSED_PUBLIC_KEY_LENGTH = 130; @@ -164,6 +168,57 @@ const normalizeStaticNodeNamespace = ( return trimmed.length === 0 ? undefined : trimmed; }; +const defaultWarn = (message: string): void => { + process.stderr.write(`${message}\n`); +}; + +type StaticNodeHostConfig = { + namespace?: string; + domain?: string; +}; + +// An enode that names its own namespace keeps pointing at the original deployment +// once the network is restored elsewhere, so hostnames stay namespace-relative by +// default and resolve through the pod's DNS search list. +const resolveStaticNodeHostConfig = ( + { + namespace, + domain, + fqdn, + }: { namespace?: string; domain?: string; fqdn?: boolean }, + warn: (message: string) => void +): StaticNodeHostConfig => { + const normalizedNamespace = normalizeStaticNodeNamespace(namespace); + const normalizedDomain = normalizeStaticNodeDomain(domain); + + if (fqdn) { + if (!normalizedNamespace) { + throw new InvalidArgumentError( + "--static-node-fqdn requires --static-node-namespace to build a resolvable hostname." + ); + } + warn( + `Warning: --static-node-fqdn embeds namespace "${normalizedNamespace}" in every static-nodes entry; a restore into a differently named namespace will point these nodes at the original deployment.` + ); + return { namespace: normalizedNamespace, domain: normalizedDomain }; + } + + if (normalizedNamespace) { + warn( + "Warning: --static-node-namespace is deprecated and ignored; static-nodes entries use namespace-relative hostnames so a restore into a renamed namespace still forms a cluster. Pass --static-node-fqdn to keep the fully qualified form." + ); + } + + if (normalizedDomain && CLUSTER_SCOPED_DOMAIN_REGEX.test(normalizedDomain)) { + warn( + `Warning: --static-node-domain "${normalizedDomain}" is cluster-scoped and was dropped; it only resolves alongside a namespace segment. Pass --static-node-fqdn with --static-node-namespace to keep it.` + ); + return {}; + } + + return { domain: normalizedDomain }; +}; + type TextOptionKey = | "staticNodeDomain" | "staticNodeNamespace" @@ -188,7 +243,7 @@ const TEXT_OPTION_DESCRIPTORS: TextOptionDescriptor[] = [ key: "staticNodeDomain", flag: "--static-node-domain ", description: - "DNS suffix appended to validator peer hostnames for static-nodes entries.", + "DNS suffix appended to validator peer hostnames for static-nodes entries. Cluster-scoped suffixes are dropped unless --static-node-fqdn is set.", parser: stripSurroundingQuotes, sanitize: (value) => normalizeStaticNodeDomain(value) ?? undefined, }, @@ -196,7 +251,7 @@ const TEXT_OPTION_DESCRIPTORS: TextOptionDescriptor[] = [ key: "staticNodeNamespace", flag: "--static-node-namespace ", description: - "Namespace segment inserted between service name and domain for static-nodes entries.", + "Deprecated and ignored unless --static-node-fqdn is set: namespace segment inserted between service name and domain for static-nodes entries.", parser: stripSurroundingQuotes, sanitize: (value) => normalizeStaticNodeNamespace(value) ?? undefined, }, @@ -330,6 +385,7 @@ const runBootstrap = async ( rpcNodes: rpcNodesOption, staticNodeDomain: staticNodeDomainOption, staticNodeNamespace: staticNodeNamespaceOption, + staticNodeFqdn: staticNodeFqdnOption, staticNodePort: staticNodePortOption, staticNodeDiscoveryPort: staticNodeDiscoveryPortOption, staticNodeServiceName: staticNodeServiceNameOption, @@ -342,6 +398,16 @@ const runBootstrap = async ( subgraphHashFile: subgraphHashFileOption, } = options; + // Resolved before any key material is generated so an unusable hostname fails fast. + const staticNodeHost = resolveStaticNodeHostConfig( + { + namespace: staticNodeNamespaceOption, + domain: staticNodeDomainOption, + fqdn: staticNodeFqdnOption, + }, + deps.warn ?? defaultWarn + ); + const resolveCount = ( label: string, provided: number | undefined, @@ -434,16 +500,16 @@ const runBootstrap = async ( const rpcNodes = generateGroup(deps.factory, rpcNodesCount); const faucet = deps.factory.generate(); const validatorStaticNodes = createStaticNodeEntries(validators, { - namespace: staticNodeNamespaceOption, - domain: staticNodeDomainOption, + namespace: staticNodeHost.namespace, + domain: staticNodeHost.domain, serviceName: staticNodeServiceName, podPrefix: staticNodePodPrefix, port: staticNodePortOption ?? DEFAULT_STATIC_NODE_PORT, discoveryPort: staticNodeDiscoveryPortOption ?? DEFAULT_STATIC_NODE_PORT, }); const rpcStaticNodes = createStaticNodeEntries(rpcNodes, { - namespace: staticNodeNamespaceOption, - domain: staticNodeDomainOption, + namespace: staticNodeHost.namespace, + domain: staticNodeHost.domain, serviceName: rpcNodeServiceName, podPrefix: rpcNodePodPrefix, port: staticNodePortOption ?? DEFAULT_STATIC_NODE_PORT, @@ -619,6 +685,10 @@ const createCliCommand = ( parseNonNegativeInteger(value, "Static node discovery port"), DEFAULT_STATIC_NODE_PORT ) + .option( + "--static-node-fqdn", + "Embed --static-node-namespace (and any cluster-scoped --static-node-domain) in static-nodes hostnames. (default: disabled)" + ) .option( "--consensus ", `Consensus algorithm (${Object.values(ALGORITHM).join(", ")}). (default: ${