Skip to content

Commit 5d10274

Browse files
committed
added workflow to check node compat during psdk upgrade
1 parent 1ff8d4c commit 5d10274

File tree

5 files changed

+1763
-0
lines changed

5 files changed

+1763
-0
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
name: Check Node Compat
2+
3+
on:
4+
pull_request:
5+
types: [labeled]
6+
branches: [devnet-ready]
7+
8+
concurrency:
9+
group: check-node-compat-${{ github.ref }}
10+
cancel-in-progress: true
11+
12+
env:
13+
CARGO_TERM_COLOR: always
14+
15+
jobs:
16+
build:
17+
name: build ${{ matrix.version.name }}
18+
runs-on: [self-hosted, type-ccx33]
19+
if: contains(github.event.pull_request.labels.*.name, 'check-node-compat')
20+
env:
21+
RUST_BACKTRACE: full
22+
strategy:
23+
matrix:
24+
version:
25+
- { name: old, ref: devnet-ready }
26+
- { name: new, ref: ${{ github.head_ref }} }
27+
28+
steps:
29+
- name: Install dependencies
30+
run: |
31+
sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update
32+
sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" build-essential clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config unzip
33+
34+
- name: Install Rust
35+
uses: actions-rs/toolchain@v1
36+
with:
37+
toolchain: stable
38+
39+
- name: Utilize Shared Rust Cache
40+
uses: Swatinem/rust-cache@v2
41+
with:
42+
key: "check-node-compat-${{ matrix.version.name }}"
43+
44+
- name: Checkout ${{ matrix.version.name }}
45+
uses: actions/checkout@v4
46+
with:
47+
ref: ${{ matrix.version.ref }}
48+
path: ${{ matrix.version.name }}
49+
50+
- name: Build ${{ matrix.version.name }}
51+
working-directory: ${{ matrix.version.name }}
52+
run: cargo build --release --locked
53+
54+
- name: Upload ${{ matrix.version.name }} node binary
55+
uses: actions/upload-artifact@v4
56+
with:
57+
name: node-subtensor-${{ matrix.version.name }}
58+
path: ${{ matrix.version.name }}/target/release/node-subtensor
59+
retention-days: 1
60+
61+
test:
62+
needs: [build]
63+
runs-on: [self-hosted, type-ccx33]
64+
steps:
65+
- name: Download old node binary
66+
uses: actions/download-artifact@v4
67+
with:
68+
name: node-subtensor-old
69+
path: /tmp/node-subtensor-old
70+
71+
- name: Download new node binary
72+
uses: actions/download-artifact@v4
73+
with:
74+
name: node-subtensor-new
75+
path: /tmp/node-subtensor-new
76+
77+
- name: Set up Node.js
78+
uses: actions/setup-node@v4
79+
with:
80+
node-version: "24"
81+
82+
- name: Install npm dependencies
83+
working-directory: ${{ github.workspace }}/.github/workflows/check-node-compat
84+
run: npm install
85+
86+
- name: Run test
87+
working-directory: ${{ github.workspace }}/.github/workflows/check-node-compat
88+
run: npm run test
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { spawnSync, spawn, ChildProcess } from "node:child_process";
2+
import { writeFile } from "node:fs/promises";
3+
4+
const all = Promise.all.bind(Promise);
5+
6+
const SECOND = 1000;
7+
const MINUTE = 60 * SECOND;
8+
9+
const OLD_BINARY_PATH = "/tmp/node-subtensor-old";
10+
const NEW_BINARY_PATH = "/tmp/node-subtensor-new";
11+
const CHAIN_SPEC_PATH = "/tmp/local.json";
12+
13+
const ONE_OPTIONS = {
14+
binaryPath: OLD_BINARY_PATH,
15+
basePath: "/tmp/one",
16+
name: "one",
17+
port: 30333,
18+
rpcPort: 9933,
19+
validator: true,
20+
};
21+
22+
const TWO_OPTIONS = {
23+
binaryPath: OLD_BINARY_PATH,
24+
basePath: "/tmp/two",
25+
name: "two",
26+
port: 30334,
27+
rpcPort: 9944,
28+
validator: true,
29+
};
30+
31+
const ALICE_OPTIONS = {
32+
binaryPath: OLD_BINARY_PATH,
33+
basePath: "/tmp/alice",
34+
name: "alice",
35+
port: 30335,
36+
rpcPort: 9955,
37+
validator: false,
38+
};
39+
40+
type Node = { name: string; binaryPath: string; process: ChildProcess };
41+
42+
async function main() {
43+
await generateChainSpec();
44+
45+
const one = startNode(ONE_OPTIONS);
46+
await started(one);
47+
48+
const two = startNode(TWO_OPTIONS);
49+
await started(two);
50+
51+
await all([peerCount(one, 1), peerCount(two, 1)]);
52+
await all([finalizedBlocks(one, 5), finalizedBlocks(two, 5)]);
53+
54+
const alice = startNode(ALICE_OPTIONS);
55+
await started(alice);
56+
57+
await all([peerCount(one, 2), peerCount(two, 2), peerCount(alice, 2)]);
58+
await all([finalizedBlocks(one, 10), finalizedBlocks(two, 10), finalizedBlocks(alice, 10)]);
59+
60+
// Swap 'alice' node with the new binary
61+
await stop(alice);
62+
const aliceNew = startNode({ ...ALICE_OPTIONS, binaryPath: NEW_BINARY_PATH });
63+
await started(aliceNew);
64+
65+
await all([peerCount(one, 2), peerCount(two, 2), peerCount(aliceNew, 2)]);
66+
await all([finalizedBlocks(one, 15), finalizedBlocks(two, 15), finalizedBlocks(aliceNew, 15)]);
67+
68+
// Swap 'one' node with the new binary
69+
await stop(one);
70+
const oneNew = startNode({ ...ONE_OPTIONS, binaryPath: NEW_BINARY_PATH });
71+
await started(oneNew);
72+
73+
await all([peerCount(two, 2), peerCount(aliceNew, 2), peerCount(oneNew, 2)]);
74+
await all([finalizedBlocks(oneNew, 20), finalizedBlocks(two, 20), finalizedBlocks(aliceNew, 20)]);
75+
76+
// Swap 'two' node with the new binary
77+
await stop(two);
78+
const twoNew = startNode({ ...TWO_OPTIONS, binaryPath: NEW_BINARY_PATH });
79+
await started(twoNew);
80+
81+
await all([peerCount(oneNew, 2), peerCount(twoNew, 2), peerCount(aliceNew, 2)]);
82+
await all([finalizedBlocks(oneNew, 50), finalizedBlocks(twoNew, 50), finalizedBlocks(aliceNew, 50)]);
83+
84+
await all([stop(oneNew), stop(twoNew), stop(aliceNew)]);
85+
86+
log("Test completed with success, binaries are compatible ✅");
87+
}
88+
89+
// Generate the chain spec for the local network
90+
const generateChainSpec = async () => {
91+
const result = spawnSync(
92+
OLD_BINARY_PATH,
93+
["build-spec", "--disable-default-bootnode", "--raw", "--chain", "local"],
94+
{ maxBuffer: 1024 * 1024 * 10 }, // 10MB
95+
);
96+
97+
if (result.status !== 0) {
98+
throw new Error(`Failed to generate chain spec: ${result.stderr.toString()}`);
99+
}
100+
101+
const stdout = result.stdout.toString();
102+
await writeFile(CHAIN_SPEC_PATH, stdout, { encoding: "utf-8" });
103+
};
104+
105+
// Start a node with the given options
106+
const startNode = (opts: {
107+
binaryPath: string;
108+
basePath: string;
109+
name: string;
110+
port: number;
111+
rpcPort: number;
112+
validator: boolean;
113+
}): Node => {
114+
const process = spawn(opts.binaryPath, [
115+
`--${opts.name}`,
116+
...["--chain", CHAIN_SPEC_PATH],
117+
...["--base-path", opts.basePath],
118+
...["--port", opts.port.toString()],
119+
...["--rpc-port", opts.rpcPort.toString()],
120+
...(opts.validator ? ["--validator"] : []),
121+
"--rpc-cors=all",
122+
"--allow-private-ipv4",
123+
"--discover-local",
124+
"--unsafe-force-node-key-generation",
125+
]);
126+
127+
process.on("error", (error) => console.error(`${opts.name} (error): ${error}`));
128+
process.on("close", (code) => log(`${opts.name}: process closed with code ${code}`));
129+
130+
return { name: opts.name, binaryPath: opts.binaryPath, process };
131+
};
132+
133+
const stop = (node: Node): Promise<void> => {
134+
return new Promise((resolve, reject) => {
135+
node.process.on("close", resolve);
136+
node.process.on("error", reject);
137+
138+
if (!node.process.kill()) {
139+
reject(new Error(`Failed to stop ${node.name}`));
140+
}
141+
});
142+
};
143+
144+
// Ensure the node has correctly started
145+
const started = (node: Node, timeout = 30 * SECOND) => {
146+
const errorMessage = `Failed to start ${node.name} in time`;
147+
148+
return innerEnsure(node, errorMessage, timeout, (data, ok) => {
149+
if (data.includes("💤 Idle")) {
150+
log(`${node.name}: started using ${node.binaryPath}`);
151+
ok();
152+
}
153+
});
154+
};
155+
156+
// Ensure the node has reached the expected number of peers
157+
const peerCount = (node: Node, expectedPeers: number, timeout = 30 * SECOND) => {
158+
const errorMessage = `Failed to reach ${expectedPeers} peers in time`;
159+
160+
return innerEnsure(node, errorMessage, timeout, (data, ok) => {
161+
const maybePeers = /Idle \((?<peers>\d+) peers\)/.exec(data)?.groups?.peers;
162+
if (!maybePeers) return;
163+
164+
const peers = parseInt(maybePeers);
165+
if (peers >= expectedPeers) {
166+
log(`${node.name}: reached ${expectedPeers} peers`);
167+
ok();
168+
}
169+
});
170+
};
171+
172+
// Ensure the node has reached the expected number of finalized blocks
173+
const finalizedBlocks = (node: Node, expectedFinalized: number, timeout = 10 * MINUTE) => {
174+
const errorMessage = `Failed to reach ${expectedFinalized} finalized blocks in time`;
175+
176+
return innerEnsure(node, errorMessage, timeout, (data, ok) => {
177+
const maybeFinalized = /finalized #(?<blocks>\d+)/.exec(data)?.groups?.blocks;
178+
if (!maybeFinalized) return;
179+
180+
const finalized = parseInt(maybeFinalized);
181+
if (finalized >= expectedFinalized) {
182+
log(`${node.name}: reached ${expectedFinalized} finalized blocks`);
183+
ok();
184+
}
185+
});
186+
};
187+
188+
// Helper function to ensure a condition is met within a timeout
189+
function innerEnsure(node: Node, errorMessage: string, timeout: number, f: (data: string, ok: () => void) => void) {
190+
return new Promise<void>((resolve, reject) => {
191+
const id = setTimeout(() => reject(new Error(errorMessage)), timeout);
192+
193+
const fn = (data: string) =>
194+
f(data, () => {
195+
clearTimeout(id);
196+
node.process.stderr?.off("data", fn);
197+
resolve();
198+
});
199+
200+
node.process.stderr?.on("data", fn);
201+
});
202+
}
203+
204+
const log = (message: string) => console.log(`[${new Date().toISOString()}] ${message}`);
205+
206+
main();

0 commit comments

Comments
 (0)