@moonwall/cli
Version:
Testing framework for the Moon family of projects
636 lines âĸ 28.6 kB
JavaScript
import "@moonbeam-network/api-augment";
import zombie from "@zombienet/orchestrator";
import { createLogger } from "@moonwall/util";
import fs from "node:fs";
import net from "node:net";
import readline from "node:readline";
import { setTimeout as timer } from "node:timers/promises";
import path from "node:path";
import { LaunchCommandParser, parseChopsticksRunCmd, parseZombieCmd, } from "../internal/commandParsers";
import { checkZombieBins, getZombieConfig, } from "../internal/foundations/zombieHelpers";
import { launchNode } from "../internal/localNode";
import { ProviderFactory, ProviderInterfaceFactory, vitestAutoUrl, } from "../internal/providerFactories";
import { getEnvironmentFromConfig, importAsyncConfig, isEthereumDevConfig, isEthereumZombieConfig, isOptionSet, } from "./configReader";
import { ChildProcess, exec, execSync } from "node:child_process";
import { promisify } from "node:util";
import { withTimeout } from "../internal";
import Docker from "dockerode";
import invariant from "tiny-invariant";
const logger = createLogger({ name: "context" });
const debugSetup = logger.debug.bind(logger);
export class MoonwallContext {
static instance;
configured = false;
environment;
providers;
nodes;
foundation;
zombieNetwork;
rtUpgradePath;
ipcServer;
injectedOptions;
nodeCleanupHandlers = [];
constructor(config, options) {
const env = config.environments.find(({ name }) => name === process.env.MOON_TEST_ENV);
invariant(env, `Environment ${process.env.MOON_TEST_ENV} not found in config`);
this.providers = [];
this.nodes = [];
this.foundation = env.foundation.type;
this.injectedOptions = options;
}
async setupFoundation() {
const config = await importAsyncConfig();
const env = config.environments.find(({ name }) => name === process.env.MOON_TEST_ENV);
invariant(env, `Environment ${process.env.MOON_TEST_ENV} not found in config`);
const foundationHandlers = {
read_only: this.handleReadOnly,
chopsticks: this.handleChopsticks,
dev: this.handleDev,
zombie: this.handleZombie,
};
const foundationHandler = foundationHandlers[env.foundation.type];
this.environment = {
providers: [],
nodes: [],
...(await foundationHandler.call(this, env, config)),
};
this.configured = true;
}
async handleZombie(env) {
invariant(env.foundation.type === "zombie", "Foundation type must be 'zombie'");
const { cmd: zombieConfig } = await parseZombieCmd(env.foundation.zombieSpec);
this.rtUpgradePath = env.foundation.rtUpgradePath;
return {
name: env.name,
foundationType: "zombie",
nodes: [
{
name: env.foundation.zombieSpec.name,
cmd: zombieConfig,
args: [],
launch: true,
},
],
};
}
async handleDev(env, config) {
invariant(env.foundation.type === "dev", "Foundation type must be 'dev'");
const { cmd, args, launch } = LaunchCommandParser.create({
launchSpec: env.foundation.launchSpec[0],
additionalRepos: config.additionalRepos,
launchOverrides: this.injectedOptions,
verbose: false,
});
return {
name: env.name,
foundationType: "dev",
nodes: [
{
name: env.foundation.launchSpec[0].name,
cmd,
args,
launch,
},
],
providers: env.connections
? ProviderFactory.prepare(env.connections)
: isEthereumDevConfig()
? ProviderFactory.prepareDefaultDev()
: ProviderFactory.prepare([
{
name: "node",
type: "polkadotJs",
endpoints: [vitestAutoUrl()],
},
]),
};
}
async handleReadOnly(env) {
invariant(env.foundation.type === "read_only", "Foundation type must be 'read_only'");
invariant(env.connections, `${env.name} env config is missing connections specification, required by foundation READ_ONLY`);
return {
name: env.name,
foundationType: "read_only",
providers: ProviderFactory.prepare(env.connections),
};
}
async handleChopsticks(env) {
invariant(env.foundation.type === "chopsticks", "Foundation type must be 'chopsticks'");
invariant(env.connections && env.connections.length > 0, `${env.name} env config is missing connections specification, required by foundation CHOPSTICKS`);
this.rtUpgradePath = env.foundation.rtUpgradePath;
return {
name: env.name,
foundationType: "chopsticks",
nodes: [parseChopsticksRunCmd(env.foundation.launchSpec)],
providers: [...ProviderFactory.prepare(env.connections)],
};
}
async startZombieNetwork() {
const env = getEnvironmentFromConfig();
invariant(env.foundation.type === "zombie", "Foundation type must be 'zombie', something has gone very wrong.");
console.log("đ§ Spawning zombie nodes ...");
const nodes = this.environment.nodes;
const zombieConfig = getZombieConfig(nodes[0].cmd);
await checkZombieBins(zombieConfig);
const network = await zombie.start("", zombieConfig, { logType: "silent" });
const ipcLogPath = path.join(network.tmpDir, "ipc-server.log");
const ipcLogger = fs.createWriteStream(ipcLogPath, { flags: "a" });
const logIpc = (message) => {
const timestamp = new Date().toISOString();
ipcLogger.write(`${timestamp} - ${message}\n`);
};
process.env.MOON_RELAY_WSS = network.relay[0].wsUri;
if (Object.entries(network.paras).length > 0) {
process.env.MOON_PARA_WSS = Object.values(network.paras)[0].nodes[0].wsUri;
}
const nodeNames = Object.keys(network.nodesByName);
process.env.MOON_ZOMBIE_DIR = `${network.tmpDir}`;
process.env.MOON_ZOMBIE_NODES = nodeNames.join("|");
const onProcessExit = () => {
try {
invariant(this.zombieNetwork, "Zombie network not found to kill");
const processIds = Object.values(this.zombieNetwork.client.processMap)
.filter((item) => item.pid)
.map((process) => process.pid);
exec(`kill ${processIds.join(" ")}`, (error) => {
if (error) {
console.error(`Error killing process: ${error.message}`);
}
});
}
catch (err) {
// console.log(err.message);
}
};
// Refactored IPC Server Implementation Starts Here
const socketPath = `${network.tmpDir}/node-ipc.sock`;
// Remove existing socket file if it exists to prevent EADDRINUSE errors
if (fs.existsSync(socketPath)) {
fs.unlinkSync(socketPath);
logIpc(`Removed existing socket at ${socketPath}`);
}
const server = net.createServer((client) => {
logIpc("đ¨ IPC server created");
logIpc(`Socket path: ${socketPath}`);
// Client message handling
client.on("data", async (data) => {
const writeToClient = (message) => {
if (client.writable) {
client.write(JSON.stringify(message));
}
else {
logIpc("Client disconnected, cannot send response.");
}
};
try {
const message = JSON.parse(data.toString());
invariant(message.nodeName, "nodeName not provided in message");
const zombieClient = network.client;
switch (message.cmd) {
case "networkmap": {
const result = Object.keys(network.nodesByName);
writeToClient({
status: "success",
result: network.nodesByName,
message: result.join("|"),
});
break;
}
case "restart": {
logIpc(`đ¨ Restart command received for node: ${message.nodeName}`);
try {
await this.disconnect();
logIpc("â
Disconnected all providers.");
}
catch (err) {
logIpc(`â Error during disconnect: ${err}`);
throw err;
}
try {
logIpc(`đ¨ Restarting node: ${message.nodeName}`);
// Timeout is in seconds đ¤Ļ
await zombieClient.restartNode(message.nodeName, 5);
logIpc(`â
Restarted node: ${message.nodeName}`);
}
catch (err) {
logIpc(`â Error during node restart: ${err}`);
throw err;
}
await timer(5000); // TODO: Replace when zombienet has an appropriate fn
try {
logIpc("đ Reconnecting environment...");
await this.connectEnvironment();
logIpc("â
Reconnected environment.");
}
catch (err) {
logIpc(`â Error during environment reconnection: ${err}`);
throw err;
}
writeToClient({
status: "success",
result: true,
message: `${message.nodeName} restarted`,
});
break;
}
case "resume": {
const node = network.getNodeByName(message.nodeName);
await this.disconnect();
const result = await node.resume();
await zombieClient.wait_node_ready(message.nodeName);
await this.connectEnvironment(true);
writeToClient({
status: "success",
result,
message: `${message.nodeName} resumed with result ${result}`,
});
break;
}
case "pause": {
const node = network.getNodeByName(message.nodeName);
await this.disconnect();
const result = await node.pause();
await timer(1000); // TODO: Replace when zombienet has an appropriate fn
writeToClient({
status: "success",
result,
message: `${message.nodeName} paused with result ${result}`,
});
break;
}
case "kill": {
// await this.disconnect();
const pid = network.client.processMap[message.nodeName].pid;
delete network.client.processMap[message.nodeName];
const killResult = execSync(`kill ${pid}`, { stdio: "ignore" });
// await this.connectEnvironment(true);
writeToClient({
status: "success",
result: true,
message: `${message.nodeName}, pid ${pid} killed`,
});
break;
}
case "isup": {
const node = network.getNodeByName(message.nodeName);
const result = await node.isUp();
writeToClient({
status: "success",
result,
message: `${message.nodeName} isUp result is ${result}`,
});
break;
}
default:
invariant(false, `Invalid command received: ${message.cmd}`);
}
}
catch (e) {
logIpc("đ¨ Error processing message from client");
logIpc(e.message);
writeToClient({
status: "failure",
result: false,
message: e.message,
});
}
});
// Handle client errors
client.on("error", (err) => {
logIpc(`đ¨ IPC client error:${err}`);
});
// Handle client disconnection
client.on("close", () => {
logIpc("đ¨ IPC client disconnected");
});
});
// Handle server errors to prevent crashes
server.on("error", (err) => {
console.error("IPC Server error:", err);
});
server.listen(socketPath, () => {
logIpc(`đ¨ IPC Server attempting to listen on ${socketPath}`);
try {
fs.chmodSync(socketPath, 0o600);
logIpc("đ¨ Successfully set socket permissions");
}
catch (err) {
console.error("đ¨ Error setting socket permissions:", err);
}
logIpc(`đ¨ IPC Server listening on ${socketPath}`);
});
this.ipcServer = server;
process.env.MOON_IPC_SOCKET = socketPath;
process.once("exit", onProcessExit);
process.once("SIGINT", onProcessExit);
this.zombieNetwork = network;
return;
}
async startNetwork() {
const ctx = await MoonwallContext.getContext();
if (process.env.MOON_RECYCLE === "true") {
return ctx;
}
if (this.nodes.length > 0) {
return ctx;
}
const nodes = ctx.environment.nodes;
if (this.environment.foundationType === "zombie") {
return await this.startZombieNetwork();
}
const env = getEnvironmentFromConfig();
const launchSpec = "launchSpec" in env.foundation ? env.foundation.launchSpec[0] : undefined;
const maxStartupTimeout = launchSpec?.useDocker ? 300000 : 30000; // 5 minutes for Docker, 30s otherwise
await withTimeout(Promise.all(nodes.map(async ({ cmd, args, name, launch }) => {
if (launch) {
try {
const result = await launchNode({
command: cmd,
args,
name: name || "node",
launchSpec,
});
this.nodes.push(result.runningNode);
if (result.runningNode instanceof ChildProcess) {
debugSetup(`â
Node '${name || "unnamed"}' started with PID ${result.runningNode.pid}`);
}
}
catch (error) {
throw new Error(`Failed to start node '${name || "unnamed"}': ${error.message}`);
}
}
})), maxStartupTimeout);
debugSetup("â
All network nodes started successfully.");
return ctx;
}
async connectEnvironment(silent = false) {
const env = getEnvironmentFromConfig();
if (this.environment.foundationType === "zombie") {
this.environment.providers = env.connections
? ProviderFactory.prepare(env.connections)
: isEthereumZombieConfig()
? ProviderFactory.prepareDefaultZombie()
: ProviderFactory.prepareNoEthDefaultZombie();
}
if (this.providers.length > 0) {
return MoonwallContext.getContext();
}
const maxRetries = 15;
const retryDelay = 1000; // 1 second
const connectTimeout = 10000; // 10 seconds per attempt
const connectWithRetry = async (provider) => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
debugSetup(`đ Connecting provider ${provider.name}, attempt ${attempt}`);
const connectedProvider = await Promise.race([
ProviderInterfaceFactory.populate(provider.name, provider.type, provider.connect),
new Promise((_, reject) => setTimeout(() => reject(new Error("Connection attempt timed out")), connectTimeout)),
]);
this.providers.push(connectedProvider);
debugSetup(`â
Provider ${provider.name} connected on attempt ${attempt}`);
return;
}
catch (error) {
console.error(`â Error connecting provider ${provider.name} on attempt ${attempt}: ${error.message}`);
if (attempt === maxRetries) {
throw new Error(`Failed to connect provider '${provider.name}' after ${maxRetries} attempts: ${error.message}`);
}
debugSetup(`â ī¸ Retrying provider ${provider.name} connection, attempt ${attempt + 1}/${maxRetries}`);
await timer(retryDelay);
}
}
};
try {
await Promise.all(this.environment.providers.map(connectWithRetry));
}
catch (error) {
console.error(`Error connecting to environment: ${error.message}`);
console.error("Current providers:", this.providers.map((p) => p.name).join(", "));
console.error(`Total providers: ${this.environment.providers.map((p) => p.name).join(", ")}`);
throw error;
}
if (this.foundation === "zombie") {
await this.handleZombiePostConnection(silent, env);
}
return MoonwallContext.getContext();
}
async handleZombiePostConnection(silent, env) {
let readStreams = [];
if (!isOptionSet("disableLogEavesdropping")) {
!silent && console.log(`đĻģ Eavesdropping on node logs at ${process.env.MOON_ZOMBIE_DIR}`);
const envVar = process.env.MOON_ZOMBIE_NODES;
invariant(envVar, "MOON_ZOMBIE_NODES not set, this is an error please raise.");
const zombieNodeLogs = envVar
.split("|")
.map((nodeName) => `${process.env.MOON_ZOMBIE_DIR}/${nodeName}.log`);
readStreams = zombieNodeLogs.map((logPath) => {
const readStream = fs.createReadStream(logPath, { encoding: "utf8" });
const lineReader = readline.createInterface({
input: readStream,
});
lineReader.on("line", (line) => {
if (line.includes("WARN") || line.includes("ERROR")) {
console.log(line);
}
});
return readStream;
});
}
const polkadotJsProviders = this.providers
.filter(({ type }) => type === "polkadotJs")
.filter(({ name }) => env.foundation.type === "zombie" &&
(!env.foundation.zombieSpec.skipBlockCheck ||
!env.foundation.zombieSpec.skipBlockCheck.includes(name)));
await Promise.all(polkadotJsProviders.map(async (provider) => {
!silent && console.log(`â˛ī¸ Waiting for chain ${provider.name} to produce blocks...`);
while ((await provider.api.rpc.chain.getBlock()).block.header.number.toNumber() === 0) {
await timer(500);
}
!silent && console.log(`â
Chain ${provider.name} producing blocks, continuing`);
}));
if (!isOptionSet("disableLogEavesdropping")) {
for (const readStream of readStreams) {
readStream.close();
}
}
}
async disconnect(providerName) {
if (providerName) {
const prov = this.providers.find(({ name }) => name === providerName);
invariant(prov, `Provider ${providerName} not found`);
try {
await prov.disconnect();
debugSetup(`â
Provider ${providerName} disconnected`);
}
catch (error) {
console.error(`â Error disconnecting provider ${providerName}: ${error.message}`);
}
}
else {
await Promise.all(this.providers.map(async (prov) => {
try {
await prov.disconnect();
debugSetup(`â
Provider ${prov.name} disconnected`);
}
catch (error) {
console.error(`â Error disconnecting provider ${prov.name}: ${error.message}`);
}
}));
this.providers = [];
}
// Clean up nodes
if (this.nodes.length > 0) {
for (const node of this.nodes) {
if (node instanceof ChildProcess) {
try {
if (node.pid) {
process.kill(node.pid);
}
}
catch (e) {
// Ignore errors when killing processes
}
}
if (node instanceof Docker.Container) {
try {
await node.stop();
await node.remove();
}
catch (e) {
// Ignore errors when stopping containers
}
}
}
this.nodes = [];
}
// Run any cleanup handlers (e.g. for Docker containers)
if (this.nodeCleanupHandlers.length > 0) {
await Promise.all(this.nodeCleanupHandlers.map((handler) => handler()));
this.nodeCleanupHandlers = [];
}
}
static async getContext(config, options, force = false) {
invariant(!(options && MoonwallContext.instance), "Attempting to open a new context with overrides when context already exists");
if (!MoonwallContext.instance?.configured || force) {
invariant(config, "Config must be provided on Global Context instantiation");
MoonwallContext.instance = new MoonwallContext(config, options);
await MoonwallContext.instance.setupFoundation();
debugSetup(`đĸ Moonwall context "${config.label}" created`);
}
return MoonwallContext.instance;
}
static async destroy(reason) {
const ctx = MoonwallContext.instance;
invariant(ctx, "No context to destroy");
try {
await ctx.disconnect();
}
catch {
console.log("đ All connections disconnected");
}
while (ctx.nodes.length > 0) {
const node = ctx.nodes.pop();
invariant(node, "No node to destroy");
if (node instanceof ChildProcess) {
const pid = node.pid;
invariant(pid, "No pid to destroy");
// Flag the process before killing it
const moonwallNode = node;
moonwallNode.isMoonwallTerminating = true;
moonwallNode.moonwallTerminationReason = reason || "shutdown";
node.kill("SIGINT");
for (;;) {
const isRunning = await isPidRunning(pid);
if (isRunning) {
await timer(10);
}
else {
break;
}
}
}
if (node instanceof Docker.Container) {
console.log("đ Stopping container");
// Try to append termination reason to Docker container log
const logLocation = process.env.MOON_LOG_LOCATION;
if (logLocation) {
const timestamp = new Date().toISOString();
const message = `${timestamp} [moonwall] container stopped. reason: ${reason || "shutdown"}\n`;
try {
fs.appendFileSync(logLocation, message);
}
catch (err) {
console.error(`Failed to append termination message to Docker log: ${err}`);
}
}
await node.stop();
await node.remove();
console.log("đ Container stopped and removed");
}
}
if (ctx.zombieNetwork) {
console.log("đĒ Killing zombie nodes");
// Log termination reason for zombie network processes
const zombieProcesses = Object.values(ctx.zombieNetwork.client.processMap).filter((item) => item.pid);
for (const proc of zombieProcesses) {
if (proc.logPath) {
const timestamp = new Date().toISOString();
const message = `${timestamp} [moonwall] zombie network stopped. reason: ${reason || "shutdown"}\n`;
try {
fs.appendFileSync(proc.logPath, message);
}
catch (err) {
console.error(`Failed to append termination message to zombie log: ${err}`);
}
}
}
await ctx.zombieNetwork.stop();
const processIds = zombieProcesses.map((process) => process.pid);
try {
execSync(`kill ${processIds.join(" ")}`, {});
}
catch (e) {
console.log(e.message);
console.log("continuing...");
}
await waitForPidsToDie(processIds);
ctx.ipcServer?.close(() => {
console.log("IPC Server closed.");
});
ctx.ipcServer?.removeAllListeners();
}
}
}
export const contextCreator = async (options) => {
const config = await importAsyncConfig();
const ctx = await MoonwallContext.getContext(config, options);
await runNetworkOnly();
await ctx.connectEnvironment();
return ctx;
};
export const runNetworkOnly = async () => {
const config = await importAsyncConfig();
const ctx = await MoonwallContext.getContext(config);
await ctx.startNetwork();
};
const execAsync = promisify(exec);
async function isPidRunning(pid) {
try {
const { stdout } = await execAsync(`ps -p ${pid} -o pid=`);
return stdout.trim() === pid.toString();
}
catch (error) {
return false;
}
}
async function waitForPidsToDie(pids) {
const checkPids = async () => {
const checks = pids.map(async (pid) => await isPidRunning(pid));
const results = await Promise.all(checks);
return results.every((running) => !running);
};
while (!(await checkPids())) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
//# sourceMappingURL=globalContext.js.map