The two mains shared no history, so this is a per-file three-way merge:
for each file the 65 forge-only commits touched (by patch-id), the base is
the newest version both histories held. 105 files merged or took one side
cleanly, 48 were already identical, and the 45 true conflicts and 31
modify/delete pairs were resolved by what runs on the cluster:
- The forge wins where it is live: explorer 1.5.56 (digest 45db11ff) and
its archive readers, explore-fe-hanzo's ConfigMap env (AI coin,
/v1/indexer/hanzo, /v1/chain/hanzo/rpc), exchange-api reading
luxd-archive, and explore-fe-{lux,zoo}-testnet, pinned to what they run
(v2.8.5-lux / v2.8.4-lux).
- GitHub wins where it is live: explore-fe-lux v2.8.5-lux, explore-fe-zoo
v2.8.4-lux, bankd v1.0.18, lux-build's routes.
- The forge's retirement of workloads that run nowhere stands: lux-mainnet
dex, lux-hb2, lux-market, traffic, wallet-web; the lux-testnet frontends,
indexer, exchange and gateways; lux-operator. deploy/lux-testnet/luxd.yaml
stays, since roll_luxd.sh reads it.
- GitHub's deletions stand: lux-finance and lux-financial (static sites
now), the k8s/ operator manifests, .hanzo/workflows/cicd.yml.
- Paths are /v1/chain/C/rpc where the forge had written /v1/chain/c. All
four spellings answer; this one is the documented form. The forge's
/{network}/v1 doc lines are dropped: a network is a host, and the prefix
404s.
Dry-run against the running objects (server-side, as CD applies), the
merged explorer shows no change where GitHub's showed 152 lines, and no
other live workload moves further from what runs. Every deploy/*/*.yaml
renders with charts/app; price.yaml, which failed on GitHub, now renders.
Claude-Session: https://claude.ai/code/session_01TzirYkGmD88w4GipqivUtV
280 lines
9.7 KiB
YAML
280 lines
9.7 KiB
YAML
# Generated from the live lux-mainnet/rpc-tip-gate Deployment on lux-k8s
|
|
# by hack/live2values.py. Live is the source: a repo of hand-applied
|
|
# manifests has unknown currency, the running fleet does not.
|
|
replicas: 1
|
|
selectorLabels:
|
|
app.kubernetes.io/name: rpc-tip-gate
|
|
containerName: gate
|
|
image:
|
|
repository: python
|
|
tag: 3.12-alpine
|
|
digest: sha256:1887c114801a8c82a4ec01daa52cfe7fc3f63573640e2247320289807ac1c3bb
|
|
pullPolicy: IfNotPresent
|
|
partOf: lux
|
|
component: rpc-gate
|
|
command:
|
|
- python
|
|
- -u
|
|
- /app/tip_gate.py
|
|
env:
|
|
- name: NAMESPACE
|
|
valueFrom:
|
|
fieldRef:
|
|
apiVersion: v1
|
|
fieldPath: metadata.namespace
|
|
- name: POD_SELECTOR
|
|
# `!job-name` excludes the export CronJob's pods, which the chart gives the
|
|
# same app.kubernetes.io/name=luxd label. They are batch pods with no RPC
|
|
# listener, so the gate probed each one every 10s, got nothing, and wrote
|
|
# `tip=None lag=- serving=False` — 20,321 lines a day about pods that will
|
|
# never serve. Harmless to the fleet (they were correctly never labelled) and
|
|
# corrosive to the log: a real node going stale prints one line into that.
|
|
value: app.kubernetes.io/name=luxd,!job-name
|
|
- name: RPC_PORT
|
|
value: '9630'
|
|
- 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'
|
|
resources:
|
|
limits:
|
|
cpu: 100m
|
|
memory: 128Mi
|
|
requests:
|
|
cpu: 10m
|
|
memory: 32Mi
|
|
volumeMounts:
|
|
- mountPath: /app
|
|
name: code
|
|
readOnly: true
|
|
securityContext:
|
|
allowPrivilegeEscalation: false
|
|
capabilities:
|
|
drop:
|
|
- ALL
|
|
readOnlyRootFilesystem: true
|
|
volumes:
|
|
- configMap:
|
|
name: rpc-tip-gate-code
|
|
name: code
|
|
# The chart OWNS this identity, it does not borrow one.
|
|
#
|
|
# `serviceAccountName` alone points the pod at a ServiceAccount the chart never
|
|
# creates. The one it named lived in k8s/lux-mainnet/rpc-tip-gate.yaml, which CD
|
|
# does not apply — it reads deploy/*/*.yaml — so the account did not exist and
|
|
# every pod creation was refused outright:
|
|
#
|
|
# pods "rpc-tip-gate-..." is forbidden: error looking up service account
|
|
# lux-mainnet/rpc-tip-gate: serviceaccount "rpc-tip-gate" not found
|
|
#
|
|
# The Deployment reported ReplicaFailure and no pod was ever made, so luxd was
|
|
# up and the RPC in front of it was not.
|
|
#
|
|
# The rules are the ones the raw Role granted and no more: read the fleet, write
|
|
# one label. Namespace-scoped, no secrets, no cluster scope, no delete.
|
|
rbac:
|
|
create: true
|
|
rules:
|
|
- apiGroups: [""]
|
|
resources: ["pods"]
|
|
verbs: ["get", "list", "patch"]
|
|
strategy: Recreate
|
|
topologySpread:
|
|
enabled: false
|
|
lifecycle: null
|
|
terminationGracePeriodSeconds: 5
|
|
defaultReadinessProbe: false
|
|
imagePullSecrets: []
|
|
podSecurityContext:
|
|
runAsUser: 65532
|
|
runAsGroup: 65532
|
|
runAsNonRoot: true
|
|
seccompProfile:
|
|
type: RuntimeDefault
|
|
|
|
# The script this gate runs, owned by the same chart release as the workload.
|
|
#
|
|
# It used to exist ONLY in the cluster — `kubectl get cm rpc-tip-gate-code`
|
|
# returned 134 lines present in no file in this repo, with no last-applied
|
|
# annotation. The Deployment is chart-native and the file it cannot start
|
|
# without was not, which is the exact split configmap.yaml's own header calls
|
|
# "precisely the split this chart exists to remove".
|
|
#
|
|
# podAnnotations carries the script's sha256 because a ConfigMap change does
|
|
# NOT restart the pods that mount it, and tip_gate.py reads its source once at
|
|
# boot. The annotation moves with the content, so the pod template changes and
|
|
# the workload rolls. rpc-tip-gate-test.py ASSERTS the two agree, so they
|
|
# cannot drift apart silently.
|
|
podAnnotations:
|
|
lux.network/tip-gate-script-sha256: "359b379b35b2f0fe"
|
|
configMaps:
|
|
- name: rpc-tip-gate-code
|
|
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()
|
|
|
|
# Enforcement switch, read by the lux-fleet ApplicationSet's templatePatch —
|
|
# not by the chart. On while this Application already read Synced, so it
|
|
# applies nothing today; what it adds is selfHeal, which returns a
|
|
# hand-edited object to what is written here. prune stays off.
|
|
cd:
|
|
automated: false
|
|
|
|
# The identity the gate runs as, lifted out of k8s/lux-mainnet/rpc-tip-gate.yaml.
|
|
# serviceAccountName above names it and nothing here created it, so the
|
|
# ServiceAccount, the Role and the binding lived only in a manifest CD does not
|
|
# read — a pod declared to run as an identity that this file cannot produce.
|
|
#
|
|
# Namespace-scoped, and only pods: the gate reads pod status and patches one
|
|
# label (lux.network/rpc: fresh) onto the luxd pods whose tip is current. The
|
|
# Service that selects on that label is luxd's, not this workload's, and stays
|
|
# a manifest — it carries a selector key beyond the chart's own, which is the
|
|
# whole mechanism.
|
|
rbac:
|
|
create: true
|
|
rules:
|
|
- apiGroups: ['']
|
|
resources: [pods]
|
|
verbs: [get, list, patch]
|