mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
Settings() raised SettingsError constructing ALLOWED_ORIGINS. The field is declared list[str] and documented "comma-separated in env", but pydantic-settings decodes complex types from the environment as JSON before any validator runs, so the plain `a,b,c` that every Deployment manifest, .env and compose file writes was fatal — not a bad value, an unconstructable Settings. That made every route depending on it 500: require_admin builds an IAMClient, which calls get_settings(). The lux.cloud fleet overview returned HTTP 500 the moment the admin gate shipped, and the failure was in the gate, nowhere near the collector it guarded. NoDecode hands the raw string to one validator that splits it, for both list settings. One variable, one format, decided in one place. Co-authored-by: Hanzo Dev <[email protected]>
367 lines
12 KiB
Python
367 lines
12 KiB
Python
"""Tests for chain.yaml config parsing and CRD generation."""
|
|
|
|
import pytest
|
|
|
|
from bootnode.core.chains.chain_config import ChainConfig, load_chain_config
|
|
from bootnode.api.launch import generate_manifests, register_chain_networks
|
|
from bootnode.core.chains.fleet_models import CHAIN_NETWORKS
|
|
|
|
|
|
VALID_CHAIN_YAML = """
|
|
version: "1"
|
|
chain:
|
|
name: "Test Chain"
|
|
slug: test
|
|
type: l1
|
|
vm: evm
|
|
networks:
|
|
devnet:
|
|
networkId: 3
|
|
chainId: 99999
|
|
validators: 3
|
|
token:
|
|
name: "Test Token"
|
|
symbol: "TST"
|
|
decimals: 18
|
|
brand:
|
|
displayName: "Test Chain"
|
|
domains:
|
|
explorer: "explorer.test.network"
|
|
rpc: "api.test.network"
|
|
services:
|
|
node:
|
|
enabled: true
|
|
indexer:
|
|
enabled: true
|
|
explorer:
|
|
enabled: true
|
|
gateway:
|
|
enabled: true
|
|
exchange:
|
|
enabled: false
|
|
wallet:
|
|
enabled: false
|
|
faucet:
|
|
enabled: true
|
|
dripAmount: "1000000000000000000"
|
|
rateLimit: "1/hour"
|
|
deploy:
|
|
platform: hanzo
|
|
namespace: "test-{network}"
|
|
ingressClass: hanzo
|
|
"""
|
|
|
|
PARS_CHAIN_YAML = """
|
|
version: "1"
|
|
chain:
|
|
name: "Pars Network"
|
|
slug: pars
|
|
type: l1
|
|
vm: pars
|
|
vmPlugins:
|
|
- name: sessionvm
|
|
vmId: "speKUgLBX6WRD5cfGeEfLa43LxTXUBckvtv4td6F3eTXvRP48"
|
|
dbType: zapdb
|
|
compression: zstd
|
|
networks:
|
|
mainnet:
|
|
networkId: 494949
|
|
chainId: 494949
|
|
validators: 5
|
|
rpcUrl: "https://api.pars.network/v1/bc/C/rpc"
|
|
snapshotInterval: 3600
|
|
testnet:
|
|
networkId: 494950
|
|
chainId: 494950
|
|
validators: 3
|
|
sybilProtection: false
|
|
snapshotInterval: 1800
|
|
devnet:
|
|
networkId: 494951
|
|
chainId: 494951
|
|
validators: 3
|
|
sybilProtection: false
|
|
token:
|
|
name: "Pars"
|
|
symbol: "PARS"
|
|
decimals: 18
|
|
brand:
|
|
displayName: "Pars Network"
|
|
primaryColor: "#F59E0B"
|
|
domains:
|
|
explorer: "explorer.pars.network"
|
|
rpc: "api.pars.network"
|
|
services:
|
|
node:
|
|
enabled: true
|
|
image: "ghcr.io/hanzoai/parsd"
|
|
storageSize: "100Gi"
|
|
storageClass: do-block-storage
|
|
stakingKms:
|
|
hostApi: "http://kms.lux-system.svc.cluster.local/api"
|
|
projectSlug: pars-infra
|
|
envSlug: mainnet
|
|
secretsPath: /staking
|
|
indexer:
|
|
enabled: true
|
|
traceEnabled: true
|
|
contractVerification: true
|
|
pollInterval: 2
|
|
explorer:
|
|
enabled: true
|
|
gateway:
|
|
enabled: true
|
|
rateLimitRps: 100
|
|
rateLimitBurst: 200
|
|
exchange:
|
|
enabled: true
|
|
image: "ghcr.io/luxfi/exchange:main"
|
|
brandPackage: "@parsdao/brand"
|
|
faucet:
|
|
enabled: true
|
|
dripAmount: "1000000000000000000"
|
|
rateLimit: "1/hour"
|
|
deploy:
|
|
platform: hanzo
|
|
namespace: "pars-{network}"
|
|
registry: "ghcr.io/hanzoai"
|
|
ingressClass: hanzo
|
|
precompiles:
|
|
- name: mldsaVerify
|
|
blockTimestamp: 0
|
|
- name: mlkemConfig
|
|
blockTimestamp: 0
|
|
- name: blake3Config
|
|
blockTimestamp: 0
|
|
"""
|
|
|
|
|
|
class TestLoadChainConfig:
|
|
def test_load_valid(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
assert cfg.chain.name == "Test Chain"
|
|
assert cfg.chain.slug == "test"
|
|
assert cfg.chain.type == "l1"
|
|
assert cfg.token.symbol == "TST"
|
|
|
|
def test_load_defaults(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
assert cfg.chain.sequencer == "lux"
|
|
assert cfg.chain.db_type == "zapdb"
|
|
assert cfg.chain.compression == "zstd"
|
|
assert cfg.deploy.ingress_class == "hanzo"
|
|
|
|
def test_load_pars(self):
|
|
cfg = load_chain_config(PARS_CHAIN_YAML)
|
|
assert cfg.chain.slug == "pars"
|
|
assert cfg.chain.vm == "pars"
|
|
assert len(cfg.networks) == 3
|
|
assert cfg.networks["mainnet"].chain_id == 494949
|
|
assert cfg.networks["testnet"].chain_id == 494950
|
|
assert cfg.networks["devnet"].chain_id == 494951
|
|
assert len(cfg.precompiles) == 3
|
|
assert cfg.services.node.staking_kms is not None
|
|
assert cfg.services.node.staking_kms.project_slug == "pars-infra"
|
|
|
|
def test_load_invalid_yaml(self):
|
|
with pytest.raises(ValueError, match="Invalid YAML"):
|
|
load_chain_config("not: [valid: yaml: {{")
|
|
|
|
def test_load_non_mapping(self):
|
|
with pytest.raises(ValueError, match="must be a YAML mapping"):
|
|
load_chain_config("- just\n- a\n- list\n")
|
|
|
|
def test_namespace_for(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
assert cfg.namespace_for("devnet") == "test-devnet"
|
|
assert cfg.namespace_for("mainnet") == "test-mainnet"
|
|
|
|
def test_namespace_for_pars(self):
|
|
cfg = load_chain_config(PARS_CHAIN_YAML)
|
|
assert cfg.namespace_for("mainnet") == "pars-mainnet"
|
|
assert cfg.namespace_for("testnet") == "pars-testnet"
|
|
assert cfg.namespace_for("devnet") == "pars-devnet"
|
|
|
|
|
|
class TestValidate:
|
|
def test_valid_config(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
errors = cfg.validate_config()
|
|
assert errors == []
|
|
|
|
def test_nginx_rejected(self):
|
|
yaml_str = VALID_CHAIN_YAML.replace("ingressClass: hanzo", "ingressClass: nginx")
|
|
cfg = load_chain_config(yaml_str)
|
|
errors = cfg.validate_config()
|
|
assert any("nginx" in e for e in errors)
|
|
|
|
def test_caddy_rejected(self):
|
|
yaml_str = VALID_CHAIN_YAML.replace("ingressClass: hanzo", "ingressClass: caddy")
|
|
cfg = load_chain_config(yaml_str)
|
|
errors = cfg.validate_config()
|
|
assert any("caddy" in e for e in errors)
|
|
|
|
def test_bad_chain_type(self):
|
|
yaml_str = VALID_CHAIN_YAML.replace("type: l1", "type: l4")
|
|
with pytest.raises(Exception):
|
|
load_chain_config(yaml_str)
|
|
|
|
|
|
class TestGenerateManifests:
|
|
def test_generate_devnet(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "devnet")
|
|
assert result.network == "devnet"
|
|
assert result.namespace == "test-devnet"
|
|
# namespace + node + indexer + explorer + gateway + faucet = 6
|
|
assert len(result.manifests) == 6
|
|
|
|
def test_generate_kinds(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "devnet")
|
|
kinds = {m.kind for m in result.manifests}
|
|
assert "Namespace" in kinds
|
|
assert "LuxNetwork" in kinds
|
|
assert "LuxIndexer" in kinds
|
|
assert "Deployment" in kinds # explorer is now a Deployment, not a CRD
|
|
assert "LuxGateway" in kinds
|
|
|
|
def test_luxnetwork_content(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "devnet")
|
|
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
|
|
assert "kind: LuxNetwork" in net_manifest.yaml_content
|
|
assert "networkId: 3" in net_manifest.yaml_content
|
|
assert "validators: 3" in net_manifest.yaml_content
|
|
|
|
def test_luxindexer_content(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "devnet")
|
|
idx_manifest = next(m for m in result.manifests if m.kind == "LuxIndexer")
|
|
assert "kind: LuxIndexer" in idx_manifest.yaml_content
|
|
assert "chainId: 99999" in idx_manifest.yaml_content
|
|
|
|
def test_explorer_content(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "devnet")
|
|
exp_manifest = next(m for m in result.manifests if m.name == "explorer.yaml")
|
|
assert "ghcr.io/luxfi/explorer:latest" in exp_manifest.yaml_content
|
|
assert "explorer.test.network" in exp_manifest.yaml_content
|
|
assert "ingressClassName: hanzo" in exp_manifest.yaml_content
|
|
assert "containerPort: 8090" in exp_manifest.yaml_content
|
|
assert 'COIN_SYMBOL' in exp_manifest.yaml_content
|
|
assert 'CHAIN_NAME' in exp_manifest.yaml_content
|
|
assert 'RPC_ENDPOINT' in exp_manifest.yaml_content
|
|
|
|
def test_faucet_on_devnet(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "devnet")
|
|
faucet = [m for m in result.manifests if m.name == "faucet.yaml"]
|
|
assert len(faucet) == 1
|
|
assert "DRIP_AMOUNT" in faucet[0].yaml_content
|
|
|
|
def test_no_faucet_on_mainnet(self):
|
|
cfg = load_chain_config(PARS_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "mainnet")
|
|
faucet = [m for m in result.manifests if m.name == "faucet.yaml"]
|
|
assert len(faucet) == 0
|
|
|
|
def test_exchange_disabled(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "devnet")
|
|
exchange = [m for m in result.manifests if m.name == "exchange.yaml"]
|
|
assert len(exchange) == 0
|
|
|
|
def test_exchange_enabled(self):
|
|
cfg = load_chain_config(PARS_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "devnet")
|
|
exchange = [m for m in result.manifests if m.name == "exchange.yaml"]
|
|
assert len(exchange) == 1
|
|
assert "@parsdao/brand" in exchange[0].yaml_content
|
|
|
|
def test_precompiles(self):
|
|
cfg = load_chain_config(PARS_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "devnet")
|
|
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
|
|
assert "mldsaVerify" in net_manifest.yaml_content
|
|
assert "mlkemConfig" in net_manifest.yaml_content
|
|
assert "blake3Config" in net_manifest.yaml_content
|
|
|
|
def test_kms_staking(self):
|
|
cfg = load_chain_config(PARS_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "mainnet")
|
|
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
|
|
assert "pars-infra" in net_manifest.yaml_content
|
|
assert "kms:" in net_manifest.yaml_content
|
|
|
|
def test_bad_network(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
with pytest.raises(ValueError, match="not defined"):
|
|
generate_manifests(cfg, "nonexistent")
|
|
|
|
def test_snapshot_schedule(self):
|
|
cfg = load_chain_config(PARS_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "mainnet")
|
|
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
|
|
assert "snapshotSchedule:" in net_manifest.yaml_content
|
|
assert "intervalSeconds: 3600" in net_manifest.yaml_content
|
|
|
|
def test_sybil_protection_disabled(self):
|
|
cfg = load_chain_config(PARS_CHAIN_YAML)
|
|
result = generate_manifests(cfg, "devnet")
|
|
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
|
|
assert "sybilProtectionEnabled: false" in net_manifest.yaml_content
|
|
|
|
|
|
class TestRegisterChainNetworks:
|
|
def test_register(self):
|
|
cfg = load_chain_config(VALID_CHAIN_YAML)
|
|
register_chain_networks(cfg)
|
|
assert "test" in CHAIN_NETWORKS
|
|
assert "devnet" in CHAIN_NETWORKS["test"]
|
|
assert CHAIN_NETWORKS["test"]["devnet"].chain_id == 99999
|
|
# Cleanup
|
|
del CHAIN_NETWORKS["test"]
|
|
|
|
def test_register_pars(self):
|
|
cfg = load_chain_config(PARS_CHAIN_YAML)
|
|
# Save original pars config
|
|
original = CHAIN_NETWORKS.get("pars", {}).copy()
|
|
register_chain_networks(cfg)
|
|
assert CHAIN_NETWORKS["pars"]["mainnet"].chain_id == 494949
|
|
assert CHAIN_NETWORKS["pars"]["testnet"].chain_id == 494950
|
|
assert CHAIN_NETWORKS["pars"]["devnet"].chain_id == 494951
|
|
# Restore
|
|
CHAIN_NETWORKS["pars"] = original
|
|
|
|
|
|
class TestListSettingsFromEnv:
|
|
"""ALLOWED_ORIGINS is written comma-separated everywhere (Deployment env,
|
|
.env, compose). pydantic-settings decodes list[str] from env as JSON, so
|
|
that value made Settings() raise SettingsError — which surfaced as HTTP 500
|
|
on every admin-gated route, including the lux.cloud fleet overview."""
|
|
|
|
def test_comma_separated_origins_parse(self, monkeypatch):
|
|
from bootnode.config import Settings
|
|
|
|
monkeypatch.setenv(
|
|
"ALLOWED_ORIGINS",
|
|
"https://lux.cloud,https://www.lux.cloud,https://cloud.lux.network",
|
|
)
|
|
s = Settings()
|
|
assert s.allowed_origins == [
|
|
"https://lux.cloud", "https://www.lux.cloud", "https://cloud.lux.network",
|
|
]
|
|
|
|
def test_comma_separated_orgs_parse(self, monkeypatch):
|
|
from bootnode.config import Settings
|
|
|
|
monkeypatch.setenv("ALLOWED_ORGS", "lux, zoo ,hanzo")
|
|
assert Settings().allowed_orgs == ["lux", "zoo", "hanzo"]
|
|
|
|
def test_unset_keeps_default(self, monkeypatch):
|
|
from bootnode.config import Settings
|
|
|
|
monkeypatch.delenv("ALLOWED_ORIGINS", raising=False)
|
|
assert Settings().allowed_origins == []
|