-
Notifications
You must be signed in to change notification settings - Fork 0
Spawn typeberry nodes in docker containers according to network specification #110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
e787e6f
"start" command to spawn typeberry nodes
skoszuta f2ac410
running tiny network with jammin-generated genesis.json
skoszuta f66b37d
dropping validator data from genesis before passing to typeberry
skoszuta 709f218
Merge remote-tracking branch 'origin/main' into sk-spawn-nodes
skoszuta 2ffe58e
fixed unit test
skoszuta 24bab85
tests for start command
skoszuta 6022d52
fix as per code review
skoszuta 83726ff
changes as per code review
skoszuta 7c28e46
changes as per code review
skoszuta 0ca0d9a
installed missing dep
skoszuta File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; | ||
| import { mkdir, rm, unlink } from "node:fs/promises"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
|
|
||
| describe("start-command", () => { | ||
| describe("createFilteredGenesis", () => { | ||
| const { createFilteredGenesis } = require("./start-command"); | ||
|
|
||
| test("filters out genesis state drop keys from genesis data", async () => { | ||
| const genesisWithState = { | ||
| network: "testnet", | ||
| genesis_state: { | ||
| "04000000000000000000000000000000000000000000000000000000000000": { data: "next" }, | ||
| "07000000000000000000000000000000000000000000000000000000000000": { data: "designated" }, | ||
| "08000000000000000000000000000000000000000000000000000000000000": { data: "current" }, | ||
| "09000000000000000000000000000000000000000000000000000000000000": { data: "previous" }, | ||
| "05000000000000000000000000000000000000000000000000000000000000": { data: "keep this" }, | ||
| }, | ||
| }; | ||
|
|
||
| const tmpGenesisPath = join(tmpdir(), `test-genesis-${Date.now()}.json`); | ||
| await Bun.write(tmpGenesisPath, JSON.stringify(genesisWithState)); | ||
|
|
||
| const resultPath = await createFilteredGenesis(tmpGenesisPath); | ||
| const result = await Bun.file(resultPath).json(); | ||
|
|
||
| expect(result.genesis_state).toBeDefined(); | ||
| expect(result.genesis_state["04000000000000000000000000000000000000000000000000000000000000"]).toBeUndefined(); | ||
| expect(result.genesis_state["07000000000000000000000000000000000000000000000000000000000000"]).toBeUndefined(); | ||
| expect(result.genesis_state["08000000000000000000000000000000000000000000000000000000000000"]).toBeUndefined(); | ||
| expect(result.genesis_state["09000000000000000000000000000000000000000000000000000000000000"]).toBeUndefined(); | ||
| expect(result.genesis_state["05000000000000000000000000000000000000000000000000000000000000"]).toEqual({ | ||
| data: "keep this", | ||
| }); | ||
|
|
||
| await unlink(tmpGenesisPath).catch(() => {}); | ||
| await unlink(resultPath).catch(() => {}); | ||
| }); | ||
|
|
||
| test("handles genesis without genesis_state field", async () => { | ||
| const genesisWithoutState = { | ||
| network: "testnet", | ||
| config: { chainId: 1 }, | ||
| }; | ||
|
|
||
| const tmpGenesisPath = join(tmpdir(), `test-genesis-${Date.now()}.json`); | ||
| await Bun.write(tmpGenesisPath, JSON.stringify(genesisWithoutState)); | ||
|
|
||
| const resultPath = await createFilteredGenesis(tmpGenesisPath); | ||
| const result = await Bun.file(resultPath).json(); | ||
|
|
||
| expect(result.network).toBe("testnet"); | ||
| expect(result.config).toEqual({ chainId: 1 }); | ||
| expect(result.genesis_state).toBeUndefined(); | ||
|
|
||
| await unlink(tmpGenesisPath).catch(() => {}); | ||
| await unlink(resultPath).catch(() => {}); | ||
| }); | ||
|
|
||
| test("writes filtered genesis to temp directory", async () => { | ||
| const genesis = { genesis_state: {} }; | ||
|
|
||
| const tmpGenesisPath = join(tmpdir(), `test-genesis-${Date.now()}.json`); | ||
| await Bun.write(tmpGenesisPath, JSON.stringify(genesis)); | ||
|
|
||
| const resultPath = await createFilteredGenesis(tmpGenesisPath); | ||
|
|
||
| expect(resultPath).toContain("jammin-genesis-"); | ||
| expect(resultPath).toContain(tmpdir()); | ||
|
|
||
| await unlink(tmpGenesisPath).catch(() => {}); | ||
| await unlink(resultPath).catch(() => {}); | ||
| }); | ||
| }); | ||
|
|
||
| describe("pullImage", () => { | ||
| let originalSpawn: typeof Bun.spawn; | ||
| let mockSpawn: ReturnType<typeof mock>; | ||
|
|
||
| beforeEach(() => { | ||
| originalSpawn = Bun.spawn; | ||
| mockSpawn = mock(() => ({ | ||
| stdout: new ReadableStream({ | ||
| start(controller) { | ||
| controller.close(); | ||
| }, | ||
| }), | ||
| stderr: new ReadableStream({ | ||
| start(controller) { | ||
| controller.close(); | ||
| }, | ||
| }), | ||
| exited: Promise.resolve(0), | ||
| })); | ||
| // biome-ignore lint/suspicious/noExplicitAny: Need to mock Bun.spawn for testing | ||
| (Bun as any).spawn = mockSpawn; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| Bun.spawn = originalSpawn; | ||
| }); | ||
|
|
||
| test("pulls the correct Docker image with platform flag", async () => { | ||
| const { pullImage } = require("./start-command"); | ||
| await pullImage(); | ||
|
|
||
| expect(mockSpawn).toHaveBeenCalledTimes(1); | ||
| const callArgs = mockSpawn.mock.calls[0]; | ||
| expect(callArgs?.[0]).toEqual([ | ||
| "docker", | ||
| "pull", | ||
| "--platform=linux/amd64", | ||
| "ghcr.io/fluffylabs/typeberry:latest", | ||
| ]); | ||
| }); | ||
|
|
||
| test("throws error when pull fails with non-zero exit code", async () => { | ||
| const failingMockSpawn = mock(() => ({ | ||
| stdout: new ReadableStream({ | ||
| start(controller) { | ||
| controller.close(); | ||
| }, | ||
| }), | ||
| stderr: new ReadableStream({ | ||
| start(controller) { | ||
| controller.enqueue(new TextEncoder().encode("pull failed: network error")); | ||
| controller.close(); | ||
| }, | ||
| }), | ||
| exited: Promise.resolve(1), | ||
| })); | ||
| // biome-ignore lint/suspicious/noExplicitAny: Need to mock Bun.spawn for testing | ||
| (Bun as any).spawn = failingMockSpawn; | ||
|
|
||
| const { pullImage } = require("./start-command"); | ||
|
|
||
| return Promise.all([ | ||
| expect(pullImage()).rejects.toThrow("Failed to pull image"), | ||
| expect(pullImage()).rejects.toThrow("exit code 1"), | ||
| ]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("startContainer", () => { | ||
| let originalSpawn: typeof Bun.spawn; | ||
| let originalCwd: () => string; | ||
| let mockSpawn: ReturnType<typeof mock>; | ||
| let testCwd: string; | ||
|
|
||
| beforeEach(async () => { | ||
| originalSpawn = Bun.spawn; | ||
| mockSpawn = mock(() => ({ | ||
| stdout: new ReadableStream({ | ||
| start(controller) { | ||
| controller.close(); | ||
| }, | ||
| }), | ||
| stderr: new ReadableStream({ | ||
| start(controller) { | ||
| controller.close(); | ||
| }, | ||
| }), | ||
| exited: Promise.resolve(0), | ||
| })); | ||
| // biome-ignore lint/suspicious/noExplicitAny: Need to mock Bun.spawn for testing | ||
| (Bun as any).spawn = mockSpawn; | ||
|
|
||
| testCwd = join(tmpdir(), `jammin-test-${Date.now()}`); | ||
| await mkdir(testCwd, { recursive: true }); | ||
| await mkdir(join(testCwd, "logs"), { recursive: true }); | ||
| originalCwd = process.cwd; | ||
| process.cwd = mock(() => testCwd); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| Bun.spawn = originalSpawn; | ||
| process.cwd = originalCwd; | ||
| await rm(testCwd, { recursive: true }).catch(() => {}); | ||
| }); | ||
|
skoszuta marked this conversation as resolved.
|
||
|
|
||
| test("spawns docker container with correct arguments in correct order", async () => { | ||
| const { startContainer } = require("./start-command"); | ||
|
|
||
| await startContainer("/tmp/filtered-genesis.json"); | ||
|
|
||
| expect(mockSpawn).toHaveBeenCalledTimes(1); | ||
| const callArgs = mockSpawn.mock.calls[0]; | ||
| const dockerArgs = callArgs?.[0] as string[]; | ||
|
|
||
| expect(dockerArgs).toEqual([ | ||
| "docker", | ||
| "run", | ||
| "--rm", | ||
| "-v", | ||
| "/tmp/filtered-genesis.json:/app/genesis.json:ro", | ||
| "-v", | ||
| `${testCwd}/logs:/app/bin/jam/logs`, | ||
| "--entrypoint", | ||
| "/bin/bash", | ||
| "ghcr.io/fluffylabs/typeberry:latest", | ||
| "-c", | ||
| "npm run tiny-network -- --config=dev --config=.chain_spec+=/app/genesis.json", | ||
| ]); | ||
| }); | ||
|
|
||
| test("passes cwd to docker spawn", async () => { | ||
| const { startContainer } = require("./start-command"); | ||
|
|
||
| await startContainer("/tmp/filtered-genesis.json"); | ||
|
|
||
| expect(mockSpawn).toHaveBeenCalledTimes(1); | ||
| const callArgs = mockSpawn.mock.calls[0]; | ||
| expect(callArgs?.[1]?.cwd).toBe(testCwd); | ||
| }); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| import { access, mkdir, unlink } from "node:fs/promises"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join, resolve } from "node:path"; | ||
| import * as p from "@clack/prompts"; | ||
| import * as state_merkleization from "@typeberry/lib/state-merkleization"; | ||
| import { Command } from "commander"; | ||
|
|
||
| const { StateKeyIdx, stateKeys } = state_merkleization; | ||
|
|
||
| const DOCKER_IMAGE = "ghcr.io/fluffylabs/typeberry:latest"; | ||
|
|
||
| // note [seko]: genesis state keys to drop before passing to typeberry. this is needed to avoid overwriting dev node validator data | ||
| const GENESIS_STATE_DROP_KEYS: string[] = [ | ||
| StateKeyIdx.Gamma, | ||
| StateKeyIdx.Iota, | ||
| StateKeyIdx.Kappa, | ||
| StateKeyIdx.Lambda, | ||
| ].map((stateKeyIdx) => stateKeys.index(stateKeyIdx).toString().slice(2, -2)); | ||
|
|
||
| export async function createFilteredGenesis(genesisPath: string): Promise<string> { | ||
| const raw = await Bun.file(genesisPath).text(); | ||
| const genesis = JSON.parse(raw); | ||
|
|
||
| if (genesis.genesis_state) { | ||
| for (const key of GENESIS_STATE_DROP_KEYS) { | ||
| delete genesis.genesis_state[key]; | ||
| } | ||
| } | ||
|
|
||
| const tmpPath = join(tmpdir(), `jammin-genesis-${Date.now()}.json`); | ||
| await Bun.write(tmpPath, JSON.stringify(genesis, null, 2)); | ||
| return tmpPath; | ||
| } | ||
|
|
||
| /** | ||
| * Pull the latest Docker image | ||
| */ | ||
| export async function pullImage(): Promise<void> { | ||
| const proc = Bun.spawn(["docker", "pull", "--platform=linux/amd64", DOCKER_IMAGE], { | ||
| stdout: "inherit", | ||
| stderr: "inherit", | ||
| cwd: process.cwd(), | ||
| }); | ||
|
|
||
| const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]); | ||
|
|
||
| if (exitCode !== 0) { | ||
| throw new Error(`Failed to pull image ${DOCKER_IMAGE} with exit code ${exitCode}: ${stderr}`); | ||
| } | ||
|
skoszuta marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * Spawn a Docker container to run a local Typeberry network. | ||
| */ | ||
| export async function startContainer(filteredGenesisPath: string): Promise<void> { | ||
| const logsPath = `${process.cwd()}/logs`; | ||
| await mkdir(logsPath, { recursive: true }); | ||
| const dockerArgs: string[] = [ | ||
| "run", | ||
| "--rm", | ||
| "-v", | ||
| `${filteredGenesisPath}:/app/genesis.json:ro`, | ||
| "-v", | ||
| `${logsPath}:/app/bin/jam/logs`, | ||
| "--entrypoint", | ||
| "/bin/bash", | ||
| DOCKER_IMAGE, | ||
| "-c", | ||
| "npm run tiny-network -- --config=dev --config=.chain_spec+=/app/genesis.json", | ||
| ]; | ||
|
|
||
| const proc = Bun.spawn(["docker", ...dockerArgs], { | ||
| stdout: "inherit", | ||
| stderr: "inherit", | ||
| cwd: process.cwd(), | ||
| }); | ||
|
|
||
| const cleanup = () => { | ||
| proc.kill(); | ||
| }; | ||
|
|
||
| process.on("SIGINT", cleanup); | ||
| process.on("SIGTERM", cleanup); | ||
| process.on("exit", cleanup); | ||
|
|
||
| await proc.exited; | ||
| } | ||
|
|
||
| /** | ||
| * "Start" command definition | ||
| */ | ||
| export const startCommand = new Command("start") | ||
| .description("start a local JAM environment in a Docker container") | ||
| .addHelpText( | ||
| "after", | ||
| ` | ||
| Examples: | ||
| $ jammin start | ||
| `, | ||
| ) | ||
| .action(async () => { | ||
| p.intro("🚀 Starting local JAM network..."); | ||
|
|
||
| const genesisPath = resolve(process.cwd(), "dist/genesis.json"); | ||
| try { | ||
| await access(genesisPath); | ||
| } catch { | ||
| p.log.error(`Genesis file not found at ${genesisPath}`); | ||
| p.log.info('Run "jammin deploy" first to generate the genesis state.'); | ||
| p.outro("❌ Start aborted."); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| let filteredGenesisPath: string | undefined; | ||
| const s = p.spinner(); | ||
| try { | ||
| s.start("Pulling latest Typeberry image..."); | ||
| await pullImage(); | ||
| s.stop("✅ Image up to date"); | ||
|
|
||
| s.start("Preparing genesis state..."); | ||
| filteredGenesisPath = await createFilteredGenesis(genesisPath); | ||
| s.stop("✅ Genesis state ready"); | ||
|
|
||
| s.start("Spawning Docker container..."); | ||
| s.stop("Typeberry container output:"); | ||
| await startContainer(filteredGenesisPath); | ||
|
|
||
| p.outro("🏁 Local JAM network finished running."); | ||
| } catch (error) { | ||
| if (error instanceof Error) { | ||
| p.log.error(error.message); | ||
| } else { | ||
| p.log.error(String(error)); | ||
| } | ||
|
|
||
| p.outro("❌ Start failed. See the output above for details."); | ||
| process.exit(1); | ||
| } finally { | ||
| if (filteredGenesisPath) { | ||
| await unlink(filteredGenesisPath); | ||
| } | ||
| } | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.