diff --git a/.github/ct.yaml b/.github/ct.yaml new file mode 100644 index 0000000..dd514f0 --- /dev/null +++ b/.github/ct.yaml @@ -0,0 +1,4 @@ +chart-dirs: + - charts +remote: origin +target-branch: main diff --git a/.github/renovate.json b/.github/renovate.json index 5f629ea..9a8061a 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -2,6 +2,7 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended", + "helpers:pinGitHubActionDigests", ":automergeMinor", ":automergePr", ":automergeRequireAllStatusChecks", diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index d2d55e5..721217b 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -60,8 +60,6 @@ jobs: SLACK_CHANNEL_ID: op://platform/slack-bot/SLACK_CHANNEL_ID HARBOR_USER: op://platform/harbor/username HARBOR_PASS: op://platform/harbor/password - PAT_TOKEN: op://platform/github-commit-pat/credential - NPM_TOKEN: op://platform/npmjs/credential # Label QA as running and notify Slack (only for non-draft PRs) - name: Label QA as running @@ -120,6 +118,15 @@ jobs: if: github.event_name == 'pull_request' || github.event_name == 'push' run: bun typecheck + - name: Set version + id: version + if: github.event_name == 'pull_request' || github.event_name == 'push' + run: bun run tools/version.ts + + - name: Run docs + if: github.event_name == 'pull_request' || github.event_name == 'push' + run: bun run docs:helm + - name: Docker meta if: github.event_name == 'pull_request' || github.event_name == 'push' id: meta @@ -135,6 +142,7 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=sha + type=raw,value=${{ steps.version.outputs.version }} - name: Build and push if: github.event_name == 'pull_request' || github.event_name == 'push' @@ -148,6 +156,52 @@ jobs: provenance: mode=max sbom: true + - name: Set up Python + if: github.event_name == 'pull_request' || github.event_name == 'push' + uses: actions/setup-python@v5 + with: + python-version: "3.x" + check-latest: true + + - name: Set up Helm + if: github.event_name == 'pull_request' || github.event_name == 'push' + uses: azure/setup-helm@v4 + + - name: Set up chart-testing + if: github.event_name == 'pull_request' || github.event_name == 'push' + uses: helm/chart-testing-action@v2.7.0 + + - name: Determine chart changes + if: github.event_name == 'pull_request' || github.event_name == 'push' + id: ct-changed + env: + CT_CONFIG: .github/ct.yaml + run: | + changed=$(ct list-changed --config "$CT_CONFIG") + if [[ -n "$changed" ]]; then + printf "changed=true\n" >> "$GITHUB_OUTPUT" + echo "$changed" + else + printf "changed=false\n" >> "$GITHUB_OUTPUT" + echo "No chart changes detected" + fi + + - name: Run chart-testing (lint) + if: (github.event_name == 'pull_request' || github.event_name == 'push') && steps.ct-changed.outputs.changed == 'true' + env: + CT_CONFIG: .github/ct.yaml + run: ct lint --config "$CT_CONFIG" --validate-yaml=false + + - name: Create kind cluster + if: (github.event_name == 'pull_request' || github.event_name == 'push') && steps.ct-changed.outputs.changed == 'true' + uses: helm/kind-action@v1.12.0 + + - name: Run chart-testing (install) + if: (github.event_name == 'pull_request' || github.event_name == 'push') && steps.ct-changed.outputs.changed == 'true' + env: + CT_CONFIG: .github/ct.yaml + run: ct install --config "$CT_CONFIG" --skip-clean-up + # Label QA results (PR only) - name: Label QA build status if: | @@ -195,7 +249,27 @@ jobs: ${{ steps.secret-scan.outcome == 'success' && 'success' || 'failure' }} - # Skip redundant notification - handled by consolidated step at the end + - name: Login to Harbor + if: | + github.event_name == 'push' || + (github.event_name == 'pull_request' && github.event.pull_request.draft == false) + uses: docker/login-action@v3 + with: + registry: harbor.settlemint.com + username: ${{ env.HARBOR_USER }} + password: ${{ env.HARBOR_PASS }} + + - name: Package chart + if: | + github.event_name == 'push' || + (github.event_name == 'pull_request' && github.event.pull_request.draft == false) + run: bun run package:pack + + - name: Push chart to Harbor + if: | + github.event_name == 'push' || + (github.event_name == 'pull_request' && github.event.pull_request.draft == false) + run: bun run package:push:harbor # Check PR review status (PR and PR review events only) - name: Check PR review status diff --git a/AGENTS.md b/AGENTS.md index 1ab9666..19de531 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ The TypeScript sources live in `src/`. CLI flows under `src/cli/` orchestrate pr ## Build, Test, and Development Commands -Install dependencies with `bun install`. Use `bun run src/index.ts` to execute the bootstrapper locally. Run the full test suite with `bun test`. Type safety is enforced by `bun run typecheck`, and formatting plus lint rules are auto-fixed with `bun run check` (Biome). Combine these commands before pushing to catch regressions early. +Install dependencies with `bun install`. Use `bun run src/index.ts` to execute the bootstrapper locally. Run the full test suite with `bun test`. Type safety is enforced by `bun run typecheck`, and formatting plus lint rules are auto-fixed with `bun run check:fix` (Biome). Combine these commands before pushing to catch regressions early. ## Coding Style & Naming Conventions diff --git a/README.md b/README.md index d73cfcf..ced1781 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,38 @@ # network-bootstrapper -To install dependencies: +Generate node identities, configure consensus, and emit a Besu genesis. -```bash -bun install -``` +## Helm chart + +The helm chart to run this on Kubernetes / OpenShift can be found [here](./charts/network-bootstrapper/README.md) -To run: +## CLI usage -```bash -bun run src/index.ts ``` +Usage: network-bootstrapper [options] -This project was created using `bun init` in bun v1.2.22. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. +Generate node identities, configure consensus, and emit a Besu genesis. + +Options: + -v, --validators Number of validator nodes to generate. + (default: 4) + -r, --rpc-nodes Number of RPC nodes to generate. (default: 2) + -a, --allocations Path to a genesis allocations JSON file. + (default: none) + -o, --outputType Output target (screen, file, kubernetes). + (default: "screen") + --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) + --gas-limit Block gas limit in decimal form. (default: + 9007199254740991) + --gas-price Base gas price (wei). (default: 0) + --evm-stack-size EVM stack size limit. (default: 2048) + --contract-size-limit Contract size limit in bytes. (default: + 2147483647) + --accept-defaults Accept default values for all prompts when CLI + flags are omitted. (default: disabled) + -h, --help display help for command +``` diff --git a/README.tpl b/README.tpl new file mode 100644 index 0000000..50a9612 --- /dev/null +++ b/README.tpl @@ -0,0 +1,9 @@ +# network-bootstrapper + +Generate node identities, configure consensus, and emit a Besu genesis. + +## Helm chart + +The helm chart to run this on Kubernetes / OpenShift can be found [here](./charts/network-bootstrapper/README.md) + +## CLI usage diff --git a/biome.jsonc b/biome.jsonc index cbd6fa0..1e2f179 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -5,6 +5,12 @@ "rules": { "suspicious": { "noConsole": "off" + }, + "complexity": { + "noExcessiveCognitiveComplexity": "off" + }, + "nursery": { + "useMaxParams": "off" } } }, diff --git a/bun.lock b/bun.lock index 4e28c8c..6b7c4d3 100644 --- a/bun.lock +++ b/bun.lock @@ -7,8 +7,10 @@ "@inquirer/prompts": "7.8.6", "@kubernetes/client-node": "1.3.0", "commander": "14.0.1", + "lefthook": "^1.13.0", "ox": "0.9.6", "viem": "2.37.6", + "yaml": "^2.8.1", "zod": "4.1.9", }, "devDependencies": { @@ -366,6 +368,28 @@ "jsonpath-plus": ["jsonpath-plus@10.3.0", "", { "dependencies": { "@jsep-plugin/assignment": "^1.3.0", "@jsep-plugin/regex": "^1.0.4", "jsep": "^1.4.0" }, "bin": { "jsonpath": "bin/jsonpath-cli.js", "jsonpath-plus": "bin/jsonpath-cli.js" } }, "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA=="], + "lefthook": ["lefthook@1.13.0", "", { "optionalDependencies": { "lefthook-darwin-arm64": "1.13.0", "lefthook-darwin-x64": "1.13.0", "lefthook-freebsd-arm64": "1.13.0", "lefthook-freebsd-x64": "1.13.0", "lefthook-linux-arm64": "1.13.0", "lefthook-linux-x64": "1.13.0", "lefthook-openbsd-arm64": "1.13.0", "lefthook-openbsd-x64": "1.13.0", "lefthook-windows-arm64": "1.13.0", "lefthook-windows-x64": "1.13.0" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-6pno+NjfBrKKt3XQmFUvwDdKXzBVh5JvzAIwcCOu9mqg81nAMCZd2FtTuU1fmDzXFNdsxjW8mwwKB+S8t5ucOQ=="], + + "lefthook-darwin-arm64": ["lefthook-darwin-arm64@1.13.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mhD4zOj2VRx34tptEc/lP643n5YAAVP95f/TiP6geQz4kpLwUrsTwQxzoXUIauU2DGSNbFtp9hVSE++0e4ESEA=="], + + "lefthook-darwin-x64": ["lefthook-darwin-x64@1.13.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-uspgWrhh9Xoyb+x0hVeMnYkSA1K/cEov4QHxcBBTIvTvjEuijSLIQEzULsHvg7a6xNM/8E3SBzOwBRK44jM2Mw=="], + + "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@1.13.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UUY+UlGuwAkO8hEY4+SGYfM1OeXSI4i2/8ROwBpu6fz0LrTL1OUYRVhLIRNJvWrF2XabfgXVUrnjGY7YSq4zpg=="], + + "lefthook-freebsd-x64": ["lefthook-freebsd-x64@1.13.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-wdF/Cwmbiblz+UaLb3a0trSKEmaY5z20latrmhim98M1H48iBHhUyUUJWaSEauyFMJWPwu7rSVZl5KktPxCxVA=="], + + "lefthook-linux-arm64": ["lefthook-linux-arm64@1.13.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tpg4pA0JTeLxGAZDFJVOGyIMjQAE7F8HcM31tj+3KOogahspOffpmSoS1SlHzUSZ8Jm+Bvoqcis/sW68HkmWHw=="], + + "lefthook-linux-x64": ["lefthook-linux-x64@1.13.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5JUhlDaYqt9vBTSQ5gkA00+0ktUSRyL60AhZID6OR4ML39SidzMTu/GrgHscPT4sD3TfSODEdGZ28sNKdLg6jA=="], + + "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@1.13.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-UNCoKrbH0Yv61jCCUIPRr7ErS3yYt2VNCFdzLf752O9K0yrfn9FzYUsyxQFEn1Ah/kq+TNgZw90gVLg5fv1t4g=="], + + "lefthook-openbsd-x64": ["lefthook-openbsd-x64@1.13.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-iyvE+jgHYnLvOoHsLykgf98lftewsQzEBciYxygna9sLZ9nLvfbwp9mWUk09yMRmPCFGDeeDecERaUa2SICWLA=="], + + "lefthook-windows-arm64": ["lefthook-windows-arm64@1.13.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-+u0GyvZouKGcecFsayIbzq1KIoDcrSqVhivLfJUq7vpMXbSHV5HbhrkdkfqkuGjGgGnWulQY29/bDubTQoqfOA=="], + + "lefthook-windows-x64": ["lefthook-windows-x64@1.13.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RG8dfOkszk6BaOA7k26NO0R1/vy1tno7/wgdg+Wjt0pYFiBo0DhmPMoAVB4kzjObqBKDd1KWidzsEv4/R0oFIg=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], @@ -490,6 +514,8 @@ "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], + "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], "zod": ["zod@4.1.9", "", {}, "sha512-HI32jTq0AUAC125z30E8bQNz0RQ+9Uc+4J7V97gLYjZVKRjeydPgGt6dvQzFrav7MYOUGFqqOGiHpA/fdbd0cQ=="], diff --git a/charts/network-bootstrapper/.helmignore b/charts/network-bootstrapper/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/charts/network-bootstrapper/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/network-bootstrapper/Chart.yaml b/charts/network-bootstrapper/Chart.yaml new file mode 100644 index 0000000..50084a3 --- /dev/null +++ b/charts/network-bootstrapper/Chart.yaml @@ -0,0 +1,10 @@ +apiVersion: v2 +name: network-bootstrapper +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: "0.1.0" +maintainers: + - name: SettleMint + email: support@settlemint.com + url: https://settlemint.com diff --git a/charts/network-bootstrapper/README.md b/charts/network-bootstrapper/README.md new file mode 100644 index 0000000..64a48d1 --- /dev/null +++ b/charts/network-bootstrapper/README.md @@ -0,0 +1,52 @@ +# network-bootstrapper + +![Version: 0.1.0](https://img.shields.io/badge/Version-0.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.1.0](https://img.shields.io/badge/AppVersion-0.1.0-informational?style=flat-square) + +A Helm chart for Kubernetes + +## Maintainers + +| Name | Email | Url | +| ---- | ------ | --- | +| SettleMint | | | + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| affinity | object | `{}` | Affinity and anti-affinity rules influencing pod placement. | +| fullnameOverride | string | `""` | Fully qualified name override for resources created by this release. | +| image | object | `{"pullPolicy":"IfNotPresent","repository":"ghcr.io/settlemint/network-bootstrapper","tag":""}` | Container image settings for the network bootstrapper workload. See https://kubernetes.io/docs/concepts/containers/images/ for background. | +| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy controlling when Kubernetes re-fetches the image layer manifest. | +| image.repository | string | `"ghcr.io/settlemint/network-bootstrapper"` | OCI repository hosting the network bootstrapper image. | +| image.tag | string | `""` | Image tag override. Defaults to the chart's `.appVersion` when left empty. | +| imagePullSecrets | list | `[]` | Image pull secrets enabling access to private registries. See https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ for usage. | +| nameOverride | string | `""` | Short name override applied to chart-scoped resource names. | +| nodeSelector | object | `{}` | Node selector constraints for scheduling the bootstrapper pod. | +| podAnnotations | object | `{}` | Pod-level annotations merged onto the generated pod template metadata. See https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/. | +| podLabels | object | `{}` | Pod-level labels applied to the pod template metadata. See https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/. | +| podSecurityContext | object | `{}` | Pod-level security context applied to all containers in the pod. | +| rbac | object | `{"create":true}` | RBAC resources granting ConfigMap access for Kubernetes output workflows. | +| rbac.create | bool | `true` | Whether to create Role and RoleBinding objects targeting the service account. | +| resources | object | `{}` | Resource requests and limits for the bootstrapper container. | +| securityContext | object | `{}` | Container security context applied to the bootstrapper container. | +| serviceAccount | object | `{"annotations":{},"automount":true,"create":true,"name":""}` | Service account configuration for the bootstrapper pod. See https://kubernetes.io/docs/concepts/security/service-accounts/ for details. | +| serviceAccount.annotations | object | `{}` | Additional metadata annotations applied to the service account object. | +| serviceAccount.automount | bool | `true` | Automatically mount the service account token into the pod. | +| serviceAccount.create | bool | `true` | Whether to create a service account automatically. | +| serviceAccount.name | string | `""` | Existing service account name to use instead of one generated by the chart. If unset and `serviceAccount.create` is true, a name is derived from the chart fullname. | +| settings | object | `{"allocations":null,"chainId":null,"consensus":null,"contractSizeLimit":null,"evmStackSize":null,"gasLimit":null,"gasPrice":null,"outputType":"kubernetes","rpcNodes":null,"secondsPerBlock":null,"validators":null}` | Network bootstrapper CLI settings translated into command-line flags. | +| settings.allocations | string | `nil` | Filesystem path, accessible to the job, pointing to a JSON file with initial account allocations. Omit to skip pre-funded accounts. | +| settings.chainId | int | `nil` | Explicit chain ID applied to the genesis configuration. Defaults to a random value in the 40000-50000 range when omitted. | +| settings.consensus | string | `nil` | Consensus engine to configure for the network (IBFTv2 or QBFT). Default: "QBFT". | +| settings.contractSizeLimit | int | `nil` | Contract size limit in bytes enforced by the EVM. Default: 2147483647. | +| settings.evmStackSize | int | `nil` | Maximum EVM stack size allowed for contract execution. Default: 2048. | +| settings.gasLimit | int | `nil` | Genesis block gas limit value expressed in decimal. Default: 9007199254740991. | +| settings.gasPrice | int | `nil` | Base gas price in wei applied to the chain. Default: 0. | +| settings.outputType | string | `"kubernetes"` | Destination for generated artefacts: `screen` (stdout), `file` (write to volume), or `kubernetes` (persist as Kubernetes secrets/configmaps). Default: "screen". | +| settings.rpcNodes | int | `nil` | Number of RPC node definitions included in the output topology. Default: 2. | +| settings.secondsPerBlock | int | `nil` | Target block time in seconds encoded into genesis. Default: 2. | +| settings.validators | int | `nil` | Number of validator node definitions the bootstrapper generates. Default: 4. | +| tolerations | list | `[]` | Kubernetes tolerations assigned to the bootstrapper pod. | +| volumeMounts | list | `[]` | Additional volume mounts added to the bootstrapper container. | +| volumes | list | `[]` | Additional volumes injected into the deployment pod spec. | diff --git a/charts/network-bootstrapper/templates/NOTES.txt b/charts/network-bootstrapper/templates/NOTES.txt new file mode 100644 index 0000000..4ddb57a --- /dev/null +++ b/charts/network-bootstrapper/templates/NOTES.txt @@ -0,0 +1,18 @@ +Thanks for installing the network bootstrapper chart! + +{{- $jobName := include "network-bootstrapper.fullname" . -}} +{{- $namespace := .Release.Namespace -}} +To generate the network configuration, monitor the job status: + + kubectl -n {{ $namespace }} wait --for=condition=complete job/{{ $jobName }} + +Once the job completes you can review the output: + + kubectl -n {{ $namespace }} logs job/{{ $jobName }} + +If `settings.outputType` is set to `kubernetes`, inspect the generated ConfigMaps and Secrets: + + kubectl -n {{ $namespace }} get configmaps + kubectl -n {{ $namespace }} get secrets + +Refer to the chart documentation for additional post-processing steps tailored to your deployment. diff --git a/charts/network-bootstrapper/templates/_helpers.tpl b/charts/network-bootstrapper/templates/_helpers.tpl new file mode 100644 index 0000000..c65af42 --- /dev/null +++ b/charts/network-bootstrapper/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "network-bootstrapper.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "network-bootstrapper.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "network-bootstrapper.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "network-bootstrapper.labels" -}} +helm.sh/chart: {{ include "network-bootstrapper.chart" . }} +{{ include "network-bootstrapper.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "network-bootstrapper.selectorLabels" -}} +app.kubernetes.io/name: {{ include "network-bootstrapper.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "network-bootstrapper.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "network-bootstrapper.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/network-bootstrapper/templates/job.yaml b/charts/network-bootstrapper/templates/job.yaml new file mode 100644 index 0000000..19486f0 --- /dev/null +++ b/charts/network-bootstrapper/templates/job.yaml @@ -0,0 +1,98 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "network-bootstrapper.fullname" . }} + labels: + {{- include "network-bootstrapper.labels" . | nindent 4 }} +spec: + backoffLimit: 3 + completions: 1 + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "network-bootstrapper.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + restartPolicy: Never + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "network-bootstrapper.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + {{- with .Values.settings.validators }} + - --validators={{ . }} + {{- end }} + {{- with .Values.settings.rpcNodes }} + - --rpc-nodes={{ . }} + {{- end }} + {{- with .Values.settings.allocations }} + - --allocations={{ . }} + {{- end }} + {{- with .Values.settings.outputType }} + - --outputType={{ . }} + {{- end }} + {{- with .Values.settings.consensus }} + - --consensus={{ . }} + {{- end }} + {{- with .Values.settings.chainId }} + - --chain-id={{ . }} + {{- end }} + {{- with .Values.settings.secondsPerBlock }} + - --seconds-per-block={{ . }} + {{- end }} + {{- with .Values.settings.gasLimit }} + - --gas-limit={{ . }} + {{- end }} + {{- with .Values.settings.gasPrice }} + - --gas-price={{ . }} + {{- end }} + {{- with .Values.settings.evmStackSize }} + - --evm-stack-size={{ . }} + {{- end }} + {{- with .Values.settings.contractSizeLimit }} + - --contract-size-limit={{ . }} + {{- end }} + - --accept-defaults + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/network-bootstrapper/templates/role.yaml b/charts/network-bootstrapper/templates/role.yaml new file mode 100644 index 0000000..169d182 --- /dev/null +++ b/charts/network-bootstrapper/templates/role.yaml @@ -0,0 +1,18 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "network-bootstrapper.fullname" . }} + labels: + {{- include "network-bootstrapper.labels" . | nindent 4 }} +rules: + - apiGroups: + - "" + resources: + - configmaps + - secrets + verbs: + - get + - list + - create +{{- end }} diff --git a/charts/network-bootstrapper/templates/rolebinding.yaml b/charts/network-bootstrapper/templates/rolebinding.yaml new file mode 100644 index 0000000..e25781f --- /dev/null +++ b/charts/network-bootstrapper/templates/rolebinding.yaml @@ -0,0 +1,16 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "network-bootstrapper.fullname" . }} + labels: + {{- include "network-bootstrapper.labels" . | nindent 4 }} +subjects: + - kind: ServiceAccount + name: {{ include "network-bootstrapper.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "network-bootstrapper.fullname" . }} +{{- end }} diff --git a/charts/network-bootstrapper/templates/serviceaccount.yaml b/charts/network-bootstrapper/templates/serviceaccount.yaml new file mode 100644 index 0000000..38df782 --- /dev/null +++ b/charts/network-bootstrapper/templates/serviceaccount.yaml @@ -0,0 +1,15 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "network-bootstrapper.serviceAccountName" . }} + labels: + {{- include "network-bootstrapper.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- if and .Values.serviceAccount (hasKey .Values.serviceAccount "automount") }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} +{{- end }} diff --git a/charts/network-bootstrapper/values.yaml b/charts/network-bootstrapper/values.yaml new file mode 100644 index 0000000..3d01ab0 --- /dev/null +++ b/charts/network-bootstrapper/values.yaml @@ -0,0 +1,113 @@ +# -- Container image settings for the network bootstrapper workload. See https://kubernetes.io/docs/concepts/containers/images/ for background. +image: + # -- (string) OCI repository hosting the network bootstrapper image. + repository: ghcr.io/settlemint/network-bootstrapper + # -- (string) Image pull policy controlling when Kubernetes re-fetches the image layer manifest. + pullPolicy: IfNotPresent + # -- (string) Image tag override. Defaults to the chart's `.appVersion` when left empty. + tag: "" + +# -- (list) Image pull secrets enabling access to private registries. See https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ for usage. +imagePullSecrets: [] +# -- (string) Short name override applied to chart-scoped resource names. +nameOverride: "" +# -- (string) Fully qualified name override for resources created by this release. +fullnameOverride: "" + +# -- (object) Service account configuration for the bootstrapper pod. See https://kubernetes.io/docs/concepts/security/service-accounts/ for details. +serviceAccount: + # -- (bool) Whether to create a service account automatically. + create: true + # -- (bool) Automatically mount the service account token into the pod. + automount: true + # -- (object) Additional metadata annotations applied to the service account object. + annotations: {} + # -- (string) Existing service account name to use instead of one generated by the chart. + # If unset and `serviceAccount.create` is true, a name is derived from the chart fullname. + name: "" + +# -- (object) RBAC resources granting ConfigMap access for Kubernetes output workflows. +rbac: + # -- (bool) Whether to create Role and RoleBinding objects targeting the service account. + create: true + +# -- (object) Pod-level annotations merged onto the generated pod template metadata. See https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/. +podAnnotations: {} +# -- (object) Pod-level labels applied to the pod template metadata. See https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/. +podLabels: {} + +# -- (object) Pod-level security context applied to all containers in the pod. +podSecurityContext: + {} + # fsGroup: 2000 + +# -- (object) Container security context applied to the bootstrapper container. +securityContext: + {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# -- (object) Resource requests and limits for the bootstrapper container. +resources: + {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# -- (list) Additional volumes injected into the deployment pod spec. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# -- (list) Additional volume mounts added to the bootstrapper container. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +# -- (object) Node selector constraints for scheduling the bootstrapper pod. +nodeSelector: {} + +# -- (list) Kubernetes tolerations assigned to the bootstrapper pod. +tolerations: [] + +# -- (object) Affinity and anti-affinity rules influencing pod placement. +affinity: {} + +# -- (object) Network bootstrapper CLI settings translated into command-line flags. +settings: + # -- (int) Number of validator node definitions the bootstrapper generates. Default: 4. + validators: + # -- (int) Number of RPC node definitions included in the output topology. Default: 2. + rpcNodes: + # -- (string) Filesystem path, accessible to the job, pointing to a JSON file with initial account allocations. Omit to skip pre-funded accounts. + allocations: + # -- (string) Destination for generated artefacts: `screen` (stdout), `file` (write to volume), or `kubernetes` (persist as Kubernetes secrets/configmaps). Default: "screen". + outputType: kubernetes + # -- (string) Consensus engine to configure for the network (IBFTv2 or QBFT). Default: "QBFT". + consensus: + # -- (int) Explicit chain ID applied to the genesis configuration. Defaults to a random value in the 40000-50000 range when omitted. + chainId: + # -- (int) Target block time in seconds encoded into genesis. Default: 2. + secondsPerBlock: + # -- (int) Genesis block gas limit value expressed in decimal. Default: 9007199254740991. + gasLimit: + # -- (int) Base gas price in wei applied to the chain. Default: 0. + gasPrice: + # -- (int) Maximum EVM stack size allowed for contract execution. Default: 2048. + evmStackSize: + # -- (int) Contract size limit in bytes enforced by the EVM. Default: 2147483647. + contractSizeLimit: diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..793d802 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,11 @@ +pre-commit: + commands: + check_fix: + run: bun run check:fix || true + stage_fixed: true + docs_cli: + run: bun run docs:cli || true + stage_fixed: true + docs_helm: + run: bun run docs:helm || true + stage_fixed: true diff --git a/package.json b/package.json index c0bc41a..76db3a3 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,14 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "check": "biome check src --write", - "test": "bun test" + "check": "ultracite check", + "check:fix": "ultracite fix", + "test": "bun test", + "helm": "helm upgrade --install networkbootstrapper ./charts/network-bootstrapper -n networkbootstrapper --create-namespace --timeout 15m", + "docs:helm": "helm-docs --chart-search-root=. --skip-version-footer", + "docs:cli": "cat README.tpl > README.md && echo '\n```' >> README.md && bun src/index.ts --help >> README.md && echo '```' >> README.md", + "package:pack": "helm package charts/network-bootstrapper --destination .", + "package:push:harbor": "helm push ./network-bootstrapper-*.tgz oci://harbor.settlemint.com/atk" }, "devDependencies": { "@biomejs/biome": "2.2.4", @@ -33,11 +39,13 @@ "typescript": "^5" }, "dependencies": { - "@kubernetes/client-node": "1.3.0", "@inquirer/prompts": "7.8.6", + "@kubernetes/client-node": "1.3.0", "commander": "14.0.1", + "lefthook": "^1.13.0", "ox": "0.9.6", "viem": "2.37.6", + "yaml": "^2.8.1", "zod": "4.1.9" } } diff --git a/src/cli/build-command.test.ts b/src/cli/build-command.test.ts index 175ccc1..8e05a8a 100644 --- a/src/cli/build-command.test.ts +++ b/src/cli/build-command.test.ts @@ -99,7 +99,13 @@ describe("CLI command bootstrap", () => { }, promptForGenesis: ( _service, - { allocations, validatorAddresses, faucetAddress, preset } + { + allocations, + validatorAddresses, + faucetAddress, + preset, + autoAcceptDefaults, + } ) => { expect(validatorAddresses).toEqual([ expectedAddress(FIRST_VALIDATOR_INDEX), @@ -109,6 +115,7 @@ describe("CLI command bootstrap", () => { expect(allocations).toEqual({ [expectedAddress(FAUCET_INDEX)]: { balance: "0x01" }, }); + expect(autoAcceptDefaults).toBe(false); expect(preset).toEqual({ algorithm: undefined, chainId: undefined, @@ -232,10 +239,7 @@ describe("CLI command bootstrap", () => { { from: "node" } ); - expect(promptCalls).toEqual([ - [VALIDATOR_LABEL, 2, EXPECTED_DEFAULT_VALIDATOR], - [RPC_LABEL, 1, EXPECTED_DEFAULT_RPC], - ]); + expect(promptCalls).toEqual([]); expect(stdout.read()).toContain("Genesis"); expect(stdout.read()).toContain(GENESIS_MARKER); }); @@ -252,7 +256,8 @@ describe("CLI command bootstrap", () => { } return Promise.resolve(provided); }, - promptForGenesis: (_service, { preset }) => { + promptForGenesis: (_service, { preset, autoAcceptDefaults }) => { + expect(autoAcceptDefaults).toBe(false); expect(preset).toEqual({ algorithm: ALGORITHM.IBFTv2, chainId: 1234, @@ -333,6 +338,43 @@ describe("CLI command bootstrap", () => { ); }); + test("createCliCommand strips surrounding quotes from output type", async () => { + let capturedOutputType: OutputType | undefined; + const deps: BootstrapDependencies = { + factory: createFactoryStub(), + promptForCount: () => Promise.resolve(EXPECTED_DEFAULT_VALIDATOR), + promptForGenesis: async () => ({ + algorithm: ALGORITHM.QBFT, + config: { + chainId: 1, + faucetWalletAddress: expectedAddress(CLI_FAUCET_INDEX), + gasLimit: "0x1", + gasPrice: 0, + secondsPerBlock: 2, + }, + genesis: { config: {}, extraData: "0x" } as any, + }), + service: {} as any, + loadAllocations: () => + Promise.resolve({} satisfies Record), + outputResult: (type) => { + capturedOutputType = type; + return Promise.resolve(); + }, + }; + + const command = createCliCommand(deps); + command.exitOverride(); + await expect( + command.parseAsync( + ["node", "cli", '--outputType="kubernetes"', "--accept-defaults"], + { from: "node" } + ) + ).resolves.toBeDefined(); + expect(command.opts().outputType).toBe("kubernetes"); + expect(capturedOutputType).toBe("kubernetes"); + }); + test("createCliCommand rejects unsupported consensus", async () => { const command = createCliCommand(); command.exitOverride(); @@ -342,4 +384,63 @@ describe("CLI command bootstrap", () => { `Consensus must be one of: ${Object.values(ALGORITHM).join(", ")}.` ); }); + + test("runBootstrap accepts defaults without prompting when flag provided", async () => { + const factory = createFactoryStub(); + let promptCountInvocations = 0; + let loadAllocationsInvoked = false; + + const deps: BootstrapDependencies = { + factory, + promptForCount: () => { + promptCountInvocations += 1; + return Promise.resolve(0); + }, + promptForGenesis: (_service, options) => { + expect(options.autoAcceptDefaults).toBe(true); + expect(options.preset).toEqual({ + algorithm: undefined, + chainId: undefined, + secondsPerBlock: undefined, + gasLimit: undefined, + gasPrice: undefined, + evmStackSize: undefined, + contractSizeLimit: undefined, + }); + + return Promise.resolve({ + algorithm: ALGORITHM.QBFT, + config: { + chainId: 1, + faucetWalletAddress: expectedAddress( + EXPECTED_DEFAULT_VALIDATOR + EXPECTED_DEFAULT_RPC + 1 + ), + gasLimit: "0x1", + secondsPerBlock: 2, + }, + genesis: { config: {}, extraData: "0xextra" } as any, + }); + }, + service: {} as any, + loadAllocations: () => { + loadAllocationsInvoked = true; + return Promise.resolve({} as Record); + }, + outputResult: (_type, payload) => { + expect(payload.validators).toHaveLength(EXPECTED_DEFAULT_VALIDATOR); + expect(payload.rpcNodes).toHaveLength(EXPECTED_DEFAULT_RPC); + return Promise.resolve(); + }, + }; + + await runBootstrap( + { + acceptDefaults: true, + }, + deps + ); + + expect(promptCountInvocations).toBe(0); + expect(loadAllocationsInvoked).toBe(false); + }); }); diff --git a/src/cli/build-command.ts b/src/cli/build-command.ts index 0e7f6a2..20e8b40 100644 --- a/src/cli/build-command.ts +++ b/src/cli/build-command.ts @@ -18,6 +18,7 @@ import { createCountParser, promptForCount } from "./prompt-helpers.ts"; type CliOptions = { allocations?: string; + acceptDefaults?: boolean; chainId?: number; consensus?: Algorithm; contractSizeLimit?: number; @@ -43,8 +44,25 @@ const DEFAULT_VALIDATOR_COUNT = 4; const DEFAULT_RPC_COUNT = 2; const OUTPUT_CHOICES: OutputType[] = ["screen", "file", "kubernetes"]; +// Normalizes CLI inputs wrapped by orchestrators that keep literal quotes. +const stripSurroundingQuotes = (value: string): string => { + const trimmed = value.trim(); + if (trimmed.length < 2) { + return trimmed; + } + const startsWithQuote = trimmed[0]; + const endsWithQuote = trimmed.at(-1); + if ( + (startsWithQuote === '"' || startsWithQuote === "'") && + startsWithQuote === endsWithQuote + ) { + return trimmed.slice(1, -1); + } + return trimmed; +}; + const parsePositiveInteger = (value: string, label: string): number => { - const parsed = Number.parseInt(value, 10); + const parsed = Number.parseInt(stripSurroundingQuotes(value), 10); if (!Number.isInteger(parsed) || parsed <= 0) { throw new InvalidArgumentError(`${label} must be a positive integer.`); } @@ -52,7 +70,7 @@ const parsePositiveInteger = (value: string, label: string): number => { }; const parseNonNegativeInteger = (value: string, label: string): number => { - const parsed = Number.parseInt(value, 10); + const parsed = Number.parseInt(stripSurroundingQuotes(value), 10); if (!Number.isInteger(parsed) || parsed < 0) { throw new InvalidArgumentError(`${label} must be a non-negative integer.`); } @@ -60,7 +78,7 @@ const parseNonNegativeInteger = (value: string, label: string): number => { }; const parsePositiveBigInt = (value: string, label: string): string => { - const trimmed = value.trim(); + const trimmed = stripSurroundingQuotes(value); try { const parsed = BigInt(trimmed); if (parsed <= 0n) { @@ -82,14 +100,43 @@ const runBootstrap = async ( options: CliOptions, deps: BootstrapDependencies ): Promise => { - const validatorsCount = await deps.promptForCount( + const { + acceptDefaults = false, + allocations, + chainId, + consensus, + contractSizeLimit, + evmStackSize, + gasLimit, + gasPrice, + outputType, + rpcNodes: rpcNodeOption, + secondsPerBlock, + validators: validatorOption, + } = options; + + const resolveCount = ( + label: string, + provided: number | undefined, + defaultValue: number + ): Promise => { + if (provided !== undefined) { + return Promise.resolve(provided); + } + if (acceptDefaults) { + return Promise.resolve(defaultValue); + } + return deps.promptForCount(label, undefined, defaultValue); + }; + + const validatorsCount = await resolveCount( "validator nodes", - options.validators, + validatorOption, DEFAULT_VALIDATOR_COUNT ); - const rpcNodeCount = await deps.promptForCount( + const rpcNodeCount = await resolveCount( "RPC nodes", - options.rpcNodes, + rpcNodeOption, DEFAULT_RPC_COUNT ); @@ -101,26 +148,26 @@ const runBootstrap = async ( const faucetAddress: HexAddress = faucet.address; - const allocationOverrides = options.allocations - ? await deps.loadAllocations(options.allocations) + const allocationOverrides = allocations + ? await deps.loadAllocations(allocations) : {}; const { genesis } = await deps.promptForGenesis(deps.service, { faucetAddress, allocations: allocationOverrides, preset: { - algorithm: options.consensus, - chainId: options.chainId, - secondsPerBlock: options.secondsPerBlock, - gasLimit: options.gasLimit, - gasPrice: options.gasPrice, - evmStackSize: options.evmStackSize, - contractSizeLimit: options.contractSizeLimit, + algorithm: consensus, + chainId, + secondsPerBlock, + gasLimit, + gasPrice, + evmStackSize, + contractSizeLimit, }, + autoAcceptDefaults: acceptDefaults, validatorAddresses, }); - const outputType = options.outputType ?? "screen"; const payload: OutputPayload = { faucet, genesis, @@ -128,7 +175,7 @@ const runBootstrap = async ( validators, }; - await deps.outputResult(outputType, payload); + await deps.outputResult(outputType ?? "screen", payload); }; /* c8 ignore start */ @@ -155,22 +202,24 @@ const createCliCommand = ( .option( "-v, --validators ", "Number of validator nodes to generate.", - createCountParser("Validators") + createCountParser("Validators"), + DEFAULT_VALIDATOR_COUNT ) .option( "-r, --rpc-nodes ", "Number of RPC nodes to generate.", - createCountParser("RPC nodes") + createCountParser("RPC nodes"), + DEFAULT_RPC_COUNT ) .option( "-a, --allocations ", - "Path to a genesis allocations JSON file." + "Path to a genesis allocations JSON file. (default: none)" ) .option( "-o, --outputType ", `Output target (${OUTPUT_CHOICES.join(", ")}).`, (value: string): OutputType => { - const normalized = value.toLowerCase(); + const normalized = stripSurroundingQuotes(value).toLowerCase(); if (OUTPUT_CHOICES.includes(normalized as OutputType)) { return normalized as OutputType; } @@ -182,9 +231,11 @@ const createCliCommand = ( ) .option( "--consensus ", - `Consensus algorithm (${Object.values(ALGORITHM).join(", ")}).`, + `Consensus algorithm (${Object.values(ALGORITHM).join(", ")}). (default: ${ + ALGORITHM.QBFT + })`, (value: string): Algorithm => { - const normalized = value.trim().toLowerCase(); + const normalized = stripSurroundingQuotes(value).toLowerCase(); const match = Object.values(ALGORITHM).find( (candidate) => candidate.toLowerCase() === normalized ); @@ -198,39 +249,63 @@ const createCliCommand = ( ) .option( "--chain-id ", - "Chain ID for the genesis config.", + "Chain ID for the genesis config. (default: random between 40000 and 50000)", (value: string): number => parsePositiveInteger(value, "Chain ID") ) .option( "--seconds-per-block ", - "Block time in seconds.", + "Block time in seconds. (default: 2)", (value: string): number => parsePositiveInteger(value, "Seconds per block") ) .option( "--gas-limit ", - "Block gas limit in decimal form.", + "Block gas limit in decimal form. (default: 9007199254740991)", (value: string): string => parsePositiveBigInt(value, "Gas limit") ) .option( "--gas-price ", - "Base gas price (wei).", + "Base gas price (wei). (default: 0)", (value: string): number => parseNonNegativeInteger(value, "Gas price") ) .option( "--evm-stack-size ", - "EVM stack size limit.", + "EVM stack size limit. (default: 2048)", (value: string): number => parsePositiveInteger(value, "EVM stack size") ) .option( "--contract-size-limit ", - "Contract size limit in bytes.", + "Contract size limit in bytes. (default: 2147483647)", (value: string): number => parsePositiveInteger(value, "Contract size limit") + ) + .option( + "--accept-defaults", + "Accept default values for all prompts when CLI flags are omitted. (default: disabled)" ); - command.action(async (options: CliOptions) => { - await runBootstrap(options, deps); + command.action(async (options: CliOptions, cmd: Command) => { + const normalizedOptions: CliOptions = { + ...options, + validators: + cmd.getOptionValueSource("validators") === "default" + ? undefined + : options.validators, + rpcNodes: + cmd.getOptionValueSource("rpcNodes") === "default" + ? undefined + : options.rpcNodes, + }; + + const sanitizedOptions: CliOptions = { + ...normalizedOptions, + allocations: + normalizedOptions.allocations === undefined + ? undefined + : stripSurroundingQuotes(normalizedOptions.allocations), + }; + + await runBootstrap(sanitizedOptions, deps); }); return command; diff --git a/src/cli/genesis-prompts.test.ts b/src/cli/genesis-prompts.test.ts index 19988dc..0b78f3c 100644 --- a/src/cli/genesis-prompts.test.ts +++ b/src/cli/genesis-prompts.test.ts @@ -26,6 +26,11 @@ const CONTRACT_SIZE_LIMIT = 10_000; const NEGATIVE_PRESET_INT = -1; const NEGATIVE_BIG_VALUE = "-1"; const NON_NUMERIC_BIG_VALUE = "not-a-number"; +const MIN_CHAIN_ID = 40_000; +const CHAIN_ID_RANGE = 10_000; +const DEFAULT_EVM_STACK_SIZE = 2048; +const DEFAULT_CONTRACT_SIZE_LIMIT = 2_147_483_647; +const RANDOM_HALF = 0.5; const withCancel = (value: T) => { const promise = Promise.resolve(value) as Promise & { cancel: () => void }; @@ -323,4 +328,54 @@ describe("promptForGenesisConfig", () => { }) ).rejects.toThrow("Gas limit must be a positive integer."); }); + + test("autoAcceptDefaults uses defaults without prompting", async () => { + const { service, generated } = createServiceStub(); + const originalRandom = Math.random; + Math.random = () => RANDOM_HALF; + + const overrides: PromptOverrides = { + selectPrompt: () => { + throw new Error( + "Select prompt should not be called when accepting defaults." + ); + }, + inputPrompt: () => { + throw new Error( + "Input prompt should not be called when accepting defaults." + ); + }, + }; + + try { + const result = await promptForGenesisConfig( + service as unknown as BesuGenesisService, + { + allocations: {} as Record, + faucetAddress: faucet, + overrides, + autoAcceptDefaults: true, + validatorAddresses: validators, + } + ); + + const expectedChainId = + Math.floor(RANDOM_HALF * CHAIN_ID_RANGE) + MIN_CHAIN_ID; + expect(result.algorithm).toBe(ALGORITHM.QBFT); + expect(result.config.chainId).toBe(expectedChainId); + expect(result.config.secondsPerBlock).toBe(2); + expect(result.config.gasLimit).toBe( + `0x${BigInt(GAS_LIMIT_DECIMAL).toString(HEX_RADIX)}` + ); + expect(result.config.gasPrice).toBeUndefined(); + expect(result.config.evmStackSize).toBe(DEFAULT_EVM_STACK_SIZE); + expect(result.config.contractSizeLimit).toBe(DEFAULT_CONTRACT_SIZE_LIMIT); + expect(result.genesis).toEqual({ + ...generated, + extraData: "0xextra", + }); + } finally { + Math.random = originalRandom; + } + }); }); diff --git a/src/cli/genesis-prompts.ts b/src/cli/genesis-prompts.ts index 7b9e1db..a057057 100644 --- a/src/cli/genesis-prompts.ts +++ b/src/cli/genesis-prompts.ts @@ -61,6 +61,7 @@ type GenesisPromptPreset = { type GenesisPromptOptions = { allocations?: Record; + autoAcceptDefaults?: boolean; faucetAddress: HexAddress; overrides?: Partial; preset?: GenesisPromptPreset; @@ -103,6 +104,7 @@ const promptForGenesisConfig = async ( service: BesuGenesisService, { allocations = {}, + autoAcceptDefaults = false, faucetAddress, overrides = {}, preset, @@ -116,6 +118,8 @@ const promptForGenesisConfig = async ( const defaults = createDefaultNetworkSettings(); + const fallbackAlgorithm = ALGORITHM.QBFT; + let resolvedAlgorithm: Algorithm; if (preset?.algorithm) { if (!Object.values(ALGORITHM).includes(preset.algorithm)) { @@ -124,6 +128,8 @@ const promptForGenesisConfig = async ( ); } resolvedAlgorithm = preset.algorithm; + } else if (autoAcceptDefaults) { + resolvedAlgorithm = fallbackAlgorithm; } else { const algorithmSelection = await selectFn({ message: accent("Select consensus algorithm"), @@ -142,66 +148,103 @@ const promptForGenesisConfig = async ( resolvedAlgorithm = algorithmSelection; } - const chainId = preset?.chainId - ? ensurePositiveInteger(preset.chainId, "Chain ID") - : await promptForInteger({ - defaultValue: defaults.chainId, - labelText: "Chain ID", - message: "Chain ID", - min: 1, - prompt: inputFn, - }); - - const secondsPerBlock = preset?.secondsPerBlock - ? ensurePositiveInteger(preset.secondsPerBlock, "Seconds per block") - : await promptForInteger({ - defaultValue: defaults.secondsPerBlock, - labelText: "Seconds per block", - message: "Seconds per block", - min: 1, - prompt: inputFn, - }); - - const gasLimitInput = preset?.gasLimit - ? ensurePositiveBigIntString(preset.gasLimit, "Gas limit") - : await promptForBigIntString({ - defaultValue: defaults.gasLimit, - labelText: "Block gas limit", - message: "Block gas limit (decimal)", - prompt: inputFn, - }); - - const gasPrice = - preset?.gasPrice ?? - (await promptForInteger({ + let chainId: number; + if (preset?.chainId !== undefined) { + chainId = ensurePositiveInteger(preset.chainId, "Chain ID"); + } else if (autoAcceptDefaults) { + chainId = defaults.chainId; + } else { + chainId = await promptForInteger({ + defaultValue: defaults.chainId, + labelText: "Chain ID", + message: "Chain ID", + min: 1, + prompt: inputFn, + }); + } + + let secondsPerBlock: number; + if (preset?.secondsPerBlock !== undefined) { + secondsPerBlock = ensurePositiveInteger( + preset.secondsPerBlock, + "Seconds per block" + ); + } else if (autoAcceptDefaults) { + secondsPerBlock = defaults.secondsPerBlock; + } else { + secondsPerBlock = await promptForInteger({ + defaultValue: defaults.secondsPerBlock, + labelText: "Seconds per block", + message: "Seconds per block", + min: 1, + prompt: inputFn, + }); + } + + let gasLimitInput: string; + if (preset?.gasLimit !== undefined) { + gasLimitInput = ensurePositiveBigIntString(preset.gasLimit, "Gas limit"); + } else if (autoAcceptDefaults) { + gasLimitInput = defaults.gasLimit; + } else { + gasLimitInput = await promptForBigIntString({ + defaultValue: defaults.gasLimit, + labelText: "Block gas limit", + message: "Block gas limit (decimal)", + prompt: inputFn, + }); + } + + let gasPrice: number; + if (preset?.gasPrice !== undefined) { + gasPrice = ensureNonNegativeInteger(preset.gasPrice, "Gas price"); + } else if (autoAcceptDefaults) { + gasPrice = defaults.gasPrice; + } else { + gasPrice = await promptForInteger({ defaultValue: defaults.gasPrice, labelText: "Base gas price", message: "Base gas price (wei)", min: 0, prompt: inputFn, - })); + }); + gasPrice = ensureNonNegativeInteger(gasPrice, "Gas price"); + } - const normalizedGasPrice = ensureNonNegativeInteger(gasPrice, "Gas price"); + let evmStackSize: number; + if (preset?.evmStackSize !== undefined) { + evmStackSize = ensurePositiveInteger(preset.evmStackSize, "EVM stack size"); + } else if (autoAcceptDefaults) { + evmStackSize = defaults.evmStackSize; + } else { + evmStackSize = await promptForInteger({ + defaultValue: defaults.evmStackSize, + labelText: "EVM stack size", + message: "EVM stack size", + min: 1, + prompt: inputFn, + }); + } - const evmStackSize = preset?.evmStackSize - ? ensurePositiveInteger(preset.evmStackSize, "EVM stack size") - : await promptForInteger({ - defaultValue: defaults.evmStackSize, - labelText: "EVM stack size", - message: "EVM stack size", - min: 1, - prompt: inputFn, - }); - - const contractSizeLimit = preset?.contractSizeLimit - ? ensurePositiveInteger(preset.contractSizeLimit, "Contract size limit") - : await promptForInteger({ - defaultValue: defaults.contractSizeLimit, - labelText: "Contract size limit", - message: "Contract size limit (bytes)", - min: 1, - prompt: inputFn, - }); + let contractSizeLimit: number; + if (preset?.contractSizeLimit !== undefined) { + contractSizeLimit = ensurePositiveInteger( + preset.contractSizeLimit, + "Contract size limit" + ); + } else if (autoAcceptDefaults) { + contractSizeLimit = defaults.contractSizeLimit; + } else { + contractSizeLimit = await promptForInteger({ + defaultValue: defaults.contractSizeLimit, + labelText: "Contract size limit", + message: "Contract size limit (bytes)", + min: 1, + prompt: inputFn, + }); + } + + const normalizedGasPrice = ensureNonNegativeInteger(gasPrice, "Gas price"); const config: BesuNetworkConfig = { chainId, diff --git a/src/cli/output.test.ts b/src/cli/output.test.ts index 504a5b1..a3e5f59 100644 --- a/src/cli/output.test.ts +++ b/src/cli/output.test.ts @@ -77,7 +77,9 @@ const HEX_RADIX = 16; const SAMPLE_VALIDATOR_INDEX = 1; const SAMPLE_RPC_INDEX = 2; const SAMPLE_FAUCET_INDEX = 99; -const EXPECTED_CONFIGMAP_COUNT = 8; +const EXPECTED_CONFIGMAP_COUNT = 9; +const EXPECTED_SECRET_COUNT = 3; +const HEX_PREFIX_PATTERN = /^0x/; const TEST_CHAIN_ID = 1; const HTTP_CONFLICT_STATUS = 409; const HTTP_INTERNAL_ERROR_STATUS = 500; @@ -153,17 +155,23 @@ describe("outputResult", () => { await rm("out", { recursive: true, force: true }); }); - test("kubernetes output creates configmaps", async () => { + test("kubernetes output creates configmaps and secrets", async () => { const originalLoad = (KubeConfig.prototype as any).loadFromCluster; const originalMake = (KubeConfig.prototype as any).makeApiClient; const originalFile = Bun.file; - const created: Array<{ + const createdConfigMaps: Array<{ namespace: string; name: string; data: Record; }> = []; - const listedNamespaces: string[] = []; + const createdSecrets: Array<{ + namespace: string; + name: string; + data: Record; + }> = []; + const listedConfigNamespaces: string[] = []; + const listedSecretNamespaces: string[] = []; try { (KubeConfig.prototype as any).loadFromCluster = @@ -174,7 +182,11 @@ describe("outputResult", () => { (KubeConfig.prototype as any).makeApiClient = function makeApiClient() { const client = { listNamespacedConfigMap: ({ namespace }: { namespace: string }) => { - listedNamespaces.push(namespace); + listedConfigNamespaces.push(namespace); + return Promise.resolve(); + }, + listNamespacedSecret: ({ namespace }: { namespace: string }) => { + listedSecretNamespaces.push(namespace); return Promise.resolve(); }, createNamespacedConfigMap: ({ @@ -184,13 +196,27 @@ describe("outputResult", () => { namespace: string; body: any; }) => { - created.push({ + createdConfigMaps.push({ namespace, name: body?.metadata?.name ?? "", data: body?.data ?? {}, }); return Promise.resolve(); }, + createNamespacedSecret: ({ + namespace, + body, + }: { + namespace: string; + body: any; + }) => { + createdSecrets.push({ + namespace, + name: body?.metadata?.name ?? "", + data: body?.stringData ?? {}, + }); + return Promise.resolve(); + }, }; return client as unknown as CoreV1Api; }; @@ -202,11 +228,26 @@ describe("outputResult", () => { await outputResult("kubernetes", samplePayload); - expect(listedNamespaces).toEqual(["test-namespace"]); - expect(created).toHaveLength(EXPECTED_CONFIGMAP_COUNT); - const names = created.map((entry) => entry.name).sort(); - expect(names).toContain("besu-node-validator-1-address"); - expect(names).toContain("besu-node-rpc-node-2-private-key"); + expect(listedConfigNamespaces).toEqual(["test-namespace"]); + expect(listedSecretNamespaces).toEqual(["test-namespace"]); + expect(createdConfigMaps).toHaveLength(EXPECTED_CONFIGMAP_COUNT); + expect(createdSecrets).toHaveLength(EXPECTED_SECRET_COUNT); + const mapNames = createdConfigMaps.map((entry) => entry.name).sort(); + expect(mapNames).toContain("besu-node-validator-1-address"); + expect(mapNames).toContain("besu-genesis"); + expect(mapNames).toContain("besu-faucet-address"); + expect(mapNames).toContain("besu-faucet-pubkey"); + expect(mapNames).not.toContain("besu-faucet-enode"); + const secretNames = createdSecrets.map((entry) => entry.name).sort(); + expect(secretNames).toEqual([ + "besu-faucet-private-key", + "besu-node-rpc-node-2-private-key", + "besu-node-validator-1-private-key", + ]); + const privateKeySecret = createdSecrets.find((entry) => + entry.name.endsWith("validator-1-private-key") + ); + expect(privateKeySecret?.data?.privateKey).toMatch(HEX_PREFIX_PATTERN); } finally { (KubeConfig.prototype as any).loadFromCluster = originalLoad; (KubeConfig.prototype as any).makeApiClient = originalMake; @@ -227,6 +268,7 @@ describe("outputResult", () => { (KubeConfig.prototype as any).makeApiClient = function makeApiClient() { const client = { listNamespacedConfigMap: () => Promise.resolve(), + listNamespacedSecret: () => Promise.resolve(), createNamespacedConfigMap: () => { const error = new Error("already exists"); ( @@ -239,6 +281,7 @@ describe("outputResult", () => { }; throw error; }, + createNamespacedSecret: () => Promise.resolve(), }; return client as unknown as CoreV1Api; }; @@ -258,6 +301,52 @@ describe("outputResult", () => { } }); + test("kubernetes output surfaces secret conflict errors", async () => { + const originalLoad = (KubeConfig.prototype as any).loadFromCluster; + const originalMake = (KubeConfig.prototype as any).makeApiClient; + const originalFile = Bun.file; + + try { + (KubeConfig.prototype as any).loadFromCluster = + function loadFromCluster(): void { + /* no-op for tests */ + }; + (KubeConfig.prototype as any).makeApiClient = function makeApiClient() { + const client = { + listNamespacedConfigMap: () => Promise.resolve(), + listNamespacedSecret: () => Promise.resolve(), + createNamespacedConfigMap: () => Promise.resolve(), + createNamespacedSecret: () => { + const error = new Error("already exists"); + ( + error as { + response?: { statusCode: number; body: { message: string } }; + } + ).response = { + statusCode: HTTP_CONFLICT_STATUS, + body: { message: "already exists" }, + }; + throw error; + }, + }; + return client as unknown as CoreV1Api; + }; + + (Bun as any).file = () => + ({ + text: () => Promise.resolve("secret-conflict-namespace"), + }) as unknown as ReturnType; + + await expect(outputResult("kubernetes", samplePayload)).rejects.toThrow( + "Secret besu-node-validator-1-private-key already exists. Delete it or choose a different output target." + ); + } finally { + (KubeConfig.prototype as any).loadFromCluster = originalLoad; + (KubeConfig.prototype as any).makeApiClient = originalMake; + (Bun as any).file = originalFile; + } + }); + test("kubernetes output fails without cluster credentials", async () => { const originalLoad = (KubeConfig.prototype as any).loadFromCluster; const originalFile = Bun.file; @@ -346,6 +435,7 @@ describe("outputResult", () => { (KubeConfig.prototype as any).makeApiClient = function makeApiClient() { const client = { listNamespacedConfigMap: () => Promise.reject(new Error("forbidden")), + listNamespacedSecret: () => Promise.resolve(), }; return client as unknown as CoreV1Api; }; @@ -377,9 +467,11 @@ describe("outputResult", () => { (KubeConfig.prototype as any).makeApiClient = function makeApiClient() { const client = { listNamespacedConfigMap: () => Promise.resolve(), + listNamespacedSecret: () => Promise.resolve(), createNamespacedConfigMap: () => { throw new Error("boom"); }, + createNamespacedSecret: () => Promise.resolve(), }; return client as unknown as CoreV1Api; }; @@ -411,12 +503,14 @@ describe("outputResult", () => { (KubeConfig.prototype as any).makeApiClient = function makeApiClient() { const client = { listNamespacedConfigMap: () => Promise.resolve(), + listNamespacedSecret: () => Promise.resolve(), createNamespacedConfigMap: () => { const error = new Error("failed"); (error as { statusCode?: number }).statusCode = HTTP_INTERNAL_ERROR_STATUS; throw error; }, + createNamespacedSecret: () => Promise.resolve(), }; return client as unknown as CoreV1Api; }; @@ -448,6 +542,7 @@ describe("outputResult", () => { (KubeConfig.prototype as any).makeApiClient = function makeApiClient() { const client = { listNamespacedConfigMap: () => Promise.resolve(), + listNamespacedSecret: () => Promise.resolve(), createNamespacedConfigMap: () => { const error = new Error("response error"); Object.defineProperty(error, "message", { value: undefined }); @@ -456,6 +551,7 @@ describe("outputResult", () => { }; throw error; }, + createNamespacedSecret: () => Promise.resolve(), }; return client as unknown as CoreV1Api; }; @@ -487,6 +583,7 @@ describe("outputResult", () => { (KubeConfig.prototype as any).makeApiClient = function makeApiClient() { const client = { listNamespacedConfigMap: () => Promise.resolve(), + listNamespacedSecret: () => Promise.resolve(), createNamespacedConfigMap: () => { const error = new Error("body error"); Object.defineProperty(error, "message", { value: undefined }); @@ -495,6 +592,7 @@ describe("outputResult", () => { }; throw error; }, + createNamespacedSecret: () => Promise.resolve(), }; return client as unknown as CoreV1Api; }; diff --git a/src/cli/output.ts b/src/cli/output.ts index f1f3535..c3056ee 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1,6 +1,6 @@ import { mkdir } from "node:fs/promises"; import { join } from "node:path"; -import type { V1ConfigMap } from "@kubernetes/client-node"; +import type { V1ConfigMap, V1Secret } from "@kubernetes/client-node"; import { CoreV1Api, KubeConfig } from "@kubernetes/client-node"; import type { GeneratedNodeKey } from "../keys/node-key-factory.ts"; @@ -23,6 +23,8 @@ type ConfigMapSpec = { value: string; }; +type SecretSpec = ConfigMapSpec; + const OUTPUT_DIR = "out"; const NAMESPACE_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"; @@ -133,13 +135,27 @@ const outputToKubernetes = async (payload: OutputPayload): Promise => { const validatorSpecs = createSpecsForGroup("validator", payload.validators); const rpcSpecs = createSpecsForGroup("rpc-node", payload.rpcNodes); const allSpecs = [...validatorSpecs, ...rpcSpecs]; + const configMapSpecs = [ + ...allSpecs.filter((spec) => spec.key !== "privateKey"), + ...createFaucetConfigSpecs(payload.faucet), + { + name: "besu-genesis", + key: "genesis.json", + value: JSON.stringify(payload.genesis, null, 2), + }, + ]; + const secretSpecs = [ + ...allSpecs.filter((spec) => spec.key === "privateKey"), + ...createFaucetSecretSpecs(payload.faucet), + ]; - await Promise.all( - allSpecs.map((spec) => upsertConfigMap(client, namespace, spec)) - ); + await Promise.all([ + ...configMapSpecs.map((spec) => upsertConfigMap(client, namespace, spec)), + ...secretSpecs.map((spec) => upsertSecret(client, namespace, spec)), + ]); process.stdout.write( - `Applied ${allSpecs.length} ConfigMaps in namespace ${namespace}.\n` + `Applied ${configMapSpecs.length} ConfigMaps and ${secretSpecs.length} Secrets in namespace ${namespace}.\n` ); }; @@ -165,10 +181,24 @@ const createSpecsForGroup = ( }); }; +const createFaucetConfigSpecs = (faucet: GeneratedNodeKey): ConfigMapSpec[] => [ + { name: "besu-faucet-address", key: "address", value: faucet.address }, + { name: "besu-faucet-pubkey", key: "publicKey", value: faucet.publicKey }, +]; + +const createFaucetSecretSpecs = (faucet: GeneratedNodeKey): SecretSpec[] => [ + { + name: "besu-faucet-private-key", + key: "privateKey", + value: faucet.privateKey, + }, +]; + const createKubernetesClient = async (): Promise<{ client: CoreV1Api; namespace: string; }> => { + Bun.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; const kubeConfig = new KubeConfig(); try { kubeConfig.loadFromCluster(); @@ -194,7 +224,10 @@ const createKubernetesClient = async (): Promise<{ const client = kubeConfig.makeApiClient(CoreV1Api); try { - await client.listNamespacedConfigMap({ namespace, limit: 1 }); + await Promise.all([ + client.listNamespacedConfigMap({ namespace, limit: 1 }), + client.listNamespacedSecret({ namespace, limit: 1 }), + ]); } catch (error) { throw new Error( `Kubernetes permissions check failed: ${extractKubernetesError(error)}` @@ -229,6 +262,32 @@ const upsertConfigMap = async ( } }; +const upsertSecret = async ( + client: CoreV1Api, + namespace: string, + spec: SecretSpec +): Promise => { + const body: V1Secret = { + metadata: { name: spec.name }, + stringData: { [spec.key]: spec.value }, + type: "Opaque", + }; + + try { + await client.createNamespacedSecret({ namespace, body }); + } catch (error) { + if (getStatusCode(error) === HTTP_CONFLICT_STATUS) { + throw new Error( + `Secret ${spec.name} already exists. Delete it or choose a different output target.` + ); + } + + throw new Error( + `Failed to create Secret ${spec.name}: ${extractKubernetesError(error)}` + ); + } +}; + const extractKubernetesError = (error: unknown): string => { if (typeof error === "string") { return error; diff --git a/tools/version.ts b/tools/version.ts new file mode 100644 index 0000000..a57b57a --- /dev/null +++ b/tools/version.ts @@ -0,0 +1,553 @@ +#!/usr/bin/env bun + +import { appendFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { Glob } from "bun"; +import { parse, stringify } from "yaml"; + +const RELEASE_TAG_PATTERN = /^v?[0-9]+\.[0-9]+\.[0-9]+$/; +const LEADING_V_PATTERN = /^v/; +const NON_ALPHANUMERIC_PATTERN = /[^0-9A-Za-z-]/g; + +const sanitizeIdentifier = (value: string) => + value.replace(NON_ALPHANUMERIC_PATTERN, ""); + +type VersionInfo = { + tag: "latest" | "main" | "pr"; + version: string; +}; + +type VersionParams = { + refSlug?: string; + refName?: string; + buildId?: string; + startPath?: string; +}; + +type PackageJson = { + version: string; + [key: string]: unknown; +}; + +type ChartYaml = { + version: string; + appVersion: string; + dependencies?: Array<{ + name: string; + version: string; + repository?: string; + [key: string]: unknown; + }>; + [key: string]: unknown; +}; + +type UpdateResult = { + changed: boolean; + logs?: string[]; +}; + +const toPosixPath = (value: string): string => value.replaceAll("\\", "/"); + +async function scanFiles( + pattern: string, + exclude: string[], + cwd: string +): Promise { + const glob = new Glob(pattern); + const files: string[] = []; + + for await (const file of glob.scan(cwd)) { + const normalized = toPosixPath(file); + if (exclude.some((entry) => normalized.includes(entry))) { + continue; + } + files.push(file); + } + + return files; +} + +async function processFile({ + filePath, + read, + update, + write, +}: { + filePath: string; + read: (raw: string) => T; + update: (data: T, filePath: string) => UpdateResult; + write: (data: T) => string; +}): Promise { + const file = Bun.file(filePath); + + if (!(await file.exists())) { + console.warn(` Skipping: ${filePath} does not exist`); + return false; + } + + const raw = await file.text(); + const data = read(raw); + const { changed, logs } = update(data, filePath); + + if (!changed) { + console.log(" No changes needed"); + return false; + } + + await Bun.write(filePath, write(data)); + if (logs?.length) { + for (const line of logs) { + console.log(` ${line}`); + } + } + console.log(` ✔ ${filePath}`); + return true; +} + +async function findPackageJsonPath(startPath?: string): Promise { + const resolvedPath = resolve(startPath ?? "."); + let currentDir = resolvedPath; + let parentDir = dirname(currentDir); + + while (currentDir !== parentDir) { + const candidate = join(currentDir, "package.json"); + if (await Bun.file(candidate).exists()) { + return candidate; + } + + currentDir = parentDir; + parentDir = dirname(currentDir); + } + + const rootCandidate = join(currentDir, "package.json"); + if (await Bun.file(rootCandidate).exists()) { + return rootCandidate; + } + + throw new Error(`package.json not found when searching from ${resolvedPath}`); +} + +/** + * Reads and parses the root package.json file + * @param startPath - Starting path for finding the monorepo root + * @returns The parsed package.json content + */ +async function readRootPackageJson(startPath?: string): Promise { + const packageJsonPath = await findPackageJsonPath(startPath); + const packageJsonFile = Bun.file(packageJsonPath); + + const packageJson = (await packageJsonFile.json()) as PackageJson; + + if (!packageJson.version) { + throw new Error(`No version found in ${packageJsonPath}`); + } + + return packageJson; +} + +/** + * Generates version string based on Git ref information and base version + * @param refSlug - Git ref slug + * @param refName - Git ref name + * @param baseVersion - Base version from package.json + * @param buildId - Optional build identifier (GitHub run counter or similar) + * @returns Object containing version and tag + */ +function generateVersionInfo( + refSlug: string, + refName: string, + baseVersion: string, + buildId?: string +): VersionInfo { + if (RELEASE_TAG_PATTERN.test(refSlug)) { + // Remove 'v' prefix if present + const version = refSlug.replace(LEADING_V_PATTERN, ""); + return { + tag: "latest", + version, + }; + } + + if (refName === "main") { + // Prefer numeric/strict BUILD_ID (or GitHub run counters) for Renovate sorting + // Fall back to a timestamp to ensure uniqueness outside CI + const id = sanitizeIdentifier(buildId || "") || `${Date.now()}`; + // Use SemVer pre-release with dot-separated identifiers: -main. + const version = `${baseVersion}-main.${id}`; + return { + tag: "main", + version, + }; + } + + // Default case (PR or other branches) + const identifier = sanitizeIdentifier(buildId || "") || `${Date.now()}`; + const version = `${baseVersion}-pr.${identifier}`; + return { + tag: "pr", + version, + }; +} + +/** + * Gets version and tag information based on Git ref information + * @param params - Configuration object with Git ref information + * @returns Object containing version and tag + */ +export async function getVersionInfo( + params: VersionParams = {} +): Promise { + const { + refSlug = process.env.GITHUB_REF_SLUG || "", + refName = process.env.GITHUB_REF_NAME || "", + buildId: providedBuildId, + startPath, + } = params; + + const buildId = + providedBuildId || + process.env.BUILD_ID || + process.env.GITHUB_RUN_NUMBER || + process.env.GITHUB_RUN_ID || + ""; + + const packageJson = await readRootPackageJson(startPath); + + return generateVersionInfo(refSlug, refName, packageJson.version, buildId); +} + +/** + * Gets version info and logs the result (useful for CI/CD) + * @param params - Configuration object with Git ref information + * @returns Object containing version and tag with console output + */ +export async function getVersionInfoWithLogging( + params: VersionParams = {} +): Promise { + const result = await getVersionInfo(params); + + console.log(`TAG=${result.tag}`); + console.log(`VERSION=${result.version}`); + + return result; +} + +/** + * Updates workspace dependencies in a dependencies object + * @param deps - Dependencies object to update + * @param depType - Type of dependencies (for logging) + * @param newVersion - New version to use + * @returns Number of workspace dependencies updated + */ +function updateWorkspaceDependencies( + deps: Record | undefined, + depType: string, + newVersion: string +): number { + if (!deps) { + return 0; + } + + let workspaceCount = 0; + for (const [depName, depVersion] of Object.entries(deps)) { + // Skip @atk/* packages - not published to npm + if (depVersion === "workspace:*" && !depName.startsWith("@atk/")) { + deps[depName] = newVersion; + workspaceCount++; + } + } + + if (workspaceCount > 0) { + console.log( + ` Updated ${workspaceCount} workspace:* references in ${depType}` + ); + } + + return workspaceCount; +} + +/** + * Updates chart dependencies with version "*" + * @param dependencies - Chart dependencies array to update + * @param newVersion - New version to use + * @returns Number of chart dependencies updated + */ +function updateChartDependencies( + dependencies: + | Array<{ name: string; version: string; [key: string]: unknown }> + | undefined, + newVersion: string +): number { + if (!dependencies) { + return 0; + } + + let dependencyCount = 0; + for (const dep of dependencies) { + if (dep.version === "*") { + dep.version = newVersion; + dependencyCount++; + } + } + + return dependencyCount; +} + +/** + * Updates all package.json files in the workspace with the new version using glob pattern + * Also replaces "workspace:*" references with the actual version + * @param startPath - Starting path for finding package.json files (defaults to current working directory) + * @returns Promise that resolves when all updates are complete + */ +export async function updatePackageVersion( + startPath?: string, + versionInfoOverride?: VersionInfo +): Promise { + try { + // Get the current version info + const versionInfo = + versionInfoOverride ?? (await getVersionInfo({ startPath })); + const newVersion = versionInfo.version; + + console.log(`Updating all package.json files to version: ${newVersion}`); + const cwd = resolve(startPath ?? "."); + const packageFiles = await scanFiles( + "**/package.json", + ["node_modules/", "kit/contracts/dependencies/"], + cwd + ); + + if (packageFiles.length === 0) { + console.warn("No package.json files found"); + return; + } + + console.log(`Found ${packageFiles.length} package.json files:`); + + let updatedCount = 0; + + for (const packagePath of packageFiles) { + try { + console.log(` Processing: ${packagePath}`); + + const changed = await processFile({ + filePath: packagePath, + read: (raw) => JSON.parse(raw) as PackageJson, + update: (packageJson) => { + if (!packageJson.version) { + console.warn(" Skipping: No version field found"); + return { changed: false }; + } + + const logs: string[] = []; + const oldVersion = packageJson.version; + const versionChanged = oldVersion !== newVersion; + + if (versionChanged) { + packageJson.version = newVersion; + logs.push(`Updated version: ${oldVersion} -> ${newVersion}`); + } + + const workspaceUpdates = [ + updateWorkspaceDependencies( + packageJson.dependencies as Record, + "dependencies", + newVersion + ), + updateWorkspaceDependencies( + packageJson.devDependencies as Record, + "devDependencies", + newVersion + ), + updateWorkspaceDependencies( + packageJson.peerDependencies as Record, + "peerDependencies", + newVersion + ), + updateWorkspaceDependencies( + packageJson.optionalDependencies as Record, + "optionalDependencies", + newVersion + ), + ]; + + const totalWorkspaceUpdates = workspaceUpdates.reduce( + (sum, count) => sum + count, + 0 + ); + + if (totalWorkspaceUpdates > 0) { + logs.push( + `Updated ${totalWorkspaceUpdates} workspace:* reference${ + totalWorkspaceUpdates === 1 ? "" : "s" + }` + ); + } + + return { + changed: versionChanged || totalWorkspaceUpdates > 0, + logs, + }; + }, + write: (packageJson) => `${JSON.stringify(packageJson, null, 2)}\n`, + }); + + if (changed) { + updatedCount++; + } + } catch (err) { + console.error(` Error processing ${packagePath}:`, err); + } + } + + console.log(`\nSuccessfully updated ${updatedCount} package.json files`); + } catch (err) { + console.error("Failed to update package versions:", err); + process.exit(1); + } +} + +/** + * Updates all Chart.yaml files in the ATK directory with the current version + */ +async function updateChartVersions( + versionInfoOverride?: VersionInfo +): Promise { + try { + // Get the current version info + const versionInfo = versionInfoOverride ?? (await getVersionInfo()); + const newVersion = versionInfo.version; + + console.log(`Updating charts to version: ${newVersion}`); + + const chartFiles = await scanFiles( + "charts/**/Chart.yaml", + [], + process.cwd() + ); + + if (chartFiles.length === 0) { + console.warn("No Chart.yaml files found in charts/"); + return; + } + + console.log(`Found ${chartFiles.length} Chart.yaml files:`); + + let updatedCount = 0; + + for (const chartPath of chartFiles) { + try { + const changed = await processFile({ + filePath: chartPath, + read: (raw) => parse(raw) as ChartYaml, + update: (chart) => { + if (!(chart.version || chart.appVersion)) { + console.warn( + " Skipping: No version or appVersion fields found" + ); + return { changed: false }; + } + + const logs: string[] = []; + const versionChanged = + typeof chart.version === "string" && chart.version !== newVersion; + const appVersionChanged = + typeof chart.appVersion === "string" && + chart.appVersion !== newVersion; + + if (versionChanged) { + logs.push(`Updated version: ${chart.version} -> ${newVersion}`); + chart.version = newVersion; + } + if (appVersionChanged) { + logs.push( + `Updated appVersion: ${chart.appVersion} -> ${newVersion}` + ); + chart.appVersion = newVersion; + } + + const dependencyUpdates = updateChartDependencies( + chart.dependencies, + newVersion + ); + + if (dependencyUpdates > 0) { + logs.push( + `Updated ${dependencyUpdates} chart dependenc${ + dependencyUpdates === 1 ? "y" : "ies" + } pinned to "*"` + ); + } + + return { + changed: + versionChanged || appVersionChanged || dependencyUpdates > 0, + logs, + }; + }, + write: (chart) => `${stringify(chart)}\n`, + }); + + if (changed) { + updatedCount++; + } + } catch (err) { + console.error(` Error processing ${chartPath}:`, err); + } + } + + console.log(`\nSuccessfully updated ${updatedCount} Chart.yaml files`); + } catch (err) { + console.error("Failed to update chart versions:", err); + process.exit(1); + } +} + +/** + * Exports version and tag information to GitHub Outputs/Env for downstream steps + */ +async function persistGithubContext(versionInfo: VersionInfo): Promise { + const { version, tag } = versionInfo; + + const appendLines = async ( + filePath: string | undefined, + lines: string[] + ): Promise => { + if (!filePath || lines.length === 0) { + return; + } + + const content = `${lines.join("\n")}\n`; + await appendFile(filePath, content, "utf8"); + }; + + await appendLines(process.env.GITHUB_OUTPUT, [ + `version=${version}`, + `tag=${tag}`, + ]); + + await appendLines(process.env.GITHUB_ENV, [ + `NETWORK_BOOTSTRAPPER_VERSION=${version}`, + `NETWORK_BOOTSTRAPPER_TAG=${tag}`, + ]); +} + +// Run the script if called directly +if (import.meta.main) { + const args = new Set(Bun.argv.slice(2)); + const allowLocal = args.has("--allow-local") || args.has("--force"); + + // Check if running in CI environment unless explicitly overridden + if (!(process.env.CI || allowLocal)) { + console.log( + "Set the CI environment variable or rerun with --allow-local to execute this script." + ); + process.exit(0); + } + + const versionInfo = await getVersionInfo(); + + await updateChartVersions(versionInfo); + await updatePackageVersion(undefined, versionInfo); + await persistGithubContext(versionInfo); +}