From 431d1f71819fb51eeac0cfc5d2057f2479ea0f92 Mon Sep 17 00:00:00 2001 From: Tim B <79199034+timbrinded@users.noreply.github.com> Date: Mon, 19 May 2025 00:31:46 +0100 Subject: [PATCH] =?UTF-8?q?test:=20=F0=9F=90=B3=20Add=20Docker=20relay=20s?= =?UTF-8?q?upport=20to=20CLI=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes - Latest changes to have working relayer :tada: component - Changed spawning snowbridge relayers to docker containers - Small logging output changes - Refactoring to `LaunchedNetwork` class - new flag `--bd` `--build-datahaven` which will build a local docker container which is **much** quicker than the proper CI build (which uses a controlled build enviroment) - new bun script `start:e2e:local`, which is everything that `start:e2e:ci` has, but with building local docker container and log_level debug set --- ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Added support for launching and managing relayer and DataHaven services using Docker containers and networks. - Introduced a CLI option to specify the relayer Docker image tag instead of a binary path. - **Improvements** - Enhanced log messages with clearer text and expressive emojis for better user feedback. - Improved summary display by removing relayer services from the output. - Updated build scripts to consistently enable the "fast-runtime" feature for cross-platform builds. - Refined validation and error reporting for checkpoint data parsing. - **Bug Fixes** - Improved Docker container cleanup and network management during service launch and teardown. - **Chores** - Updated and refactored npm scripts for Docker operations and end-to-end test cleanup. --------- Co-authored-by: Facundo Farall <37149322+ffarall@users.noreply.github.com> --- test/cli/handlers/launch/datahaven.ts | 90 ++++++++---- test/cli/handlers/launch/index.ts | 4 +- test/cli/handlers/launch/kurtosis.ts | 16 +-- test/cli/handlers/launch/launchedNetwork.ts | 38 +++-- test/cli/handlers/launch/relayer.ts | 151 +++++++++++++------- test/cli/handlers/launch/summary.ts | 29 +--- test/cli/handlers/launch/validator.ts | 6 +- test/cli/index.ts | 6 +- test/package.json | 7 +- test/scripts/cargo-crossbuild.ts | 7 +- test/scripts/deploy-contracts.ts | 4 +- test/scripts/fund-validators.ts | 4 +- test/scripts/setup-validators.ts | 4 +- test/scripts/snowbridge-relayer.ts | 2 +- test/utils/docker.ts | 22 ++- test/utils/shell.ts | 4 +- test/utils/types.ts | 127 ++++++++-------- test/utils/viem.ts | 5 +- 18 files changed, 304 insertions(+), 222 deletions(-) diff --git a/test/cli/handlers/launch/datahaven.ts b/test/cli/handlers/launch/datahaven.ts index 6fb487fd..04099151 100644 --- a/test/cli/handlers/launch/datahaven.ts +++ b/test/cli/handlers/launch/datahaven.ts @@ -8,13 +8,21 @@ import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat"; import { getWsProvider } from "polkadot-api/ws-provider/web"; import { cargoCrossbuild } from "scripts/cargo-crossbuild"; import invariant from "tiny-invariant"; -import { waitForContainerToStart } from "utils"; -import { confirmWithTimeout, logger, printDivider, printHeader } from "utils"; +import { + confirmWithTimeout, + killExistingContainers, + logger, + printDivider, + printHeader, + waitForContainerToStart +} from "utils"; import { type Hex, keccak256, toHex } from "viem"; import { publicKeyToAddress } from "viem/accounts"; import type { LaunchOptions } from "."; import type { LaunchedNetwork } from "./launchedNetwork"; +const DOCKER_NETWORK_NAME = "datahaven-net"; + const LOG_LEVEL = Bun.env.LOG_LEVEL || "info"; const COMMON_LAUNCH_ARGS = [ @@ -75,8 +83,7 @@ export const launchDataHavenSolochain = async ( } if (options.datahaven === true) { - logger.info("Proceeding to clean and relaunch DataHaven containers..."); - await cleanDataHavenContainers(); + await cleanDataHavenContainers(options); } else { const shouldRelaunch = await confirmWithTimeout( "Do you want to clean and relaunch the DataHaven containers?", @@ -91,8 +98,7 @@ export const launchDataHavenSolochain = async ( printDivider(); return; } - logger.info("Proceeding to clean and relaunch DataHaven containers..."); - await cleanDataHavenContainers(); + await cleanDataHavenContainers(options); } } @@ -104,7 +110,7 @@ export const launchDataHavenSolochain = async ( ); } else { logger.info( - `Using flag option: ${shouldLaunchDataHaven ? "will launch" : "will not launch"} DataHaven network` + `๐Ÿณ๏ธ Using flag option: ${shouldLaunchDataHaven ? "will launch" : "will not launch"} DataHaven network` ); } @@ -114,13 +120,20 @@ export const launchDataHavenSolochain = async ( return; } + logger.info(`โ›“๏ธโ€๐Ÿ’ฅ Creating Docker network: ${DOCKER_NETWORK_NAME}`); + logger.debug(await $`docker network rm ${DOCKER_NETWORK_NAME} -f`.text()); + logger.debug(await $`docker network create ${DOCKER_NETWORK_NAME}`.text()); + invariant(options.datahavenImageTag, "โŒ DataHaven image tag not defined"); await buildLocalImage(options); await checkTagExists(options.datahavenImageTag); + launchedNetwork.networkName = DOCKER_NETWORK_NAME; + logger.success(`DataHaven nodes will use Docker network: ${DOCKER_NETWORK_NAME}`); + for (const id of CLI_AUTHORITY_IDS) { - logger.info(`Starting ${id}...`); + logger.info(`๐Ÿš€ Starting ${id}...`); const containerName = `datahaven-${id}`; const command: string[] = [ @@ -129,13 +142,15 @@ export const launchDataHavenSolochain = async ( "-d", "--name", containerName, + "--network", + DOCKER_NETWORK_NAME, ...(id === "alice" ? ["-p", `${DEFAULT_PUBLIC_WS_PORT}:9944`] : []), options.datahavenImageTag, `--${id}`, ...COMMON_LAUNCH_ARGS ]; - logger.debug($`sh -c "${command.join(" ")}"`.text()); + logger.debug(await $`sh -c "${command.join(" ")}"`.text()); await waitForContainerToStart(containerName); @@ -151,7 +166,7 @@ export const launchDataHavenSolochain = async ( } for (let i = 0; i < 30; i++) { - logger.info("Waiting for datahaven to start..."); + logger.info("โŒ›๏ธ Waiting for datahaven to start..."); if (await isNetworkReady(DEFAULT_PUBLIC_WS_PORT)) { logger.success( `DataHaven network started, primary node accessible on port ${DEFAULT_PUBLIC_WS_PORT}` @@ -160,7 +175,7 @@ export const launchDataHavenSolochain = async ( await registerNodes(launchedNetwork); // Call setupDataHavenValidatorConfig now that nodes are up - logger.info("Proceeding with DataHaven validator configuration setup..."); + logger.info("๐Ÿ”ง Proceeding with DataHaven validator configuration setup..."); await setupDataHavenValidatorConfig(launchedNetwork); printDivider(); @@ -180,29 +195,42 @@ export const launchDataHavenSolochain = async ( */ const checkDataHavenRunning = async (): Promise => { // Check for any container whose name starts with "datahaven-" - const PIDS = await $`docker ps -q --filter "name=^datahaven-"`.text(); - return PIDS.trim().length > 0; + const containerIds = await $`docker ps -q --filter "name=^datahaven-"`.text(); + const networkOutput = + await $`docker network ls --filter "name=^${DOCKER_NETWORK_NAME}$" --format "{{.Name}}"`.text(); + + // Check if containerIds has any actual IDs (not just whitespace) + const containersExist = containerIds.trim().length > 0; + // Check if networkOutput has any network names (not just whitespace or empty lines) + const networksExist = + networkOutput + .trim() + .split("\n") + .filter((line) => line.trim().length > 0).length > 0; + + return containersExist || networksExist; }; /** * Stops and removes all DataHaven containers. */ -const cleanDataHavenContainers = async (): Promise => { +const cleanDataHavenContainers = async (options: LaunchOptions): Promise => { logger.info("๐Ÿงน Stopping and removing existing DataHaven containers..."); - const containerIds = (await $`docker ps -a -q --filter "name=^datahaven-"`.text()).trim(); - logger.debug(`Container IDs: ${containerIds}`); - if (containerIds.length > 0) { - const idsArray = containerIds - .split("\n") - .map((id) => id.trim()) - .filter((id) => id.length > 0); - for (const id of idsArray) { - logger.debug(`Stopping container ${id}`); - logger.debug(await $`docker stop ${id}`.nothrow().text()); - logger.debug(await $`docker rm ${id}`.nothrow().text()); - } + + invariant(options.datahavenImageTag, "โŒ DataHaven image tag not defined"); + await killExistingContainers(options.datahavenImageTag); + + if (options.relayerImageTag) { + logger.info( + "๐Ÿงน Stopping and removing existing relayer containers (relayers depend on DataHaven nodes)..." + ); + await killExistingContainers(options.relayerImageTag); } + logger.info("โœ… Existing DataHaven containers stopped and removed."); + + logger.debug(await $`docker network rm -f ${DOCKER_NETWORK_NAME}`.text()); + logger.info("โœ… DataHaven Docker network removed."); }; /** @@ -241,12 +269,12 @@ const buildLocalImage = async (options: LaunchOptions) => { ); } else { logger.info( - `Using flag option: ${shouldBuildDataHaven ? "will build" : "will not build"} DataHaven node local Docker image` + `๐Ÿณ๏ธ Using flag option: ${shouldBuildDataHaven ? "will build" : "will not build"} DataHaven node local Docker image` ); } if (!shouldBuildDataHaven) { - logger.info("Skipping DataHaven node local Docker image build. Done!"); + logger.info("๐Ÿ‘ Skipping DataHaven node local Docker image build. Done!"); return; } @@ -302,11 +330,11 @@ const registerNodes = async (launchedNetwork: LaunchedNetwork) => { // If the Docker container is running, proceed to register it in launchedNetwork. // We use the standard host WS port that "datahaven-alice" is expected to use. - logger.info( - `โœ… Docker container ${targetContainerName} is running. Registering with WS port ${aliceHostWsPort}.` + logger.debug( + `Docker container ${targetContainerName} is running. Registering with WS port ${aliceHostWsPort}.` ); launchedNetwork.addContainer(targetContainerName, { ws: aliceHostWsPort }); - logger.success(`๐Ÿ‘ Node ${targetContainerName} successfully registered in launchedNetwork.`); + logger.info(`๐Ÿ“ Node ${targetContainerName} successfully registered in launchedNetwork.`); }; // Function to convert compressed public key to Ethereum address diff --git a/test/cli/handlers/launch/index.ts b/test/cli/handlers/launch/index.ts index 6f4c929a..b7d305f7 100644 --- a/test/cli/handlers/launch/index.ts +++ b/test/cli/handlers/launch/index.ts @@ -18,7 +18,7 @@ export interface LaunchOptions { updateValidatorSet?: boolean; blockscout?: boolean; relayer?: boolean; - relayerBinPath?: string; + relayerImageTag?: string; skipCleaning?: boolean; alwaysClean?: boolean; datahaven?: boolean; @@ -75,7 +75,7 @@ const launchFunction = async (options: LaunchOptions, launchedNetwork: LaunchedN await launchRelayers(options, launchedNetwork); - performSummaryOperations(options, launchedNetwork); + await performSummaryOperations(options, launchedNetwork); const fullEnd = performance.now(); const fullMinutes = ((fullEnd - timeStart) / (1000 * 60)).toFixed(1); logger.success(`Launch function completed successfully in ${fullMinutes} minutes`); diff --git a/test/cli/handlers/launch/kurtosis.ts b/test/cli/handlers/launch/kurtosis.ts index 48c23e31..3c3c39b4 100644 --- a/test/cli/handlers/launch/kurtosis.ts +++ b/test/cli/handlers/launch/kurtosis.ts @@ -22,16 +22,14 @@ export const launchKurtosis = async ( logger.trace("Checking if launchKurtosis option was set via flags"); if (options.launchKurtosis === false) { - logger.info("Keeping existing Kurtosis enclave."); + logger.info("๐Ÿ‘ Keeping existing Kurtosis enclave."); await registerServices(launchedNetwork); printDivider(); return; } - if (options.launchKurtosis === true) { - logger.info("Proceeding to clean and relaunch the Kurtosis enclave..."); - } else { + if (options.launchKurtosis !== true) { const shouldRelaunch = await confirmWithTimeout( "Do you want to clean and relaunch the Kurtosis enclave?", true, @@ -39,14 +37,12 @@ export const launchKurtosis = async ( ); if (!shouldRelaunch) { - logger.info("Keeping existing Kurtosis enclave."); + logger.info("๐Ÿ‘ Keeping existing Kurtosis enclave."); await registerServices(launchedNetwork); printDivider(); return; } - - logger.info("Proceeding to clean and relaunch the Kurtosis enclave..."); } } @@ -142,14 +138,14 @@ const modifyConfig = async (options: LaunchOptions, configFile: string) => { * @param launchedNetwork - The LaunchedNetwork instance to store network details. */ const registerServices = async (launchedNetwork: LaunchedNetwork) => { - logger.info("โš™๏ธ Registering Kurtosis service endpoints..."); + logger.info("๐Ÿ“ Registering Kurtosis service endpoints..."); // Configure EL RPC URL const rethPublicPort = await getPortFromKurtosis("el-1-reth-lighthouse", "rpc"); invariant(rethPublicPort && rethPublicPort > 0, "โŒ Could not find EL RPC port"); const elRpcUrl = `http://127.0.0.1:${rethPublicPort}`; launchedNetwork.elRpcUrl = elRpcUrl; - logger.info(`๐Ÿ‘ Execution Layer RPC URL configured: ${elRpcUrl}`); + logger.info(`๐Ÿ“ Execution Layer RPC URL configured: ${elRpcUrl}`); // Configure CL Endpoint const lighthousePublicPort = await getPortFromKurtosis("cl-1-lighthouse-reth", "http"); @@ -159,5 +155,5 @@ const registerServices = async (launchedNetwork: LaunchedNetwork) => { "โŒ CL Endpoint could not be determined from Kurtosis service cl-1-lighthouse-reth" ); launchedNetwork.clEndpoint = clEndpoint; - logger.info(`๐Ÿ‘ Consensus Layer Endpoint configured: ${clEndpoint}`); + logger.info(`๐Ÿ“ Consensus Layer Endpoint configured: ${clEndpoint}`); }; diff --git a/test/cli/handlers/launch/launchedNetwork.ts b/test/cli/handlers/launch/launchedNetwork.ts index b36fe2d3..4411d87b 100644 --- a/test/cli/handlers/launch/launchedNetwork.ts +++ b/test/cli/handlers/launch/launchedNetwork.ts @@ -15,6 +15,7 @@ export class LaunchedNetwork { protected processes: BunProcess[]; protected _containers: ContainerSpec[]; protected fileDescriptors: number[]; + protected _networkName: string; protected _activeRelayers: RelayerType[]; /** The RPC URL for the Ethereum Execution Layer (EL) client. */ protected _elRpcUrl?: string; @@ -27,10 +28,20 @@ export class LaunchedNetwork { this.fileDescriptors = []; this._containers = []; this._activeRelayers = []; + this._networkName = ""; this._elRpcUrl = undefined; this._clEndpoint = undefined; } + public set networkName(name: string) { + invariant(name.trim().length > 0, "โŒ networkName cannot be empty"); + this._networkName = name.trim(); + } + + public get networkName(): string { + return this._networkName; + } + /** * Gets the unique ID for this run of the launched network. * @returns The run ID string. @@ -73,12 +84,6 @@ export class LaunchedNetwork { this._containers.push({ name: containerName, publicPorts }); } - registerRelayerType(type: RelayerType): void { - if (!this._activeRelayers.includes(type)) { - this._activeRelayers.push(type); - } - } - public getPublicWsPort(): number { logger.debug("Getting public WebSocket port for LaunchedNetwork"); logger.debug("Containers:"); @@ -88,13 +93,6 @@ export class LaunchedNetwork { return port; } - public get containers(): ContainerSpec[] { - return this._containers; - } - - public get relayers(): RelayerType[] { - return [...this._activeRelayers]; - } /** * Sets the RPC URL for the Ethereum Execution Layer (EL) client. * @param url - The EL RPC URL string. @@ -131,6 +129,20 @@ export class LaunchedNetwork { return this._clEndpoint; } + registerRelayerType(type: RelayerType): void { + if (!this._activeRelayers.includes(type)) { + this._activeRelayers.push(type); + } + } + + public get containers(): ContainerSpec[] { + return this._containers; + } + + public get relayers(): RelayerType[] { + return [...this._activeRelayers]; + } + async cleanup() { logger.debug("Running cleanup"); for (const process of this.processes) { diff --git a/test/cli/handlers/launch/relayer.ts b/test/cli/handlers/launch/relayer.ts index 6cad657f..7dffd8a4 100644 --- a/test/cli/handlers/launch/relayer.ts +++ b/test/cli/handlers/launch/relayer.ts @@ -1,4 +1,3 @@ -import fs from "node:fs"; import path from "node:path"; import { datahaven } from "@polkadot-api/descriptors"; import { $ } from "bun"; @@ -13,11 +12,14 @@ import { confirmWithTimeout, getEvmEcdsaSigner, getPortFromKurtosis, + killExistingContainers, logger, parseDeploymentsFile, parseRelayConfig, printDivider, - printHeader + printHeader, + runShellCommandWithLogger, + waitForContainerToStart } from "utils"; import type { BeaconCheckpoint, FinalityCheckpointsResponse } from "utils/types"; import { parseJsonToBeaconCheckpoint } from "utils/types"; @@ -38,7 +40,9 @@ const RELAYER_CONFIG_PATHS = { BEACON: path.join(RELAYER_CONFIG_DIR, "beacon-relay.json"), BEEFY: path.join(RELAYER_CONFIG_DIR, "beefy-relay.json") }; -const INITIAL_CHECKPOINT_PATH = "./dump-initial-checkpoint.json"; +const INITIAL_CHECKPOINT_FILE = "dump-initial-checkpoint.json"; +const INITIAL_CHECKPOINT_DIR = "tmp/beacon-checkpoint"; +const INITIAL_CHECKPOINT_PATH = path.join(INITIAL_CHECKPOINT_DIR, INITIAL_CHECKPOINT_FILE); /** * Launches Snowbridge relayers for the DataHaven network. @@ -58,7 +62,7 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La ); } else { logger.info( - `Using flag option: ${shouldLaunchRelayers ? "will launch" : "will not launch"} Snowbridge relayers` + `๐Ÿณ๏ธ Using flag option: ${shouldLaunchRelayers ? "will launch" : "will not launch"} Snowbridge relayers` ); } @@ -90,8 +94,8 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La ); } - // Kill any pre-existing relayer processes if they exist - await $`pkill snowbridge-relay`.nothrow().quiet(); + invariant(options.relayerImageTag, "โŒ relayerImageTag is required"); + await killExistingContainers(options.relayerImageTag); // Check if BEEFY is ready before proceeding await waitBeefyReady(launchedNetwork, 2000, 60000); @@ -109,10 +113,6 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La logger.debug(`Ensuring datastore directory exists: ${datastorePath}`); await $`mkdir -p ${datastorePath}`.quiet(); - const logsPath = `tmp/logs/${launchedNetwork.getRunId()}/`; - logger.debug(`Ensuring logs directory exists: ${logsPath}`); - await $`mkdir -p ${logsPath}`.quiet(); - const relayersToStart: RelayerSpec[] = [ { name: "relayer-๐Ÿฅฉ", @@ -139,7 +139,7 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La logger.debug(`Creating config for ${name}`); const templateFilePath = `configs/snowbridge/${configFileName}`; - const outputFilePath = `tmp/configs/${configFileName}`; + const outputFilePath = path.resolve(RELAYER_CONFIG_DIR, configFileName); logger.debug(`Reading config file ${templateFilePath}`); const file = Bun.file(templateFilePath); @@ -157,18 +157,17 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La if (type === "beacon") { const cfg = parseRelayConfig(json, type); - cfg.source.beacon.endpoint = `http://127.0.0.1:${ethHttpPort}`; - cfg.source.beacon.stateEndpoint = `http://127.0.0.1:${ethHttpPort}`; + cfg.source.beacon.endpoint = `http://host.docker.internal:${ethHttpPort}`; + cfg.source.beacon.stateEndpoint = `http://host.docker.internal:${ethHttpPort}`; + cfg.source.beacon.datastore.location = "/data"; + cfg.sink.parachain.endpoint = `ws://${substrateNodeId}:${substrateWsPort}`; - cfg.source.beacon.datastore.location = datastorePath; - - cfg.sink.parachain.endpoint = `ws://127.0.0.1:${substrateWsPort}`; await Bun.write(outputFilePath, JSON.stringify(cfg, null, 4)); logger.success(`Updated beacon config written to ${outputFilePath}`); } else { const cfg = parseRelayConfig(json, type); - cfg.source.polkadot.endpoint = `ws://127.0.0.1:${substrateWsPort}`; - cfg.sink.ethereum.endpoint = `ws://127.0.0.1:${ethWsPort}`; + cfg.source.polkadot.endpoint = `ws://${substrateNodeId}:${substrateWsPort}`; + cfg.sink.ethereum.endpoint = `ws://host.docker.internal:${ethWsPort}`; cfg.sink.contracts.BeefyClient = beefyClientAddress; cfg.sink.contracts.Gateway = gatewayAddress; await Bun.write(outputFilePath, JSON.stringify(cfg, null, 4)); @@ -176,27 +175,43 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La } } - logger.info("Spawning Snowbridge relayers processes"); - - invariant(options.relayerBinPath, "โŒ Relayer binary path not defined"); - invariant( - await Bun.file(options.relayerBinPath).exists(), - `โŒ Relayer binary does not exist at ${options.relayerBinPath}` - ); + invariant(options.relayerImageTag, "โŒ Relayer image tag not defined"); await initEthClientPallet(options, launchedNetwork); for (const { config, name, type, pk } of relayersToStart) { try { - logger.info(`Starting relayer ${name} ...`); - const logFileName = `${type}-${name.replace(/[^a-zA-Z0-9-]/g, "")}.log`; - const logFilePath = path.join(logsPath, logFileName); - logger.debug(`Writing logs to ${logFilePath}`); + const containerName = `snowbridge-${type}-relay`; + logger.info(`๐Ÿš€ Starting relayer ${containerName} ...`); - const fd = fs.openSync(logFilePath, "a"); + const hostConfigFilePath = path.resolve(config); + const containerConfigFilePath = `/${config}`; + const networkName = launchedNetwork.networkName; + invariant(networkName, "โŒ Docker network name not found in LaunchedNetwork instance"); - const spawnCommand = [ - options.relayerBinPath, + const commandBase: string[] = [ + "docker", + "run", + "-d", + "--platform", + "linux/amd64", + "--add-host", + "host.docker.internal:host-gateway", + "--name", + containerName, + "--network", + networkName + ]; + + const volumeMounts: string[] = ["-v", `${hostConfigFilePath}:${containerConfigFilePath}`]; + + if (type === "beacon") { + const hostDatastorePath = path.resolve(datastorePath); + const containerDatastorePath = "/data"; + volumeMounts.push("-v", `${hostDatastorePath}:${containerDatastorePath}`); + } + + const relayerCommandArgs: string[] = [ "run", type, "--config", @@ -205,17 +220,28 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La pk.value ]; - logger.debug(`Spawning command: ${spawnCommand.join(" ")}`); + const command: string[] = [ + ...commandBase, + ...volumeMounts, + options.relayerImageTag, + ...relayerCommandArgs + ]; - const process = Bun.spawn(spawnCommand, { - stdout: fd, - stderr: fd - }); + logger.debug(`Running command: ${command.join(" ")}`); + await runShellCommandWithLogger(command.join(" "), { logLevel: "debug" }); - process.unref(); + launchedNetwork.addContainer(containerName); + + await waitForContainerToStart(containerName); + + // TODO: Re-enable when we know what we want to tail for + // await waitForLog({ + // searchString: "", + // containerName, + // timeoutSeconds: 30, + // tail: 1 + // }); - launchedNetwork.addFileDescriptor(fd); - launchedNetwork.addProcess(process); logger.debug(`Started relayer ${name} with process ${process.pid}`); } catch (e) { logger.error(`Error starting relayer ${name}`); @@ -244,7 +270,7 @@ const waitBeefyReady = async ( const wsUrl = `ws://127.0.0.1:${port}`; const maxAttempts = Math.floor(timeoutMs / pollIntervalMs); - logger.info(`Waiting for BEEFY to be ready on port ${port}...`); + logger.info(`โŒ›๏ธ Waiting for BEEFY to be ready on port ${port}...`); let client: PolkadotClient | undefined; try { @@ -256,7 +282,7 @@ const waitBeefyReady = async ( const finalizedHeadHex = await client._request("beefy_getFinalizedHead", []); if (finalizedHeadHex && finalizedHeadHex !== ZERO_HASH) { - logger.success(`๐Ÿฅฉ BEEFY is ready. Finalized head: ${finalizedHeadHex}`); + logger.info(`๐Ÿฅฉ BEEFY is ready. Finalized head: ${finalizedHeadHex}`); client.destroy(); return; } @@ -296,24 +322,40 @@ export const initEthClientPallet = async ( options: LaunchOptions, launchedNetwork: LaunchedNetwork ) => { + logger.debug("Initialising eth client pallet"); // Poll the beacon chain until it's ready every 10 seconds for 5 minutes await waitBeaconChainReady(launchedNetwork, 10000, 300000); - // Generate the initial checkpoint for the CL client in Substrate - const { stdout, stderr, exitCode } = - await $`${options.relayerBinPath} generate-beacon-checkpoint --config ${RELAYER_CONFIG_PATHS.BEACON} --export-json` - .nothrow() - .quiet(); - if (exitCode !== 0) { - logger.error(stderr); - throw new Error("Error generating beacon checkpoint"); - } - logger.trace(`Beacon checkpoint stdout: ${stdout}`); + const beaconConfigHostPath = path.resolve(RELAYER_CONFIG_PATHS.BEACON); + const beaconConfigContainerPath = `/app/${RELAYER_CONFIG_PATHS.BEACON}`; + const checkpointHostPath = path.resolve(INITIAL_CHECKPOINT_PATH); + const checkpointContainerPath = `/app/${INITIAL_CHECKPOINT_FILE}`; + + logger.debug("Generating beacon checkpoint"); + // Pre-create the checkpoint file so that Docker doesn't interpret it as a directory + await Bun.write(INITIAL_CHECKPOINT_PATH, ""); + + logger.debug("Removing 'generate-beacon-checkpoint' container if it exists"); + logger.debug(await $`docker rm -f generate-beacon-checkpoint`.text()); + + logger.debug("Generating beacon checkpoint"); + const command = `docker run \ + -v ${beaconConfigHostPath}:${beaconConfigContainerPath}:ro \ + -v ${checkpointHostPath}:${checkpointContainerPath} \ + --name generate-beacon-checkpoint \ + --workdir /app \ + --add-host host.docker.internal:host-gateway \ + --network ${launchedNetwork.networkName} \ + ${options.relayerImageTag} \ + generate-beacon-checkpoint --config ${RELAYER_CONFIG_PATHS.BEACON} --export-json`; + logger.debug(`Running command: ${command}`); + logger.debug(await $`sh -c "${command}"`.text()); // Load the checkpoint into a JSON object and clean it up - const initialCheckpointRaw = fs.readFileSync(INITIAL_CHECKPOINT_PATH, "utf-8"); + const initialCheckpointFile = Bun.file(INITIAL_CHECKPOINT_PATH); + const initialCheckpointRaw = await initialCheckpointFile.text(); const initialCheckpoint = parseJsonToBeaconCheckpoint(JSON.parse(initialCheckpointRaw)); - fs.unlinkSync(INITIAL_CHECKPOINT_PATH); + await initialCheckpointFile.delete(); logger.trace("Initial checkpoint:"); logger.trace(initialCheckpoint.toJSON()); @@ -321,6 +363,7 @@ export const initEthClientPallet = async ( // Send the checkpoint to the Substrate runtime const substrateRpcUrl = `http://127.0.0.1:${launchedNetwork.getPublicWsPort()}`; await sendCheckpointToSubstrate(substrateRpcUrl, initialCheckpoint); + logger.success("Ethereum Beacon Client pallet initialised"); }; /** diff --git a/test/cli/handlers/launch/summary.ts b/test/cli/handlers/launch/summary.ts index 2c5ee7f3..04a43bcb 100644 --- a/test/cli/handlers/launch/summary.ts +++ b/test/cli/handlers/launch/summary.ts @@ -19,11 +19,6 @@ export const performSummaryOperations = async ( servicesToDisplay.push("datahaven-alice"); } - const activeRelayers = launchedNetwork.relayers; - for (const relayer of activeRelayers) { - servicesToDisplay.push(`${relayer}-relayer`); - } - logger.trace("Services to display", servicesToDisplay); const displayData: { service: string; ports: Record; url: string }[] = []; @@ -101,24 +96,6 @@ export const performSummaryOperations = async ( break; } - case service === "beefy-relayer": { - displayData.push({ - service, - ports: {}, - url: "Background process (connects to other services)" - }); - break; - } - - case service === "beacon-relayer": { - displayData.push({ - service, - ports: {}, - url: "Background process (connects to other services)" - }); - break; - } - default: { logger.error(`Unknown service: ${service}`); } @@ -127,8 +104,10 @@ export const performSummaryOperations = async ( const containers = launchedNetwork.containers.filter((c) => !c.name.startsWith("datahaven-")); for (const { name, publicPorts } of containers) { - const url = "ws" in publicPorts ? `ws://127.0.0.1:${publicPorts.ws}` : "un-exposed"; - displayData.push({ service: name, ports: publicPorts, url }); + const url = "ws" in publicPorts ? `ws://127.0.0.1:${publicPorts.ws}` : undefined; + if (url) { + displayData.push({ service: name, ports: publicPorts, url }); + } } console.table(displayData); diff --git a/test/cli/handlers/launch/validator.ts b/test/cli/handlers/launch/validator.ts index 774fa730..df031cdd 100644 --- a/test/cli/handlers/launch/validator.ts +++ b/test/cli/handlers/launch/validator.ts @@ -19,7 +19,7 @@ export const performValidatorOperations = async ( ); } else { logger.info( - `Using flag option: ${shouldFundValidators ? "will fund" : "will not fund"} validators` + `๐Ÿณ๏ธ Using flag option: ${shouldFundValidators ? "will fund" : "will not fund"} validators` ); } @@ -48,7 +48,7 @@ export const performValidatorOperations = async ( ); } else { logger.info( - `Using flag option: ${shouldSetupValidators ? "will register" : "will not register"} validators` + `๐Ÿณ๏ธ Using flag option: ${shouldSetupValidators ? "will register" : "will not register"} validators` ); } @@ -73,7 +73,7 @@ export const performValidatorOperations = async ( ); } else { logger.info( - `Using flag option: ${shouldUpdateValidatorSet ? "will update" : "will not update"} validator set` + `๐Ÿณ๏ธ Using flag option: ${shouldUpdateValidatorSet ? "will update" : "will not update"} validator set` ); } diff --git a/test/cli/index.ts b/test/cli/index.ts index ac632263..a85f95be 100644 --- a/test/cli/index.ts +++ b/test/cli/index.ts @@ -46,7 +46,11 @@ const program = new Command() "Tag of the datahaven image to use", "moonsonglabs/datahaven:local" ) - .option("--relayer-bin-path ", "Path to the relayer binary", "tmp/bin/snowbridge-relay") + .option( + "-p, --relayer-image-tag ", + "Tag of the relayer", + "moonsonglabs/snowbridge-relayer:latest" + ) .hook("preAction", launchPreActionHook) .action(launch); diff --git a/test/package.json b/test/package.json index 5c053d3d..e452c325 100644 --- a/test/package.json +++ b/test/package.json @@ -8,16 +8,19 @@ "fmt": "biome check .", "fmt:fix": "biome check --write .", "build:docker:operator": "docker build -t moonsonglabs/datahaven:local -f ./docker/datahaven-node-local.dockerfile ../.", + "build:docker:operator:timbo": "docker build -t moonsonglabs/datahaven:local -f ./docker/Local.Dockerfile ../.", "build:docker:relayer": "bun -e \"import build from './scripts/snowbridge-relayer.ts'; build()\"", "generate:wagmi": "wagmi generate", "generate:snowbridge-cfgs": "bun -e \"import {generateSnowbridgeConfigs} from './scripts/gen-snowbridge-cfgs.ts'; await generateSnowbridgeConfigs()\"", "generate:types": "(cd ../operator && cargo build --release) && bun x papi add --wasm \"../operator/target/release/wbuild/datahaven-stagenet-runtime/datahaven_stagenet_runtime.wasm\" datahaven", "start:e2e:verified": "bun cli --verified --blockscout --deploy-contracts --setup-validators --update-validator-set --fund-validators", "start:e2e:verified:relayers": "bun cli --verified --blockscout --deploy-contracts --setup-validators --update-validator-set --fund-validators --slot-time 1 --relayer --datahaven", + "start:e2e:local": "LOG_LEVEL=debug bun start:e2e:ci --bd", "start:e2e:ci": "bun cli --datahaven --no-build-datahaven --launch-kurtosis --deploy-contracts --fund-validators --setup-validators --update-validator-set --relayer --always-clean", "start:e2e:minrelayer": "bun cli --relayer --deploy-contracts --no-setup-validators --no-update-validator-set --no-fund-validators --datahaven", - "stop:docker": "docker ps -a --filter 'ancestor=moonsonglabs/datahaven:local' -q | xargs -r docker rm -f", - "stop:e2e": "bun stop:docker ;pkill datahaven ; pkill snowbridge-relay ; kurtosis enclave stop datahaven-ethereum && kurtosis clean && kurtosis engine stop && docker container prune -f", + "stop:docker:datahaven": "docker rm -f $(docker ps -aq --filter name='^datahaven-') 2>/dev/null || true", + "stop:docker:relayer": "docker rm -f $(docker ps -aq --filter name='^snowbridge-relayer-') 2>/dev/null || true", + "stop:e2e": "bun stop:docker:datahaven ; bun stop:docker:relayer ; (kurtosis enclave stop datahaven-ethereum || true) && kurtosis clean && kurtosis engine stop && docker container prune -f", "start:e2e:minimal:relayer": "bun cli --relayer --deploy-contracts --no-setup-validators --no-update-validator-set --no-fund-validators --datahaven", "stop:e2e:verified": "bun stop:e2e", "stop:e2e:quick": "kurtosis enclave stop datahaven-ethereum", diff --git a/test/scripts/cargo-crossbuild.ts b/test/scripts/cargo-crossbuild.ts index 23ce4d99..86974142 100644 --- a/test/scripts/cargo-crossbuild.ts +++ b/test/scripts/cargo-crossbuild.ts @@ -8,6 +8,7 @@ const LOG_LEVEL = Bun.env.LOG_LEVEL || "info"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const RUNTIME_FEATURES = ["fast-runtime"]; export const cargoCrossbuild = async (options: { datahavenBuildExtraArgs?: string; @@ -40,7 +41,7 @@ export const cargoCrossbuild = async (options: { // Get additional arguments from command line const additionalArgs = options.datahavenBuildExtraArgs ?? ""; - const command = `cargo zigbuild --target ${target} --release ${additionalArgs}`; + const command = `cargo zigbuild --target ${target} --release ${additionalArgs} --features ${RUNTIME_FEATURES.join(",")}`; logger.debug(`Running build command: ${command}`); if (LOG_LEVEL === "debug") { @@ -53,7 +54,9 @@ export const cargoCrossbuild = async (options: { } else if (ARCH === "x86_64" && OS === "Linux") { logger.info("๐Ÿ–ฅ๏ธ Linux AMD64 detected. Proceeding with cross-building..."); - const command = "cargo build --release"; + const target = "x86_64-unknown-linux-gnu"; + addRustupTarget(target); + const command = `cargo build --target ${target} --release --features ${RUNTIME_FEATURES.join(",")}`; logger.debug(`Running build command: ${command}`); if (LOG_LEVEL === "debug") { diff --git a/test/scripts/deploy-contracts.ts b/test/scripts/deploy-contracts.ts index 4b0b836a..4d801bef 100644 --- a/test/scripts/deploy-contracts.ts +++ b/test/scripts/deploy-contracts.ts @@ -38,7 +38,7 @@ export const deployContracts = async (options: DeployContractsOptions): Promise< ); } else { logger.info( - `Using flag option: ${shouldDeployContracts ? "will deploy" : "will not deploy"} smart contracts` + `๐Ÿณ๏ธ Using flag option: ${shouldDeployContracts ? "will deploy" : "will not deploy"} smart contracts` ); } @@ -78,7 +78,7 @@ export const deployContracts = async (options: DeployContractsOptions): Promise< logger.info("๐Ÿ” Contract verification enabled"); } - logger.info("โณ Deploying contracts (this might take a few minutes)..."); + logger.info("โŒ›๏ธ Deploying contracts (this might take a few minutes)..."); // Using custom shell command to improve logging with forge's stdoutput await runShellCommandWithLogger(deployCommand, { cwd: "../contracts" }); diff --git a/test/scripts/fund-validators.ts b/test/scripts/fund-validators.ts index c2feef5e..a3216f62 100644 --- a/test/scripts/fund-validators.ts +++ b/test/scripts/fund-validators.ts @@ -101,7 +101,7 @@ export const fundValidators = async (options: FundValidatorsOptions): Promise asset.name === "snowbridge-relay"); diff --git a/test/utils/docker.ts b/test/utils/docker.ts index 52182bf1..253b5a76 100644 --- a/test/utils/docker.ts +++ b/test/utils/docker.ts @@ -108,7 +108,7 @@ export async function waitForLog(opts: { () => pass.destroy( new Error( - `Timed out after ${timeoutMs} ms waiting for โ€œ${opts.search}โ€ in ${opts.containerName}` + `Timed out after ${timeoutMs} ms waiting for "${opts.search}" in ${opts.containerName}` ) ), timeoutMs @@ -125,7 +125,7 @@ export async function waitForLog(opts: { } throw new Error( - `Log stream ended before โ€œ${opts.search}โ€ appeared for container ${opts.containerName}` + `Log stream ended before "${opts.search}" appeared for container ${opts.containerName}` ); } finally { if (timer) { @@ -172,3 +172,21 @@ export const waitForContainerToStart = async ( `โŒ container ${containerName} cannot be found in running container list after ${seconds} seconds` ); }; + +export const killExistingContainers = async (imageName: string) => { + logger.debug(`Searching for containers with image ${imageName}...`); + const docker = new Docker(); + const containerInfos = (await docker.listContainers({ all: true })).filter((container) => + container.Image.includes(imageName) + ); + + if (containerInfos.length === 0) { + logger.debug(`No containers found with image ${imageName}`); + return; + } + + const promises = containerInfos.map(({ Id }) => docker.getContainer(Id).remove({ force: true })); + await Promise.all(promises); + + logger.debug(`${containerInfos.length} containers with image ${imageName} killed`); +}; diff --git a/test/utils/shell.ts b/test/utils/shell.ts index bdc49dc7..db5e6e73 100644 --- a/test/utils/shell.ts +++ b/test/utils/shell.ts @@ -46,7 +46,9 @@ export const runShellCommandWithLogger = async ( const text = new TextDecoder().decode(value); const trimmedText = text.trim(); if (trimmedText) { - logger[logLevel](trimmedText.includes("\n") ? `\n${trimmedText}` : trimmedText); + logger[logLevel]( + trimmedText.includes("\n") ? `>_ \n${trimmedText}` : `>_ ${trimmedText}` + ); } } } catch (err) { diff --git a/test/utils/types.ts b/test/utils/types.ts index fd1103a7..97a1c168 100644 --- a/test/utils/types.ts +++ b/test/utils/types.ts @@ -1,4 +1,5 @@ import { type FixedSizeArray, FixedSizeBinary } from "polkadot-api"; +import { z } from "zod"; /** * The type of the response from the `/eth/v1/beacon/states/head/finality_checkpoints` @@ -49,28 +50,6 @@ export interface BeaconCheckpoint { toJSON: () => JsonBeaconCheckpoint; } -/** - * Represents the structure of the BeaconCheckpoint as it might be after JSON.parse - * before specific type coercions (e.g., to BigInt). - */ -interface RawBeaconCheckpoint { - header: { - slot: number | string | bigint; // JSON.parse will yield number or string for big numbers - proposer_index: number | string | bigint; // Same as above - parent_root: string; // Assuming hex string - state_root: string; // Assuming hex string - body_root: string; // Assuming hex string - }; - current_sync_committee: { - pubkeys: string[]; // Assuming array of hex strings - aggregate_pubkey: string; // Assuming hex string - }; - current_sync_committee_branch: string[]; // Assuming array of hex strings - validators_root: string; // Assuming hex string - block_roots_root: string; // Assuming hex string - block_roots_branch: string[]; // Assuming array of hex strings -} - /** * Represents the structure of a BeaconCheckpoint when serialized to JSON. * BigInts are converted to strings, and FixedSizeBinary types are converted to hex strings. @@ -93,51 +72,35 @@ interface JsonBeaconCheckpoint { block_roots_branch: string[]; } -/** - * Parses a JSON object into a BeaconCheckpoint. - * - * @param jsonInput - The JSON object to parse. - * @returns The parsed BeaconCheckpoint. - */ -export const parseJsonToBeaconCheckpoint = (jsonInput: any): BeaconCheckpoint => { - const raw = jsonInput as RawBeaconCheckpoint; +// Zod schema for hex strings, ensuring they start with "0x" if not empty +const hexStringSchema = z.union([ + z.string().regex(/^0x[0-9a-fA-F]*$/, { + message: "Invalid hex string" + }), + z.literal("") +]); - // Basic validation - if (!raw || typeof raw.header !== "object" || raw.header === null) { - throw new Error("Invalid JSON structure for BeaconCheckpoint: missing or invalid header"); - } - if (typeof raw.header.slot === "undefined" || typeof raw.header.proposer_index === "undefined") { - throw new Error( - "Invalid JSON structure for BeaconCheckpoint: header missing slot or proposer_index" - ); - } - - if ( - !raw.current_sync_committee?.pubkeys || - !raw.current_sync_committee.aggregate_pubkey || - !Array.isArray(raw.current_sync_committee.pubkeys) || - !Array.isArray(raw.current_sync_committee_branch) || - !raw.validators_root || - !raw.block_roots_root || - !Array.isArray(raw.block_roots_branch) - ) { - throw new Error( - "Invalid JSON structure for BeaconCheckpoint: missing sync-committee or root fields" - ); - } - - if (raw.current_sync_committee.pubkeys.length !== 512) { - throw new Error( - `Invalid sync-committee size. Expected 512 pubkeys, got ${raw.current_sync_committee.pubkeys.length}` - ); - } - - // Map pubkeys to FixedSizeBinary<48> - const pubkeys = new Array>(512); - for (let i = 0; i < raw.current_sync_committee.pubkeys.length; i++) { - pubkeys[i] = new FixedSizeBinary<48>(hexToUint8Array(raw.current_sync_committee.pubkeys[i])); - } +// Zod schema for the RawBeaconCheckpoint structure +const rawBeaconCheckpointSchema = z.object({ + header: z.object({ + slot: z.union([z.number(), z.string(), z.bigint()]), + proposer_index: z.union([z.number(), z.string(), z.bigint()]), + parent_root: hexStringSchema, + state_root: hexStringSchema, + body_root: hexStringSchema + }), + current_sync_committee: z.object({ + pubkeys: z.array(hexStringSchema).length(512), + aggregate_pubkey: hexStringSchema + }), + current_sync_committee_branch: z.array(hexStringSchema), + validators_root: hexStringSchema, + block_roots_root: hexStringSchema, + block_roots_branch: z.array(hexStringSchema) +}); +// Zod schema for transforming RawBeaconCheckpoint to BeaconCheckpoint +const beaconCheckpointSchema = rawBeaconCheckpointSchema.transform((raw) => { const checkpointData: Omit = { header: { slot: BigInt(raw.header.slot), @@ -147,7 +110,12 @@ export const parseJsonToBeaconCheckpoint = (jsonInput: any): BeaconCheckpoint => body_root: new FixedSizeBinary<32>(hexToUint8Array(raw.header.body_root)) }, current_sync_committee: { - pubkeys: asFixedSizeArray(pubkeys, 512), + pubkeys: asFixedSizeArray( + raw.current_sync_committee.pubkeys.map( + (pk) => new FixedSizeBinary<48>(hexToUint8Array(pk)) + ), + 512 + ), aggregate_pubkey: new FixedSizeBinary<48>( hexToUint8Array(raw.current_sync_committee.aggregate_pubkey) ) @@ -186,6 +154,30 @@ export const parseJsonToBeaconCheckpoint = (jsonInput: any): BeaconCheckpoint => }; } }; +}); + +/** + * Parses a JSON object into a BeaconCheckpoint. + * + * @param jsonInput - The JSON object to parse. + * @returns The parsed BeaconCheckpoint. + */ +export const parseJsonToBeaconCheckpoint = (jsonInput: any): BeaconCheckpoint => { + try { + return beaconCheckpointSchema.parse(jsonInput); + } catch (error) { + if (error instanceof z.ZodError) { + // You can customize error handling here, e.g., throw a more specific error + // or log the validation issues. + throw new Error( + `Invalid JSON structure for BeaconCheckpoint: ${error.errors + .map((e) => `${e.path.join(".")} - ${e.message}`) + .join(", ")}` + ); + } + // Re-throw other errors + throw error; + } }; /** @@ -217,6 +209,9 @@ const hexToUint8Array = (hex: string): Uint8Array => { if (hexString.startsWith("0x")) { hexString = hexString.slice(2); } + if (hexString.length % 2 !== 0) { + throw new Error("Hex string must have an even number of characters"); + } return Buffer.from(hexString, "hex"); }; diff --git a/test/utils/viem.ts b/test/utils/viem.ts index afb06e9e..342795f2 100644 --- a/test/utils/viem.ts +++ b/test/utils/viem.ts @@ -1,6 +1,7 @@ import { ANVIL_FUNDED_ACCOUNTS, CHAIN_ID, getRPCUrl, getWSUrl } from "utils"; import { http, createWalletClient, defineChain, publicActions } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import type { Prettify } from "./types"; export const createChainConfig = async () => defineChain({ @@ -37,8 +38,6 @@ export const createDefaultClient = async () => transport: http() }).extend(publicActions); -// export interface ViemClientInterface extends WalletClient, PublicActions {} - -export type ViemClientInterface = Awaited>; +export type ViemClientInterface = Prettify>>; export const generateRandomAccount = () => privateKeyToAccount(generatePrivateKey());