mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
Full-stack fleet lifecycle management for Lux blockchain validators deployed via Helm to DOKS clusters. Backend (Python/FastAPI): - HelmDeployer: async helm/kubectl CLI wrapper with temp file cleanup, per-release locking, and command injection protection - LuxFleetManager: fleet CRUD, scale, rolling restart, node health probes via luxd RPC (health + C-chain block height) - Fleet API: authenticated REST endpoints (ApiKeyDep) with RFC 1123 input validation, kubeconfig error sanitization - Pydantic models: 3 networks (mainnet/testnet/devnet), fleet status enums, helm values translation - 110 unit tests (0.30s, all passing) Frontend (TypeScript/Next.js): - Fleet dashboard: cluster selector, stats banner, fleet cards with scale/restart/destroy operations, node status table, create dialog - Interactive SVG world map: zero-dep Mercator projection with DOKS region markers, pulsing animations, P2P mesh lines, click-to-zoom - DOKS region data: 11 regions with coordinates and API client - Dark theme throughout
907 lines
30 KiB
Python
907 lines
30 KiB
Python
"""Comprehensive tests for Lux fleet management.
|
|
|
|
Covers:
|
|
- lux_models.py: Pydantic models, validators, enums, helm value translation
|
|
- helm.py: HelmDeployer internals (mocked subprocess)
|
|
- lux.py: LuxFleetManager helper methods (mocked HelmDeployer)
|
|
"""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from bootnode.core.chains.lux_models import (
|
|
APIConfig,
|
|
BootstrapConfig,
|
|
ChainTrackingConfig,
|
|
ConsensusConfig,
|
|
ImageConfig,
|
|
LuxFleetCreate,
|
|
LuxFleetStatus,
|
|
LuxFleetUpdate,
|
|
LuxNetwork,
|
|
LuxNetworkConfig,
|
|
LuxNodeStatus,
|
|
NETWORK_CONFIGS,
|
|
NodeServicesConfig,
|
|
RLPImportConfig,
|
|
ResourceConfig,
|
|
ResourceSpec,
|
|
StorageConfig,
|
|
fleet_create_to_helm_values,
|
|
fleet_update_to_helm_values,
|
|
)
|
|
from bootnode.core.deploy.helm import (
|
|
HelmDeployer,
|
|
HelmError,
|
|
HelmRelease,
|
|
HelmReleaseStatus,
|
|
PodInfo,
|
|
)
|
|
from bootnode.core.chains.lux import (
|
|
LuxFleetManager,
|
|
NETWORK_VALUES_FILES,
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# 1. lux_models.py -- pure unit tests
|
|
# =========================================================================
|
|
|
|
class TestLuxNetworkEnum:
|
|
"""LuxNetwork enum values."""
|
|
|
|
def test_values(self):
|
|
assert LuxNetwork.MAINNET.value == "mainnet"
|
|
assert LuxNetwork.TESTNET.value == "testnet"
|
|
assert LuxNetwork.DEVNET.value == "devnet"
|
|
|
|
def test_from_string(self):
|
|
assert LuxNetwork("mainnet") == LuxNetwork.MAINNET
|
|
assert LuxNetwork("testnet") == LuxNetwork.TESTNET
|
|
assert LuxNetwork("devnet") == LuxNetwork.DEVNET
|
|
|
|
def test_member_count(self):
|
|
assert len(LuxNetwork) == 3
|
|
|
|
|
|
class TestLuxFleetStatusEnum:
|
|
"""LuxFleetStatus enum values."""
|
|
|
|
def test_all_values(self):
|
|
expected = {
|
|
"pending", "deploying", "running", "degraded",
|
|
"updating", "error", "destroying", "destroyed",
|
|
}
|
|
actual = {s.value for s in LuxFleetStatus}
|
|
assert actual == expected
|
|
|
|
def test_member_count(self):
|
|
assert len(LuxFleetStatus) == 8
|
|
|
|
|
|
class TestLuxNodeStatusEnum:
|
|
"""LuxNodeStatus enum values."""
|
|
|
|
def test_all_values(self):
|
|
expected = {
|
|
"pending", "init", "starting", "bootstrapping",
|
|
"healthy", "unhealthy", "terminated",
|
|
}
|
|
actual = {s.value for s in LuxNodeStatus}
|
|
assert actual == expected
|
|
|
|
def test_member_count(self):
|
|
assert len(LuxNodeStatus) == 7
|
|
|
|
|
|
class TestNetworkConfigs:
|
|
"""NETWORK_CONFIGS canonical reference data."""
|
|
|
|
def test_all_networks_present(self):
|
|
assert set(NETWORK_CONFIGS.keys()) == {
|
|
LuxNetwork.MAINNET,
|
|
LuxNetwork.TESTNET,
|
|
LuxNetwork.DEVNET,
|
|
}
|
|
|
|
def test_mainnet_config(self):
|
|
cfg = NETWORK_CONFIGS[LuxNetwork.MAINNET]
|
|
assert cfg.network_id == 1
|
|
assert cfg.chain_id == 96369
|
|
assert cfg.http_port == 9630
|
|
assert cfg.staking_port == 9631
|
|
assert cfg.namespace == "lux-mainnet"
|
|
|
|
def test_testnet_config(self):
|
|
cfg = NETWORK_CONFIGS[LuxNetwork.TESTNET]
|
|
assert cfg.network_id == 2
|
|
assert cfg.chain_id == 96368
|
|
assert cfg.http_port == 9640
|
|
assert cfg.staking_port == 9641
|
|
assert cfg.namespace == "lux-testnet"
|
|
|
|
def test_devnet_config(self):
|
|
cfg = NETWORK_CONFIGS[LuxNetwork.DEVNET]
|
|
assert cfg.network_id == 3
|
|
assert cfg.chain_id == 96370
|
|
assert cfg.http_port == 9650
|
|
assert cfg.staking_port == 9651
|
|
assert cfg.namespace == "lux-devnet"
|
|
|
|
def test_unique_network_ids(self):
|
|
ids = [c.network_id for c in NETWORK_CONFIGS.values()]
|
|
assert len(ids) == len(set(ids))
|
|
|
|
def test_unique_chain_ids(self):
|
|
ids = [c.chain_id for c in NETWORK_CONFIGS.values()]
|
|
assert len(ids) == len(set(ids))
|
|
|
|
def test_unique_namespaces(self):
|
|
ns = [c.namespace for c in NETWORK_CONFIGS.values()]
|
|
assert len(ns) == len(set(ns))
|
|
|
|
|
|
class TestBootstrapConfig:
|
|
"""BootstrapConfig validator tests."""
|
|
|
|
def test_valid_node_ids(self):
|
|
cfg = BootstrapConfig(node_ids=["NodeID-abc123", "NodeID-xyz789"])
|
|
assert len(cfg.node_ids) == 2
|
|
|
|
def test_empty_node_ids(self):
|
|
cfg = BootstrapConfig(node_ids=[])
|
|
assert cfg.node_ids == []
|
|
|
|
def test_invalid_node_id_format(self):
|
|
with pytest.raises(ValueError, match="Invalid node ID format"):
|
|
BootstrapConfig(node_ids=["bad-id"])
|
|
|
|
def test_invalid_node_id_mixed(self):
|
|
"""One valid + one invalid should fail."""
|
|
with pytest.raises(ValueError, match="Invalid node ID format"):
|
|
BootstrapConfig(node_ids=["NodeID-good", "missing-prefix"])
|
|
|
|
def test_defaults(self):
|
|
cfg = BootstrapConfig()
|
|
assert cfg.node_ids == []
|
|
assert cfg.use_hostnames is True
|
|
assert cfg.external_ips == []
|
|
assert cfg.rlp_import.enabled is False
|
|
|
|
|
|
class TestLuxFleetCreate:
|
|
"""LuxFleetCreate validation tests."""
|
|
|
|
def _valid_params(self, **overrides):
|
|
defaults = {"name": "my-fleet", "cluster_id": "cluster-abc"}
|
|
defaults.update(overrides)
|
|
return defaults
|
|
|
|
def test_valid_minimal(self):
|
|
req = LuxFleetCreate(**self._valid_params())
|
|
assert req.name == "my-fleet"
|
|
assert req.cluster_id == "cluster-abc"
|
|
assert req.network == LuxNetwork.DEVNET
|
|
assert req.replicas == 5
|
|
|
|
def test_valid_name_lowercase_with_hyphens(self):
|
|
req = LuxFleetCreate(**self._valid_params(name="fleet-01"))
|
|
assert req.name == "fleet-01"
|
|
|
|
def test_valid_name_single_char(self):
|
|
req = LuxFleetCreate(**self._valid_params(name="a"))
|
|
assert req.name == "a"
|
|
|
|
def test_invalid_name_uppercase(self):
|
|
with pytest.raises(ValueError):
|
|
LuxFleetCreate(**self._valid_params(name="MyFleet"))
|
|
|
|
def test_invalid_name_starts_with_digit(self):
|
|
with pytest.raises(ValueError):
|
|
LuxFleetCreate(**self._valid_params(name="1fleet"))
|
|
|
|
def test_invalid_name_starts_with_hyphen(self):
|
|
with pytest.raises(ValueError):
|
|
LuxFleetCreate(**self._valid_params(name="-fleet"))
|
|
|
|
def test_invalid_name_underscore(self):
|
|
with pytest.raises(ValueError):
|
|
LuxFleetCreate(**self._valid_params(name="my_fleet"))
|
|
|
|
def test_invalid_name_empty(self):
|
|
with pytest.raises(ValueError):
|
|
LuxFleetCreate(**self._valid_params(name=""))
|
|
|
|
def test_invalid_name_too_long(self):
|
|
with pytest.raises(ValueError):
|
|
LuxFleetCreate(**self._valid_params(name="a" * 64))
|
|
|
|
def test_name_max_length_63(self):
|
|
req = LuxFleetCreate(**self._valid_params(name="a" * 63))
|
|
assert len(req.name) == 63
|
|
|
|
def test_replicas_default(self):
|
|
req = LuxFleetCreate(**self._valid_params())
|
|
assert req.replicas == 5
|
|
|
|
def test_replicas_min_1(self):
|
|
req = LuxFleetCreate(**self._valid_params(replicas=1))
|
|
assert req.replicas == 1
|
|
|
|
def test_replicas_max_20(self):
|
|
req = LuxFleetCreate(**self._valid_params(replicas=20))
|
|
assert req.replicas == 20
|
|
|
|
def test_replicas_below_min(self):
|
|
with pytest.raises(ValueError):
|
|
LuxFleetCreate(**self._valid_params(replicas=0))
|
|
|
|
def test_replicas_above_max(self):
|
|
with pytest.raises(ValueError):
|
|
LuxFleetCreate(**self._valid_params(replicas=21))
|
|
|
|
def test_all_networks_accepted(self):
|
|
for net in LuxNetwork:
|
|
req = LuxFleetCreate(**self._valid_params(network=net))
|
|
assert req.network == net
|
|
|
|
def test_optional_overrides_default_none(self):
|
|
req = LuxFleetCreate(**self._valid_params())
|
|
assert req.image is None
|
|
assert req.bootstrap is None
|
|
assert req.consensus is None
|
|
assert req.chain_tracking is None
|
|
assert req.resources is None
|
|
assert req.storage is None
|
|
assert req.node_services is None
|
|
assert req.api is None
|
|
|
|
def test_log_level_default(self):
|
|
req = LuxFleetCreate(**self._valid_params())
|
|
assert req.log_level == "info"
|
|
|
|
def test_db_type_default(self):
|
|
req = LuxFleetCreate(**self._valid_params())
|
|
assert req.db_type == "badgerdb"
|
|
|
|
|
|
class TestLuxFleetUpdate:
|
|
"""LuxFleetUpdate partial update tests."""
|
|
|
|
def test_all_none_by_default(self):
|
|
req = LuxFleetUpdate()
|
|
assert req.replicas is None
|
|
assert req.image is None
|
|
assert req.consensus is None
|
|
assert req.chain_tracking is None
|
|
assert req.resources is None
|
|
assert req.log_level is None
|
|
|
|
def test_replicas_bounds(self):
|
|
req = LuxFleetUpdate(replicas=10)
|
|
assert req.replicas == 10
|
|
|
|
def test_replicas_min(self):
|
|
req = LuxFleetUpdate(replicas=1)
|
|
assert req.replicas == 1
|
|
|
|
def test_replicas_max(self):
|
|
req = LuxFleetUpdate(replicas=20)
|
|
assert req.replicas == 20
|
|
|
|
def test_replicas_below_min(self):
|
|
with pytest.raises(ValueError):
|
|
LuxFleetUpdate(replicas=0)
|
|
|
|
def test_replicas_above_max(self):
|
|
with pytest.raises(ValueError):
|
|
LuxFleetUpdate(replicas=21)
|
|
|
|
|
|
class TestFleetCreateToHelmValues:
|
|
"""fleet_create_to_helm_values() translation tests."""
|
|
|
|
def _minimal_req(self, **overrides):
|
|
defaults = {"name": "test", "cluster_id": "c1"}
|
|
defaults.update(overrides)
|
|
return LuxFleetCreate(**defaults)
|
|
|
|
def test_minimal_devnet(self):
|
|
req = self._minimal_req()
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["network"] == "devnet"
|
|
assert vals["replicas"] == 5
|
|
assert vals["logLevel"] == "info"
|
|
assert vals["dbType"] == "badgerdb"
|
|
|
|
def test_mainnet(self):
|
|
req = self._minimal_req(network=LuxNetwork.MAINNET)
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["network"] == "mainnet"
|
|
|
|
def test_testnet(self):
|
|
req = self._minimal_req(network=LuxNetwork.TESTNET)
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["network"] == "testnet"
|
|
|
|
def test_custom_replicas(self):
|
|
req = self._minimal_req(replicas=10)
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["replicas"] == 10
|
|
|
|
def test_no_image_keys_when_none(self):
|
|
req = self._minimal_req()
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert "image.repository" not in vals
|
|
assert "image.tag" not in vals
|
|
|
|
def test_image_override(self):
|
|
req = self._minimal_req(image=ImageConfig(
|
|
repository="ghcr.io/test/luxd",
|
|
tag="v2.0.0",
|
|
pull_policy="IfNotPresent",
|
|
))
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["image.repository"] == "ghcr.io/test/luxd"
|
|
assert vals["image.tag"] == "v2.0.0"
|
|
assert vals["image.pullPolicy"] == "IfNotPresent"
|
|
|
|
def test_bootstrap_with_node_ids(self):
|
|
req = self._minimal_req(bootstrap=BootstrapConfig(
|
|
node_ids=["NodeID-aaa", "NodeID-bbb"],
|
|
use_hostnames=False,
|
|
))
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["bootstrap.useHostnames"] is False
|
|
assert vals["bootstrap.nodeIDs"] == ["NodeID-aaa", "NodeID-bbb"]
|
|
|
|
def test_bootstrap_rlp_import(self):
|
|
req = self._minimal_req(bootstrap=BootstrapConfig(
|
|
rlp_import=RLPImportConfig(
|
|
enabled=True,
|
|
base_url="https://example.com",
|
|
rlp_filename="chain.rlp",
|
|
timeout=3600,
|
|
),
|
|
))
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["bootstrap.rlpImport.enabled"] is True
|
|
assert vals["bootstrap.rlpImport.baseUrl"] == "https://example.com"
|
|
assert vals["bootstrap.rlpImport.rlpFilename"] == "chain.rlp"
|
|
assert vals["bootstrap.rlpImport.timeout"] == 3600
|
|
|
|
def test_consensus_override(self):
|
|
req = self._minimal_req(consensus=ConsensusConfig(
|
|
sample_size=10,
|
|
quorum_size=8,
|
|
sybil_protection_enabled=True,
|
|
))
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["consensus.sampleSize"] == 10
|
|
assert vals["consensus.quorumSize"] == 8
|
|
assert vals["consensus.sybilProtectionEnabled"] is True
|
|
|
|
def test_chain_tracking_override(self):
|
|
req = self._minimal_req(chain_tracking=ChainTrackingConfig(
|
|
track_all_chains=False,
|
|
tracked_chains=["X", "P", "C"],
|
|
aliases=["zoo"],
|
|
))
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["chainTracking.trackAllChains"] is False
|
|
assert vals["chainTracking.trackedChains"] == ["X", "P", "C"]
|
|
assert vals["chainTracking.aliases"] == ["zoo"]
|
|
|
|
def test_resources_override(self):
|
|
req = self._minimal_req(resources=ResourceConfig(
|
|
requests=ResourceSpec(memory="2Gi", cpu="1"),
|
|
limits=ResourceSpec(memory="8Gi", cpu="4"),
|
|
))
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["resources.requests.memory"] == "2Gi"
|
|
assert vals["resources.requests.cpu"] == "1"
|
|
assert vals["resources.limits.memory"] == "8Gi"
|
|
assert vals["resources.limits.cpu"] == "4"
|
|
|
|
def test_storage_override(self):
|
|
req = self._minimal_req(storage=StorageConfig(
|
|
size="500Gi",
|
|
storage_class="premium-rwo",
|
|
))
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["storage.size"] == "500Gi"
|
|
assert vals["storage.storageClass"] == "premium-rwo"
|
|
|
|
def test_node_services_override(self):
|
|
req = self._minimal_req(node_services=NodeServicesConfig(
|
|
enabled=False,
|
|
type="ClusterIP",
|
|
))
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["nodeServices.enabled"] is False
|
|
assert vals["nodeServices.type"] == "ClusterIP"
|
|
|
|
def test_api_override(self):
|
|
req = self._minimal_req(api=APIConfig(
|
|
admin_enabled=False,
|
|
metrics_enabled=True,
|
|
index_enabled=False,
|
|
http_allowed_hosts="127.0.0.1",
|
|
))
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["api.adminEnabled"] is False
|
|
assert vals["api.metricsEnabled"] is True
|
|
assert vals["api.indexEnabled"] is False
|
|
assert vals["api.httpAllowedHosts"] == "127.0.0.1"
|
|
|
|
def test_all_three_networks_produce_correct_network_key(self):
|
|
for net in LuxNetwork:
|
|
req = self._minimal_req(network=net)
|
|
vals = fleet_create_to_helm_values(req)
|
|
assert vals["network"] == net.value
|
|
|
|
|
|
class TestFleetUpdateToHelmValues:
|
|
"""fleet_update_to_helm_values() partial translation tests."""
|
|
|
|
def test_empty_update_produces_empty_dict(self):
|
|
req = LuxFleetUpdate()
|
|
vals = fleet_update_to_helm_values(req)
|
|
assert vals == {}
|
|
|
|
def test_replicas_only(self):
|
|
req = LuxFleetUpdate(replicas=3)
|
|
vals = fleet_update_to_helm_values(req)
|
|
assert vals == {"replicas": 3}
|
|
|
|
def test_log_level_only(self):
|
|
req = LuxFleetUpdate(log_level="debug")
|
|
vals = fleet_update_to_helm_values(req)
|
|
assert vals == {"logLevel": "debug"}
|
|
|
|
def test_image_only(self):
|
|
req = LuxFleetUpdate(image=ImageConfig(
|
|
repository="test", tag="v1", pull_policy="Never",
|
|
))
|
|
vals = fleet_update_to_helm_values(req)
|
|
assert vals["image.repository"] == "test"
|
|
assert vals["image.tag"] == "v1"
|
|
assert vals["image.pullPolicy"] == "Never"
|
|
assert "replicas" not in vals
|
|
assert "logLevel" not in vals
|
|
|
|
def test_consensus_only(self):
|
|
req = LuxFleetUpdate(consensus=ConsensusConfig(
|
|
sample_size=7,
|
|
quorum_size=5,
|
|
sybil_protection_enabled=True,
|
|
))
|
|
vals = fleet_update_to_helm_values(req)
|
|
assert vals["consensus.sampleSize"] == 7
|
|
assert vals["consensus.quorumSize"] == 5
|
|
assert vals["consensus.sybilProtectionEnabled"] is True
|
|
assert "replicas" not in vals
|
|
|
|
def test_resources_only(self):
|
|
req = LuxFleetUpdate(resources=ResourceConfig())
|
|
vals = fleet_update_to_helm_values(req)
|
|
assert "resources.requests.memory" in vals
|
|
assert "resources.limits.cpu" in vals
|
|
assert "replicas" not in vals
|
|
|
|
def test_chain_tracking_only(self):
|
|
req = LuxFleetUpdate(chain_tracking=ChainTrackingConfig(
|
|
track_all_chains=True,
|
|
))
|
|
vals = fleet_update_to_helm_values(req)
|
|
assert vals["chainTracking.trackAllChains"] is True
|
|
|
|
def test_multiple_fields(self):
|
|
req = LuxFleetUpdate(replicas=8, log_level="warn")
|
|
vals = fleet_update_to_helm_values(req)
|
|
assert vals["replicas"] == 8
|
|
assert vals["logLevel"] == "warn"
|
|
assert len(vals) == 2
|
|
|
|
|
|
# =========================================================================
|
|
# 2. helm.py -- HelmDeployer tests (mock subprocess)
|
|
# =========================================================================
|
|
|
|
class TestHelmReleaseStatusEnum:
|
|
"""HelmReleaseStatus enum values."""
|
|
|
|
def test_all_values(self):
|
|
expected = {
|
|
"deployed", "failed", "pending-install", "pending-upgrade",
|
|
"pending-rollback", "superseded", "uninstalled", "uninstalling",
|
|
"unknown",
|
|
}
|
|
actual = {s.value for s in HelmReleaseStatus}
|
|
assert actual == expected
|
|
|
|
def test_member_count(self):
|
|
assert len(HelmReleaseStatus) == 9
|
|
|
|
|
|
class TestHelmRelease:
|
|
"""HelmRelease dataclass creation."""
|
|
|
|
def test_basic_creation(self):
|
|
r = HelmRelease(
|
|
name="luxd-devnet",
|
|
namespace="lux-devnet",
|
|
revision=3,
|
|
status=HelmReleaseStatus.DEPLOYED,
|
|
)
|
|
assert r.name == "luxd-devnet"
|
|
assert r.namespace == "lux-devnet"
|
|
assert r.revision == 3
|
|
assert r.status == HelmReleaseStatus.DEPLOYED
|
|
assert r.chart == ""
|
|
assert r.app_version == ""
|
|
assert r.updated == ""
|
|
|
|
def test_full_creation(self):
|
|
r = HelmRelease(
|
|
name="luxd-mainnet",
|
|
namespace="lux-mainnet",
|
|
revision=7,
|
|
status=HelmReleaseStatus.DEPLOYED,
|
|
chart="lux-0.1.0",
|
|
app_version="1.23.11",
|
|
updated="2026-02-10T00:00:00Z",
|
|
)
|
|
assert r.chart == "lux-0.1.0"
|
|
assert r.app_version == "1.23.11"
|
|
assert r.updated == "2026-02-10T00:00:00Z"
|
|
|
|
|
|
class TestHelmError:
|
|
"""HelmError exception."""
|
|
|
|
def test_basic(self):
|
|
e = HelmError("deploy failed")
|
|
assert str(e) == "deploy failed"
|
|
assert e.stderr == ""
|
|
assert e.returncode == 1
|
|
|
|
def test_with_details(self):
|
|
e = HelmError("timeout", stderr="connection refused", returncode=2)
|
|
assert e.stderr == "connection refused"
|
|
assert e.returncode == 2
|
|
|
|
|
|
class TestPodInfo:
|
|
"""PodInfo dataclass."""
|
|
|
|
def test_creation(self):
|
|
p = PodInfo(name="luxd-0", ready=True, status="Running")
|
|
assert p.name == "luxd-0"
|
|
assert p.ready is True
|
|
assert p.restarts == 0
|
|
assert p.node == ""
|
|
assert p.ip == ""
|
|
|
|
|
|
class TestHelmDeployerBaseArgs:
|
|
"""HelmDeployer._base_helm_args() tests."""
|
|
|
|
def test_no_kubeconfig(self):
|
|
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
|
args = d._base_helm_args()
|
|
assert args == ["helm"]
|
|
|
|
def test_with_kubeconfig(self):
|
|
d = HelmDeployer(
|
|
chart_path=Path("/charts/lux"),
|
|
kubeconfig_path=Path("/tmp/kube.conf"),
|
|
)
|
|
args = d._base_helm_args()
|
|
assert args == ["helm", "--kubeconfig", "/tmp/kube.conf"]
|
|
|
|
def test_with_context(self):
|
|
d = HelmDeployer(
|
|
chart_path=Path("/charts/lux"),
|
|
kube_context="my-ctx",
|
|
)
|
|
args = d._base_helm_args()
|
|
assert args == ["helm", "--kube-context", "my-ctx"]
|
|
|
|
def test_with_kubeconfig_and_context(self):
|
|
d = HelmDeployer(
|
|
chart_path=Path("/charts/lux"),
|
|
kubeconfig_path=Path("/tmp/kc"),
|
|
kube_context="ctx",
|
|
)
|
|
args = d._base_helm_args()
|
|
assert args == [
|
|
"helm",
|
|
"--kubeconfig", "/tmp/kc",
|
|
"--kube-context", "ctx",
|
|
]
|
|
|
|
def test_custom_binary(self):
|
|
d = HelmDeployer(
|
|
chart_path=Path("/charts/lux"),
|
|
helm_binary="/usr/local/bin/helm3",
|
|
)
|
|
args = d._base_helm_args()
|
|
assert args[0] == "/usr/local/bin/helm3"
|
|
|
|
|
|
class TestHelmDeployerBaseKubectlArgs:
|
|
"""HelmDeployer._base_kubectl_args() tests."""
|
|
|
|
def test_no_config(self):
|
|
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
|
args = d._base_kubectl_args()
|
|
assert args == ["kubectl"]
|
|
|
|
def test_with_kubeconfig(self):
|
|
d = HelmDeployer(
|
|
chart_path=Path("/charts/lux"),
|
|
kubeconfig_path=Path("/tmp/kc"),
|
|
)
|
|
args = d._base_kubectl_args()
|
|
assert args == ["kubectl", "--kubeconfig", "/tmp/kc"]
|
|
|
|
def test_with_context(self):
|
|
d = HelmDeployer(
|
|
chart_path=Path("/charts/lux"),
|
|
kube_context="ctx",
|
|
)
|
|
args = d._base_kubectl_args()
|
|
# kubectl uses --context not --kube-context
|
|
assert args == ["kubectl", "--context", "ctx"]
|
|
|
|
|
|
class TestHelmDeployerWriteValues:
|
|
"""HelmDeployer._write_values_file() tests."""
|
|
|
|
def test_creates_valid_yaml(self):
|
|
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
|
values = {
|
|
"network": "devnet",
|
|
"replicas": 5,
|
|
"logLevel": "info",
|
|
"nested": {"key": "value"},
|
|
}
|
|
path = d._write_values_file(values)
|
|
try:
|
|
assert path.exists()
|
|
assert path.suffix == ".yaml"
|
|
assert "helm-values-" in path.name
|
|
content = yaml.safe_load(path.read_text())
|
|
assert content["network"] == "devnet"
|
|
assert content["replicas"] == 5
|
|
assert content["logLevel"] == "info"
|
|
assert content["nested"]["key"] == "value"
|
|
finally:
|
|
path.unlink(missing_ok=True)
|
|
|
|
def test_empty_dict(self):
|
|
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
|
path = d._write_values_file({})
|
|
try:
|
|
assert path.exists()
|
|
content = yaml.safe_load(path.read_text())
|
|
# yaml.safe_load returns None for empty doc
|
|
assert content is None or content == {}
|
|
finally:
|
|
path.unlink(missing_ok=True)
|
|
|
|
def test_list_values(self):
|
|
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
|
values = {"nodeIDs": ["NodeID-a", "NodeID-b"]}
|
|
path = d._write_values_file(values)
|
|
try:
|
|
content = yaml.safe_load(path.read_text())
|
|
assert content["nodeIDs"] == ["NodeID-a", "NodeID-b"]
|
|
finally:
|
|
path.unlink(missing_ok=True)
|
|
|
|
|
|
class TestHelmDeployerLock:
|
|
"""HelmDeployer._get_lock() tests."""
|
|
|
|
def test_same_release_returns_same_lock(self):
|
|
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
|
lock1 = d._get_lock("luxd-devnet")
|
|
lock2 = d._get_lock("luxd-devnet")
|
|
assert lock1 is lock2
|
|
|
|
def test_different_releases_return_different_locks(self):
|
|
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
|
lock1 = d._get_lock("luxd-devnet")
|
|
lock2 = d._get_lock("luxd-mainnet")
|
|
assert lock1 is not lock2
|
|
|
|
|
|
# =========================================================================
|
|
# 3. lux.py -- LuxFleetManager tests (mock HelmDeployer)
|
|
# =========================================================================
|
|
|
|
class TestLuxFleetManagerReleaseName:
|
|
"""LuxFleetManager._release_name() tests."""
|
|
|
|
def test_mainnet(self):
|
|
mgr = LuxFleetManager()
|
|
assert mgr._release_name(LuxNetwork.MAINNET) == "luxd-mainnet"
|
|
|
|
def test_testnet(self):
|
|
mgr = LuxFleetManager()
|
|
assert mgr._release_name(LuxNetwork.TESTNET) == "luxd-testnet"
|
|
|
|
def test_devnet(self):
|
|
mgr = LuxFleetManager()
|
|
assert mgr._release_name(LuxNetwork.DEVNET) == "luxd-devnet"
|
|
|
|
|
|
class TestLuxFleetManagerNamespace:
|
|
"""LuxFleetManager._namespace() tests."""
|
|
|
|
def test_mainnet(self):
|
|
mgr = LuxFleetManager()
|
|
assert mgr._namespace(LuxNetwork.MAINNET) == "lux-mainnet"
|
|
|
|
def test_testnet(self):
|
|
mgr = LuxFleetManager()
|
|
assert mgr._namespace(LuxNetwork.TESTNET) == "lux-testnet"
|
|
|
|
def test_devnet(self):
|
|
mgr = LuxFleetManager()
|
|
assert mgr._namespace(LuxNetwork.DEVNET) == "lux-devnet"
|
|
|
|
|
|
class TestLuxFleetManagerFleetId:
|
|
"""LuxFleetManager._fleet_id() tests."""
|
|
|
|
def test_format(self):
|
|
mgr = LuxFleetManager()
|
|
assert mgr._fleet_id("my-fleet", LuxNetwork.DEVNET) == "my-fleet-devnet"
|
|
assert mgr._fleet_id("prod", LuxNetwork.MAINNET) == "prod-mainnet"
|
|
|
|
|
|
class TestLuxFleetManagerGetValuesFiles:
|
|
"""LuxFleetManager._get_values_files() tests."""
|
|
|
|
def test_mainnet_returns_file_if_exists(self, tmp_path):
|
|
chart_dir = tmp_path / "charts" / "lux"
|
|
chart_dir.mkdir(parents=True)
|
|
values_file = chart_dir / "values-mainnet.yaml"
|
|
values_file.write_text("network: mainnet\n")
|
|
|
|
mgr = LuxFleetManager(chart_path=chart_dir)
|
|
files = mgr._get_values_files(LuxNetwork.MAINNET)
|
|
assert len(files) == 1
|
|
assert files[0] == values_file
|
|
|
|
def test_testnet_returns_file_if_exists(self, tmp_path):
|
|
chart_dir = tmp_path / "charts" / "lux"
|
|
chart_dir.mkdir(parents=True)
|
|
values_file = chart_dir / "values-testnet.yaml"
|
|
values_file.write_text("network: testnet\n")
|
|
|
|
mgr = LuxFleetManager(chart_path=chart_dir)
|
|
files = mgr._get_values_files(LuxNetwork.TESTNET)
|
|
assert len(files) == 1
|
|
assert files[0] == values_file
|
|
|
|
def test_devnet_returns_empty_list(self, tmp_path):
|
|
"""Devnet has no network-specific values file in NETWORK_VALUES_FILES."""
|
|
chart_dir = tmp_path / "charts" / "lux"
|
|
chart_dir.mkdir(parents=True)
|
|
|
|
mgr = LuxFleetManager(chart_path=chart_dir)
|
|
files = mgr._get_values_files(LuxNetwork.DEVNET)
|
|
assert files == []
|
|
|
|
def test_mainnet_returns_empty_if_file_missing(self, tmp_path):
|
|
chart_dir = tmp_path / "charts" / "lux"
|
|
chart_dir.mkdir(parents=True)
|
|
# Do not create the values-mainnet.yaml file
|
|
|
|
mgr = LuxFleetManager(chart_path=chart_dir)
|
|
files = mgr._get_values_files(LuxNetwork.MAINNET)
|
|
assert files == []
|
|
|
|
|
|
class TestNetworkValuesFiles:
|
|
"""NETWORK_VALUES_FILES constant."""
|
|
|
|
def test_mainnet_has_values_file(self):
|
|
assert "mainnet" in NETWORK_VALUES_FILES
|
|
assert NETWORK_VALUES_FILES["mainnet"] == "values-mainnet.yaml"
|
|
|
|
def test_testnet_has_values_file(self):
|
|
assert "testnet" in NETWORK_VALUES_FILES
|
|
assert NETWORK_VALUES_FILES["testnet"] == "values-testnet.yaml"
|
|
|
|
def test_devnet_has_no_values_file(self):
|
|
assert "devnet" not in NETWORK_VALUES_FILES
|
|
|
|
|
|
class TestLuxFleetManagerGetDeployer:
|
|
"""LuxFleetManager._get_deployer() creates HelmDeployer from kubeconfig."""
|
|
|
|
async def test_creates_deployer_with_kubeconfig(self):
|
|
mgr = LuxFleetManager(chart_path=Path("/opt/charts/lux"))
|
|
kubeconfig_yaml = "apiVersion: v1\nkind: Config\nclusters: []\n"
|
|
deployer = await mgr._get_deployer(kubeconfig_yaml)
|
|
assert isinstance(deployer, HelmDeployer)
|
|
assert deployer.chart_path == Path("/opt/charts/lux")
|
|
assert deployer.kubeconfig_path is not None
|
|
assert deployer.kubeconfig_path.exists()
|
|
content = deployer.kubeconfig_path.read_text()
|
|
assert "apiVersion: v1" in content
|
|
# Cleanup
|
|
deployer.kubeconfig_path.unlink(missing_ok=True)
|
|
|
|
|
|
# =========================================================================
|
|
# Sub-model default value tests
|
|
# =========================================================================
|
|
|
|
class TestSubModelDefaults:
|
|
"""Verify defaults of sub-models match chart expectations."""
|
|
|
|
def test_rlp_import_defaults(self):
|
|
r = RLPImportConfig()
|
|
assert r.enabled is False
|
|
assert r.base_url == ""
|
|
assert r.rlp_filename == ""
|
|
assert r.multi_part is False
|
|
assert r.parts == []
|
|
assert r.min_height == 1
|
|
assert r.timeout == 7200
|
|
|
|
def test_consensus_defaults(self):
|
|
c = ConsensusConfig()
|
|
assert c.sample_size == 5
|
|
assert c.quorum_size == 4
|
|
assert c.sybil_protection_enabled is False
|
|
assert c.require_validator_to_connect is False
|
|
assert c.allow_private_ips is True
|
|
|
|
def test_chain_tracking_defaults(self):
|
|
ct = ChainTrackingConfig()
|
|
assert ct.track_all_chains is True
|
|
assert ct.tracked_chains == []
|
|
assert ct.aliases == ["zoo", "hanzo", "spc", "pars"]
|
|
|
|
def test_image_defaults(self):
|
|
img = ImageConfig()
|
|
assert img.repository == "registry.digitalocean.com/hanzo/bootnode"
|
|
assert img.tag == "luxd-v1.23.11"
|
|
assert img.pull_policy == "Always"
|
|
|
|
def test_resource_defaults(self):
|
|
r = ResourceConfig()
|
|
assert r.requests.memory == "1Gi"
|
|
assert r.requests.cpu == "500m"
|
|
assert r.limits.memory == "4Gi"
|
|
assert r.limits.cpu == "2"
|
|
|
|
def test_storage_defaults(self):
|
|
s = StorageConfig()
|
|
assert s.size == "100Gi"
|
|
assert s.storage_class == "do-block-storage"
|
|
|
|
def test_node_services_defaults(self):
|
|
ns = NodeServicesConfig()
|
|
assert ns.enabled is True
|
|
assert ns.type == "LoadBalancer"
|
|
assert ns.annotations == {}
|
|
|
|
def test_api_defaults(self):
|
|
a = APIConfig()
|
|
assert a.admin_enabled is True
|
|
assert a.metrics_enabled is True
|
|
assert a.index_enabled is True
|
|
assert a.http_allowed_hosts == "*"
|