Skip to content

Commit 042252c

Browse files
authored
fix(cli): emit namespace-relative static-nodes hostnames (#303)
## What Static-nodes entries no longer embed the deployment's own namespace. - `--static-node-namespace` is **deprecated and ignored**. Hostnames stay relative — `besu-validators-0.besu-validators` — and resolve through the pod's DNS search list. - A cluster-scoped `--static-node-domain` is dropped with a warning: `svc.*`, anything ending in `cluster.local`, and namespace-smuggling forms such as `network.svc.cluster.local`. It only resolves alongside a namespace segment, so keeping it would produce an unresolvable host. Non-cluster suffixes (`example.com`) are still appended. - New `--static-node-fqdn` opts back into the fully qualified form. It requires `--static-node-namespace` (hard error, exit 1, otherwise) and warns that the namespace is pinned. - Resolution runs before key generation so unusable flag combinations fail fast; warnings go to stderr so `-o screen` output stays consumable. ## Why BC/DR finding **I-9** ([PRD-13096](https://linear.app/settlemint/issue/PRD-13096)): distributed-ledger addresses embed the deployment's own environment name. Recovered into a differently named namespace, every enode points back at the *original* deployment — ledger data and identity restore correctly, but consensus never resumes and RPC nodes wait indefinitely on a validator that will never appear. It presents as a successful restore of a stalled chain. The chart-side fix (`besu-stack` in `SettleMint-Collaboration/besu-helm`) does not cover deployments that supply raw enode URLs via `staticNodes.raw` — this repository is the second, independent producer of the same defect. This PR is **step 3** of that issue's work breakdown. The generated list only ever names its own namespace, so a namespace-relative hostname is semantically identical at deployment time and correct after a restore anywhere. ## Blast radius No repository in the fleet passes `--static-node-namespace` from a chart or script — checked `dalp`, `besu-helm`, `deployment-rer`, `deployment-ocbc`. It is operator-typed (`docker run … generate`), so the change takes effect on the next image pull without any chart edit. Operators who keep passing the flag get a warning and the corrected output rather than a failure. Existing `staticNodes.raw` lists already pasted into values files (for example `deployment-ocbc/values-ibft-sit.yaml`) still carry pinned FQDNs — they need regeneration, or the DR-suite rewrite step, which stays in place per step 5 of the issue. No chart or version bump: this repository has no `charts/` directory, and `tools/version.ts` stamps `package.json` from the release tag in CI. ## Commands run - `bun test` — 119 pass, 0 fail - `bun run typecheck` — clean - `bun run check` — clean - `bun run docs:cli` — README regenerated ## CLI output ``` $ network-bootstrapper generate --static-node-namespace dalp-staging --static-node-domain svc.cluster.local 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. Warning: --static-node-domain "svc.cluster.local" is cluster-scoped and was dropped; it only resolves alongside a namespace segment. Pass --static-node-fqdn with --static-node-namespace to keep it. "enode://…@besu-validators-0.besu-validators:30303?discport=30303", "enode://…@besu-rpc-0.besu-rpc-headless:30303?discport=30303" $ network-bootstrapper generate --static-node-fqdn --static-node-namespace dalp-staging --static-node-domain svc.cluster.local Warning: --static-node-fqdn embeds namespace "dalp-staging" in every static-nodes entry; a restore into a differently named namespace will point these nodes at the original deployment. "enode://…@besu-validators-0.besu-validators.dalp-staging.svc.cluster.local:30303?discport=30303" $ network-bootstrapper generate --static-node-fqdn --static-node-fqdn requires --static-node-namespace to build a resolvable hostname. # exit 1 ``` ## Tests Five new cases in `bootstrap.command.test.ts`: default drop plus both warnings, non-cluster domain kept, smuggled-namespace domain dropped, `--static-node-fqdn` without a namespace rejected, and the FQDN opt-in warning. Two pre-existing tests now pass `--static-node-fqdn` to keep coverage of the fully qualified form. Closes step 3 of PRD-13096. https://claude.ai/code/session_01FXPrQuEEnF32SBEFwEuEp8
1 parent cff1b8b commit 042252c

3 files changed

Lines changed: 242 additions & 8 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ Usage: network-bootstrapper generate [options]
3232
Generate node identities, configure consensus, and emit a Besu genesis.
3333
3434
Options:
35-
--static-node-domain <domain> DNS suffix appended to validator peer hostnames for static-nodes entries.
36-
--static-node-namespace <name> Namespace segment inserted between service name and domain for static-nodes entries.
35+
--static-node-domain <domain> DNS suffix appended to validator peer hostnames for static-nodes entries. Cluster-scoped suffixes are dropped unless --static-node-fqdn is set.
36+
--static-node-namespace <name> Deprecated and ignored unless --static-node-fqdn is set: namespace segment inserted between service name and domain for static-nodes entries.
3737
--static-node-service-name <name> Headless Service name used when constructing static-nodes hostnames.
3838
--static-node-pod-prefix <prefix> StatefulSet prefix used when constructing validator pod hostnames.
3939
--rpc-node-service-name <name> Headless Service name used when constructing RPC static-nodes hostnames.
@@ -50,6 +50,7 @@ Options:
5050
-o, --outputType <type> Output target (screen, file, kubernetes). (default: "screen")
5151
--static-node-port <number> P2P port used for static-nodes enode URIs. (default: 30303)
5252
--static-node-discovery-port <number> Discovery port used for static-nodes enode URIs. (default: 30303)
53+
--static-node-fqdn Embed --static-node-namespace (and any cluster-scoped --static-node-domain) in static-nodes hostnames. (default: disabled)
5354
--consensus <algorithm> Consensus algorithm (IBFTv2, QBFT). (default: QBFT)
5455
--chain-id <number> Chain ID for the genesis config. (default: random between 40000 and 50000)
5556
--seconds-per-block <number> Block time in seconds. (default: 2)

‎src/cli/commands/bootstrap/bootstrap.command.test.ts‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,9 @@ describe("CLI command bootstrap", () => {
449449
Promise.resolve({} satisfies Record<string, BesuAllocAccount>),
450450
loadAbis: () => Promise.resolve([]),
451451
loadSubgraphHash: () => Promise.resolve(SAMPLE_SUBGRAPH_HASH),
452+
warn: (_message: string) => {
453+
// Warnings are asserted in the dedicated static-nodes tests.
454+
},
452455
outputResult: async (type, payload) => {
453456
await realOutputResult(type, payload);
454457
},
@@ -520,6 +523,9 @@ describe("CLI command bootstrap", () => {
520523
Promise.resolve({} satisfies Record<string, BesuAllocAccount>),
521524
loadAbis: () => Promise.resolve([]),
522525
loadSubgraphHash: () => Promise.resolve(SAMPLE_SUBGRAPH_HASH),
526+
warn: (_message: string) => {
527+
// Warnings are asserted in the dedicated static-nodes tests.
528+
},
523529
outputResult: (_type, payload) => {
524530
capturedPayload = payload;
525531
return Promise.resolve();
@@ -538,6 +544,7 @@ describe("CLI command bootstrap", () => {
538544
"svc.cluster.local",
539545
"--static-node-namespace",
540546
"network",
547+
"--static-node-fqdn",
541548
"--static-node-port",
542549
"40000",
543550
"--static-node-discovery-port",
@@ -621,6 +628,9 @@ describe("CLI command bootstrap", () => {
621628
Promise.resolve({} satisfies Record<string, BesuAllocAccount>),
622629
loadAbis: () => Promise.resolve([]),
623630
loadSubgraphHash: () => Promise.resolve(SAMPLE_SUBGRAPH_HASH),
631+
warn: (_message: string) => {
632+
// Warnings are asserted in the dedicated static-nodes tests.
633+
},
624634
outputResult: (_type, payload) => {
625635
capturedPayload = payload;
626636
return Promise.resolve();
@@ -633,6 +643,7 @@ describe("CLI command bootstrap", () => {
633643
rpcNodes: 0,
634644
staticNodeDomain: "svc.cluster.local",
635645
staticNodeNamespace: "network",
646+
staticNodeFqdn: true,
636647
staticNodePort: CUSTOM_STATIC_NODE_PORT,
637648
staticNodeDiscoveryPort: 0,
638649
},
@@ -650,6 +661,158 @@ describe("CLI command bootstrap", () => {
650661
]);
651662
});
652663

664+
const createStaticNodeDeps = (
665+
onPayload: (payload: OutputPayload) => void,
666+
warnings: string[]
667+
): BootstrapDependencies => ({
668+
factory: createFactoryStub(),
669+
promptForCount: (_label, provided, defaultValue) =>
670+
Promise.resolve(provided ?? defaultValue),
671+
promptForGenesis: (_service, { faucetAddress }) =>
672+
Promise.resolve({
673+
algorithm: ALGORITHM.qbft,
674+
config: {
675+
chainId: 77,
676+
faucetWalletAddress: faucetAddress,
677+
gasLimit: "0x1",
678+
secondsPerBlock: 2,
679+
},
680+
genesis: { config: {}, extraData: "0xextra" } as any,
681+
}),
682+
promptForText: passthroughTextPrompt,
683+
service: {} as any,
684+
loadAllocations: () =>
685+
Promise.resolve({} satisfies Record<string, BesuAllocAccount>),
686+
loadAbis: () => Promise.resolve([]),
687+
loadSubgraphHash: () => Promise.resolve(SAMPLE_SUBGRAPH_HASH),
688+
warn: (message: string) => {
689+
warnings.push(message);
690+
},
691+
outputResult: (_type, payload) => {
692+
onPayload(payload);
693+
return Promise.resolve();
694+
},
695+
});
696+
697+
test("runBootstrap drops the namespace and cluster-scoped domain by default", async () => {
698+
let capturedPayload: OutputPayload | undefined;
699+
const warnings: string[] = [];
700+
701+
await runBootstrap(
702+
{
703+
validators: 1,
704+
rpcNodes: 1,
705+
staticNodeDomain: "svc.cluster.local",
706+
staticNodeNamespace: "network",
707+
},
708+
createStaticNodeDeps((payload) => {
709+
capturedPayload = payload;
710+
}, warnings)
711+
);
712+
713+
expect(capturedPayload?.staticNodes).toEqual([
714+
expectedStaticNodeUri(1),
715+
expectedRpcStaticNodeUri(2, 0),
716+
]);
717+
expect(
718+
warnings.some((message) =>
719+
message.includes("--static-node-namespace is deprecated")
720+
)
721+
).toBe(true);
722+
expect(
723+
warnings.some((message) => message.includes("is cluster-scoped"))
724+
).toBe(true);
725+
});
726+
727+
test("runBootstrap keeps a namespace-agnostic domain suffix", async () => {
728+
let capturedPayload: OutputPayload | undefined;
729+
const warnings: string[] = [];
730+
731+
await runBootstrap(
732+
{
733+
validators: 1,
734+
rpcNodes: 0,
735+
staticNodeDomain: "example.com",
736+
},
737+
createStaticNodeDeps((payload) => {
738+
capturedPayload = payload;
739+
}, warnings)
740+
);
741+
742+
expect(capturedPayload?.staticNodes).toEqual([
743+
expectedStaticNodeUri(1, "example.com"),
744+
]);
745+
expect(warnings).toEqual([]);
746+
});
747+
748+
test("runBootstrap drops a domain that smuggles the namespace in", async () => {
749+
let capturedPayload: OutputPayload | undefined;
750+
const warnings: string[] = [];
751+
752+
await runBootstrap(
753+
{
754+
validators: 1,
755+
rpcNodes: 0,
756+
staticNodeDomain: "network.svc.cluster.local",
757+
},
758+
createStaticNodeDeps((payload) => {
759+
capturedPayload = payload;
760+
}, warnings)
761+
);
762+
763+
expect(capturedPayload?.staticNodes).toEqual([expectedStaticNodeUri(1)]);
764+
expect(warnings).toHaveLength(1);
765+
});
766+
767+
test("runBootstrap rejects --static-node-fqdn without a namespace", async () => {
768+
const warnings: string[] = [];
769+
770+
await expect(
771+
runBootstrap(
772+
{
773+
validators: 1,
774+
rpcNodes: 0,
775+
staticNodeDomain: "svc.cluster.local",
776+
staticNodeFqdn: true,
777+
},
778+
createStaticNodeDeps(() => {
779+
// No payload is emitted; the call is expected to reject.
780+
}, warnings)
781+
)
782+
).rejects.toThrow("--static-node-fqdn requires --static-node-namespace");
783+
expect(warnings).toEqual([]);
784+
});
785+
786+
test("runBootstrap warns when the fully qualified form is opted into", async () => {
787+
let capturedPayload: OutputPayload | undefined;
788+
const warnings: string[] = [];
789+
790+
await runBootstrap(
791+
{
792+
validators: 1,
793+
rpcNodes: 0,
794+
staticNodeDomain: "svc.cluster.local",
795+
staticNodeNamespace: "network",
796+
staticNodeFqdn: true,
797+
},
798+
createStaticNodeDeps((payload) => {
799+
capturedPayload = payload;
800+
}, warnings)
801+
);
802+
803+
expect(capturedPayload?.staticNodes).toEqual([
804+
expectedStaticNodeUri(
805+
1,
806+
"svc.cluster.local",
807+
DEFAULT_STATIC_NODE_PORT,
808+
DEFAULT_STATIC_NODE_PORT,
809+
"network"
810+
),
811+
]);
812+
expect(warnings).toHaveLength(1);
813+
expect(warnings[0]).toContain("--static-node-fqdn embeds namespace");
814+
});
815+
653816
test("runBootstrap bypasses genesis prompts when CLI overrides provided", async () => {
654817
const factory = createFactoryStub();
655818
const validatorOverride = 1;

‎src/cli/commands/bootstrap/bootstrap.command.ts‎

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type CliOptions = {
4848
secondsPerBlock?: number;
4949
staticNodeDomain?: string;
5050
staticNodeNamespace?: string;
51+
staticNodeFqdn?: boolean;
5152
staticNodePort?: number;
5253
staticNodeDiscoveryPort?: number;
5354
staticNodeServiceName?: string;
@@ -69,6 +70,7 @@ type BootstrapDependencies = {
6970
loadAllocations: typeof loadAllocations;
7071
loadAbis: typeof loadAbis;
7172
loadSubgraphHash: typeof loadSubgraphHash;
73+
warn?: (message: string) => void;
7274
outputResult: (type: OutputType, payload: OutputPayload) => Promise<void>;
7375
};
7476

@@ -87,6 +89,8 @@ const {
8789
} = ARTIFACT_DEFAULTS;
8890
const OUTPUT_CHOICES: OutputType[] = ["screen", "file", "kubernetes"];
8991
const LEADING_DOT_REGEX = /^\./u;
92+
// `svc` and `cluster.local` suffixes only resolve alongside a namespace segment.
93+
const CLUSTER_SCOPED_DOMAIN_REGEX = /(^|\.)(svc|cluster\.local)(\.|$)/u;
9094
const UNCOMPRESSED_PUBLIC_KEY_PREFIX = "04";
9195
const UNCOMPRESSED_PUBLIC_KEY_LENGTH = 130;
9296

@@ -164,6 +168,57 @@ const normalizeStaticNodeNamespace = (
164168
return trimmed.length === 0 ? undefined : trimmed;
165169
};
166170

171+
const defaultWarn = (message: string): void => {
172+
process.stderr.write(`${message}\n`);
173+
};
174+
175+
type StaticNodeHostConfig = {
176+
namespace?: string;
177+
domain?: string;
178+
};
179+
180+
// An enode that names its own namespace keeps pointing at the original deployment
181+
// once the network is restored elsewhere, so hostnames stay namespace-relative by
182+
// default and resolve through the pod's DNS search list.
183+
const resolveStaticNodeHostConfig = (
184+
{
185+
namespace,
186+
domain,
187+
fqdn,
188+
}: { namespace?: string; domain?: string; fqdn?: boolean },
189+
warn: (message: string) => void
190+
): StaticNodeHostConfig => {
191+
const normalizedNamespace = normalizeStaticNodeNamespace(namespace);
192+
const normalizedDomain = normalizeStaticNodeDomain(domain);
193+
194+
if (fqdn) {
195+
if (!normalizedNamespace) {
196+
throw new InvalidArgumentError(
197+
"--static-node-fqdn requires --static-node-namespace to build a resolvable hostname."
198+
);
199+
}
200+
warn(
201+
`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.`
202+
);
203+
return { namespace: normalizedNamespace, domain: normalizedDomain };
204+
}
205+
206+
if (normalizedNamespace) {
207+
warn(
208+
"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."
209+
);
210+
}
211+
212+
if (normalizedDomain && CLUSTER_SCOPED_DOMAIN_REGEX.test(normalizedDomain)) {
213+
warn(
214+
`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.`
215+
);
216+
return {};
217+
}
218+
219+
return { domain: normalizedDomain };
220+
};
221+
167222
type TextOptionKey =
168223
| "staticNodeDomain"
169224
| "staticNodeNamespace"
@@ -188,15 +243,15 @@ const TEXT_OPTION_DESCRIPTORS: TextOptionDescriptor<TextOptionKey>[] = [
188243
key: "staticNodeDomain",
189244
flag: "--static-node-domain <domain>",
190245
description:
191-
"DNS suffix appended to validator peer hostnames for static-nodes entries.",
246+
"DNS suffix appended to validator peer hostnames for static-nodes entries. Cluster-scoped suffixes are dropped unless --static-node-fqdn is set.",
192247
parser: stripSurroundingQuotes,
193248
sanitize: (value) => normalizeStaticNodeDomain(value) ?? undefined,
194249
},
195250
{
196251
key: "staticNodeNamespace",
197252
flag: "--static-node-namespace <name>",
198253
description:
199-
"Namespace segment inserted between service name and domain for static-nodes entries.",
254+
"Deprecated and ignored unless --static-node-fqdn is set: namespace segment inserted between service name and domain for static-nodes entries.",
200255
parser: stripSurroundingQuotes,
201256
sanitize: (value) => normalizeStaticNodeNamespace(value) ?? undefined,
202257
},
@@ -330,6 +385,7 @@ const runBootstrap = async (
330385
rpcNodes: rpcNodesOption,
331386
staticNodeDomain: staticNodeDomainOption,
332387
staticNodeNamespace: staticNodeNamespaceOption,
388+
staticNodeFqdn: staticNodeFqdnOption,
333389
staticNodePort: staticNodePortOption,
334390
staticNodeDiscoveryPort: staticNodeDiscoveryPortOption,
335391
staticNodeServiceName: staticNodeServiceNameOption,
@@ -342,6 +398,16 @@ const runBootstrap = async (
342398
subgraphHashFile: subgraphHashFileOption,
343399
} = options;
344400

401+
// Resolved before any key material is generated so an unusable hostname fails fast.
402+
const staticNodeHost = resolveStaticNodeHostConfig(
403+
{
404+
namespace: staticNodeNamespaceOption,
405+
domain: staticNodeDomainOption,
406+
fqdn: staticNodeFqdnOption,
407+
},
408+
deps.warn ?? defaultWarn
409+
);
410+
345411
const resolveCount = (
346412
label: string,
347413
provided: number | undefined,
@@ -434,16 +500,16 @@ const runBootstrap = async (
434500
const rpcNodes = generateGroup(deps.factory, rpcNodesCount);
435501
const faucet = deps.factory.generate();
436502
const validatorStaticNodes = createStaticNodeEntries(validators, {
437-
namespace: staticNodeNamespaceOption,
438-
domain: staticNodeDomainOption,
503+
namespace: staticNodeHost.namespace,
504+
domain: staticNodeHost.domain,
439505
serviceName: staticNodeServiceName,
440506
podPrefix: staticNodePodPrefix,
441507
port: staticNodePortOption ?? DEFAULT_STATIC_NODE_PORT,
442508
discoveryPort: staticNodeDiscoveryPortOption ?? DEFAULT_STATIC_NODE_PORT,
443509
});
444510
const rpcStaticNodes = createStaticNodeEntries(rpcNodes, {
445-
namespace: staticNodeNamespaceOption,
446-
domain: staticNodeDomainOption,
511+
namespace: staticNodeHost.namespace,
512+
domain: staticNodeHost.domain,
447513
serviceName: rpcNodeServiceName,
448514
podPrefix: rpcNodePodPrefix,
449515
port: staticNodePortOption ?? DEFAULT_STATIC_NODE_PORT,
@@ -619,6 +685,10 @@ const createCliCommand = (
619685
parseNonNegativeInteger(value, "Static node discovery port"),
620686
DEFAULT_STATIC_NODE_PORT
621687
)
688+
.option(
689+
"--static-node-fqdn",
690+
"Embed --static-node-namespace (and any cluster-scoped --static-node-domain) in static-nodes hostnames. (default: disabled)"
691+
)
622692
.option(
623693
"--consensus <algorithm>",
624694
`Consensus algorithm (${Object.values(ALGORITHM).join(", ")}). (default: ${

0 commit comments

Comments
 (0)