Files
zeekayandHanzo Dev 7417193940 A chain answers under one segment, and the fleet asks for it there
luxd moved chain APIs from /v1/bc/<alias> to /v1/chain/<alias> and deleted the
old prefix, and health moved onto typed ops at /v1/health/ops/*. Both landed in
node 1.36.181. Nothing here could move before that image existed, and the image
cannot roll out while the manifests still ask for addresses it no longer serves,
so the pin and the paths move in one commit.

What moved:
  image    1.36.171 -> 1.36.181, digest with it, on devnet/testnet/mainnet
  internal exchange-api, lux-hb2, rpc-tip-gate, the luxd startup script
  probes   /v1/health/liveness -> /v1/health/ops/liveness (bare /v1/health,
           which the local compose healthcheck used, no longer routes at all)
  public   the devnet and testnet gateway routes

Comments that RECORD an observation keep their original spelling: a note saying
/v1/bc/zoo/rpc returned 404 on a given date is a fact about that date, and
rewriting it would make the record false. Only live addresses moved.

1.36.181 also carries chains v1.7.38, so the deployed node has the zkvm
content-derived transaction identity.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-30 16:17:20 -07:00

260 lines
7.9 KiB
YAML

# rpc-tip-gate for lux-testnet.
#
# Mainnet has had one since 2026-07-28; testnet has not, and that asymmetry is
# what let two validators frozen 580 blocks behind keep answering
# api.lux-test.network -- they were Ready, and Ready was the only thing between
# a stale pod and a public read.
#
# Readiness cannot carry freshness: it is a property of ONE pod asked about
# itself, and "am I behind" is only answerable against the others. This gate
# polls every luxd pod, compares heights, and owns the lux.network/rpc=fresh
# label; luxd-rpc selects on it, so a node that falls behind leaves the serving
# set without anyone deciding to remove it.
#
# The script is byte-identical to the mainnet gate's and reads its whole
# configuration from env -- only RPC_PORT differs (9640 here, 9630 there).
apiVersion: v1
kind: ServiceAccount
metadata:
name: rpc-tip-gate
namespace: lux-testnet
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: rpc-tip-gate
namespace: lux-testnet
rules:
# patch is for the label and nothing else; the gate never deletes or evicts.
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: rpc-tip-gate
namespace: lux-testnet
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: rpc-tip-gate
subjects:
- kind: ServiceAccount
name: rpc-tip-gate
namespace: lux-testnet
---
apiVersion: v1
kind: ConfigMap
metadata:
name: rpc-tip-gate-code
namespace: lux-testnet
data:
tip_gate.py: |
"""Label luxd pods that are keeping up; unlabel the ones that are not.
Stdlib only, so it runs on a stock python image with no build step and no
registry artifact of its own.
"""
import json
import os
import ssl
import time
import urllib.request
from urllib.parse import quote
NS = os.environ["NAMESPACE"]
SELECTOR = os.environ["POD_SELECTOR"]
RPC_PORT = os.environ["RPC_PORT"]
RPC_PATH = os.environ["RPC_PATH"]
LABEL_KEY = os.environ["LABEL_KEY"]
LABEL_VALUE = os.environ["LABEL_VALUE"]
MAX_LAG = int(os.environ["MAX_LAG_BLOCKS"])
INTERVAL = float(os.environ["INTERVAL_SECONDS"])
TIMEOUT = float(os.environ["PROBE_TIMEOUT_SECONDS"])
API = "https://kubernetes.default.svc"
SA = "/var/run/secrets/kubernetes.io/serviceaccount"
_TLS = None
def tls():
"""Built on first use, not at import, so a missing or not-yet-mounted
CA surfaces inside the retry loop instead of killing the process
before the loop exists."""
global _TLS
if _TLS is None:
_TLS = ssl.create_default_context(cafile=SA + "/ca.crt")
return _TLS
def kube(method, path, body=None):
"""One call to the apiserver. The token is re-read every time because
projected serviceaccount tokens are rotated under a running pod."""
with open(SA + "/token") as fh:
token = fh.read().strip()
req = urllib.request.Request(API + path, method=method)
req.add_header("Authorization", "Bearer " + token)
data = None
if body is not None:
data = json.dumps(body).encode()
req.add_header("Content-Type", "application/merge-patch+json")
with urllib.request.urlopen(req, data=data, timeout=TIMEOUT, context=tls()) as r:
return json.load(r)
def tip(ip):
"""Accepted C-Chain head, or None if this node will not answer.
None is the honest answer for every failure mode that matters -- a
connection refused, a bootstrapping node that 404s the chain handler,
a body that is not JSON -- and all of them mean the same thing: do not
send it public traffic."""
body = json.dumps(
{"jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": []}
).encode()
req = urllib.request.Request(
"http://%s:%s%s" % (ip, RPC_PORT, RPC_PATH), data=body, method="POST"
)
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return int(json.load(r)["result"], 16)
except Exception:
return None
def reconcile():
pods = kube(
"GET",
"/api/v1/namespaces/%s/pods?labelSelector=%s" % (NS, quote(SELECTOR)),
)["items"]
tips = {}
for p in pods:
status = p.get("status", {})
ip = status.get("podIP")
tips[p["metadata"]["name"]] = (
tip(ip) if ip and status.get("phase") == "Running" else None
)
answered = [t for t in tips.values() if t is not None]
if not answered:
# Fail static. Losing sight of the fleet is not evidence that the
# fleet is unfit, and emptying the pool would take the endpoint
# down outright.
print("no node answered; labels unchanged", flush=True)
return
top = max(answered)
for p in pods:
name = p["metadata"]["name"]
t = tips[name]
want = t is not None and top - t <= MAX_LAG
has = p["metadata"].get("labels", {}).get(LABEL_KEY) == LABEL_VALUE
if want != has:
kube(
"PATCH",
"/api/v1/namespaces/%s/pods/%s" % (NS, name),
{"metadata": {"labels": {LABEL_KEY: LABEL_VALUE if want else None}}},
)
print(
"%s tip=%s lag=%s serving=%s%s"
% (
name,
t,
"-" if t is None else top - t,
want,
" (changed)" if want != has else "",
),
flush=True,
)
def main():
while True:
try:
reconcile()
except Exception as err:
# Never exit the loop. A crash-looping gate stops reconciling,
# and the labels it leaves behind freeze whatever it last
# decided.
print("reconcile failed: %r" % (err,), flush=True)
time.sleep(INTERVAL)
if __name__ == "__main__":
main()
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rpc-tip-gate
namespace: lux-testnet
spec:
replicas: 1
selector:
matchLabels:
app: rpc-tip-gate
template:
metadata:
labels:
app: rpc-tip-gate
spec:
serviceAccountName: rpc-tip-gate
containers:
- name: gate
image: python:3.12-alpine
command: ["python", "-u", "/app/tip_gate.py"]
env:
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: POD_SELECTOR
value: app.kubernetes.io/name=luxd,!job-name
# testnet serves the C-Chain on 9640, not 9630.
- name: RPC_PORT
value: "9640"
- name: RPC_PATH
value: /v1/chain/C/rpc
- name: LABEL_KEY
value: lux.network/rpc
- name: LABEL_VALUE
value: fresh
- name: MAX_LAG_BLOCKS
value: "1"
- name: INTERVAL_SECONDS
value: "10"
- name: PROBE_TIMEOUT_SECONDS
value: "5"
volumeMounts:
- name: code
mountPath: /app
resources:
requests: {cpu: 10m, memory: 32Mi}
limits: {cpu: 200m, memory: 128Mi}
volumes:
- name: code
configMap:
name: rpc-tip-gate-code
---
apiVersion: v1
kind: Service
metadata:
name: luxd-rpc
namespace: lux-testnet
spec:
type: ClusterIP
# The freshness label is part of the SELECTOR, so falling behind removes a pod
# from this Service without any controller acting on the Service itself.
selector:
app.kubernetes.io/instance: luxd
app.kubernetes.io/name: luxd
lux.network/rpc: fresh
ports:
- name: http
port: 9640
targetPort: 9640