diff --git a/package.json b/package.json index 02caec52..202efaf4 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "ora": "6.3.1", "semver": "7.5.4", "shelljs": "0.8.5", + "toml": "^3.0.0", "ts-mocha": "^10.0.0", "winston": "^3.10.0" }, diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index 6a2ed7a7..1cbe45ce 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -197,7 +197,7 @@ export class Init extends SwankyCommand { callback(result as string); } } - this.log("🎉 😎 Swanky project successfully initialised! 😎 🎉"); + this.log("🎉 😎 Swanky project successfully initialized! 😎 🎉"); } async generate(projectName: string) { diff --git a/src/commands/zombienet/init.ts b/src/commands/zombienet/init.ts new file mode 100644 index 00000000..cea138f8 --- /dev/null +++ b/src/commands/zombienet/init.ts @@ -0,0 +1,105 @@ +import path from "node:path"; +import { Flags } from "@oclif/core"; +import { SwankyCommand } from "../../lib/swankyCommand.js"; +import { + buildZombienetConfigFromBinaries, + copyZombienetTemplateFile, + downloadZombienetBinaries, + getSwankyConfig, + getTemplates, + osCheck, + Spinner, +} from "../../lib/index.js"; +import { pathExistsSync } from "fs-extra/esm"; +import { zombienet, zombienetBinariesList } from "../../lib/zombienetInfo.js"; +import { ConfigBuilder } from "../../lib/config-builder.js"; +import { SwankyConfig, ZombienetData } from "../../index.js"; + +export const zombienetConfig = "zombienet.config.toml"; + +export class InitZombienet extends SwankyCommand { + static description = "Initialize Zombienet"; + + static flags = { + binaries: Flags.string({ + char: "b", + multiple: true, + required: false, + options: zombienetBinariesList, + default: [], + description: "Binaries to install", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(InitZombienet); + + const localConfig = getSwankyConfig("local") as SwankyConfig; + + const platform = osCheck().platform; + if (platform === "darwin") { + this.warn(`Note for MacOs users: Polkadot binary is not currently supported for MacOs. +As a result users of MacOS need to clone the Polkadot repo (https://github.com/paritytech/polkadot), create a release and add it in your PATH manually (setup will advice you so as well). Check the official zombienet documentation for manual settings: https://paritytech.github.io/zombienet/.`); + } + + const projectPath = path.resolve(); + if (pathExistsSync(path.resolve(projectPath, "zombienet", "bin", "zombienet"))) { + this.error("Zombienet config already initialized"); + } + + const spinner = new Spinner(flags.verbose); + + const zombienetData: ZombienetData = { + version: zombienet.version, + downloadUrl: zombienet.downloadUrl, + binaries: {}, + }; + + if (!flags.binaries.includes("polkadot")) { + flags.binaries.push("polkadot"); + } + + for (const binaryName of flags.binaries) { + if (platform === "darwin" && binaryName.startsWith("polkadot")) { + continue; + } + if (!Object.keys(zombienet.binaries).includes(binaryName)) { + this.error(`Binary ${binaryName} not found in Zombienet config`); + } + zombienetData.binaries[binaryName] = zombienet.binaries[binaryName as keyof typeof zombienet.binaries]; + } + + await this.spinner.runCommand(async () => { + const newLocalConfig = new ConfigBuilder(localConfig) + .addZombienet(zombienetData) + .build(); + await this.storeConfig(newLocalConfig, "local"); + }, "Writing config"); + + const zombienetTemplatePath = getTemplates().zombienetTemplatesPath; + + const configPath = path.resolve(projectPath, "zombienet", "config"); + + if (flags.binaries.length === 1 && flags.binaries[0] === "polkadot") { + await spinner.runCommand( + () => + copyZombienetTemplateFile(zombienetTemplatePath, configPath), + "Copying template files", + ); + } else { + await spinner.runCommand( + () => buildZombienetConfigFromBinaries(flags.binaries, zombienetTemplatePath, configPath), + "Copying template files", + ); + } + + // Install binaries based on zombie config + await this.spinner.runCommand( + () => downloadZombienetBinaries(flags.binaries, projectPath, localConfig, this.spinner), + "Downloading Zombienet binaries", + ); + + this.log("ZombieNet config Installed successfully"); + } +} + diff --git a/src/commands/zombienet/start.ts b/src/commands/zombienet/start.ts new file mode 100644 index 00000000..72f5b221 --- /dev/null +++ b/src/commands/zombienet/start.ts @@ -0,0 +1,40 @@ +import { SwankyCommand } from "../../lib/swankyCommand.js"; +import path from "node:path"; +import { pathExistsSync } from "fs-extra/esm"; +import { execaCommand } from "execa"; +import { Flags } from "@oclif/core"; + + +export class StartZombienet extends SwankyCommand { + static description = "Start Zombienet"; + + static flags = { + "config-path": Flags.string({ + char: "c", + required: false, + default: "./zombienet/config/zombienet.config.toml", + description: "Path to zombienet config", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(StartZombienet); + const projectPath = path.resolve(); + const binPath = path.resolve(projectPath, "zombienet", "bin") + if (!pathExistsSync(path.resolve(binPath, "zombienet"))) { + this.error("Zombienet has not initialized. Run `swanky zombienet:init` first"); + } + + await execaCommand( + `./zombienet/bin/zombienet \ + spawn --provider native \ + ${flags["config-path"]} + `, + { + stdio: "inherit", + } + ); + + this.log("ZombieNet started successfully"); + } +} \ No newline at end of file diff --git a/src/lib/config-builder.ts b/src/lib/config-builder.ts index ace3fccd..386dd9fd 100644 --- a/src/lib/config-builder.ts +++ b/src/lib/config-builder.ts @@ -1,4 +1,4 @@ -import { AccountData, BuildData, DeploymentData, SwankyConfig, SwankySystemConfig } from "../index.js"; +import { AccountData, BuildData, DeploymentData, SwankyConfig, SwankySystemConfig, ZombienetData } from "../index.js"; import { snakeCase } from "change-case"; export class ConfigBuilder { @@ -64,6 +64,13 @@ export class ConfigBuilder { return this; } + addZombienet(data: ZombienetData): ConfigBuilder { + if ("zombienet" in this.config) { + this.config.zombienet = data; + } + return this; + } + build(): T { return this.config; } diff --git a/src/lib/tasks.ts b/src/lib/tasks.ts index b3eb3a1f..11af82fb 100644 --- a/src/lib/tasks.ts +++ b/src/lib/tasks.ts @@ -1,6 +1,6 @@ import { execaCommand } from "execa"; -import { ensureDir, copy, remove } from "fs-extra/esm"; -import { rename, readFile, rm, writeFile } from "fs/promises"; +import { copy, ensureDir, remove } from "fs-extra/esm"; +import { readFile, rename, rm, writeFile } from "fs/promises"; import path from "node:path"; import { globby } from "globby"; import handlebars from "handlebars"; @@ -10,8 +10,13 @@ import semver from "semver"; import { nodeInfo } from "./nodeInfo.js"; import decompress from "decompress"; import { Spinner } from "./spinner.js"; -import { SupportedPlatforms, SupportedArch, TestType } from "../types/index.js"; +import { Relaychain, SupportedArch, SupportedPlatforms, SwankyConfig, TestType, ZombienetConfig } from "../types/index.js"; import { ConfigError, NetworkError, ProcessError } from "./errors.js"; +import { BinaryNames } from "./zombienetInfo.js"; +import { zombienetConfig } from "../commands/zombienet/init.js"; +import { readFileSync } from "fs"; +import TOML from "@iarna/toml"; +import { writeFileSync } from "node:fs"; import { commandStdoutOrNull } from "./command-utils.js"; export async function checkCliDependencies(spinner: Spinner) { @@ -30,18 +35,41 @@ export async function checkCliDependencies(spinner: Spinner) { } } +export function osCheck() { + const platform = process.platform; + const arch = process.arch; + + const supportedConfigs = { + darwin: ["x64", "arm64"], + linux: ["x64", "arm64"], + }; + + if (!(platform in supportedConfigs)) { + throw new ConfigError(`Platform '${platform}' is not supported!`); + } + + const supportedArchs = supportedConfigs[platform as keyof typeof supportedConfigs]; + if (!supportedArchs.includes(arch)) { + throw new ConfigError( + `Architecture '${arch}' is not supported on platform '${platform}'.` + ); + } + + return { platform, arch }; +} + export async function copyCommonTemplateFiles(templatesPath: string, projectPath: string) { await ensureDir(projectPath); const commonFiles = await globby(`*`, { cwd: templatesPath }); await Promise.all( commonFiles.map(async (file) => { await copy(path.resolve(templatesPath, file), path.resolve(projectPath, file)); - }) + }), ); await rename(path.resolve(projectPath, "gitignore"), path.resolve(projectPath, ".gitignore")); await rename( path.resolve(projectPath, "mocharc.json"), - path.resolve(projectPath, ".mocharc.json") + path.resolve(projectPath, ".mocharc.json"), ); await copy(path.resolve(templatesPath, "github"), path.resolve(projectPath, ".github")); } @@ -49,11 +77,11 @@ export async function copyCommonTemplateFiles(templatesPath: string, projectPath export async function copyContractTemplateFiles( contractTemplatePath: string, contractName: string, - projectPath: string + projectPath: string, ) { await copy( path.resolve(contractTemplatePath, "contract"), - path.resolve(projectPath, "contracts", contractName) + path.resolve(projectPath, "contracts", contractName), ); } @@ -113,13 +141,13 @@ export async function downloadNode(projectPath: string, nodeInfo: nodeInfo, spin const platformDlUrls = nodeInfo.downloadUrl[process.platform as SupportedPlatforms]; if (!platformDlUrls) throw new ConfigError( - `Could not download swanky-node. Platform ${process.platform} not supported!` + `Could not download swanky-node. Platform ${process.platform} not supported!`, ); const dlUrl = platformDlUrls[process.arch as SupportedArch]; if (!dlUrl) throw new ConfigError( - `Could not download swanky-node. Platform ${process.platform} Arch ${process.arch} not supported!` + `Could not download swanky-node. Platform ${process.platform} Arch ${process.arch} not supported!`, ); const dlFileDetails = await new Promise((resolve, reject) => { @@ -136,7 +164,7 @@ export async function downloadNode(projectPath: string, nodeInfo: nodeInfo, spin }); dl.start().catch((error: Error) => - reject(new Error(`Error downloading node: , ${error.message}`)) + reject(new Error(`Error downloading node: , ${error.message}`)), ); }); @@ -157,6 +185,134 @@ export async function downloadNode(projectPath: string, nodeInfo: nodeInfo, spin return path.resolve(binPath, dlFileDetails.filePath); } +export async function copyZombienetTemplateFile(templatePath: string, configPath: string) { + await ensureDir(configPath); + await copy( + path.resolve(templatePath, zombienetConfig), + path.resolve(configPath, zombienetConfig), + ); +} + +export async function downloadZombienetBinaries(binaries: string[], projectPath: string, swankyConfig: SwankyConfig, spinner: Spinner) { + const binPath = path.resolve(projectPath, "zombienet", "bin"); + await ensureDir(binPath); + + const zombienetInfo = swankyConfig.zombienet; + + if (!zombienetInfo) { + throw new ConfigError("No zombienet config found"); + } + + const dlUrls = new Map(); + if (zombienetInfo.version) { + const version = zombienetInfo.version; + const binaryName = "zombienet"; + const platformDlUrls = zombienetInfo.downloadUrl[process.platform as SupportedPlatforms]; + if (!platformDlUrls) + throw new ConfigError( + `Could not download ${binaryName}. Platform ${process.platform} not supported!`, + ); + let dlUrl = platformDlUrls[process.arch as SupportedArch]; + if (!dlUrl) + throw new ConfigError( + `Could not download ${binaryName}. Platform ${process.platform} Arch ${process.arch} not supported!`, + ); + dlUrl = dlUrl.replace("${version}", version); + dlUrls.set(binaryName, dlUrl); + } + + for (const binaryName of Object.keys(zombienetInfo.binaries).filter((binaryName) => binaries.includes(binaryName))) { + const binaryInfo = zombienetInfo.binaries[binaryName as BinaryNames]; + const version = binaryInfo.version; + const platformDlUrls = binaryInfo.downloadUrl[process.platform as SupportedPlatforms]; + if (!platformDlUrls) + throw new ConfigError( + `Could not download ${binaryName}. Platform ${process.platform} not supported!`, + ); + let dlUrl = platformDlUrls[process.arch as SupportedArch]; + if (!dlUrl) + throw new ConfigError( + `Could not download ${binaryName}. Platform ${process.platform} Arch ${process.arch} not supported!`, + ); + dlUrl = dlUrl.replace(/\$\{version}/gi, version); + dlUrls.set(binaryName, dlUrl); + } + + for (const [binaryName, dlUrl] of dlUrls) { + const dlFileDetails = await new Promise((resolve, reject) => { + const dl = new DownloaderHelper(dlUrl, binPath); + + dl.on("progress", (event) => { + spinner.text(`Downloading ${binaryName} ${event.progress.toFixed(2)}%`); + }); + dl.on("end", (event) => { + resolve(event); + }); + dl.on("error", (error) => { + reject(new Error(`Error downloading ${binaryName}: , ${error.message}`)); + }); + + dl.start().catch((error: Error) => + reject(new Error(`Error downloading ${binaryName}: , ${error.message}`)), + ); + }); + + if (dlFileDetails.incomplete) { + throw new NetworkError("${binaryName} download incomplete"); + } + + let fileName = dlFileDetails.fileName; + + if (dlFileDetails.filePath.endsWith(".tar.gz")) { + const compressedFilePath = path.resolve(binPath, dlFileDetails.filePath); + const decompressed = await decompress(compressedFilePath, binPath); + await remove(compressedFilePath); + fileName = decompressed[0].path; + } + + if (fileName !== binaryName) { + await execaCommand(`mv ${binPath}/${fileName} ${binPath}/${binaryName}`); + } + await execaCommand(`chmod +x ${binPath}/${binaryName}`); + } +} + +export async function buildZombienetConfigFromBinaries(binaries: string[], templatePath: string, configPath: string) { + await ensureDir(configPath); + const configBuilder = { + settings: { + timeout: 1000, + }, + relaychain: { + default_command: "", + chain: "", + nodes: [], + }, + parachains: [], + } as ZombienetConfig; + + for (const binaryName of binaries) { + const template = TOML.parse(readFileSync(path.resolve(templatePath, binaryName + ".toml"), "utf8")); + if (template.parachains !== undefined) { + (template.parachains as any).forEach((parachain: any) => { + configBuilder.parachains.push(parachain); + }); + } + if (template.hrmp_channels !== undefined) { + configBuilder.hrmp_channels = []; + (template.hrmp_channels as any).forEach((hrmp_channel: any) => { + configBuilder.hrmp_channels!.push(hrmp_channel); + }); + } + if (template.relaychain !== undefined) { + configBuilder.relaychain = template.relaychain as unknown as Relaychain; + } + + } + + writeFileSync(path.resolve(configPath, zombienetConfig), TOML.stringify(configBuilder as any)); +} + export async function installDeps(projectPath: string) { let installCommand = "npm install"; diff --git a/src/lib/templates.ts b/src/lib/templates.ts index ef814298..90996e34 100644 --- a/src/lib/templates.ts +++ b/src/lib/templates.ts @@ -8,6 +8,7 @@ const __dirname = path.dirname(__filename); export function getTemplates() { const templatesPath = path.resolve(__dirname, "..", "templates"); const contractTemplatesPath = path.resolve(templatesPath, "contracts"); + const zombienetTemplatesPath = path.resolve(templatesPath, "zombienet"); const fileList = readdirSync(contractTemplatesPath, { withFileTypes: true, }); @@ -19,5 +20,6 @@ export function getTemplates() { templatesPath, contractTemplatesPath, contractTemplatesList, + zombienetTemplatesPath, }; } diff --git a/src/lib/zombienetInfo.ts b/src/lib/zombienetInfo.ts new file mode 100644 index 00000000..3b6b0faf --- /dev/null +++ b/src/lib/zombienetInfo.ts @@ -0,0 +1,52 @@ +export type zombienetInfo = typeof zombienet; + +export type BinaryNames = "zombienet" | "polkadot" | "polkadot-parachain" | "astar-collator"; + +export const zombienet = { + version: "1.3.89", + downloadUrl: { + darwin: { + "arm64": "https://github.com/paritytech/zombienet/releases/download/v${version}/zombienet-macos", + "x64": "https://github.com/paritytech/zombienet/releases/download/v${version}/zombienet-macos", + }, + linux: { + "arm64": "https://github.com/paritytech/zombienet/releases/download/v${version}/zombienet-linux-arm64", + "x64": "https://github.com/paritytech/zombienet/releases/download/v${version}/zombienet-linux-x64", + }, + }, + binaries: { + "polkadot": { + version: "0.9.43", + downloadUrl: { + linux: { + "arm64": "https://github.com/paritytech/polkadot/releases/download/v${version}/polkadot", + "x64": "https://github.com/paritytech/polkadot/releases/download/v${version}/polkadot", + }, + }, + }, + "polkadot-parachain": { + version: "0.9.430", + downloadUrl: { + linux: { + "arm64": "https://github.com/paritytech/cumulus/releases/download/v${version}/polkadot-parachain", + "x64": "https://github.com/paritytech/cumulus/releases/download/v${version}/polkadot-parachain", + }, + }, + }, + "astar-collator": { + version: "5.28.0", + downloadUrl: { + darwin: { + "arm64": "https://github.com/AstarNetwork/Astar/releases/download/v${version}/astar-collator-v${version}-macOS-x86_64.tar.gz", + "x64": "https://github.com/AstarNetwork/Astar/releases/download/v${version}/astar-collator-v${version}-macOS-x86_64.tar.gz", + }, + linux: { + "arm64": "https://github.com/AstarNetwork/Astar/releases/download/v${version}/astar-collator-v${version}-ubuntu-aarch64.tar.gz", + "x64": "https://github.com/AstarNetwork/Astar/releases/download/v${version}/astar-collator-v${version}-ubuntu-x86_64.tar.gz", + }, + }, + }, + }, +}; + +export const zombienetBinariesList = Object.keys(zombienet.binaries); diff --git a/src/templates/zombienet/astar-collator.toml b/src/templates/zombienet/astar-collator.toml new file mode 100644 index 00000000..a5a14ffb --- /dev/null +++ b/src/templates/zombienet/astar-collator.toml @@ -0,0 +1,69 @@ +[[parachains]] +id = 2006 +chain = "astar-dev" +cumulus_based = true + + [parachains.collator] + name = "astar" + command = "./zombienet/bin/astar-collator" + rpc_port = 8545 + args = [ "-l=xcm=trace", "--enable-evm-rpc" ] + +[[parachains]] +id = 2007 +chain = "shiden-dev" +cumulus_based = true + + [parachains.collator] + name = "shiden" + command = "./zombienet/bin/astar-collator" + rpc_port = 8546 + args = [ "-l=xcm=trace", "--enable-evm-rpc" ] + +[[parachains]] +id = 2008 +chain = "shibuya-dev" +cumulus_based = true + + [parachains.collator] + name = "shibuya" + command = "./zombienet/bin/astar-collator" + rpc_port = 8546 + args = [ "-l=xcm=trace", "--enable-evm-rpc" ] + +[[hrmp_channels]] +sender = 2006 +recipient = 2007 +max_capacity = 8 +max_message_size = 512 + +[[hrmp_channels]] +sender = 2007 +recipient = 2006 +max_capacity = 8 +max_message_size = 512 + +[[hrmp_channels]] +sender = 2006 +recipient = 2008 +max_capacity = 8 +max_message_size = 512 + +[[hrmp_channels]] +sender = 2008 +recipient = 2006 +max_capacity = 8 +max_message_size = 512 + + +[[hrmp_channels]] +sender = 2008 +recipient = 2007 +max_capacity = 8 +max_message_size = 512 + +[[hrmp_channels]] +sender = 2007 +recipient = 2008 +max_capacity = 8 +max_message_size = 512 \ No newline at end of file diff --git a/src/templates/zombienet/polkadot-parachain.toml b/src/templates/zombienet/polkadot-parachain.toml new file mode 100644 index 00000000..8f0b1005 --- /dev/null +++ b/src/templates/zombienet/polkadot-parachain.toml @@ -0,0 +1,7 @@ +[[parachains]] +id = 100 + + [parachains.collator] + name = "collator01" + command = "./zombienet/bin/polkadot-parachain" + args = ["-lparachain=debug"] \ No newline at end of file diff --git a/src/templates/zombienet/polkadot.toml b/src/templates/zombienet/polkadot.toml new file mode 100644 index 00000000..9538d660 --- /dev/null +++ b/src/templates/zombienet/polkadot.toml @@ -0,0 +1,15 @@ +[settings] +timeout = 1000 + +[relaychain] +default_command = "./zombienet/bin/polkadot" +chain = "rococo-local" + + [[relaychain.nodes]] + name = "relay01" + + [[relaychain.nodes]] + name = "relay02" + + [[relaychain.nodes]] + name = "relay03" \ No newline at end of file diff --git a/src/templates/zombienet/zombienet.config.toml b/src/templates/zombienet/zombienet.config.toml new file mode 100644 index 00000000..e71ba918 --- /dev/null +++ b/src/templates/zombienet/zombienet.config.toml @@ -0,0 +1,16 @@ +[settings] +timeout = 1000 + +[relaychain] +default_command = "./zombienet/bin/polkadot" +chain = "rococo-local" + + [[relaychain.nodes]] + name = "relay01" + + [[relaychain.nodes]] + name = "relay02" + + [[relaychain.nodes]] + name = "relay03" + diff --git a/src/types/index.ts b/src/types/index.ts index 8e0af423..77f1ede1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -40,6 +40,17 @@ export interface DeploymentData { deployerAlias: string; address: string; } + +export interface DownloadUrl { + darwin: { + "arm64": string, + "x64": string + }, + linux: { + "arm64": string, + "x64": string + } +} export interface SwankyConfig extends SwankySystemConfig{ node: { polkadotPalletVersions: string; @@ -48,6 +59,7 @@ export interface SwankyConfig extends SwankySystemConfig{ version: string; }; contracts: Record | Record; + zombienet?: ZombienetData; } export interface SwankySystemConfig { @@ -56,6 +68,46 @@ export interface SwankySystemConfig { networks: Record } +export interface ZombienetData { + version: string; + downloadUrl: DownloadUrl; + binaries: Record }>; +} + +export interface ZombienetConfig { + settings: { timeout: number }, + relaychain: Relaychain, + parachains: Parachain[], + hrmp_channels?: HrmpChannel[], +} + +export interface Relaychain { + default_command: string, + chain: string, + nodes: Node[] +} +export interface Node { + name: string, +} +export interface HrmpChannel { + sender: number, + recipient: number, + max_capacity: number, + max_message_size: number +} +export interface Parachain { + id: number, + chain: string, + cumulus_based: boolean, + collator: Collator +} +export interface Collator { + name: string, + command: string, + rpc_port: number, + args: string[], +} + export enum BuildMode { Debug = "Debug", Release = "Release",