node: a deployable validator chart, pinned by digest

The chart carried no templates, so nothing could deploy from it and every
network ran its own hand-written manifests at a different node build.

One chart now serves all three networks, with per-network values selecting
only the network id: Lux (1), Hanzo (36963), Zoo (200200). The image
resolves from an explicit digest, falling back to the chart's appVersion —
a floating tag can no longer reach a cluster.

Probes address the node's own endpoints: readiness reports bootstrap and
router state, liveness reports only that the process serves, and a startup
window grants 30 minutes to bootstrap before liveness counts.

Named "node", not "lux-node" — the repository already says whose it is.
This commit is contained in:
2026-09-03 15:52:36 -07:00
parent 63b19a0e0a
commit bcee281e4b
14 changed files with 353 additions and 16 deletions
-11
View File
@@ -1,11 +0,0 @@
apiVersion: v2
name: lux-node
description: Lux Network blockchain node
type: application
version: 0.1.0
appVersion: "1.0.0"
home: https://lux.network
icon: https://lux.network/icon.png
maintainers:
- name: Lux Network
email: [email protected]
-5
View File
@@ -1,5 +0,0 @@
replicaCount: 1
image:
repository: ghcr.io/luxfi/node
tag: latest
pullPolicy: Always
+2
View File
@@ -0,0 +1,2 @@
.git/
*.tgz
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v2
name: node
description: A Lux network validator — luxd, its chains and its peer identity.
type: application
version: 0.1.0
# The node build this chart deploys unless values pin one explicitly.
appVersion: "1.36.181"
home: https://lux.network
sources:
- https://github.com/luxfi/node
maintainers:
- name: Lux Industries Inc
url: https://lux.network
keywords:
- lux
- validator
- consensus
+63
View File
@@ -0,0 +1,63 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "node.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "node.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 "node.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "node.labels" -}}
helm.sh/chart: {{ include "node.chart" . }}
{{ include "node.selector" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
lux.network/id: {{ .Values.network.id | quote }}
{{- end }}
{{- define "node.selector" -}}
app.kubernetes.io/name: {{ include "node.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "node.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "node.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
The image reference. A digest pins exactly and wins over the tag; otherwise the
tag, defaulting to the chart's appVersion. Never "latest".
*/}}
{{- define "node.image" -}}
{{- $tag := default .Chart.AppVersion .Values.image.tag -}}
{{- if .Values.image.digest -}}
{{ .Values.image.repository }}:{{ $tag }}@{{ .Values.image.digest }}
{{- else -}}
{{ .Values.image.repository }}:{{ $tag }}
{{- end -}}
{{- end }}
+11
View File
@@ -0,0 +1,11 @@
{{- if gt (int .Values.replicas) 1 }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "node.fullname" . }}
labels: {{- include "node.labels" . | nindent 4 }}
spec:
maxUnavailable: {{ .Values.budget.maxUnavailable }}
selector:
matchLabels: {{- include "node.selector" . | nindent 6 }}
{{- end }}
+16
View File
@@ -0,0 +1,16 @@
{{- if or .Values.network.genesis .Values.network.chains }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "node.fullname" . }}
labels: {{- include "node.labels" . | nindent 4 }}
data:
{{- if .Values.network.genesis }}
genesis.json: |
{{ .Values.network.genesis | indent 4 }}
{{- end }}
{{- range $alias, $cfg := .Values.network.chains }}
chain-{{ $alias }}.json: |
{{ toPrettyJson $cfg | indent 4 }}
{{- end }}
{{- end }}
+34
View File
@@ -0,0 +1,34 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "node.fullname" . }}
labels: {{- include "node.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations: {{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
selector: {{- include "node.selector" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.ports.http }}
targetPort: http
---
# Headless: every validator gets stable DNS so peers find each other by name
# rather than by an address that moves.
apiVersion: v1
kind: Service
metadata:
name: {{ include "node.fullname" . }}-peer
labels: {{- include "node.labels" . | nindent 4 }}
spec:
clusterIP: None
publishNotReadyAddresses: true
selector: {{- include "node.selector" . | nindent 4 }}
ports:
- name: staking
port: {{ .Values.ports.staking }}
targetPort: staking
- name: http
port: {{ .Values.ports.http }}
targetPort: http
+10
View File
@@ -0,0 +1,10 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "node.serviceAccountName" . }}
labels: {{- include "node.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations: {{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
+106
View File
@@ -0,0 +1,106 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "node.fullname" . }}
labels: {{- include "node.labels" . | nindent 4 }}
spec:
serviceName: {{ include "node.fullname" . }}-peer
replicas: {{ .Values.replicas }}
podManagementPolicy: Parallel
selector:
matchLabels: {{- include "node.selector" . | nindent 6 }}
template:
metadata:
labels: {{- include "node.labels" . | nindent 8 }}
annotations:
# Roll the set when genesis or chain config changes.
lux.network/config: {{ include (print $.Template.BasePath "/config.yaml") . | sha256sum | trunc 16 }}
{{- with .Values.podAnnotations }}{{- toYaml . | nindent 8 }}{{- end }}
spec:
serviceAccountName: {{ include "node.serviceAccountName" . }}
{{- with .Values.pullSecrets }}
imagePullSecrets: {{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
fsGroup: 1000
containers:
- name: node
image: {{ include "node.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
args:
- --network-id={{ .Values.network.id }}
- --http-host=0.0.0.0
- --http-port={{ .Values.ports.http }}
- --staking-port={{ .Values.ports.staking }}
- --data-dir=/data
- --db-type={{ .Values.db }}
{{- if .Values.network.genesis }}
- --genesis-file=/genesis/genesis.json
{{- end }}
{{- if .Values.network.chains }}
- --chain-config-dir=/chainconf
{{- end }}
{{- with .Values.args }}{{- toYaml . | nindent 12 }}{{- end }}
ports:
- name: http
containerPort: {{ .Values.ports.http }}
- name: staking
containerPort: {{ .Values.ports.staking }}
startupProbe:
httpGet:
path: {{ .Values.probes.liveness }}
port: http
periodSeconds: 10
failureThreshold: {{ div .Values.probes.startupSeconds 10 }}
readinessProbe:
httpGet:
path: {{ .Values.probes.readiness }}
port: http
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: {{ .Values.probes.liveness }}
port: http
periodSeconds: 30
failureThreshold: 5
resources: {{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /data
{{- if .Values.network.genesis }}
- name: config
mountPath: /genesis/genesis.json
subPath: genesis.json
{{- end }}
{{- range $alias, $_ := .Values.network.chains }}
- name: config
mountPath: /chainconf/{{ $alias }}/config.json
subPath: chain-{{ $alias }}.json
{{- end }}
volumes:
{{- if or .Values.network.genesis .Values.network.chains }}
- name: config
configMap:
name: {{ include "node.fullname" . }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations: {{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity: {{- toYaml . | nindent 8 }}
{{- end }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
{{- if .Values.storage.class }}
storageClassName: {{ .Values.storage.class }}
{{- end }}
resources:
requests:
storage: {{ .Values.storage.size }}
+7
View File
@@ -0,0 +1,7 @@
# Hanzo L1 — sovereign, so its primary network id equals its EVM id.
network:
id: 36963
image:
tag: "1.36.181"
digest: "sha256:3ff4841e76b407e774b59ea33cdb691bc0b5b6dfdebc0e5ea71bd1119d059cad"
+7
View File
@@ -0,0 +1,7 @@
# Lux Network — the primary network. Its C-chain carries EVM id 96369.
network:
id: 1
image:
tag: "1.36.181"
digest: "sha256:3ff4841e76b407e774b59ea33cdb691bc0b5b6dfdebc0e5ea71bd1119d059cad"
+7
View File
@@ -0,0 +1,7 @@
# Zoo L1 — sovereign, so its primary network id equals its EVM id.
network:
id: 200200
image:
tag: "1.36.181"
digest: "sha256:3ff4841e76b407e774b59ea33cdb691bc0b5b6dfdebc0e5ea71bd1119d059cad"
+73
View File
@@ -0,0 +1,73 @@
# A validator set for one network. Per-network values live beside this file
# (values-lux.yaml, values-hanzo.yaml, values-zoo.yaml); the universe repos
# supply the exact pin per environment.
replicas: 5
image:
repository: ghcr.io/luxfi/node
# Empty tag follows Chart.appVersion. Set digest to pin exactly — a digest
# wins over the tag, and a floating tag never reaches a cluster.
tag: ""
digest: ""
pullPolicy: IfNotPresent
pullSecrets: []
# The network these validators form. id is the primary network id: 1/2/3 for
# Lux mainnet/testnet/local, and for a sovereign L1 it equals that chain's
# EVM id, so one id names one network everywhere.
network:
id: 1
# Inline genesis JSON. Empty uses the build's own genesis for network.id.
genesis: ""
# Chain configs, keyed by chain alias: chains.C is written to C/config.json.
chains: {}
ports:
http: 9630
staking: 9631
db: zapdb
# Extra luxd flags, as a list of "--flag=value".
args: []
storage:
size: 200Gi
class: ""
resources:
requests:
cpu: "2"
memory: 8Gi
limits:
memory: 16Gi
# Paths are the node's own probe endpoints. Readiness reports bootstrap and
# router state; liveness reports only that the process serves, so a node that
# is still catching up is left alone instead of being restarted.
probes:
readiness: /v1/health/ops/readiness
liveness: /v1/health/ops/liveness
# Bootstrapping a large chain takes a while — the startup probe grants that
# time, and liveness only starts counting once it passes.
startupSeconds: 1800
service:
type: ClusterIP
annotations: {}
budget:
# One validator may be unavailable at a time; consensus keeps its margin.
maxUnavailable: 1
serviceAccount:
create: true
name: ""
annotations: {}
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}