Skip to content

Commit 626e860

Browse files
authored
feat: ✨ Add docker support (#456)
* update pkgs * update pkgs * chore: 📦 Update packages * removed anys * chore * feat: ✨ added docker option and launch feature * fix * fix: 🐛 added port mapping feature to docker environments * add pull logic * feat: ✨ Add check for existing containers
1 parent 4bc50ea commit 626e860

File tree

18 files changed

+1461
-2338
lines changed

18 files changed

+1461
-2338
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@moonwall/types": minor
3+
"@moonwall/cli": minor
4+
"@moonwall/docs": minor
5+
"@moonwall/tests": minor
6+
---
7+
8+
Add Docker Support

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ jobs:
114114
strategy:
115115
fail-fast: false
116116
matrix:
117-
suite: ["dev_test", "dev_multi", "dev_seq", "dev_smoke", "papi_dev", "fork_test"]
117+
suite: ["dev_test", "dev_multi", "dev_seq", "dev_smoke", "papi_dev", "fork_test", "dev_docker"]
118118
shard: [1, 2, 3, 4]
119119
steps:
120120
- uses: actions/checkout@v4

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
},
6666
"pnpm": {
6767
"overrides": {
68-
"@moonbeam-network/api-augment": "0.3401.2",
68+
"@moonbeam-network/api-augment": "0.3401.2",
6969
"@polkadot/api": "15.0.1",
7070
"@polkadot/api-base": "15.0.1",
7171
"@polkadot/api-derive": "15.0.1",

packages/cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"cli-progress": "3.12.0",
8484
"colors": "1.4.0",
8585
"debug": "4.4.0",
86+
"dockerode": "4.0.4",
8687
"dotenv": "16.4.7",
8788
"ethers": "*",
8889
"get-port": "^7.1.0",
@@ -104,6 +105,7 @@
104105
},
105106
"devDependencies": {
106107
"@biomejs/biome": "*",
108+
"@types/dockerode": "3.3.34",
107109
"@types/clear": "^0.1.4",
108110
"@types/cli-progress": "3.11.6",
109111
"@types/debug": "*",

packages/cli/src/cmds/runTests.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ export async function executeTests(env: Environment, testRunArgs?: testRunArgs &
143143
...vitestOptions,
144144
} satisfies UserConfig;
145145

146-
console.log(`Options to use: ${JSON.stringify(optionsToUse, null, 2)}`);
146+
if (env.printVitestOptions) {
147+
console.log(`Options to use: ${JSON.stringify(optionsToUse, null, 2)}`);
148+
}
147149
resolve((await startVitest("test", folders, optionsToUse)) as Vitest);
148150
} catch (e) {
149151
console.error(e);

packages/cli/src/internal/cmdFunctions/downloader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export async function downloader(url: string, outputPath: string): Promise<void>
5757
fs.rmSync(tempPath);
5858
}
5959

60-
function initializeProgressBar(): SingleBar {
60+
export function initializeProgressBar(): SingleBar {
6161
const options: ProgressBarOptions = {
6262
etaAsynchronousUpdate: true,
6363
etaBuffer: 40,

packages/cli/src/internal/commandParsers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ export class LaunchCommandParser {
6262
this.cmd = launchSpec.binPath;
6363
this.args = launchSpec.options
6464
? [...launchSpec.options]
65-
: fetchDefaultArgs(path.basename(launchSpec.binPath), additionalRepos);
65+
: launchSpec.useDocker
66+
? []
67+
: fetchDefaultArgs(path.basename(launchSpec.binPath), additionalRepos);
6668
}
6769

6870
private overrideArg(newArg: string): void {

packages/cli/src/internal/launcherCommon.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import fs from "node:fs";
55
import path from "node:path";
66
import { importAsyncConfig, parseZombieConfigForBins } from "../lib/configReader";
77
import { checkAlreadyRunning, downloadBinsIfMissing, promptAlreadyRunning } from "./fileCheckers";
8+
import Docker from "dockerode";
9+
import { select } from "@inquirer/prompts";
810

911
export async function commonChecks(env: Environment) {
1012
const globalConfig = await importAsyncConfig();
@@ -45,12 +47,85 @@ async function devBinCheck(env: Environment) {
4547
throw new Error("This function is only for dev environments");
4648
}
4749

50+
if (!env.foundation.launchSpec || !env.foundation.launchSpec[0]) {
51+
throw new Error("Dev environment requires a launchSpec configuration");
52+
}
53+
54+
if (env.foundation.launchSpec[0].useDocker) {
55+
const docker = new Docker();
56+
const imageName = env.foundation.launchSpec[0].binPath;
57+
58+
console.log(`Checking if ${imageName} is running...`);
59+
const matchingContainers = (
60+
await docker.listContainers({
61+
filters: { ancestor: [imageName] },
62+
})
63+
).flat();
64+
65+
if (matchingContainers.length === 0) {
66+
return;
67+
}
68+
69+
if (!process.env.CI) {
70+
await promptKillContainers(matchingContainers);
71+
return;
72+
}
73+
74+
const runningContainers = matchingContainers.map(({ Id, Ports }) => ({
75+
Id: Id.slice(0, 12),
76+
Ports: Ports.map(({ PublicPort, PrivatePort }) =>
77+
PublicPort ? `${PublicPort} -> ${PrivatePort}` : `${PrivatePort}`
78+
).join(", "),
79+
}));
80+
81+
console.table(runningContainers);
82+
83+
throw new Error(`${imageName} is already running, aborting`);
84+
}
85+
4886
const binName = path.basename(env.foundation.launchSpec[0].binPath);
4987
const pids = checkAlreadyRunning(binName);
5088
pids.length === 0 || process.env.CI || (await promptAlreadyRunning(pids));
5189
await downloadBinsIfMissing(env.foundation.launchSpec[0].binPath);
5290
}
5391

92+
async function promptKillContainers(matchingContainers: Docker.ContainerInfo[]) {
93+
const answer = await select({
94+
message: `The following containers are already running image ${matchingContainers[0].Image}: ${matchingContainers.map(({ Id }) => Id).join(", ")}\n Would you like to kill them?`,
95+
choices: [
96+
{ name: "🪓 Kill containers", value: "kill" },
97+
{ name: "👋 Quit", value: "goodbye" },
98+
],
99+
});
100+
101+
if (answer === "goodbye") {
102+
console.log("Goodbye!");
103+
process.exit(0);
104+
}
105+
106+
if (answer === "kill") {
107+
const docker = new Docker();
108+
for (const { Id } of matchingContainers) {
109+
const container = docker.getContainer(Id);
110+
await container.stop();
111+
await container.remove();
112+
}
113+
114+
const containers = await docker.listContainers({
115+
filters: { ancestor: matchingContainers.map(({ Image }) => Image) },
116+
});
117+
118+
if (containers.length > 0) {
119+
console.error(
120+
`The following containers are still running: ${containers.map(({ Id }) => Id).join(", ")}`
121+
);
122+
process.exit(1);
123+
}
124+
125+
return;
126+
}
127+
}
128+
54129
export async function executeScript(scriptCommand: string, args?: string) {
55130
const scriptsDir = (await importAsyncConfig()).scriptsDir;
56131

packages/cli/src/internal/localNode.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,103 @@ import { checkAccess, checkExists } from "./fileCheckers";
66
import Debug from "debug";
77
import { setTimeout as timer } from "node:timers/promises";
88
import util from "node:util";
9+
import type { DevLaunchSpec } from "@moonwall/types";
10+
import Docker from "dockerode";
11+
import invariant from "tiny-invariant";
912

1013
const execAsync = util.promisify(exec);
1114
const debug = Debug("global:localNode");
1215

13-
export async function launchNode(cmd: string, args: string[], name: string) {
16+
// TODO: Add multi-threading support
17+
async function launchDockerContainer(
18+
imageName: string,
19+
args: string[],
20+
name: string,
21+
dockerConfig?: DevLaunchSpec["dockerConfig"]
22+
) {
23+
const docker = new Docker();
24+
const port = args.find((a) => a.includes("port"))?.split("=")[1];
25+
debug(`\x1b[36mStarting Docker container ${imageName} on port ${port}...\x1b[0m`);
26+
27+
const dirPath = path.join(process.cwd(), "tmp", "node_logs");
28+
const logLocation = path.join(dirPath, `${name}_docker_${Date.now()}.log`);
29+
const fsStream = fs.createWriteStream(logLocation);
30+
process.env.MOON_LOG_LOCATION = logLocation;
31+
32+
const portBindings = dockerConfig?.exposePorts?.reduce<Record<string, { HostPort: string }[]>>(
33+
(acc, { hostPort, internalPort }) => {
34+
acc[`${internalPort}/tcp`] = [{ HostPort: hostPort.toString() }];
35+
return acc;
36+
},
37+
{}
38+
);
39+
40+
const rpcPort = args.find((a) => a.includes("rpc-port"))?.split("=")[1];
41+
invariant(rpcPort, "RPC port not found, this is a bug");
42+
43+
const containerOptions = {
44+
Image: imageName,
45+
platform: "linux/amd64",
46+
Cmd: args,
47+
name: dockerConfig?.containerName || `moonwall_${name}_${Date.now()}`,
48+
ExposedPorts: {
49+
...Object.fromEntries(
50+
dockerConfig?.exposePorts?.map(({ internalPort }) => [`${internalPort}/tcp`, {}]) || []
51+
),
52+
[`${rpcPort}/tcp`]: {},
53+
},
54+
HostConfig: {
55+
PortBindings: {
56+
...portBindings,
57+
[`${rpcPort}/tcp`]: [{ HostPort: rpcPort }],
58+
},
59+
},
60+
Env: dockerConfig?.runArgs?.filter((arg) => arg.startsWith("env:")).map((arg) => arg.slice(4)),
61+
} satisfies Docker.ContainerCreateOptions;
62+
63+
try {
64+
await pullImage(imageName, docker);
65+
66+
const container = await docker.createContainer(containerOptions);
67+
await container.start();
68+
69+
const containerInfo = await container.inspect();
70+
if (!containerInfo.State.Running) {
71+
const errorMessage = `Container failed to start: ${containerInfo.State.Error}`;
72+
console.error(errorMessage);
73+
fs.appendFileSync(logLocation, `${errorMessage}\n`);
74+
throw new Error(errorMessage);
75+
}
76+
77+
for (let i = 0; i < 300; i++) {
78+
if (await checkWebSocketJSONRPC(Number.parseInt(rpcPort))) {
79+
break;
80+
}
81+
await timer(100);
82+
}
83+
84+
return { runningNode: container, fsStream };
85+
} catch (error: unknown) {
86+
if (error instanceof Error) {
87+
console.error(`Docker container launch failed: ${error.message}`);
88+
fs.appendFileSync(logLocation, `Docker launch error: ${error.message}\n`);
89+
}
90+
throw error;
91+
}
92+
}
93+
94+
export async function launchNode(options: {
95+
command: string;
96+
args: string[];
97+
name: string;
98+
launchSpec?: DevLaunchSpec;
99+
}) {
100+
const { command: cmd, args, name, launchSpec: config } = options;
101+
102+
if (config?.useDocker) {
103+
return launchDockerContainer(cmd, args, name, config.dockerConfig);
104+
}
105+
14106
if (cmd.includes("moonbeam")) {
15107
await checkExists(cmd);
16108
checkAccess(cmd);
@@ -193,3 +285,19 @@ async function findPortsByPid(pid: number, retryCount = 600, retryDelay = 100):
193285

194286
return [];
195287
}
288+
289+
async function pullImage(imageName: string, docker: Docker) {
290+
console.log(`Pulling Docker image: ${imageName}`);
291+
292+
const pullStream = await docker.pull(imageName);
293+
// Dockerode pull doesn't wait for completion by default 🫠
294+
await new Promise((resolve, reject) => {
295+
docker.modem.followProgress(pullStream, (err: Error | null, output: any[]) => {
296+
if (err) {
297+
reject(err);
298+
} else {
299+
resolve(output);
300+
}
301+
});
302+
});
303+
}

packages/cli/src/internal/providerFactories.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ export class ProviderFactory {
9595
name: this.providerConfig.name,
9696
type: this.providerConfig.type,
9797
connect: () => {
98-
const provider = new ethers.WebSocketProvider(this.url);
98+
const provider = this.url.startsWith("ws")
99+
? new ethers.WebSocketProvider(this.url)
100+
: new ethers.JsonRpcProvider(this.url);
99101
return new Wallet(this.privateKey, provider);
100102
},
101103
};

0 commit comments

Comments
 (0)