Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ Usage: network-bootstrapper generate [options]
Generate node identities, configure consensus, and emit a Besu genesis.

Options:
--static-node-domain <domain> DNS suffix appended to validator peer hostnames for static-nodes entries.
--static-node-namespace <name> Namespace segment inserted between service name and domain for static-nodes entries.
--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.
--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.
--static-node-service-name <name> Headless Service name used when constructing static-nodes hostnames.
--static-node-pod-prefix <prefix> StatefulSet prefix used when constructing validator pod hostnames.
--rpc-node-service-name <name> Headless Service name used when constructing RPC static-nodes hostnames.
Expand All @@ -50,6 +50,7 @@ Options:
-o, --outputType <type> Output target (screen, file, kubernetes). (default: "screen")
--static-node-port <number> P2P port used for static-nodes enode URIs. (default: 30303)
--static-node-discovery-port <number> 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 <algorithm> Consensus algorithm (IBFTv2, QBFT). (default: QBFT)
--chain-id <number> Chain ID for the genesis config. (default: random between 40000 and 50000)
--seconds-per-block <number> Block time in seconds. (default: 2)
Expand Down
163 changes: 163 additions & 0 deletions src/cli/commands/bootstrap/bootstrap.command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,9 @@ describe("CLI command bootstrap", () => {
Promise.resolve({} satisfies Record<string, BesuAllocAccount>),
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);
},
Expand Down Expand Up @@ -520,6 +523,9 @@ describe("CLI command bootstrap", () => {
Promise.resolve({} satisfies Record<string, BesuAllocAccount>),
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();
Expand All @@ -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",
Expand Down Expand Up @@ -621,6 +628,9 @@ describe("CLI command bootstrap", () => {
Promise.resolve({} satisfies Record<string, BesuAllocAccount>),
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();
Expand All @@ -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,
},
Expand All @@ -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<string, BesuAllocAccount>),
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;
Expand Down
82 changes: 76 additions & 6 deletions src/cli/commands/bootstrap/bootstrap.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type CliOptions = {
secondsPerBlock?: number;
staticNodeDomain?: string;
staticNodeNamespace?: string;
staticNodeFqdn?: boolean;
staticNodePort?: number;
staticNodeDiscoveryPort?: number;
staticNodeServiceName?: string;
Expand All @@ -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<void>;
};

Expand All @@ -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;

Expand Down Expand Up @@ -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"
Expand All @@ -188,15 +243,15 @@ const TEXT_OPTION_DESCRIPTORS: TextOptionDescriptor<TextOptionKey>[] = [
key: "staticNodeDomain",
flag: "--static-node-domain <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,
},
{
key: "staticNodeNamespace",
flag: "--static-node-namespace <name>",
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,
},
Expand Down Expand Up @@ -330,6 +385,7 @@ const runBootstrap = async (
rpcNodes: rpcNodesOption,
staticNodeDomain: staticNodeDomainOption,
staticNodeNamespace: staticNodeNamespaceOption,
staticNodeFqdn: staticNodeFqdnOption,
staticNodePort: staticNodePortOption,
staticNodeDiscoveryPort: staticNodeDiscoveryPortOption,
staticNodeServiceName: staticNodeServiceNameOption,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 <algorithm>",
`Consensus algorithm (${Object.values(ALGORITHM).join(", ")}). (default: ${
Expand Down