diff --git a/charts/network/README.md b/charts/network/README.md index d9f1063..6a2f696 100644 --- a/charts/network/README.md +++ b/charts/network/README.md @@ -17,3 +17,14 @@ A Helm chart for a blockchain network on Kubernetes | | network-bootstrapper | * | | | network-nodes | * | +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| global | object | `{"networkNodes":{"faucetArtifactPrefix":"besu-faucet","genesisConfigMapName":"besu-genesis","podPrefix":"","serviceName":"","staticNodesConfigMapName":"besu-static-nodes"}}` | Global configuration shared across subcharts. | +| global.networkNodes | object | `{"faucetArtifactPrefix":"besu-faucet","genesisConfigMapName":"besu-genesis","podPrefix":"","serviceName":"","staticNodesConfigMapName":"besu-static-nodes"}` | Defaults consumed by Besu network node workloads. | +| global.networkNodes.faucetArtifactPrefix | string | `"besu-faucet"` | Prefix used for faucet ConfigMaps and Secrets. | +| global.networkNodes.genesisConfigMapName | string | `"besu-genesis"` | ConfigMap name storing the generated genesis.json artifact. | +| global.networkNodes.podPrefix | string | `""` | StatefulSet prefix used for validator pod hostnames. | +| global.networkNodes.serviceName | string | `""` | Kubernetes Service name fronting validator pods to align bootstrapper static-nodes output. | +| global.networkNodes.staticNodesConfigMapName | string | `"besu-static-nodes"` | ConfigMap name storing static-nodes.json entries. | diff --git a/charts/network/charts/network-bootstrapper/README.md b/charts/network/charts/network-bootstrapper/README.md index 5912d14..c83fcc5 100644 --- a/charts/network/charts/network-bootstrapper/README.md +++ b/charts/network/charts/network-bootstrapper/README.md @@ -47,14 +47,19 @@ A Helm chart for Kubernetes | settings.defaultStaticNodeDiscoveryPort | int | `30303` | Default UDP discovery port used in generated enode URIs when no override is provided. | | settings.defaultStaticNodePort | int | `30303` | Default TCP P2P port used in generated enode URIs when no override is provided. | | settings.evmStackSize | int | `nil` | Maximum EVM stack size permitted for contract execution. | +| settings.faucetArtifactPrefix | string | `nil` | Prefix used for faucet ConfigMaps and Secrets. | | settings.gasLimit | int | `nil` | Genesis block gas limit expressed as a decimal number. | | settings.gasPrice | int | `nil` | Base gas price in wei applied across the network. | +| settings.genesisConfigMapName | string | `nil` | ConfigMap name storing the generated genesis.json payload. | | settings.outputType | string | `"kubernetes"` | Output target for generated artefacts: screen, file, or kubernetes. | | settings.secondsPerBlock | int | `nil` | Block interval in seconds for the genesis configuration. | | settings.staticNodeDiscoveryPort | int | `nil` | UDP discovery port embedded in static node enode URIs. | | settings.staticNodeDomain | string | `nil` | DNS suffix appended to generated static node hostnames. | | settings.staticNodeNamespace | string | `nil` | Namespace component inserted between service name and domain in static node hostnames. | +| settings.staticNodePodPrefix | string | `nil` | StatefulSet prefix applied to validator pod hostnames. | | settings.staticNodePort | int | `nil` | TCP P2P port embedded in static node enode URIs. | +| settings.staticNodeServiceName | string | `nil` | Headless Service name used when constructing static node hostnames. | +| settings.staticNodesConfigMapName | string | `nil` | ConfigMap name storing the generated static-nodes.json payload. | | settings.validators | int | `nil` | Number of validator identities to generate (default 4). | | tolerations | list | `[]` | Tolerations allowing the bootstrapper pod onto tainted nodes. | | volumeMounts | list | `[]` | Extra volume mounts applied to the bootstrapper container. | diff --git a/charts/network/charts/network-bootstrapper/templates/job.yaml b/charts/network/charts/network-bootstrapper/templates/job.yaml index b0e902b..d4c1475 100644 --- a/charts/network/charts/network-bootstrapper/templates/job.yaml +++ b/charts/network/charts/network-bootstrapper/templates/job.yaml @@ -47,6 +47,20 @@ spec: {{- $resolvedStaticDomain := default $clusterDomain .Values.settings.staticNodeDomain }} {{- $resolvedStaticPort := default $defaultStaticPort .Values.settings.staticNodePort }} {{- $resolvedStaticDiscovery := default $defaultStaticDiscovery .Values.settings.staticNodeDiscoveryPort }} + {{- $globalNodes := default (dict) .Values.global.networkNodes }} + {{- $autoNames := dict "service" "besu-node" "podPrefix" "besu-node-validator" }} + {{- with (index $.Subcharts "network-nodes") }} + {{- $service := include "nodes.fullname" . }} + {{- $_ := set $autoNames "service" $service }} + {{- $_ := set $autoNames "podPrefix" (printf "%s-validator" $service) }} + {{- end }} + {{- $serviceOverride := coalesce .Values.settings.staticNodeServiceName (get $globalNodes "serviceName") }} + {{- $resolvedServiceName := default (index $autoNames "service") $serviceOverride }} + {{- $podOverride := coalesce .Values.settings.staticNodePodPrefix (get $globalNodes "podPrefix") }} + {{- $resolvedPodPrefix := default (index $autoNames "podPrefix") $podOverride }} + {{- $resolvedGenesisName := default "besu-genesis" (default (get $globalNodes "genesisConfigMapName") .Values.settings.genesisConfigMapName) }} + {{- $resolvedStaticNodesName := default "besu-static-nodes" (default (get $globalNodes "staticNodesConfigMapName") .Values.settings.staticNodesConfigMapName) }} + {{- $resolvedFaucetPrefix := default "besu-faucet" (default (get $globalNodes "faucetArtifactPrefix") .Values.settings.faucetArtifactPrefix) }} {{- with .Values.settings.validators }} - --validators={{ . }} {{- end }} @@ -56,6 +70,11 @@ spec: {{- end }} - --static-node-port={{ $resolvedStaticPort }} - --static-node-discovery-port={{ $resolvedStaticDiscovery }} + - --static-node-service-name={{ $resolvedServiceName }} + - --static-node-pod-prefix={{ $resolvedPodPrefix }} + - --genesis-configmap-name={{ $resolvedGenesisName }} + - --static-nodes-configmap-name={{ $resolvedStaticNodesName }} + - --faucet-artifact-prefix={{ $resolvedFaucetPrefix }} {{- with .Values.settings.allocations }} - --allocations={{ . }} {{- end }} diff --git a/charts/network/charts/network-bootstrapper/values.yaml b/charts/network/charts/network-bootstrapper/values.yaml index e28632c..3ae34a5 100644 --- a/charts/network/charts/network-bootstrapper/values.yaml +++ b/charts/network/charts/network-bootstrapper/values.yaml @@ -103,6 +103,16 @@ settings: staticNodePort: # -- (int) UDP discovery port embedded in static node enode URIs. staticNodeDiscoveryPort: + # -- (string) Headless Service name used when constructing static node hostnames. + staticNodeServiceName: + # -- (string) StatefulSet prefix applied to validator pod hostnames. + staticNodePodPrefix: + # -- (string) ConfigMap name storing the generated genesis.json payload. + genesisConfigMapName: + # -- (string) ConfigMap name storing the generated static-nodes.json payload. + staticNodesConfigMapName: + # -- (string) Prefix used for faucet ConfigMaps and Secrets. + faucetArtifactPrefix: # -- (string) Path to a JSON allocations file providing pre-funded accounts. allocations: # -- (string) Output target for generated artefacts: screen, file, or kubernetes. diff --git a/charts/network/charts/network-nodes/templates/statefulset-rpc.yaml b/charts/network/charts/network-nodes/templates/statefulset-rpc.yaml index 287cd84..929c11c 100644 --- a/charts/network/charts/network-nodes/templates/statefulset-rpc.yaml +++ b/charts/network/charts/network-nodes/templates/statefulset-rpc.yaml @@ -7,6 +7,9 @@ metadata: app.kubernetes.io/component: rpc spec: {{- $root := . }} + {{- $globalNodes := .Values.global.networkNodes | default (dict) }} + {{- $genesisConfigMapName := default "besu-genesis" (get $globalNodes "genesisConfigMapName") }} + {{- $staticNodesConfigMapName := default "besu-static-nodes" (get $globalNodes "staticNodesConfigMapName") }} {{- $persistence := .Values.persistence | default (dict) }} {{- $persistenceEnabled := default false (get $persistence "enabled") }} {{- $existingClaim := default "" (get $persistence "existingClaim") }} @@ -161,10 +164,10 @@ spec: name: {{ include "nodes.fullname" . }}-config - name: besu-genesis configMap: - name: besu-genesis + name: {{ $genesisConfigMapName }} - name: besu-static-nodes configMap: - name: besu-static-nodes + name: {{ $staticNodesConfigMapName }} {{- if $useExistingClaim }} - name: {{ $volumeName }} persistentVolumeClaim: diff --git a/charts/network/charts/network-nodes/templates/statefulset-validator.yaml b/charts/network/charts/network-nodes/templates/statefulset-validator.yaml index 085d040..bfb12a5 100644 --- a/charts/network/charts/network-nodes/templates/statefulset-validator.yaml +++ b/charts/network/charts/network-nodes/templates/statefulset-validator.yaml @@ -27,6 +27,10 @@ spec: {{- $useClaimTemplate := and $persistenceEnabled (not $useExistingClaim) }} {{- $privateKeyFilename := default "privateKey" .Values.config.privateKeyFilename }} {{- $shouldMountPersistence := $persistenceEnabled }} + {{- $globalNodes := .Values.global.networkNodes | default (dict) }} + {{- $genesisConfigMapName := default "besu-genesis" (get $globalNodes "genesisConfigMapName") }} + {{- $staticNodesConfigMapName := default "besu-static-nodes" (get $globalNodes "staticNodesConfigMapName") }} + {{- $validatorPodPrefix := default "besu-node-validator" (get $globalNodes "podPrefix") }} {{- $validatorReplicaBudget := (include "nodes.validatorReplicaCount" . | int) }} replicas: {{ $validatorReplicaBudget }} serviceName: {{ include "nodes.fullname" . }} @@ -164,17 +168,17 @@ spec: name: {{ include "nodes.fullname" . }}-config - name: besu-genesis configMap: - name: besu-genesis + name: {{ $genesisConfigMapName }} - name: besu-static-nodes configMap: - name: besu-static-nodes + name: {{ $staticNodesConfigMapName }} - name: besu-private-key projected: {{- if gt $validatorReplicaBudget 0 }} sources: {{- range $i, $_ := until $validatorReplicaBudget }} - secret: - name: {{ printf "besu-node-validator-%d-private-key" $i }} + name: {{ printf "%s-%d-private-key" $validatorPodPrefix $i }} items: - key: privateKey path: {{ printf "%s-validator-%d/privateKey" (include "nodes.fullname" $root) $i }} diff --git a/charts/network/values.yaml b/charts/network/values.yaml index 84847fe..524c728 100644 --- a/charts/network/values.yaml +++ b/charts/network/values.yaml @@ -1 +1,16 @@ # This parent chart does not define additional values; override subchart defaults here when packaging composite releases. + +# -- (object) Global configuration shared across subcharts. +global: + # -- (object) Defaults consumed by Besu network node workloads. + networkNodes: + # -- (string) Kubernetes Service name fronting validator pods to align bootstrapper static-nodes output. + serviceName: "" + # -- (string) StatefulSet prefix used for validator pod hostnames. + podPrefix: "" + # -- (string) ConfigMap name storing the generated genesis.json artifact. + genesisConfigMapName: besu-genesis + # -- (string) ConfigMap name storing static-nodes.json entries. + staticNodesConfigMapName: besu-static-nodes + # -- (string) Prefix used for faucet ConfigMaps and Secrets. + faucetArtifactPrefix: besu-faucet diff --git a/src/cli/build-command.test.ts b/src/cli/build-command.test.ts index 149dffb..b70d95d 100644 --- a/src/cli/build-command.test.ts +++ b/src/cli/build-command.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { getAddress } from "viem"; +import { ARTIFACT_DEFAULTS } from "../constants/artifact-defaults.ts"; import type { BesuAllocAccount } from "../genesis/besu-genesis.service.ts"; import { ALGORITHM } from "../genesis/besu-genesis.service.ts"; import type { GeneratedNodeKey } from "../keys/node-key-factory.ts"; @@ -16,6 +17,13 @@ const EXPECTED_DEFAULT_VALIDATOR = 4; const DEFAULT_STATIC_NODE_PORT = 30_303; const CUSTOM_STATIC_NODE_PORT = 40_000; const LEADING_DOT_REGEX = /^\./u; +const { + staticNodeServiceName: DEFAULT_SERVICE_NAME, + staticNodePodPrefix: DEFAULT_POD_PREFIX, + genesisConfigMapName: DEFAULT_GENESIS_CONFIGMAP_NAME, + staticNodesConfigMapName: DEFAULT_STATIC_NODES_CONFIGMAP_NAME, + faucetArtifactPrefix: DEFAULT_FAUCET_PREFIX, +} = ARTIFACT_DEFAULTS; const UNCOMPRESSED_PUBLIC_KEY_PREFIX = "04"; const UNCOMPRESSED_PUBLIC_KEY_LENGTH = 130; const HEX_RADIX = 16; @@ -46,6 +54,10 @@ const createFactoryStub = () => { }; }; +const passthroughTextPrompt: BootstrapDependencies["promptForText"] = ({ + defaultValue, +}) => Promise.resolve(defaultValue); + const expectedAddress = (index: number) => { const pattern = index.toString(HEX_RADIX).padStart(PAD_WIDTH, PAD_CHAR); return getAddress(`0x${pattern.repeat(ADDRESS_REPEAT)}`); @@ -61,7 +73,9 @@ const expectedStaticNodeUri = ( domain?: string, port: number = DEFAULT_STATIC_NODE_PORT, discoveryPort: number = DEFAULT_STATIC_NODE_PORT, - namespace?: string + namespace?: string, + serviceName: string = DEFAULT_SERVICE_NAME, + podPrefix: string = DEFAULT_POD_PREFIX ): string => { const normalizedDomain = domain === undefined || domain.trim().length === 0 @@ -72,8 +86,7 @@ const expectedStaticNodeUri = ( ? undefined : namespace.trim(); const ordinal = index - 1; - const podName = `besu-node-validator-${ordinal}`; - const serviceName = "besu-node"; + const podName = `${podPrefix}-${ordinal}`; const segments = [podName, serviceName]; if (normalizedNamespace) { segments.push(normalizedNamespace); @@ -121,6 +134,7 @@ describe("CLI command bootstrap", () => { test("runBootstrap orchestrates prompts and writes genesis", async () => { const factory = createFactoryStub(); const promptCalls: [string, number | undefined, number][] = []; + const textPromptCalls: [string, string][] = []; let loadAllocationsPath: string | undefined; let outputInvocation: | { @@ -174,6 +188,10 @@ describe("CLI command bootstrap", () => { genesis: { config: {}, extraData: "0xextra" } as any, }); }, + promptForText: ({ labelText, defaultValue }) => { + textPromptCalls.push([labelText, defaultValue]); + return Promise.resolve(defaultValue); + }, service: {} as any, loadAllocations: (path: string) => { loadAllocationsPath = path; @@ -194,6 +212,13 @@ describe("CLI command bootstrap", () => { expect(promptCalls).toEqual([ [VALIDATOR_LABEL, undefined, EXPECTED_DEFAULT_VALIDATOR], ]); + expect(textPromptCalls).toEqual([ + ["Static node service name", DEFAULT_SERVICE_NAME], + ["Static node pod prefix", DEFAULT_POD_PREFIX], + ["Genesis ConfigMap name", DEFAULT_GENESIS_CONFIGMAP_NAME], + ["Static nodes ConfigMap name", DEFAULT_STATIC_NODES_CONFIGMAP_NAME], + ["Faucet artifact prefix", DEFAULT_FAUCET_PREFIX], + ]); const output = stdout.read(); expect(output).toContain("Genesis"); expect(output).toContain("Validator Nodes"); @@ -205,6 +230,12 @@ describe("CLI command bootstrap", () => { expectedStaticNodeUri(FIRST_VALIDATOR_INDEX), expectedStaticNodeUri(SECOND_VALIDATOR_INDEX), ]); + expect(outputInvocation?.payload.artifactNames).toEqual({ + faucetPrefix: DEFAULT_FAUCET_PREFIX, + validatorPrefix: DEFAULT_POD_PREFIX, + genesisConfigMapName: DEFAULT_GENESIS_CONFIGMAP_NAME, + staticNodesConfigMapName: DEFAULT_STATIC_NODES_CONFIGMAP_NAME, + }); }); test("createCliCommand wires metadata", () => { @@ -243,6 +274,7 @@ describe("CLI command bootstrap", () => { genesis: { config: {}, extraData: "0xextra" } as any, }); }, + promptForText: passthroughTextPrompt, service: {} as any, loadAllocations: () => Promise.resolve({} satisfies Record), @@ -309,6 +341,7 @@ describe("CLI command bootstrap", () => { }, genesis: { config: {}, extraData: "0xextra" } as any, }), + promptForText: passthroughTextPrompt, service: {} as any, loadAllocations: () => Promise.resolve({} satisfies Record), @@ -334,6 +367,16 @@ describe("CLI command bootstrap", () => { "40000", "--static-node-discovery-port", "0", + "--static-node-service-name", + "custom-service", + "--static-node-pod-prefix", + "custom-validator", + "--genesis-configmap-name", + "custom-genesis", + "--static-nodes-configmap-name", + "custom-static-nodes", + "--faucet-artifact-prefix", + "custom-faucet", ], { from: "node" } ); @@ -344,9 +387,17 @@ describe("CLI command bootstrap", () => { "svc.cluster.local", CUSTOM_STATIC_NODE_PORT, 0, - "network" + "network", + "custom-service", + "custom-validator" ), ]); + expect(capturedPayload?.artifactNames).toEqual({ + faucetPrefix: "custom-faucet", + validatorPrefix: "custom-validator", + genesisConfigMapName: "custom-genesis", + staticNodesConfigMapName: "custom-static-nodes", + }); }); test("runBootstrap builds static nodes with domain and custom ports", async () => { @@ -371,6 +422,7 @@ describe("CLI command bootstrap", () => { genesis: { config: {}, extraData: "0xextra" } as any, }); }, + promptForText: passthroughTextPrompt, service: {} as any, loadAllocations: () => Promise.resolve({} satisfies Record), @@ -438,6 +490,7 @@ describe("CLI command bootstrap", () => { genesis: { config: {}, extraData: "0xextra" } as any, }); }, + promptForText: passthroughTextPrompt, service: {} as any, loadAllocations: () => Promise.resolve({} satisfies Record), @@ -519,6 +572,7 @@ describe("CLI command bootstrap", () => { }, genesis: { config: {}, extraData: "0x" } as any, }), + promptForText: passthroughTextPrompt, service: {} as any, loadAllocations: () => Promise.resolve({} satisfies Record), @@ -602,6 +656,7 @@ describe("CLI command bootstrap", () => { genesis: { config: {}, extraData: "0xextra" } as any, }); }, + promptForText: passthroughTextPrompt, service: {} as any, loadAllocations: () => { loadAllocationsInvoked = true; @@ -609,6 +664,12 @@ describe("CLI command bootstrap", () => { }, outputResult: (_type, payload) => { expect(payload.validators).toHaveLength(EXPECTED_DEFAULT_VALIDATOR); + expect(payload.artifactNames).toEqual({ + faucetPrefix: DEFAULT_FAUCET_PREFIX, + validatorPrefix: DEFAULT_POD_PREFIX, + genesisConfigMapName: DEFAULT_GENESIS_CONFIGMAP_NAME, + staticNodesConfigMapName: DEFAULT_STATIC_NODES_CONFIGMAP_NAME, + }); return Promise.resolve(); }, }; diff --git a/src/cli/build-command.ts b/src/cli/build-command.ts index 72663ec..a9b8637 100644 --- a/src/cli/build-command.ts +++ b/src/cli/build-command.ts @@ -1,5 +1,5 @@ import { Command, InvalidArgumentError } from "commander"; - +import { ARTIFACT_DEFAULTS } from "../constants/artifact-defaults.ts"; import { ALGORITHM, type Algorithm, @@ -14,7 +14,11 @@ import { type OutputPayload, type OutputType, } from "./output.ts"; -import { createCountParser, promptForCount } from "./prompt-helpers.ts"; +import { + createCountParser, + promptForCount, + promptForText, +} from "./prompt-helpers.ts"; type CliOptions = { allocations?: string; @@ -32,12 +36,18 @@ type CliOptions = { staticNodeNamespace?: string; staticNodePort?: number; staticNodeDiscoveryPort?: number; + staticNodeServiceName?: string; + staticNodePodPrefix?: string; + genesisConfigmapName?: string; + staticNodesConfigmapName?: string; + faucetArtifactPrefix?: string; }; type BootstrapDependencies = { factory: NodeKeyFactory; promptForCount: typeof promptForCount; promptForGenesis: typeof promptForGenesisConfig; + promptForText: typeof promptForText; service: BesuGenesisService; loadAllocations: typeof loadAllocations; outputResult: (type: OutputType, payload: OutputPayload) => Promise; @@ -45,6 +55,13 @@ type BootstrapDependencies = { const DEFAULT_VALIDATOR_COUNT = 4; const DEFAULT_STATIC_NODE_PORT = 30_303; +const { + staticNodeServiceName: DEFAULT_STATIC_NODE_SERVICE_NAME, + staticNodePodPrefix: DEFAULT_STATIC_NODE_POD_PREFIX, + genesisConfigMapName: DEFAULT_GENESIS_CONFIGMAP_NAME, + staticNodesConfigMapName: DEFAULT_STATIC_NODES_CONFIGMAP_NAME, + faucetArtifactPrefix: DEFAULT_FAUCET_ARTIFACT_PREFIX, +} = ARTIFACT_DEFAULTS; const OUTPUT_CHOICES: OutputType[] = ["screen", "file", "kubernetes"]; const LEADING_DOT_REGEX = /^\./u; const UNCOMPRESSED_PUBLIC_KEY_PREFIX = "04"; @@ -124,6 +141,81 @@ const normalizeStaticNodeNamespace = ( return trimmed.length === 0 ? undefined : trimmed; }; +type TextOptionKey = + | "staticNodeDomain" + | "staticNodeNamespace" + | "staticNodeServiceName" + | "staticNodePodPrefix" + | "genesisConfigmapName" + | "staticNodesConfigmapName" + | "faucetArtifactPrefix"; + +type TextOptionDescriptor = { + key: T; + flag: string; + description: string; + parser?: (value: string) => CliOptions[T]; + sanitize?: (value: NonNullable) => CliOptions[T] | undefined; +}; + +const TEXT_OPTION_DESCRIPTORS: TextOptionDescriptor[] = [ + { + key: "staticNodeDomain", + flag: "--static-node-domain ", + description: + "DNS suffix appended to validator peer hostnames for static-nodes entries.", + parser: stripSurroundingQuotes, + sanitize: (value) => normalizeStaticNodeDomain(value) ?? undefined, + }, + { + key: "staticNodeNamespace", + flag: "--static-node-namespace ", + description: + "Namespace segment inserted between service name and domain for static-nodes entries.", + parser: stripSurroundingQuotes, + sanitize: (value) => normalizeStaticNodeNamespace(value) ?? undefined, + }, + { + key: "staticNodeServiceName", + flag: "--static-node-service-name ", + description: + "Headless Service name used when constructing static-nodes hostnames.", + parser: stripSurroundingQuotes, + sanitize: (value) => stripSurroundingQuotes(value), + }, + { + key: "staticNodePodPrefix", + flag: "--static-node-pod-prefix ", + description: + "StatefulSet prefix used when constructing validator pod hostnames.", + parser: stripSurroundingQuotes, + sanitize: (value) => stripSurroundingQuotes(value), + }, + { + key: "genesisConfigmapName", + flag: "--genesis-configmap-name ", + description: + "ConfigMap name that stores the generated genesis.json payload.", + parser: stripSurroundingQuotes, + sanitize: (value) => stripSurroundingQuotes(value), + }, + { + key: "staticNodesConfigmapName", + flag: "--static-nodes-configmap-name ", + description: + "ConfigMap name that stores the generated static-nodes.json payload.", + parser: stripSurroundingQuotes, + sanitize: (value) => stripSurroundingQuotes(value), + }, + { + key: "faucetArtifactPrefix", + flag: "--faucet-artifact-prefix ", + description: "Prefix applied to faucet ConfigMaps and Secrets.", + parser: stripSurroundingQuotes, + sanitize: (value) => stripSurroundingQuotes(value), + }, +]; + const deriveNodeId = (publicKey: string): string => { const trimmed = publicKey.startsWith("0x") ? publicKey.slice(2) : publicKey; if ( @@ -140,24 +232,30 @@ const createStaticNodeEntries = ( { namespace, domain, + serviceName, + podPrefix, port, discoveryPort, }: { namespace?: string; domain?: string; + serviceName: string; + podPrefix: string; port: number; discoveryPort: number; } ): string[] => { const normalizedDomain = normalizeStaticNodeDomain(domain); const normalizedNamespace = normalizeStaticNodeNamespace(namespace); + const hostServiceName = + normalizeStaticNodeNamespace(serviceName) ?? serviceName; + const podNamePrefix = normalizeStaticNodeNamespace(podPrefix) ?? podPrefix; return nodes.map((node) => { // StatefulSet pod ordinals start at 0 even though our generator indexes start at 1. const ordinal = node.index - 1; - const podName = `besu-node-validator-${ordinal}`; - const serviceName = "besu-node"; // Headless service fronting validator pods - const segments = [podName, serviceName]; + const podName = `${podNamePrefix}-${ordinal}`; + const segments = [podName, hostServiceName]; if (normalizedNamespace) { segments.push(normalizedNamespace); } @@ -191,6 +289,11 @@ const runBootstrap = async ( staticNodeNamespace: staticNodeNamespaceOption, staticNodePort: staticNodePortOption, staticNodeDiscoveryPort: staticNodeDiscoveryPortOption, + staticNodeServiceName: staticNodeServiceNameOption, + staticNodePodPrefix: staticNodePodPrefixOption, + genesisConfigmapName: genesisConfigmapNameOption, + staticNodesConfigmapName: staticNodesConfigmapNameOption, + faucetArtifactPrefix: faucetArtifactPrefixOption, } = options; const resolveCount = ( @@ -207,17 +310,69 @@ const runBootstrap = async ( return deps.promptForCount(label, undefined, defaultValue); }; + const resolveText = async ( + label: string, + provided: string | undefined, + defaultValue: string + ): Promise => { + if (provided && provided.trim().length > 0) { + return provided.trim(); + } + if (acceptDefaults) { + return defaultValue; + } + const response = await deps.promptForText({ + defaultValue, + labelText: label, + message: label, + }); + const trimmed = response.trim(); + return trimmed.length === 0 ? defaultValue : trimmed; + }; + const validatorsCount = await resolveCount( "validator nodes", validatorOption, DEFAULT_VALIDATOR_COUNT ); + const staticNodeServiceName = await resolveText( + "Static node service name", + staticNodeServiceNameOption, + DEFAULT_STATIC_NODE_SERVICE_NAME + ); + + const staticNodePodPrefix = await resolveText( + "Static node pod prefix", + staticNodePodPrefixOption, + DEFAULT_STATIC_NODE_POD_PREFIX + ); + + const genesisConfigMapName = await resolveText( + "Genesis ConfigMap name", + genesisConfigmapNameOption, + DEFAULT_GENESIS_CONFIGMAP_NAME + ); + + const staticNodesConfigMapName = await resolveText( + "Static nodes ConfigMap name", + staticNodesConfigmapNameOption, + DEFAULT_STATIC_NODES_CONFIGMAP_NAME + ); + + const faucetArtifactPrefix = await resolveText( + "Faucet artifact prefix", + faucetArtifactPrefixOption, + DEFAULT_FAUCET_ARTIFACT_PREFIX + ); + const validators = generateGroup(deps.factory, validatorsCount); const faucet = deps.factory.generate(); const staticNodes = createStaticNodeEntries(validators, { namespace: staticNodeNamespaceOption, domain: staticNodeDomainOption, + serviceName: staticNodeServiceName, + podPrefix: staticNodePodPrefix, port: staticNodePortOption ?? DEFAULT_STATIC_NODE_PORT, discoveryPort: staticNodeDiscoveryPortOption ?? DEFAULT_STATIC_NODE_PORT, }); @@ -251,6 +406,12 @@ const runBootstrap = async ( genesis, validators, staticNodes, + artifactNames: { + faucetPrefix: faucetArtifactPrefix, + validatorPrefix: staticNodePodPrefix, + genesisConfigMapName, + staticNodesConfigMapName, + }, }; await deps.outputResult(outputType ?? "screen", payload); @@ -260,6 +421,7 @@ const runBootstrap = async ( const defaultDependencies: BootstrapDependencies = { factory: new NodeKeyFactory(), promptForCount, + promptForText, promptForGenesis: promptForGenesisConfig, service: new BesuGenesisService(), loadAllocations, @@ -283,6 +445,16 @@ const createCliCommand = ( "Generate node identities, configure consensus, and emit a Besu genesis." ); + const identityParser = (value: T): T => value; + for (const descriptor of TEXT_OPTION_DESCRIPTORS) { + const parser = descriptor.parser ?? identityParser; + generate.option( + descriptor.flag, + descriptor.description, + parser as (value: string) => unknown + ); + } + generate .option( "-v, --validators ", @@ -308,16 +480,6 @@ const createCliCommand = ( }, "screen" ) - .option( - "--static-node-domain ", - "DNS suffix appended to validator peer hostnames for static-nodes entries.", - (value: string) => stripSurroundingQuotes(value) - ) - .option( - "--static-node-namespace ", - "Namespace segment inserted between service name and domain for static-nodes entries.", - (value: string) => stripSurroundingQuotes(value) - ) .option( "--static-node-port ", "P2P port used for static-nodes enode URIs.", @@ -392,14 +554,6 @@ const createCliCommand = ( cmd.getOptionValueSource("validators") === "default" ? undefined : options.validators, - staticNodeDomain: - cmd.getOptionValueSource("staticNodeDomain") === "default" - ? undefined - : options.staticNodeDomain, - staticNodeNamespace: - cmd.getOptionValueSource("staticNodeNamespace") === "default" - ? undefined - : options.staticNodeNamespace, staticNodePort: cmd.getOptionValueSource("staticNodePort") === "default" ? undefined @@ -410,20 +564,39 @@ const createCliCommand = ( : options.staticNodeDiscoveryPort, }; + for (const { key } of TEXT_OPTION_DESCRIPTORS) { + if (cmd.getOptionValueSource(key) === "default") { + normalizedOptions[key] = undefined; + } + } + const sanitizedOptions: CliOptions = { ...normalizedOptions, allocations: normalizedOptions.allocations === undefined ? undefined : stripSurroundingQuotes(normalizedOptions.allocations), - staticNodeDomain: normalizeStaticNodeDomain( - normalizedOptions.staticNodeDomain - ), - staticNodeNamespace: normalizeStaticNodeNamespace( - normalizedOptions.staticNodeNamespace - ), }; + for (const { key, sanitize } of TEXT_OPTION_DESCRIPTORS) { + const currentValue = normalizedOptions[key]; + if (currentValue === undefined) { + sanitizedOptions[key] = undefined; + continue; + } + + if (!sanitize) { + sanitizedOptions[key] = currentValue; + continue; + } + + const sanitizedValue = sanitize( + currentValue as NonNullable + ); + sanitizedOptions[key] = (sanitizedValue ?? + undefined) as CliOptions[typeof key]; + } + await runBootstrap(sanitizedOptions, deps); }); diff --git a/src/cli/output.test.ts b/src/cli/output.test.ts index f8d58e4..2b0efa6 100644 --- a/src/cli/output.test.ts +++ b/src/cli/output.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { type CoreV1Api, KubeConfig } from "@kubernetes/client-node"; +import { ARTIFACT_DEFAULTS } from "../constants/artifact-defaults.ts"; import type { IndexedNode, OutputPayload, OutputType } from "./output.ts"; import { outputResult, @@ -86,11 +87,81 @@ const HTTP_SERVICE_UNAVAILABLE_STATUS = 503; const LEADING_DOT_REGEX = /^\./u; const DEFAULT_STATIC_NODE_PORT = 30_303; const DEFAULT_STATIC_NODE_DISCOVERY_PORT = 30_303; +const { + staticNodeServiceName: DEFAULT_SERVICE_NAME, + staticNodePodPrefix: DEFAULT_POD_PREFIX, + genesisConfigMapName: DEFAULT_GENESIS_CONFIGMAP_NAME, + staticNodesConfigMapName: DEFAULT_STATIC_NODES_CONFIGMAP_NAME, + faucetArtifactPrefix: DEFAULT_FAUCET_PREFIX, +} = ARTIFACT_DEFAULTS; const SAMPLE_STATIC_DOMAIN = "svc.cluster.local"; const SAMPLE_STATIC_NAMESPACE = "network"; const UNCOMPRESSED_PUBLIC_KEY_PREFIX = "04"; const UNCOMPRESSED_PUBLIC_KEY_LENGTH = 130; +type KubeMockHandles = { + createdConfigMaps: string[]; + createdSecrets: string[]; + restore: () => void; +}; + +const setupKubeMocks = (): KubeMockHandles => { + const createdConfigMaps: string[] = []; + const createdSecrets: string[] = []; + const kubePrototype = KubeConfig.prototype as unknown as { + loadFromCluster: () => void; + makeApiClient: () => CoreV1Api; + }; + const originalLoad = kubePrototype.loadFromCluster; + const originalMake = kubePrototype.makeApiClient; + const originalFile = Bun.file; + + kubePrototype.loadFromCluster = function loadFromCluster(): void { + /* no-op for tests */ + }; + + kubePrototype.makeApiClient = function makeApiClient(): CoreV1Api { + return { + listNamespacedConfigMap: () => Promise.resolve(), + listNamespacedSecret: () => Promise.resolve(), + createNamespacedConfigMap: ({ + body, + }: { + body: { metadata?: { name?: string } }; + }) => { + createdConfigMaps.push(body?.metadata?.name ?? ""); + return Promise.resolve(); + }, + createNamespacedSecret: ({ + body, + }: { + body: { metadata?: { name?: string } }; + }) => { + createdSecrets.push(body?.metadata?.name ?? ""); + return Promise.resolve(); + }, + } as unknown as CoreV1Api; + }; + + (Bun as any).file = () => + ({ + text: () => Promise.resolve("custom-namespace"), + }) as ReturnType; + + return { + createdConfigMaps, + createdSecrets, + restore: () => { + kubePrototype.loadFromCluster = originalLoad; + kubePrototype.makeApiClient = originalMake; + (Bun as any).file = originalFile; + }, + }; +}; + +const containsAll = (actual: readonly string[], expected: readonly string[]) => + expected.every((name) => actual.includes(name)); + const sampleNode = (index: number): IndexedNode => { const hexValue = index.toString(HEX_RADIX); const address = @@ -111,7 +182,9 @@ const staticNodeUri = ( domain?: string, port = DEFAULT_STATIC_NODE_PORT, discoveryPort = DEFAULT_STATIC_NODE_DISCOVERY_PORT, - namespace?: string + namespace?: string, + serviceName: string = DEFAULT_SERVICE_NAME, + podPrefix: string = DEFAULT_POD_PREFIX ): string => { const trimmedDomain = domain === undefined || domain.trim().length === 0 @@ -122,8 +195,7 @@ const staticNodeUri = ( ? undefined : namespace.trim(); const ordinal = node.index - 1; - const podName = `besu-node-validator-${ordinal}`; - const serviceName = "besu-node"; + const podName = `${podPrefix}-${ordinal}`; const segments = [podName, serviceName]; if (trimmedNamespace) { segments.push(trimmedNamespace); @@ -159,6 +231,12 @@ const samplePayload: OutputPayload = { SAMPLE_STATIC_NAMESPACE ), ], + artifactNames: { + faucetPrefix: DEFAULT_FAUCET_PREFIX, + validatorPrefix: DEFAULT_POD_PREFIX, + genesisConfigMapName: DEFAULT_GENESIS_CONFIGMAP_NAME, + staticNodesConfigMapName: DEFAULT_STATIC_NODES_CONFIGMAP_NAME, + }, }; describe("outputResult", () => { @@ -193,19 +271,19 @@ describe("outputResult", () => { "besu-node-validator-0-enode", "besu-node-validator-0-private-key", "besu-node-validator-0-pubkey", - "genesis", - "static-nodes.json", + "besu-genesis.json", + "besu-static-nodes.json", ].sort() ); const genesisContent = await readFile( - join(targetDirPath, "genesis"), + join(targetDirPath, "besu-genesis.json"), "utf8" ); expect(genesisContent).toContain(`"chainId": ${TEST_CHAIN_ID}`); const staticNodesContent = await readFile( - join(targetDirPath, "static-nodes.json"), + join(targetDirPath, "besu-static-nodes.json"), "utf8" ); expect(JSON.parse(staticNodesContent)).toEqual(samplePayload.staticNodes); @@ -320,6 +398,53 @@ describe("outputResult", () => { } }); + test("kubernetes output respects custom artifact names", async () => { + const { createdConfigMaps, createdSecrets, restore } = setupKubeMocks(); + + try { + const payload: OutputPayload = { + ...samplePayload, + artifactNames: { + faucetPrefix: "custom-faucet", + validatorPrefix: "custom-validator", + genesisConfigMapName: "custom-genesis", + staticNodesConfigMapName: "custom-static", + }, + staticNodes: [ + staticNodeUri( + sampleValidator, + SAMPLE_STATIC_DOMAIN, + DEFAULT_STATIC_NODE_PORT, + DEFAULT_STATIC_NODE_DISCOVERY_PORT, + SAMPLE_STATIC_NAMESPACE, + "custom-service", + "custom-validator" + ), + ], + }; + + await outputResult("kubernetes", payload); + + const expectedConfigMaps = [ + "custom-genesis", + "custom-static", + "custom-validator-0-address", + "custom-faucet-address", + ]; + const expectedSecrets = [ + "custom-faucet-private-key", + "custom-validator-0-private-key", + ]; + + expect(containsAll(createdConfigMaps.sort(), expectedConfigMaps)).toBe( + true + ); + expect(containsAll(createdSecrets.sort(), expectedSecrets)).toBe(true); + } finally { + restore(); + } + }); + test("kubernetes output surfaces conflict errors", async () => { const originalLoad = (KubeConfig.prototype as any).loadFromCluster; const originalMake = (KubeConfig.prototype as any).makeApiClient; diff --git a/src/cli/output.ts b/src/cli/output.ts index f47aa9c..d014982 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -10,11 +10,19 @@ type IndexedNode = GeneratedNodeKey & { index: number }; type OutputType = "screen" | "file" | "kubernetes"; +type ArtifactNames = { + faucetPrefix: string; + validatorPrefix: string; + genesisConfigMapName: string; + staticNodesConfigMapName: string; +}; + type OutputPayload = { faucet: GeneratedNodeKey; genesis: unknown; validators: readonly IndexedNode[]; staticNodes: readonly string[]; + artifactNames: ArtifactNames; }; type ConfigMapSpec = { @@ -96,34 +104,33 @@ const outputToFile = async (payload: OutputPayload): Promise => { const directory = join(OUTPUT_DIR, timestamp); await mkdir(directory, { recursive: true }); - const validatorSpecs = createValidatorSpecs(payload.validators); + const { artifactNames } = payload; + const validatorSpecs = createValidatorSpecs( + payload.validators, + artifactNames.validatorPrefix + ); + const faucetConfigSpecs = createFaucetConfigSpecs( + payload.faucet, + artifactNames.faucetPrefix + ); + const faucetSecretSpecs = createFaucetSecretSpecs( + payload.faucet, + artifactNames.faucetPrefix + ); const faucetSpecs: ConfigMapSpec[] = [ + ...faucetConfigSpecs, + ...faucetSecretSpecs, { - name: "besu-faucet-address", - key: "address", - value: payload.faucet.address, - }, - { - name: "besu-faucet-private-key", - key: "privateKey", - value: payload.faucet.privateKey, - }, - { - name: "besu-faucet-enode", + name: `${artifactNames.faucetPrefix}-enode`, key: "enode", value: payload.faucet.enode, }, - { - name: "besu-faucet-pubkey", - key: "publicKey", - value: payload.faucet.publicKey, - }, ]; const writes: Promise[] = [ Bun.write( - join(directory, "genesis"), + join(directory, `${artifactNames.genesisConfigMapName}.json`), `${JSON.stringify(payload.genesis, null, 2)}\n` ), ...[...validatorSpecs, ...faucetSpecs].map((spec) => @@ -133,7 +140,7 @@ const outputToFile = async (payload: OutputPayload): Promise => { ) ), Bun.write( - join(directory, "static-nodes.json"), + join(directory, `${artifactNames.staticNodesConfigMapName}.json`), `${JSON.stringify(payload.staticNodes, null, 2)}\n` ), ]; @@ -144,25 +151,29 @@ const outputToFile = async (payload: OutputPayload): Promise => { const outputToKubernetes = async (payload: OutputPayload): Promise => { const { client, namespace } = await createKubernetesClient(); - const validatorSpecs = createValidatorSpecs(payload.validators); + const { artifactNames } = payload; + const validatorSpecs = createValidatorSpecs( + payload.validators, + artifactNames.validatorPrefix + ); const allSpecs = [...validatorSpecs]; const configMapSpecs = [ ...allSpecs.filter((spec) => spec.key !== "privateKey"), - ...createFaucetConfigSpecs(payload.faucet), + ...createFaucetConfigSpecs(payload.faucet, artifactNames.faucetPrefix), { - name: "besu-genesis", + name: artifactNames.genesisConfigMapName, key: "genesis.json", value: JSON.stringify(payload.genesis, null, 2), }, { - name: "besu-static-nodes", + name: artifactNames.staticNodesConfigMapName, key: "static-nodes.json", value: JSON.stringify(payload.staticNodes, null, 2), }, ]; const secretSpecs = [ ...allSpecs.filter((spec) => spec.key === "privateKey"), - ...createFaucetSecretSpecs(payload.faucet), + ...createFaucetSecretSpecs(payload.faucet, artifactNames.faucetPrefix), ]; await Promise.all([ @@ -175,11 +186,14 @@ const outputToKubernetes = async (payload: OutputPayload): Promise => { ); }; -const createValidatorSpecs = (nodes: readonly IndexedNode[]): ConfigMapSpec[] => +const createValidatorSpecs = ( + nodes: readonly IndexedNode[], + validatorPrefix: string +): ConfigMapSpec[] => nodes.flatMap((node) => { // Align artifact names with 0-indexed StatefulSet pod ordinals. const ordinal = node.index - 1; - const base = `besu-node-validator-${ordinal}`; + const base = `${validatorPrefix}-${ordinal}`; return [ { name: `${base}-address`, key: "address", value: node.address }, { @@ -192,14 +206,20 @@ const createValidatorSpecs = (nodes: readonly IndexedNode[]): ConfigMapSpec[] => ]; }); -const createFaucetConfigSpecs = (faucet: GeneratedNodeKey): ConfigMapSpec[] => [ - { name: "besu-faucet-address", key: "address", value: faucet.address }, - { name: "besu-faucet-pubkey", key: "publicKey", value: faucet.publicKey }, +const createFaucetConfigSpecs = ( + faucet: GeneratedNodeKey, + prefix: string +): ConfigMapSpec[] => [ + { name: `${prefix}-address`, key: "address", value: faucet.address }, + { name: `${prefix}-pubkey`, key: "publicKey", value: faucet.publicKey }, ]; -const createFaucetSecretSpecs = (faucet: GeneratedNodeKey): SecretSpec[] => [ +const createFaucetSecretSpecs = ( + faucet: GeneratedNodeKey, + prefix: string +): SecretSpec[] => [ { - name: "besu-faucet-private-key", + name: `${prefix}-private-key`, key: "privateKey", value: faucet.privateKey, }, @@ -373,5 +393,5 @@ const outputResult = async ( throw new Error(`Unsupported output type: ${exhaustiveCheck}`); }; -export type { IndexedNode, OutputPayload, OutputType }; +export type { ArtifactNames, IndexedNode, OutputPayload, OutputType }; export { outputResult, printFaucet, printGenesis, printGroup }; diff --git a/src/cli/prompt-helpers.test.ts b/src/cli/prompt-helpers.test.ts index 23ea511..63a0f25 100644 --- a/src/cli/prompt-helpers.test.ts +++ b/src/cli/prompt-helpers.test.ts @@ -6,6 +6,7 @@ import { promptForBigIntString, promptForCount, promptForInteger, + promptForText, } from "./prompt-helpers.ts"; const PROVIDED_RESULT = 7; @@ -16,6 +17,7 @@ const VALID_COUNT_INPUT = "3"; const VALID_COUNT_EXPECTED = 3; const INTEGER_DEFAULT = 4; const MINIMUM_INTEGER = 1; +const TEXT_DEFAULT = "default-value"; const stubInput = (responses: string[]) => { let index = 0; @@ -121,4 +123,46 @@ describe("prompt helpers", () => { }) ).rejects.toThrow(`Abort aborted via ${ABORT_OPTION}.`); }); + + test("promptForText returns trimmed input", async () => { + const result = await promptForText({ + defaultValue: TEXT_DEFAULT, + labelText: "Service name", + message: "Service name", + prompt: stubInput([" custom-service "]), + }); + expect(result).toBe("custom-service"); + }); + + test("promptForText falls back to default when blank", async () => { + const result = await promptForText({ + defaultValue: TEXT_DEFAULT, + labelText: "Service name", + message: "Service name", + prompt: stubInput([""]), + }); + expect(result).toBe(TEXT_DEFAULT); + }); + + test("promptForText permits empty string when allowed", async () => { + const result = await promptForText({ + allowEmpty: true, + defaultValue: TEXT_DEFAULT, + labelText: "Optional", + message: "Optional", + prompt: stubInput([" "]), + }); + expect(result).toBe(""); + }); + + test("promptForText aborts when sentinel provided", async () => { + await expect( + promptForText({ + defaultValue: TEXT_DEFAULT, + labelText: "Abort", + message: "Abort", + prompt: stubInput([ABORT_OPTION]), + }) + ).rejects.toThrow(`Abort aborted via ${ABORT_OPTION}.`); + }); }); diff --git a/src/cli/prompt-helpers.ts b/src/cli/prompt-helpers.ts index faac953..05a023f 100644 --- a/src/cli/prompt-helpers.ts +++ b/src/cli/prompt-helpers.ts @@ -172,6 +172,54 @@ const promptForBigIntString = async ({ } }; +type TextPromptOptions = { + allowEmpty?: boolean; + defaultValue: string; + labelText: string; + message: string; + prompt?: InputPrompt; +}; + +const promptForText = async ({ + allowEmpty = false, + defaultValue, + labelText, + message, + prompt = inputPrompt, +}: TextPromptOptions): Promise => { + const formattedMessage = accent( + `${message} (enter ${ABORT_OPTION} to abort)` + ); + + for (;;) { + const raw = ( + await prompt({ + message: formattedMessage, + default: defaultValue, + }) + ) + .toString() + .trim(); + + if (raw.length === 0) { + if (allowEmpty) { + return ""; + } + return defaultValue; + } + + ensureNotAborted(raw, labelText); + + if (raw.length > 0) { + return raw; + } + + process.stdout.write( + `${labelText} must be a non-empty value or ${ABORT_OPTION} to abort.\n` + ); + } +}; + export type { InputPrompt }; export { ABORT_OPTION, @@ -180,4 +228,5 @@ export { promptForBigIntString, promptForCount, promptForInteger, + promptForText, }; diff --git a/src/constants/artifact-defaults.ts b/src/constants/artifact-defaults.ts new file mode 100644 index 0000000..ca7f632 --- /dev/null +++ b/src/constants/artifact-defaults.ts @@ -0,0 +1,9 @@ +const ARTIFACT_DEFAULTS = { + staticNodeServiceName: "besu-node", + staticNodePodPrefix: "besu-node-validator", + genesisConfigMapName: "besu-genesis", + staticNodesConfigMapName: "besu-static-nodes", + faucetArtifactPrefix: "besu-faucet", +} as const; + +export { ARTIFACT_DEFAULTS };