datahaven/test/cli/handlers/deploy/relayer.ts
Steve Degosserie 1f0cd6de27
feat: Enhanced Helm deployment with ingress management and solochain support (#115)
This PR enhances the Helm-based deployment system with several key
improvements organized into the following areas:

## New Features

### Ingress Management
- **Ingress per replica**: Added `ingress-per-replica` chart template
that automatically generates an ingress for each node replica, exposing
individual pod instances
- **Traefik integration**: Added chart values to deploy Traefik as the
ingress controller for local K8s clusters, enabling proper ingress
testing (requires adding names to `/etc/hosts`)

### Solochain Support
- **New relay chart**: Added dedicated Helm chart for Solochain relay
deployment
- **CLI integration**: Updated DataHaven CLI to deploy both Execution
and Solochain relayers

## Configuration Improvements

### Environment Structure
- **Modular configs**: Refactored environmental configurations from
single `values.yaml` files into separate component-specific overrides
for better organization

### Node Configuration
- **Base config updates**: Improved base configurations for bootnode and
validator nodes
- **Network protocol**: Reverted from litep2p back to libp2p to resolve
node communication issues on Stagenet
- **Archive node routing**: In Stagenet, relayers now connect to the
bootnode (configured as archive node) instead of validator nodes

## Storage & Deployment

### Persistent Storage
- **Relayer database**: Added support for persistent volumes to store
relayer databases instead of using ephemeral storage

### Deployment Scripts
- **Documentation cleanup**: Removed obsolete test deploy.sh script and
updated the deployment README with clearer instructions

These changes provide a more robust, scalable, and maintainable
deployment system for DataHaven infrastructure.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for deploying new relayer types: Solochain and Execution
relayers, with dedicated configuration and secret management.
* Introduced per-replica ingress configuration for node deployments,
allowing each replica to have its own ingress resource and hostname.
* Added persistent storage options for relay data, configurable via
storage path, class, and size.
* Added new deployment configuration files for local and stagenet
environments, including Traefik ingress controller setup.
* Introduced a new relay category, "Solochain Relayers," for standalone
chain operations and cross-chain communication.

* **Improvements**
* Updated deployment configurations to use container-specific
environment YAML files for more granular control.
* Enhanced relay and node configurations with new flags and backend
options, including dynamic peer ID generation and automatic bootnode
discovery.
* Updated relayer endpoints to consistently use the bootnode for
connections.
* Refined relay configuration files for improved structure, clarity, and
endpoint management.

* **Bug Fixes**
* Corrected deployment logic to reference the correct
environment-specific configuration files during Helm deployments.

* **Documentation**
* Simplified and updated deployment documentation to focus on CLI-based
deployment, removing outdated manual instructions and adding a concise
overview of components and relayer types.

* **Chores**
* Removed deprecated deployment scripts and outdated configuration files
to streamline the deployment process.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-11 14:30:01 +03:00

317 lines
11 KiB
TypeScript

import path from "node:path";
import { $ } from "bun";
import { createClient, type PolkadotClient } from "polkadot-api";
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
import { getWsProvider } from "polkadot-api/ws-provider/web";
import invariant from "tiny-invariant";
import {
ANVIL_FUNDED_ACCOUNTS,
logger,
parseDeploymentsFile,
printDivider,
printHeader,
SUBSTRATE_FUNDED_ACCOUNTS
} from "utils";
import { waitFor } from "utils/waits";
import {
generateRelayerConfig,
initEthClientPallet,
type RelayerSpec
} from "../../../launcher/relayers";
import type { LaunchedNetwork } from "../../../launcher/types/launchedNetwork";
import { ZERO_HASH } from "../../../launcher/utils/constants";
import type { DeployOptions } from ".";
// Standard ports for the Ethereum network
const ETH_EL_RPC_PORT = 8546;
const ETH_CL_HTTP_PORT = 4000;
const RELAYER_CONFIG_DIR = "../deploy/charts/relay/configs";
const RELAYER_CONFIG_PATHS = {
BEACON: path.join(RELAYER_CONFIG_DIR, "beacon-relay.json"),
BEEFY: path.join(RELAYER_CONFIG_DIR, "beefy-relay.json"),
EXECUTION: path.join(RELAYER_CONFIG_DIR, "execution-relay.json"),
SOLOCHAIN: path.join(RELAYER_CONFIG_DIR, "solochain-relay.json")
};
/**
* Deploys Snowbridge relayers for the DataHaven network in a Kubernetes namespace.
*
* @param options - Configuration options for launching the relayers.
* @param launchedNetwork - An instance of LaunchedNetwork to track the network's state.
*/
export const deployRelayers = async (options: DeployOptions, launchedNetwork: LaunchedNetwork) => {
printHeader("Starting Snowbridge Relayers");
if (options.skipRelayers) {
logger.info("🏳️ Skipping relayer deployment");
printDivider();
return;
}
// Get DataHaven node port
const dhNodes = launchedNetwork.containers.filter((container) =>
container.name.includes("dh-validator")
);
invariant(dhNodes.length > 0, "❌ No DataHaven nodes found in launchedNetwork");
const firstDhNode = dhNodes[0];
const substrateWsPort = firstDhNode.publicPorts.ws;
const substrateNodeId = firstDhNode.name;
logger.info(
`🔌 Using DataHaven node ${substrateNodeId} on port ${substrateWsPort} for relayers and BEEFY check.`
);
invariant(options.relayerImageTag, "❌ relayerImageTag is required");
// Check if BEEFY is ready before proceeding
await waitBeefyReady(launchedNetwork, 2000, 60000);
const anvilDeployments = await parseDeploymentsFile();
const beefyClientAddress = anvilDeployments.BeefyClient;
const gatewayAddress = anvilDeployments.Gateway;
const rewardsRegistryAddress = anvilDeployments.RewardsRegistry;
invariant(beefyClientAddress, "❌ BeefyClient address not found in anvil.json");
invariant(gatewayAddress, "❌ Gateway address not found in anvil.json");
invariant(rewardsRegistryAddress, "❌ RewardsRegistry address not found in anvil.json");
logger.debug(`Ensuring output directory exists: ${RELAYER_CONFIG_DIR}`);
await $`mkdir -p ${RELAYER_CONFIG_DIR}`.quiet();
const ethElRpcEndpoint = `ws://el-1-reth-lodestar:${ETH_EL_RPC_PORT}`;
const ethClEndpoint = `http://cl-1-lodestar-reth:${ETH_CL_HTTP_PORT}`;
const substrateWsEndpoint = `ws://dh-bootnode-0:${substrateWsPort}`;
const relayersToStart: RelayerSpec[] = [
{
name: "relayer-🥩",
configFilePath: RELAYER_CONFIG_PATHS.BEEFY,
config: {
type: "beefy",
ethElRpcEndpoint,
substrateWsEndpoint,
beefyClientAddress,
gatewayAddress
},
pk: {
ethereum: ANVIL_FUNDED_ACCOUNTS[1].privateKey
}
},
{
name: "relayer-🥓",
configFilePath: RELAYER_CONFIG_PATHS.BEACON,
config: {
type: "beacon",
ethClEndpoint,
substrateWsEndpoint
},
pk: {
substrate: SUBSTRATE_FUNDED_ACCOUNTS.BALTATHAR.privateKey
}
},
{
name: "relayer-⛓️",
configFilePath: RELAYER_CONFIG_PATHS.SOLOCHAIN,
config: {
type: "solochain",
ethElRpcEndpoint,
substrateWsEndpoint,
beefyClientAddress,
gatewayAddress,
rewardsRegistryAddress,
ethClEndpoint
},
pk: {
ethereum: ANVIL_FUNDED_ACCOUNTS[1].privateKey,
substrate: SUBSTRATE_FUNDED_ACCOUNTS.CHARLETH.privateKey
}
},
{
name: "relayer-⚙️",
configFilePath: RELAYER_CONFIG_PATHS.EXECUTION,
config: {
type: "execution",
ethElRpcEndpoint,
ethClEndpoint,
substrateWsEndpoint,
gatewayAddress
},
pk: {
substrate: SUBSTRATE_FUNDED_ACCOUNTS.DOROTHY.privateKey
}
}
];
for (const relayerSpec of relayersToStart) {
await generateRelayerConfig(relayerSpec, options.environment, RELAYER_CONFIG_DIR);
}
invariant(options.relayerImageTag, "❌ Relayer image tag not defined");
// Generating the relayer config file for running the beacon relayer locally, to generate the first checkpoint
const localBeaconConfigDir = "tmp/configs";
const localBeaconConfigFilePath = path.join(localBeaconConfigDir, "beacon-relay-checkpoint.json");
const localBeaconConfig: RelayerSpec = {
name: "relayer-🥓-local",
configFilePath: localBeaconConfigFilePath,
templateFilePath: "configs/snowbridge/local/beacon-relay.json",
config: {
type: "beacon",
ethClEndpoint: launchedNetwork.clEndpoint.replace("127.0.0.1", "host.docker.internal"),
substrateWsEndpoint: `ws://${substrateNodeId}:${substrateWsPort}`
},
pk: {
substrate: SUBSTRATE_FUNDED_ACCOUNTS.BALTATHAR.privateKey
}
};
await generateRelayerConfig(localBeaconConfig, options.environment, localBeaconConfigDir);
await initEthClientPallet(
"cli-deploy",
path.resolve(localBeaconConfigFilePath),
options.relayerImageTag,
"tmp/datastore",
launchedNetwork
);
for (const { name, config, pk } of relayersToStart) {
try {
const containerName = `dh-${config.type}-relay`;
logger.info(`🚀 Starting relayer ${containerName} ...`);
// Adding secret key as Kubernetes secret
const secrets: { pk: string; name: string }[] = [];
switch (config.type) {
case "beacon":
invariant(pk.substrate, "❌ Substrate private key is required for beacon relayer");
secrets.push({
pk: pk.substrate,
name: `dh-${config.type}-relay-substrate-key`
});
break;
case "beefy":
invariant(pk.ethereum, "❌ Ethereum private key is required for beefy relayer");
secrets.push({
pk: pk.ethereum,
name: `dh-${config.type}-relay-ethereum-key`
});
break;
case "solochain":
invariant(pk.substrate, "❌ Substrate private key is required for solochain relayer");
invariant(pk.ethereum, "❌ Ethereum private key is required for solochain relayer");
secrets.push({
pk: pk.substrate,
name: `dh-${config.type}-relay-substrate-key`
});
secrets.push({
pk: pk.ethereum,
name: `dh-${config.type}-relay-ethereum-key`
});
break;
case "execution":
invariant(pk.substrate, "❌ Substrate private key is required for execution relayer");
secrets.push({
pk: pk.substrate,
name: `dh-${config.type}-relay-substrate-key`
});
break;
}
for (const secret of secrets) {
logger.debug(
await $`kubectl create secret generic ${secret.name} \
--from-literal=pvk="${secret.pk}" \
-n ${launchedNetwork.kubeNamespace}`.text()
);
logger.success(`Secret key ${secret.name} added to Kubernetes`);
}
// Deploying relayer with helm chart
const relayerTimeout = "2m"; // 2 minutes
logger.debug(
await $`helm upgrade --install ${containerName} charts/relay \
-f charts/relay/snowbridge/${containerName}.yaml \
-f environments/${options.environment}/${containerName}.yaml \
-n ${launchedNetwork.kubeNamespace} \
--wait \
--timeout ${relayerTimeout}`
.cwd(path.join(process.cwd(), "../deploy"))
.text()
);
logger.success(`Started relayer ${name}`);
} catch (e) {
logger.error(`Error starting relayer ${name}`);
logger.error(e);
}
}
logger.success("Snowbridge relayers started");
printDivider();
};
/**
* Waits for the BEEFY protocol to be ready by polling its finalized head.
*
* @param launchedNetwork - An instance of LaunchedNetwork to get the node endpoint.
* @param pollIntervalMs - The interval in milliseconds to poll the BEEFY endpoint.
* @param timeoutMs - The total time in milliseconds to wait before timing out.
* @throws Error if BEEFY is not ready within the timeout.
*/
const waitBeefyReady = async (
launchedNetwork: LaunchedNetwork,
pollIntervalMs: number,
timeoutMs: number
): Promise<void> => {
const port = launchedNetwork.getPublicWsPort();
const wsUrl = `ws://127.0.0.1:${port}`;
const iterations = Math.floor(timeoutMs / pollIntervalMs);
logger.info(`⌛️ Waiting for BEEFY to be ready on port ${port}...`);
let client: PolkadotClient | undefined;
const clientTimeoutMs = pollIntervalMs / 2;
const delayMs = pollIntervalMs / 2;
try {
client = createClient(withPolkadotSdkCompat(getWsProvider(wsUrl)));
await waitFor({
lambda: async () => {
try {
logger.debug("Attempting to to check beefy_getFinalizedHead");
// Add timeout to the RPC call to prevent hanging.
const finalisedHeadPromise = client?._request<string>("beefy_getFinalizedHead", []);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("RPC call timeout")), clientTimeoutMs);
});
const finalisedHeadHex = await Promise.race([finalisedHeadPromise, timeoutPromise]);
if (finalisedHeadHex && finalisedHeadHex !== ZERO_HASH) {
logger.info(`🥩 BEEFY is ready. Finalised head: ${finalisedHeadHex}.`);
return true;
}
logger.debug(
`BEEFY not ready or finalised head is zero. Retrying in ${delayMs / 1000}s...`
);
return false;
} catch (rpcError) {
logger.warn(`RPC error checking BEEFY status: ${rpcError}. Retrying...`);
return false;
}
},
iterations,
delay: delayMs,
errorMessage: "BEEFY protocol not ready. Relayers cannot be launched."
});
} catch (error) {
logger.error(`❌ Failed to connect to DataHaven node for BEEFY check: ${error}`);
throw new Error("BEEFY protocol not ready. Relayers cannot be launched.");
} finally {
if (client) {
client.destroy();
}
}
};