mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 01:38:32 +00:00
test: 🐳 Add docker support for datahaven nodes (#71)
> [!NOTE] > This is `Part 3` of the ongoing _Docker Series._ ## New Additions: - Launching Datahaven network will spin up containers, as opposed to native binaries - `stop:docker` script to kill all dh containers - `e2e` test suite for datahaven solochain network - Contains reference test file that uses papi for storage queries, submitting exts, runtime calls (good job on that facu and tobi) - Added new utils: - `waitForLog()` to wait for log lines in docker container logs - `createPapiConnectors()` helper for test cases to build and connect to dh network - `getPapiSigner()` helper to return a papi compatible signer using our prefunded accounts (alith by default) - `sendTxn()` helper to submit txn and wait for block inclusion, instead of finalization, which std library provides ## Changes: > [!CAUTION] > Launching native binaries for datahaven no longer supported. - Datahaven binary location cli option changed to `-i, --datahaven-image-tag` - To locally run this you'll need a datahaven docker image handy, you'll need to either: - Point to remote dockerhub e.g. `moonsonglabs/datahaven:main` (must be logged in and have permission) - Build this locally with `bun build:docker:operator` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added end-to-end tests for the Datahaven solochain, including runtime API queries, storage lookups, extrinsic submissions, and event listening. - Introduced CLI option to specify the Datahaven Docker image tag, with a default value. - Added CLI option to disable the Relayer. - Provided new scripts to stop Docker containers associated with Datahaven. - Added utility functions for Docker log monitoring and container startup checks. - Introduced utilities for interacting with the Datahaven Polkadot API. - **Improvements** - Switched Datahaven network launch from local binaries to Docker containers. - Enhanced cache accuracy in build workflows by including Rust source files in cache keys. - Improved build performance with TypeScript incremental build options. - Increased timeout for end-to-end tests for better reliability. - Updated CLI version to 0.2.0. - Modified Dockerfile build to enable the `fast-runtime` feature. - Extended network launch summary to include relayer and container details. - **Bug Fixes** - Fixed cleanup logic by tracking and preparing for forced removal of Docker containers after tests. - **Chores** - Updated workflow steps for Docker image handling and network checks. - Adjusted scripts and workflow logic for improved Docker and test management. - Removed top-level disk usage summaries from cleanup workflow for streamlined reporting. - Enhanced shell command utility to support asynchronous wait during execution. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Facundo Farall <37149322+ffarall@users.noreply.github.com>
This commit is contained in:
parent
625718c6d2
commit
82145b882b
23 changed files with 522 additions and 168 deletions
|
|
@ -10,9 +10,6 @@ runs:
|
|||
echo "Overall disk space before cleanup (df -h /):"
|
||||
df -h /
|
||||
echo "-------------------------------------------"
|
||||
echo "Top-level directories before cleanup (du -h --max-depth=1 /):"
|
||||
sudo du -h --max-depth=1 --exclude=/proc --exclude=/sys --exclude=/dev / | sort -rh | head -n 10
|
||||
echo "-------------------------------------------"
|
||||
echo "Detailed breakdown for /usr/ before cleanup:"
|
||||
sudo du -h --max-depth=1 /usr/ | sort -rh | head -n 10
|
||||
echo "-------------------------------------------"
|
||||
|
|
@ -51,9 +48,6 @@ runs:
|
|||
echo "Overall disk space after cleanup (df -h /):"
|
||||
df -h /
|
||||
echo "-------------------------------------------"
|
||||
echo "Top-level directories after cleanup (du -h --max-depth=1 /):"
|
||||
sudo du -h --max-depth=1 --exclude=/proc --exclude=/sys --exclude=/dev / | sort -rh | head -n 10
|
||||
echo "-------------------------------------------"
|
||||
echo "Detailed breakdown for /usr/ before cleanup:"
|
||||
sudo du -h --max-depth=1 /usr/ | sort -rh | head -n 10
|
||||
echo "-------------------------------------------"
|
||||
|
|
|
|||
3
.github/workflows/task-docker.yml
vendored
3
.github/workflows/task-docker.yml
vendored
|
|
@ -65,8 +65,9 @@ jobs:
|
|||
cargo-registry
|
||||
cargo-git
|
||||
sccache
|
||||
key: cache-mount-${{ hashFiles('./operator/Dockerfile', './operator/Cargo.lock') }}
|
||||
key: cache-mount-${{ hashFiles('./operator/Dockerfile', './operator/Cargo.lock') }}-${{hashFiles('./operator/runtime/**/*.rs','./operator/pallets/**/*.rs', './operator/node/**/*.rs')}}
|
||||
restore-keys: |
|
||||
cache-mount-${{ hashFiles('./operator/Dockerfile', './operator/Cargo.lock') }}
|
||||
cache-mount-${{ hashFiles('./operator/Dockerfile') }}
|
||||
cache-mount-
|
||||
- name: Inject cache into docker
|
||||
|
|
|
|||
15
.github/workflows/task-e2e.yml
vendored
15
.github/workflows/task-e2e.yml
vendored
|
|
@ -84,14 +84,11 @@ jobs:
|
|||
chmod +x tmp/bin/snowbridge-relay
|
||||
docker rm temp
|
||||
- run: tmp/bin/snowbridge-relay --help
|
||||
- name: Download datahaven binary
|
||||
run: |
|
||||
docker create --name temp ${{ inputs.image-tag }}
|
||||
mkdir -p ../operator/target/release/
|
||||
docker cp temp:/usr/local/bin/datahaven-node ../operator/target/release/
|
||||
chmod +x ../operator/target/release/datahaven-node
|
||||
docker rm temp
|
||||
- run: ../operator/target/release/datahaven-node --help
|
||||
- run: docker pull ${{ inputs.image-tag }}
|
||||
- run: bun install
|
||||
- run: bun start:e2e:ci
|
||||
- run: bun start:e2e:ci --datahaven-image-tag ${{ inputs.image-tag }}
|
||||
- name: Check network
|
||||
run: |
|
||||
kurtosis enclave inspect datahaven-ethereum
|
||||
docker container ls
|
||||
- run: bun test:e2e
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"trailingCommas": "none",
|
||||
"semicolons": "always",
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100,
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
|
|
@ -35,6 +36,10 @@
|
|||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn",
|
||||
"noUnusedImports": "warn"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noAsyncPromiseExecutor": "off"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ FROM docker.io/paritytech/ci-unified:bullseye-1.85.0 AS base
|
|||
|
||||
ARG MOLD_VERSION=2.39.0
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
ARG FAST_RUNTIME=TRUE
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
xz-utils \
|
||||
|
|
@ -43,7 +44,11 @@ COPY ./operator/ .
|
|||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/usr/local/sccache,sharing=locked \
|
||||
cargo build --locked --release
|
||||
if [ "$FAST_RUNTIME" = "TRUE" ]; then \
|
||||
cargo build --locked --release --features fast-runtime; \
|
||||
else \
|
||||
cargo build --locked --release; \
|
||||
fi
|
||||
|
||||
# --- Create final lightweight runtime image ---
|
||||
FROM docker.io/parity/base-bin:latest
|
||||
|
|
|
|||
|
|
@ -7,26 +7,31 @@ import { type PolkadotClient, createClient } 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 { waitForContainerToStart } from "utils";
|
||||
import { confirmWithTimeout, logger, printDivider, printHeader } from "utils";
|
||||
import { type Hex, keccak256, toHex } from "viem";
|
||||
import { publicKeyToAddress } from "viem/utils";
|
||||
import { publicKeyToAddress } from "viem/accounts";
|
||||
import type { LaunchOptions } from ".";
|
||||
import type { LaunchedNetwork } from "./launchedNetwork";
|
||||
|
||||
const COMMON_LAUNCH_ARGS = [
|
||||
"--unsafe-force-node-key-generation",
|
||||
"--tmp",
|
||||
"--port=0",
|
||||
"--validator",
|
||||
"--discover-local",
|
||||
"--no-prometheus",
|
||||
"--unsafe-rpc-external",
|
||||
"--rpc-cors=all",
|
||||
"--force-authoring",
|
||||
"--no-telemetry",
|
||||
"--enable-offchain-indexing=true"
|
||||
];
|
||||
|
||||
const DEFAULT_PUBLIC_WS_PORT = 9944;
|
||||
|
||||
// We need 5 since the (2/3 + 1) of 6 authority set is 5
|
||||
// <repo_root>/operator/runtime/src/genesis_config_presets.rs#L94
|
||||
const CLI_AUTHORITY_IDS = ["alice", "bob", "charlie", "dave", "eve"];
|
||||
const CLI_AUTHORITY_IDS = ["alice", "bob", "charlie", "dave", "eve"] as const;
|
||||
|
||||
// 33-byte compressed public keys for DataHaven next validator set
|
||||
// These correspond to Alice, Bob, Charlie, Dave, Eve, Ferdie
|
||||
|
|
@ -38,7 +43,7 @@ const FALLBACK_DATAHAVEN_AUTHORITY_PUBLIC_KEYS: Record<string, string> = {
|
|||
dave: "0x0291f1217d5a04cb83312ee3d88a6e6b33284e053e6ccfc3a90339a0299d12967c",
|
||||
eve: "0x0389411795514af1627765eceffcbd002719f031604fadd7d188e2dc585b4e1afb",
|
||||
ferdie: "0x03bc9d0ca094bd5b8b3225d7651eac5d18c1c04bf8ae8f8b263eebca4e1410ed0c"
|
||||
};
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Prepares the configuration for DataHaven authorities by converting their
|
||||
|
|
@ -51,7 +56,7 @@ export async function setupDataHavenValidatorConfig(
|
|||
logger.info(`🔧 Preparing DataHaven authorities configuration for network: ${networkName}...`);
|
||||
|
||||
let authorityPublicKeys: string[] = [];
|
||||
const dhNodes = launchedNetwork.getDHNodes();
|
||||
const dhNodes = launchedNetwork.containers.filter((x) => x.name.startsWith("datahaven-"));
|
||||
|
||||
if (dhNodes.length === 0) {
|
||||
logger.warn(
|
||||
|
|
@ -60,11 +65,11 @@ export async function setupDataHavenValidatorConfig(
|
|||
authorityPublicKeys = Object.values(FALLBACK_DATAHAVEN_AUTHORITY_PUBLIC_KEYS);
|
||||
} else {
|
||||
const firstNode = dhNodes[0];
|
||||
const wsUrl = `ws://127.0.0.1:${firstNode.port}`;
|
||||
const wsUrl = `ws://127.0.0.1:${firstNode.publicPorts.ws}`;
|
||||
let papiClient: PolkadotClient | undefined;
|
||||
try {
|
||||
logger.info(
|
||||
`📡 Attempting to fetch BEEFY next authorities from node ${firstNode.id} (port ${firstNode.port})...`
|
||||
`📡 Attempting to fetch BEEFY next authorities from node ${firstNode.name} (port ${firstNode.publicPorts.ws})...`
|
||||
);
|
||||
papiClient = createClient(withPolkadotSdkCompat(getWsProvider(wsUrl)));
|
||||
const dhApi = papiClient.getTypedApi(datahaven);
|
||||
|
|
@ -84,14 +89,14 @@ export async function setupDataHavenValidatorConfig(
|
|||
);
|
||||
authorityPublicKeys = Object.values(FALLBACK_DATAHAVEN_AUTHORITY_PUBLIC_KEYS);
|
||||
}
|
||||
await papiClient.destroy();
|
||||
papiClient.destroy();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Error fetching BEEFY next authorities from node ${firstNode.id}: ${error}. Falling back to hardcoded authority set.`
|
||||
`❌ Error fetching BEEFY next authorities from node ${firstNode.name}: ${error}. Falling back to hardcoded authority set.`
|
||||
);
|
||||
authorityPublicKeys = Object.values(FALLBACK_DATAHAVEN_AUTHORITY_PUBLIC_KEYS);
|
||||
if (papiClient) {
|
||||
await papiClient.destroy(); // Ensure client is destroyed even on error
|
||||
papiClient.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -151,7 +156,6 @@ export async function setupDataHavenValidatorConfig(
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: This is very rough and will need something more substantial when we know what we want!
|
||||
/**
|
||||
* Launches a DataHaven solochain network for testing.
|
||||
*
|
||||
|
|
@ -165,6 +169,41 @@ export const launchDataHavenSolochain = async (
|
|||
printHeader("Starting DataHaven Network");
|
||||
|
||||
let shouldLaunchDataHaven = options.datahaven;
|
||||
|
||||
if ((await checkDataHavenRunning()) && !options.alwaysClean) {
|
||||
logger.info("ℹ️ DataHaven network (Docker containers) is already running.");
|
||||
|
||||
logger.trace("Checking if datahaven option was set via flags");
|
||||
if (options.datahaven === false) {
|
||||
logger.info("Keeping existing DataHaven containers.");
|
||||
|
||||
await registerNodes(launchedNetwork);
|
||||
printDivider();
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.datahaven === true) {
|
||||
logger.info("Proceeding to clean and relaunch DataHaven containers...");
|
||||
await cleanDataHavenContainers();
|
||||
} else {
|
||||
const shouldRelaunch = await confirmWithTimeout(
|
||||
"Do you want to clean and relaunch the DataHaven containers?",
|
||||
true,
|
||||
10
|
||||
);
|
||||
|
||||
if (!shouldRelaunch) {
|
||||
logger.info("Keeping existing DataHaven containers.");
|
||||
|
||||
await registerNodes(launchedNetwork);
|
||||
printDivider();
|
||||
return;
|
||||
}
|
||||
logger.info("Proceeding to clean and relaunch DataHaven containers...");
|
||||
await cleanDataHavenContainers();
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldLaunchDataHaven === undefined) {
|
||||
shouldLaunchDataHaven = await confirmWithTimeout(
|
||||
"Do you want to launch the DataHaven network?",
|
||||
|
|
@ -183,15 +222,9 @@ export const launchDataHavenSolochain = async (
|
|||
return;
|
||||
}
|
||||
|
||||
// Kill any pre-existing datahaven processes if they exist
|
||||
await $`pkill datahaven`.nothrow().quiet();
|
||||
invariant(options.datahavenImageTag, "❌ Datahaven image tag not defined");
|
||||
|
||||
invariant(options.datahavenBinPath, "❌ DataHaven binary path not defined");
|
||||
|
||||
invariant(
|
||||
await Bun.file(options.datahavenBinPath).exists(),
|
||||
"❌ DataHaven binary does not exist"
|
||||
);
|
||||
await checkTagExists(options.datahavenImageTag);
|
||||
|
||||
const logsPath = `tmp/logs/${launchedNetwork.getRunId()}/`;
|
||||
logger.debug(`Ensuring logs directory exists: ${logsPath}`);
|
||||
|
|
@ -199,57 +232,46 @@ export const launchDataHavenSolochain = async (
|
|||
|
||||
for (const id of CLI_AUTHORITY_IDS) {
|
||||
logger.info(`Starting ${id}...`);
|
||||
const containerName = `datahaven-${id}`;
|
||||
|
||||
const command: string[] = [options.datahavenBinPath, ...COMMON_LAUNCH_ARGS, `--${id}`];
|
||||
const command: string[] = [
|
||||
"docker",
|
||||
"run",
|
||||
"-d",
|
||||
"--platform",
|
||||
"linux/amd64",
|
||||
"--name",
|
||||
containerName,
|
||||
...(id === "alice" ? ["-p", `${DEFAULT_PUBLIC_WS_PORT}:9944`] : []),
|
||||
options.datahavenImageTag,
|
||||
`--${id}`,
|
||||
...COMMON_LAUNCH_ARGS
|
||||
];
|
||||
|
||||
const logFileName = `datahaven-${id}.log`;
|
||||
const logFilePath = path.join(logsPath, logFileName);
|
||||
logger.debug(`Writing logs to ${logFilePath}`);
|
||||
logger.debug($`sh -c "${command.join(" ")}"`.text());
|
||||
|
||||
const fd = fs.openSync(logFilePath, "a");
|
||||
launchedNetwork.addFileDescriptor(fd);
|
||||
await waitForContainerToStart(containerName);
|
||||
|
||||
logger.debug(`Spawning command: ${command.join(" ")}`);
|
||||
const process = Bun.spawn(command, {
|
||||
stdout: fd,
|
||||
stderr: fd
|
||||
});
|
||||
|
||||
process.unref();
|
||||
|
||||
let completed = false;
|
||||
const file = Bun.file(logFilePath);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const pattern = "Running JSON-RPC server: addr=127.0.0.1:";
|
||||
const blob = await file.text();
|
||||
logger.debug(`Blob: ${blob}`);
|
||||
if (blob.includes(pattern)) {
|
||||
const port = blob.split(pattern)[1].split("\n")[0].replaceAll(",", "");
|
||||
launchedNetwork.addDHNode(id, Number.parseInt(port));
|
||||
logger.debug(`${id} started at port ${port}`);
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
invariant(completed, "❌ Could not find 'Running JSON-RPC server:' in logs");
|
||||
|
||||
launchedNetwork.addProcess(process);
|
||||
logger.debug(`Started ${id} at ${process.pid}`);
|
||||
// TODO: Un-comment this when it doesn't stop process from hanging
|
||||
// This is working on SH, but not here so probably a Bun defect
|
||||
//
|
||||
// const listeningLine = await waitForLog({
|
||||
// search: "Running JSON-RPC server: addr=0.0.0.0:",
|
||||
// containerName,
|
||||
// timeoutSeconds: 30
|
||||
// });
|
||||
// logger.debug(listeningLine);
|
||||
}
|
||||
|
||||
// Check if network is ready
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
logger.info("Waiting for datahaven to start...");
|
||||
|
||||
// Get the port of the primary node (or default)
|
||||
const primaryNodePort = launchedNetwork.getDHNodes()[0]?.port || 9944;
|
||||
|
||||
if (await isNetworkReady(primaryNodePort)) {
|
||||
if (await isNetworkReady(DEFAULT_PUBLIC_WS_PORT)) {
|
||||
logger.success(
|
||||
`DataHaven network started, primary node accessible on port ${primaryNodePort}`
|
||||
`DataHaven network started, primary node accessible on port ${DEFAULT_PUBLIC_WS_PORT}`
|
||||
);
|
||||
|
||||
await registerNodes(launchedNetwork);
|
||||
|
||||
// Call setupDataHavenValidatorConfig now that nodes are up
|
||||
logger.info("Proceeding with DataHaven validator configuration setup...");
|
||||
await setupDataHavenValidatorConfig(launchedNetwork);
|
||||
|
|
@ -261,9 +283,47 @@ export const launchDataHavenSolochain = async (
|
|||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
throw new Error("DataHaven network failed to start after 10 seconds");
|
||||
throw new Error("Datahaven network failed to start after 30 seconds");
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if any DataHaven containers are currently running.
|
||||
*
|
||||
* @returns True if any DataHaven containers are running, false otherwise.
|
||||
*/
|
||||
const checkDataHavenRunning = async (): Promise<boolean> => {
|
||||
// Check for any container whose name starts with "datahaven-"
|
||||
const PIDS = await $`docker ps -q --filter "name=^datahaven-"`.text();
|
||||
return PIDS.trim().length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops and removes all DataHaven containers.
|
||||
*/
|
||||
const cleanDataHavenContainers = async (): Promise<void> => {
|
||||
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());
|
||||
}
|
||||
}
|
||||
logger.info("✅ Existing DataHaven containers stopped and removed.");
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the DataHaven network is ready by sending a POST request to the system_chain method.
|
||||
*
|
||||
* @param port - The port number to check.
|
||||
* @returns True if the network is ready, false otherwise.
|
||||
*/
|
||||
export const isNetworkReady = async (port: number): Promise<boolean> => {
|
||||
const wsUrl = `ws://127.0.0.1:${port}`;
|
||||
let client: PolkadotClient | undefined;
|
||||
|
|
@ -272,17 +332,65 @@ export const isNetworkReady = async (port: number): Promise<boolean> => {
|
|||
client = createClient(withPolkadotSdkCompat(getWsProvider(wsUrl)));
|
||||
const chainName = await client._request<string>("system_chain", []);
|
||||
logger.debug(`isNetworkReady PAPI check successful for port ${port}, chain: ${chainName}`);
|
||||
await client.destroy();
|
||||
client.destroy();
|
||||
return !!chainName; // Ensure it's a boolean and chainName is truthy
|
||||
} catch (error) {
|
||||
logger.debug(`isNetworkReady PAPI check failed for port ${port}: ${error}`);
|
||||
if (client) {
|
||||
await client.destroy();
|
||||
client.destroy();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if an image exists locally or on Docker Hub.
|
||||
*
|
||||
* @param tag - The tag of the image to check.
|
||||
* @returns A promise that resolves when the image is found.
|
||||
*/
|
||||
const checkTagExists = async (tag: string) => {
|
||||
const cleaned = tag.trim();
|
||||
logger.debug(`Checking if image ${cleaned} is available locally`);
|
||||
const { exitCode: localExists } = await $`docker image inspect ${cleaned}`.nothrow().quiet();
|
||||
|
||||
if (localExists !== 0) {
|
||||
logger.debug(`Checking if image ${cleaned} is available on docker hub`);
|
||||
const result = await $`docker manifest inspect ${cleaned}`.nothrow().quiet();
|
||||
invariant(
|
||||
result.exitCode === 0,
|
||||
`❌ Image ${tag} not found.\n Does this image exist?\n Are you logged and have access to the repository?`
|
||||
);
|
||||
}
|
||||
|
||||
logger.success(`Image ${tag} found locally`);
|
||||
};
|
||||
|
||||
const registerNodes = async (launchedNetwork: LaunchedNetwork) => {
|
||||
const targetContainerName = "datahaven-alice";
|
||||
const aliceHostWsPort = 9944; // Standard host port for Alice's WS, as set during launch.
|
||||
|
||||
logger.debug(`Checking Docker status for container: ${targetContainerName}`);
|
||||
// Use ^ and $ for an exact name match in the filter.
|
||||
const dockerPsOutput = await $`docker ps -q --filter "name=^${targetContainerName}$"`.text();
|
||||
const isContainerRunning = dockerPsOutput.trim().length > 0;
|
||||
|
||||
if (!isContainerRunning) {
|
||||
// If the target Docker container is not running, we cannot register it.
|
||||
throw new Error(
|
||||
`❌ Docker container ${targetContainerName} is not running. Cannot register node.`
|
||||
);
|
||||
}
|
||||
|
||||
// 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}.`
|
||||
);
|
||||
launchedNetwork.addContainer(targetContainerName, { ws: aliceHostWsPort });
|
||||
logger.success(`👍 Node ${targetContainerName} successfully registered in launchedNetwork.`);
|
||||
};
|
||||
|
||||
// Function to convert compressed public key to Ethereum address
|
||||
export const compressedPubKeyToEthereumAddress = (compressedPubKey: string): string => {
|
||||
// Ensure the input is a hex string and remove "0x" prefix
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ export interface LaunchOptions {
|
|||
relayerBinPath?: string;
|
||||
skipCleaning?: boolean;
|
||||
alwaysClean?: boolean;
|
||||
datahavenBinPath?: string;
|
||||
datahaven?: boolean;
|
||||
datahavenImageTag?: string;
|
||||
kurtosisNetworkArgs?: string;
|
||||
slotTime?: number;
|
||||
}
|
||||
|
|
@ -63,13 +63,13 @@ const launchFunction = async (options: LaunchOptions, launchedNetwork: LaunchedN
|
|||
}
|
||||
|
||||
const contractsDeployed = await deployContracts({
|
||||
rpcUrl: launchedNetwork.getElRpcUrl(),
|
||||
rpcUrl: launchedNetwork.elRpcUrl,
|
||||
verified: options.verified,
|
||||
blockscoutBackendUrl,
|
||||
deployContracts: options.deployContracts
|
||||
});
|
||||
|
||||
await performValidatorOperations(options, launchedNetwork.getElRpcUrl(), contractsDeployed);
|
||||
await performValidatorOperations(options, launchedNetwork.elRpcUrl, contractsDeployed);
|
||||
|
||||
await launchRelayers(options, launchedNetwork);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const launchKurtosis = async (
|
|||
logger.trace("Checking if launchKurtosis option was set via flags");
|
||||
if (options.launchKurtosis === false) {
|
||||
logger.info("Keeping existing Kurtosis enclave.");
|
||||
|
||||
await registerServices(launchedNetwork);
|
||||
printDivider();
|
||||
return;
|
||||
|
|
@ -39,6 +40,7 @@ export const launchKurtosis = async (
|
|||
|
||||
if (!shouldRelaunch) {
|
||||
logger.info("Keeping existing Kurtosis enclave.");
|
||||
|
||||
await registerServices(launchedNetwork);
|
||||
printDivider();
|
||||
return;
|
||||
|
|
@ -146,7 +148,7 @@ const registerServices = async (launchedNetwork: LaunchedNetwork) => {
|
|||
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.setElRpcUrl(elRpcUrl);
|
||||
launchedNetwork.elRpcUrl = elRpcUrl;
|
||||
logger.info(`👍 Execution Layer RPC URL configured: ${elRpcUrl}`);
|
||||
|
||||
// Configure CL Endpoint
|
||||
|
|
@ -156,6 +158,6 @@ const registerServices = async (launchedNetwork: LaunchedNetwork) => {
|
|||
clEndpoint,
|
||||
"❌ CL Endpoint could not be determined from Kurtosis service cl-1-lighthouse-reth"
|
||||
);
|
||||
launchedNetwork.setClEndpoint(clEndpoint);
|
||||
launchedNetwork.clEndpoint = clEndpoint;
|
||||
logger.info(`👍 Consensus Layer Endpoint configured: ${clEndpoint}`);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import fs from "node:fs";
|
||||
import invariant from "tiny-invariant";
|
||||
import { logger } from "utils";
|
||||
import { type RelayerType, logger } from "utils";
|
||||
|
||||
type PipeOptions = number | "inherit" | "pipe" | "ignore";
|
||||
type BunProcess = Bun.Subprocess<PipeOptions, PipeOptions, PipeOptions>;
|
||||
type ContainerSpec = { name: string; publicPorts: Record<string, number> };
|
||||
|
||||
/**
|
||||
* Represents the state and associated resources of a launched network environment,
|
||||
|
|
@ -8,21 +12,23 @@ import { logger } from "utils";
|
|||
*/
|
||||
export class LaunchedNetwork {
|
||||
protected runId: string;
|
||||
protected processes: Bun.Subprocess<"inherit" | "pipe" | "ignore", number, number>[];
|
||||
protected processes: BunProcess[];
|
||||
protected _containers: ContainerSpec[];
|
||||
protected fileDescriptors: number[];
|
||||
protected DHNodes: { id: string; port: number }[];
|
||||
protected _activeRelayers: RelayerType[];
|
||||
/** The RPC URL for the Ethereum Execution Layer (EL) client. */
|
||||
protected elRpcUrl?: string;
|
||||
protected _elRpcUrl?: string;
|
||||
/** The HTTP endpoint for the Ethereum Consensus Layer (CL) client. */
|
||||
protected clEndpoint?: string;
|
||||
protected _clEndpoint?: string;
|
||||
|
||||
constructor() {
|
||||
this.runId = crypto.randomUUID();
|
||||
this.processes = [];
|
||||
this.fileDescriptors = [];
|
||||
this.DHNodes = [];
|
||||
this.elRpcUrl = undefined;
|
||||
this.clEndpoint = undefined;
|
||||
this._containers = [];
|
||||
this._activeRelayers = [];
|
||||
this._elRpcUrl = undefined;
|
||||
this._clEndpoint = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -34,23 +40,17 @@ export class LaunchedNetwork {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets the list of launched DataHaven (DH) nodes.
|
||||
* @returns An array of DH node objects, each with an id and port.
|
||||
* Gets the port for a DataHaven RPC node.
|
||||
*
|
||||
* In reality, it just looks for the "ws" port of the
|
||||
* `datahaven-alice` container, if it was registered.
|
||||
* @returns The port number of the container, or -1 if ws port is not found.
|
||||
* @throws If the container is not found.
|
||||
*/
|
||||
getDHNodes(): { id: string; port: number }[] {
|
||||
return [...this.DHNodes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the port for a specific DataHaven (DH) node by its ID.
|
||||
* @param id - The ID of the DH node.
|
||||
* @returns The port number of the DH node.
|
||||
* @throws If the node with the given ID is not found.
|
||||
*/
|
||||
getDHPort(id: string): number {
|
||||
const node = this.DHNodes.find((x) => x.id === id);
|
||||
invariant(node, `❌ DataHaven node ${id} not found`);
|
||||
return node.port;
|
||||
getContainerPort(id: string): number {
|
||||
const container = this._containers.find((x) => x.name === id);
|
||||
invariant(container, `❌ Container ${id} not found`);
|
||||
return container.publicPorts.ws ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -65,25 +65,42 @@ export class LaunchedNetwork {
|
|||
* Adds a running process to be managed and cleaned up.
|
||||
* @param process - The Bun subprocess object.
|
||||
*/
|
||||
addProcess(process: Bun.Subprocess<"inherit" | "pipe" | "ignore", number, number>) {
|
||||
addProcess(process: BunProcess) {
|
||||
this.processes.push(process);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DataHaven (DH) node to the list of launched nodes.
|
||||
* @param id - The ID of the DH node.
|
||||
* @param port - The port number the DH node is running on.
|
||||
*/
|
||||
addDHNode(id: string, port: number) {
|
||||
this.DHNodes.push({ id, port });
|
||||
addContainer(containerName: string, publicPorts: Record<string, number> = {}) {
|
||||
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:");
|
||||
logger.debug(JSON.stringify(this.containers));
|
||||
const port = this.containers.map((x) => x.publicPorts.ws).find((x) => x !== -1);
|
||||
invariant(port !== undefined, "❌ No public port found in containers");
|
||||
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.
|
||||
*/
|
||||
setElRpcUrl(url: string) {
|
||||
this.elRpcUrl = url;
|
||||
public set elRpcUrl(url: string) {
|
||||
this._elRpcUrl = url;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -91,17 +108,17 @@ export class LaunchedNetwork {
|
|||
* @returns The EL RPC URL string.
|
||||
* @throws If the EL RPC URL has not been set.
|
||||
*/
|
||||
getElRpcUrl(): string {
|
||||
invariant(this.elRpcUrl, "❌ EL RPC URL not set in LaunchedNetwork");
|
||||
return this.elRpcUrl;
|
||||
public get elRpcUrl(): string {
|
||||
invariant(this._elRpcUrl, "❌ EL RPC URL not set in LaunchedNetwork");
|
||||
return this._elRpcUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the HTTP endpoint for the Ethereum Consensus Layer (CL) client.
|
||||
* @param url - The CL HTTP endpoint string.
|
||||
*/
|
||||
setClEndpoint(url: string) {
|
||||
this.clEndpoint = url;
|
||||
public set clEndpoint(url: string) {
|
||||
this._clEndpoint = url;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -109,14 +126,16 @@ export class LaunchedNetwork {
|
|||
* @returns The CL HTTP endpoint string.
|
||||
* @throws If the CL HTTP endpoint has not been set.
|
||||
*/
|
||||
getClEndpoint(): string {
|
||||
invariant(this.clEndpoint, "❌ CL HTTP Endpoint not set in LaunchedNetwork");
|
||||
return this.clEndpoint;
|
||||
public get clEndpoint(): string {
|
||||
invariant(this._clEndpoint, "❌ CL HTTP Endpoint not set in LaunchedNetwork");
|
||||
return this._clEndpoint;
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
logger.debug("Running cleanup");
|
||||
for (const process of this.processes) {
|
||||
logger.debug(`Process is still running: ${process.pid}`);
|
||||
process.unref();
|
||||
}
|
||||
|
||||
for (const fd of this.fileDescriptors) {
|
||||
|
|
|
|||
|
|
@ -69,7 +69,9 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La
|
|||
}
|
||||
|
||||
// Get DataHaven node port
|
||||
const dhNodes = launchedNetwork.getDHNodes();
|
||||
const dhNodes = launchedNetwork.containers.filter((container) =>
|
||||
container.name.includes("datahaven")
|
||||
);
|
||||
let substrateWsPort: number;
|
||||
let substrateNodeId: string;
|
||||
|
||||
|
|
@ -81,8 +83,8 @@ export const launchRelayers = async (options: LaunchOptions, launchedNetwork: La
|
|||
substrateNodeId = "default (assumed)";
|
||||
} else {
|
||||
const firstDhNode = dhNodes[0];
|
||||
substrateWsPort = firstDhNode.port;
|
||||
substrateNodeId = firstDhNode.id;
|
||||
substrateWsPort = firstDhNode.publicPorts.ws;
|
||||
substrateNodeId = firstDhNode.name;
|
||||
logger.info(
|
||||
`🔌 Using DataHaven node ${substrateNodeId} on port ${substrateWsPort} for relayers and BEEFY check.`
|
||||
);
|
||||
|
|
@ -238,7 +240,7 @@ const waitBeefyReady = async (
|
|||
pollIntervalMs: number,
|
||||
timeoutMs: number
|
||||
): Promise<void> => {
|
||||
const port = launchedNetwork.getDHNodes()[0]?.port ?? 9944;
|
||||
const port = launchedNetwork.getPublicWsPort();
|
||||
const wsUrl = `ws://127.0.0.1:${port}`;
|
||||
const maxAttempts = Math.floor(timeoutMs / pollIntervalMs);
|
||||
|
||||
|
|
@ -255,7 +257,7 @@ const waitBeefyReady = async (
|
|||
|
||||
if (finalizedHeadHex && finalizedHeadHex !== ZERO_HASH) {
|
||||
logger.success(`🥩 BEEFY is ready. Finalized head: ${finalizedHeadHex}`);
|
||||
await client.destroy();
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -270,12 +272,12 @@ const waitBeefyReady = async (
|
|||
}
|
||||
|
||||
logger.error(`❌ BEEFY failed to become ready after ${timeoutMs / 1000} seconds`);
|
||||
if (client) await client.destroy();
|
||||
if (client) client.destroy();
|
||||
throw new Error("BEEFY protocol not ready. Relayers cannot be launched.");
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to connect to DataHaven node for BEEFY check: ${error}`);
|
||||
if (client) {
|
||||
await client.destroy();
|
||||
client.destroy();
|
||||
}
|
||||
throw new Error("BEEFY protocol not ready. Relayers cannot be launched.");
|
||||
}
|
||||
|
|
@ -317,7 +319,7 @@ export const initEthClientPallet = async (
|
|||
logger.trace(initialCheckpoint.toJSON());
|
||||
|
||||
// Send the checkpoint to the Substrate runtime
|
||||
const substrateRpcUrl = `http://127.0.0.1:${launchedNetwork.getDHNodes()[0].port}`;
|
||||
const substrateRpcUrl = `http://127.0.0.1:${launchedNetwork.getPublicWsPort()}`;
|
||||
await sendCheckpointToSubstrate(substrateRpcUrl, initialCheckpoint);
|
||||
};
|
||||
|
||||
|
|
@ -344,7 +346,7 @@ const waitBeaconChainReady = async (
|
|||
while (keepPolling) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${launchedNetwork.getClEndpoint()}/eth/v1/beacon/states/head/finality_checkpoints`
|
||||
`${launchedNetwork.clEndpoint}/eth/v1/beacon/states/head/finality_checkpoints`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
|
|
|
|||
|
|
@ -15,9 +15,13 @@ export const performSummaryOperations = async (
|
|||
servicesToDisplay.push(...["blockscout", "blockscout-frontend"]);
|
||||
}
|
||||
|
||||
const dhNodes = launchedNetwork.getDHNodes();
|
||||
for (const { id } of dhNodes) {
|
||||
servicesToDisplay.push(`datahaven-${id}`);
|
||||
if (launchedNetwork.containers.find((c) => c.name === "datahaven-alice")) {
|
||||
servicesToDisplay.push("datahaven-alice");
|
||||
}
|
||||
|
||||
const activeRelayers = launchedNetwork.relayers;
|
||||
for (const relayer of activeRelayers) {
|
||||
servicesToDisplay.push(`${relayer}-relayer`);
|
||||
}
|
||||
|
||||
logger.trace("Services to display", servicesToDisplay);
|
||||
|
|
@ -87,21 +91,45 @@ export const performSummaryOperations = async (
|
|||
break;
|
||||
}
|
||||
|
||||
case service.startsWith("datahaven-"): {
|
||||
const port = launchedNetwork.getDHPort(service.split("datahaven-")[1]);
|
||||
case service === "datahaven-alice": {
|
||||
const port = launchedNetwork.getContainerPort(service);
|
||||
displayData.push({
|
||||
service,
|
||||
ports: { http: port },
|
||||
ports: { ws: port },
|
||||
url: `http://127.0.0.1:${port}`
|
||||
});
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
console.table(displayData);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ const program = new Command()
|
|||
.option("--always-clean", "Always clean Kurtosis", false)
|
||||
.option("--skip-cleaning", "Skip cleaning Kurtosis")
|
||||
.option(
|
||||
"--datahaven-bin-path <value>",
|
||||
"Path to the datahaven binary",
|
||||
"../operator/target/release/datahaven-node"
|
||||
"-i, --datahaven-image-tag <value>",
|
||||
"Tag of the datahaven image to use",
|
||||
"moonsonglabs/datahaven:local"
|
||||
)
|
||||
.option("--relayer-bin-path <value>", "Path to the relayer binary", "tmp/bin/snowbridge-relay")
|
||||
.hook("preAction", launchPreActionHook)
|
||||
|
|
@ -45,7 +45,7 @@ const program = new Command()
|
|||
|
||||
// ===== Program =====
|
||||
program
|
||||
.version("0.1.0")
|
||||
.version("0.2.0")
|
||||
.name("bun cli")
|
||||
.summary("🫎 DataHaven: Network Launcher CLI")
|
||||
.usage("[options]")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"cli": "bun run cli/index.ts",
|
||||
"fmt": "biome check .",
|
||||
"fmt:fix": "biome check --write .",
|
||||
"build:docker:operator": "docker buildx build -t moonsonglabs/datahaven-node:local -f ../operator/Dockerfile ../.",
|
||||
"build:docker:operator": "docker buildx build --platform=linux/amd64 -t moonsonglabs/datahaven:local -f ../operator/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()\"",
|
||||
|
|
@ -15,12 +15,14 @@
|
|||
"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:ci": "bun cli --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",
|
||||
"start:e2e:minimal:relayer": "bun cli --relayer --deploy-contracts --no-setup-validators --no-update-validator-set --no-fund-validators --datahaven",
|
||||
"stop:e2e": "pkill datahaven ; pkill snowbridge-relay ; kurtosis enclave stop datahaven-ethereum && kurtosis clean && kurtosis engine stop && docker container prune -f",
|
||||
"stop:e2e:verified": "bun stop:e2e",
|
||||
"stop:e2e:quick": "kurtosis enclave stop datahaven-ethereum",
|
||||
"stop:kurtosis-engine": "kurtosis engine stop && docker container prune -f",
|
||||
"test:e2e": "bun test suites/e2e --timeout 30000",
|
||||
"test:e2e": "bun test suites/e2e --timeout 60000",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"postinstall": "papi"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@ async function waitBeaconChainReady(options: SnowbridgeConfigOptions): Promise<v
|
|||
logger.info(`Beacon chain finalized. Finalized root: ${initialBeaconBlock}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
logger.trace({ attempt: i + 1 }, "Beacon finality check failed or not ready, retrying...");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import path from "node:path";
|
|||
import { $ } from "bun";
|
||||
import { Octokit } from "octokit";
|
||||
import invariant from "tiny-invariant";
|
||||
import { logger, printDivider, printHeader } from "utils";
|
||||
import { logger, printHeader } from "utils";
|
||||
|
||||
const IMAGE_NAME = "snowbridge-relay:local";
|
||||
const RELATIVE_DOCKER_FILE_PATH = "../../docker/SnowbridgeRelayer.dockerfile";
|
||||
|
|
|
|||
74
test/suites/e2e/datahaven-basic.test.ts
Normal file
74
test/suites/e2e/datahaven-basic.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import type { PolkadotSigner } from "polkadot-api";
|
||||
import {
|
||||
type DataHavenApi,
|
||||
SUBSTRATE_FUNDED_ACCOUNTS,
|
||||
createPapiConnectors,
|
||||
generateRandomAccount,
|
||||
getPapiSigner,
|
||||
logger
|
||||
} from "utils";
|
||||
import { isAddress, parseEther } from "viem";
|
||||
|
||||
describe("Datahaven solochain", () => {
|
||||
let api: DataHavenApi;
|
||||
let signer: PolkadotSigner;
|
||||
|
||||
beforeAll(() => {
|
||||
const { typedApi } = createPapiConnectors();
|
||||
api = typedApi;
|
||||
signer = getPapiSigner();
|
||||
});
|
||||
|
||||
it("Can query runtime API", async () => {
|
||||
const address = await api.apis.EthereumRuntimeRPCApi.author();
|
||||
logger.debug(`Author Address is: ${address.asHex()}`);
|
||||
expect(isAddress(address.asHex())).toBeTrue();
|
||||
});
|
||||
|
||||
it("Can lookup storages ", async () => {
|
||||
const {
|
||||
data: { free: freeBalance }
|
||||
} = await api.query.System.Account.getValue(SUBSTRATE_FUNDED_ACCOUNTS.ALITH.publicKey);
|
||||
logger.debug(`Balance of ALITH on DH is ${freeBalance}`);
|
||||
expect(freeBalance).toBeGreaterThan(0n);
|
||||
});
|
||||
|
||||
it("Can submit extrinsics into finalized block", async () => {
|
||||
const value = parseEther("1");
|
||||
const { address: dest } = generateRandomAccount();
|
||||
const ext = api.tx.Balances.transfer_allow_death({
|
||||
dest,
|
||||
value
|
||||
});
|
||||
|
||||
// This will wait until finalized block
|
||||
const resp = await ext.signAndSubmit(signer, {});
|
||||
logger.debug(`Transaction in finalized block: ${resp.txHash}`);
|
||||
});
|
||||
|
||||
// This is way faster and should be how we submit build tests
|
||||
it("Can submit extrinsics into best block", async () => {
|
||||
const value = parseEther("1");
|
||||
const { address: dest } = generateRandomAccount();
|
||||
const ext = api.tx.Balances.transfer_allow_death({
|
||||
dest,
|
||||
value
|
||||
});
|
||||
|
||||
const resp = await ext.signAndSubmit(signer, { at: "best" });
|
||||
logger.debug(`Transaction submitted: ${resp.txHash}`);
|
||||
const {
|
||||
data: { free: freeBalance }
|
||||
} = await api.query.System.Account.getValue(dest, { at: "best" });
|
||||
logger.debug(`Balance of ${dest} on DH is ${freeBalance}`);
|
||||
expect(freeBalance).toBeGreaterThan(0n);
|
||||
});
|
||||
|
||||
it("Can listen to events", async () => {
|
||||
const event = await api.event.System.ExtrinsicSuccess.pull();
|
||||
logger.debug(event[0]);
|
||||
expect(event).not.toBeEmpty();
|
||||
expect(event[0].payload.dispatch_info.weight.ref_time).toBeGreaterThan(0n);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,5 @@
|
|||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { beefyClientAbi } from "contract-bindings";
|
||||
import {
|
||||
type AnvilDeployments,
|
||||
type ContractInstance,
|
||||
type ViemClientInterface,
|
||||
createDefaultClient,
|
||||
getContractInstance,
|
||||
logger,
|
||||
parseDeploymentsFile
|
||||
} from "utils";
|
||||
import { type ContractInstance, getContractInstance, logger } from "utils";
|
||||
import { isAddress } from "viem";
|
||||
|
||||
describe("BeefyClient contract", async () => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@
|
|||
"baseUrl": "./",
|
||||
"preserveConstEnums": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
|
||||
// Speed
|
||||
"tsBuildInfoFile": "tmp/tsconfig.tsbuildinfo",
|
||||
"assumeChangesOnlyAffectDirectDependencies": true
|
||||
},
|
||||
"include": [
|
||||
"utils/*.ts",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { type Duplex, PassThrough, Transform } from "node:stream";
|
||||
import Docker from "dockerode";
|
||||
import invariant from "tiny-invariant";
|
||||
import { type ServiceInfo, type ServiceMapping, StandardServiceMappings, logger } from "utils";
|
||||
import { type ServiceInfo, StandardServiceMappings, logger } from "utils";
|
||||
|
||||
const docker = new Docker({});
|
||||
|
||||
export const getServicesFromDocker = async (): Promise<ServiceInfo[]> => {
|
||||
const docker = new Docker();
|
||||
|
||||
const containers = await docker.listContainers();
|
||||
|
||||
const services: ServiceInfo[] = [];
|
||||
|
||||
for (const mapping of StandardServiceMappings) {
|
||||
|
|
@ -83,3 +83,92 @@ export const getPublicPort = async (
|
|||
invariant(portMappings, `❌ port mapping not found for ${containerName}:${internalPort}`);
|
||||
return portMappings.PublicPort;
|
||||
};
|
||||
|
||||
export async function waitForLog(opts: {
|
||||
search: string | RegExp;
|
||||
containerName: string;
|
||||
timeoutSeconds?: number;
|
||||
}): Promise<string> {
|
||||
const container = docker.getContainer(opts.containerName);
|
||||
await container.inspect();
|
||||
const timeoutMs = (opts.timeoutSeconds ?? 10) * 1_000;
|
||||
|
||||
const rawStream = (await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
follow: true,
|
||||
since: 0
|
||||
})) as Duplex;
|
||||
const pass = new PassThrough();
|
||||
container.modem.demuxStream(rawStream, pass, pass);
|
||||
|
||||
const { readable } = Transform.toWeb(pass);
|
||||
const decoder = new TextDecoder();
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
pass.destroy(
|
||||
new Error(
|
||||
`Timed out after ${timeoutMs} ms waiting for “${opts.search}” in ${opts.containerName}`
|
||||
)
|
||||
),
|
||||
timeoutMs
|
||||
);
|
||||
|
||||
try {
|
||||
for await (const chunk of readable) {
|
||||
const text = decoder.decode(chunk as Uint8Array, { stream: false });
|
||||
|
||||
const hit =
|
||||
typeof opts.search === "string" ? text.includes(opts.search) : opts.search.test(text);
|
||||
|
||||
if (hit) return text.trim();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Log stream ended before “${opts.search}” appeared for container ${opts.containerName}`
|
||||
);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (pass && typeof pass.destroy === "function" && !pass.destroyed) {
|
||||
pass.destroy();
|
||||
}
|
||||
|
||||
if (rawStream) {
|
||||
if (typeof rawStream.destroy === "function" && !rawStream.destroyed) {
|
||||
rawStream.destroy();
|
||||
}
|
||||
const socket = (rawStream as any).socket;
|
||||
if (socket && typeof socket.destroy === "function" && !socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const waitForContainerToStart = async (
|
||||
containerName: string,
|
||||
options?: { timeoutSeconds?: number }
|
||||
) => {
|
||||
logger.debug(`Waiting for container ${containerName} to start...`);
|
||||
const docker = new Docker();
|
||||
const seconds = options?.timeoutSeconds ?? 30;
|
||||
|
||||
for (let i = 0; i < seconds; i++) {
|
||||
const containers = await docker.listContainers();
|
||||
const container = containers.find((container) =>
|
||||
container.Names.some((name) => name.includes(containerName))
|
||||
);
|
||||
if (container) {
|
||||
logger.debug(`Container ${containerName} started after ${i} seconds`);
|
||||
return;
|
||||
}
|
||||
await Bun.sleep(1000);
|
||||
}
|
||||
invariant(
|
||||
false,
|
||||
`❌ container ${containerName} cannot be found in running container list after ${seconds} seconds`
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ const portDetailSchema = z.object({
|
|||
});
|
||||
|
||||
const portsListSchema = z.record(z.string(), portDetailSchema);
|
||||
type PortsList = z.infer<typeof portsListSchema>;
|
||||
|
||||
const serviceSchema = z.object({
|
||||
image: z.string(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { secp256k1 } from "@noble/curves/secp256k1";
|
||||
import { keccak_256 } from "@noble/hashes/sha3";
|
||||
import { datahaven } from "@polkadot-api/descriptors";
|
||||
import { type PolkadotClient, type TypedApi, createClient } from "polkadot-api";
|
||||
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
|
||||
import { type PolkadotSigner, getPolkadotSigner } from "polkadot-api/signer";
|
||||
import { getWsProvider } from "polkadot-api/ws-provider/web";
|
||||
import { SUBSTRATE_FUNDED_ACCOUNTS } from "./constants";
|
||||
import type { Prettify } from "./types";
|
||||
|
||||
// A signer for EVM like chains that use AccountId20 as their public address
|
||||
export const getEvmEcdsaSigner = (privateKey: string): PolkadotSigner => {
|
||||
|
|
@ -31,3 +37,16 @@ export const signEcdsa = (
|
|||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const createPapiConnectors = (
|
||||
wsUrl?: string
|
||||
): { client: PolkadotClient; typedApi: DataHavenApi } => {
|
||||
const url = wsUrl ?? "ws://127.0.0.1:9944";
|
||||
const client = createClient(withPolkadotSdkCompat(getWsProvider(url)));
|
||||
return { client, typedApi: client.getTypedApi(datahaven) };
|
||||
};
|
||||
|
||||
export const getPapiSigner = (person: keyof typeof SUBSTRATE_FUNDED_ACCOUNTS = "ALITH") =>
|
||||
getEvmEcdsaSigner(SUBSTRATE_FUNDED_ACCOUNTS[person].privateKey);
|
||||
|
||||
export type DataHavenApi = Prettify<TypedApi<typeof datahaven>>;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ export type LogLevel = "info" | "debug" | "error" | "warn";
|
|||
|
||||
export const runShellCommandWithLogger = async (
|
||||
command: string,
|
||||
options?: { cwd?: string; env?: object; logLevel?: LogLevel }
|
||||
options?: {
|
||||
cwd?: string;
|
||||
env?: object;
|
||||
logLevel?: LogLevel;
|
||||
waitFor?: (...args: unknown[]) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
const { cwd = ".", env = {}, logLevel = "info" as LogLevel } = options || {};
|
||||
|
||||
|
|
@ -56,6 +61,10 @@ export const runShellCommandWithLogger = async (
|
|||
readStream(stderrReader, "stderr", "error")
|
||||
]);
|
||||
|
||||
if (options?.waitFor) {
|
||||
await options.waitFor();
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
} catch (err) {
|
||||
logger.error("❌ Error running shell command:", command, "in", cwd);
|
||||
|
|
|
|||
|
|
@ -219,3 +219,9 @@ const hexToUint8Array = (hex: string): Uint8Array => {
|
|||
}
|
||||
return Buffer.from(hexString, "hex");
|
||||
};
|
||||
|
||||
// This squashes together the properties of the input type T
|
||||
// making it easier to view in an IDE
|
||||
export type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & {};
|
||||
|
|
|
|||
Loading…
Reference in a new issue