Skip to content

Commit

Permalink
@defichain/testcontainers MasternodeGroup (#470)
Browse files Browse the repository at this point in the history
* multi nodes

* refactor and add ability to run multiple container in a group

* added randomly generated network name

* fix restart to reset cachedRpcUrl

* fixed updated tests

Co-authored-by: Fuxing Loh <[email protected]>
  • Loading branch information
canonbrother and fuxingloh authored Aug 2, 2021
1 parent 4a26526 commit 7b48f0f
Show file tree
Hide file tree
Showing 10 changed files with 227 additions and 63 deletions.
12 changes: 2 additions & 10 deletions packages/testcontainers/__tests__/chains/container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ describe('container error handling', () => {
}

container = new InvalidCmd()
return expect(container.start({ timeout: 3000 }))
.rejects.toThrow(/Unable to find rpc port, the container might have crashed/)
return expect(container.start({ timeout: 5000 }))
.rejects.toThrow(/waitForRpc is not ready within given timeout of 5000ms./)
})

it('should get error: container not found if container is stopped', async () => {
Expand All @@ -46,12 +46,4 @@ describe('container error handling', () => {
return await expect(container.getRpcPort())
.rejects.toThrow(/\(HTTP code 404\) no such container - No such container:/)
})

it('should fail waitForCondition as condition is never valid', async () => {
container = new RegTestContainer()
await container.start()
await container.waitForReady()
return await expect(container.waitForCondition(async () => false, 3000))
.rejects.toThrow('waitForCondition is not ready within given timeout of 3000ms.')
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { GenesisKeys, MasterNodeRegTestContainer } from '../../src'

describe('container restart', () => {
describe('container restart with setDeFiConf', () => {
const container = new MasterNodeRegTestContainer()

beforeEach(async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { GenesisKeys, MasternodeGroup, MasterNodeRegTestContainer } from '../../../src'

const group = new MasternodeGroup([
new MasterNodeRegTestContainer(GenesisKeys[0]),
new MasterNodeRegTestContainer(GenesisKeys[1])
])

beforeAll(async () => {
await group.start()
await group.get(0).waitForWalletCoinbaseMaturity()
})

afterAll(async () => {
await group.stop()
})

it('send balance and wait for sync for balance to reach', async () => {
const before = await group.get(1).call('getbalance')

const address = await group.get(1).getNewAddress()
await group.get(0).call('sendtoaddress', [address, 314])
await group.get(0).generate(1)
await group.waitForSync()

const after = await group.get(1).call('getbalance')
expect(after - before).toStrictEqual(314)
})

it('should add another container to already running group', async () => {
const container = new MasterNodeRegTestContainer()
await container.start()

await group.add(container)
await group.waitForSync()

const count = await group.get(2).getBlockCount()
expect(count).toBeGreaterThan(100)
})
1 change: 1 addition & 0 deletions packages/testcontainers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"build": "tsc -b ./tsconfig.build.json"
},
"dependencies": {
"abort-controller": "^3.0.0",
"dockerode": "^3.3.0",
"node-fetch": "^2.6.1"
},
Expand Down
92 changes: 43 additions & 49 deletions packages/testcontainers/src/chains/defid_container.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import AbortController from 'abort-controller'
import Dockerode, { ContainerInfo, DockerOptions } from 'dockerode'
import fetch from 'node-fetch'
import { DockerContainer } from './docker_container'
import { waitForCondition } from '../wait_for_condition'

/**
* Types of network as per https://github.com/DeFiCh/ain/blob/bc231241/src/chainparams.cpp#L825-L836
Expand Down Expand Up @@ -59,7 +61,7 @@ export abstract class DeFiDContainer extends DockerContainer {
return [
'defid',
'-printtoconsole',
'-rpcallowip=172.17.0.0/16',
'-rpcallowip=0.0.0.0/0',
'-rpcbind=0.0.0.0',
`-rpcuser=${opts.user!}`,
`-rpcpassword=${opts.password!}`
Expand Down Expand Up @@ -150,15 +152,30 @@ export abstract class DeFiDContainer extends DockerContainer {

/**
* For convenience sake, HTTP post to the RPC URL for the current node.
* Not error checked, returns the raw JSON as string.
* Timeout error checked, in case if the node froze.
* Returns the raw JSON as string.
*/
async post (body: string): Promise<string> {
async post (body: string, timeout = 10000): Promise<string> {
const controller = new AbortController()
const id = setTimeout(() => controller.abort(), timeout)

const url = await this.getCachedRpcUrl()
const response = await fetch(url, {
const request = fetch(url, {
method: 'POST',
body: body
body: body,
signal: controller.signal
})
return await response.text()

try {
const response = await request
clearTimeout(id)
return response.text()
} catch (err) {
if (err.type === 'aborted') {
throw new DeFiDRpcError(err)
}
throw err
}
}

/**
Expand All @@ -176,52 +193,29 @@ export abstract class DeFiDContainer extends DockerContainer {
}

/**
* Wait for rpc to be ready
* @param {number} [timeout=20000] in millis
* Convenience method to getbestblockhash, typing mapping is non exhaustive
*/
private async waitForRpc (timeout = 20000): Promise<void> {
const expiredAt = Date.now() + timeout

return await new Promise((resolve, reject) => {
const checkReady = (): void => {
this.cachedRpcUrl = undefined
this.getMiningInfo().then(() => {
resolve()
}).catch(err => {
if (expiredAt < Date.now()) {
reject(new Error(`DeFiDContainer docker not ready within given timeout of ${timeout}ms.\n${err.message as string}`))
} else {
setTimeout(() => void checkReady(), 200)
}
})
}

checkReady()
})
async getBestBlockHash (): Promise<string> {
return await this.call('getbestblockhash', [])
}

/**
* @param {() => Promise<boolean>} condition to wait for true
* @param {number} timeout duration when condition is not met
* @param {number} [interval=200] duration in ms
* Connect another node
* @param {string} ip
* @return {Promise<void>}
*/
async waitForCondition (condition: () => Promise<boolean>, timeout: number, interval: number = 200): Promise<void> {
const expiredAt = Date.now() + timeout

return await new Promise((resolve, reject) => {
const checkCondition = async (): Promise<void> => {
const isReady = await condition().catch(() => false)
if (isReady) {
resolve()
} else if (expiredAt < Date.now()) {
reject(new Error(`waitForCondition is not ready within given timeout of ${timeout}ms.`))
} else {
setTimeout(() => void checkCondition(), interval)
}
}
async addNode (ip: string): Promise<void> {
return await this.call('addnode', [ip, 'onetry'])
}

void checkCondition()
})
/**
* Wait for rpc to be ready
* @param {number} [timeout=20000] in millis
*/
private async waitForRpc (timeout = 20000): Promise<void> {
await waitForCondition(async () => {
return await this.getBlockCount().then(() => true).catch(() => false)
}, timeout, 200, 'waitForRpc')
}

/**
Expand Down Expand Up @@ -255,8 +249,8 @@ export abstract class DeFiDContainer extends DockerContainer {
* @param {number} [timeout=30000] in millis
*/
async restart (timeout: number = 30000): Promise<void> {
await this.container?.stop()
await this.container?.start()
await this.container?.restart()
this.cachedRpcUrl = undefined
await this.waitForRpc(timeout)
}
}
Expand Down Expand Up @@ -297,7 +291,7 @@ async function cleanUpStale (prefix: string, docker: Dockerode): Promise<void> {
}

return await new Promise((resolve, reject) => {
docker.listContainers({ all: 1 }, (error, result) => {
docker.listContainers({ all: true }, (error, result) => {
if (error instanceof Error) {
return reject(error)
}
Expand Down
20 changes: 20 additions & 0 deletions packages/testcontainers/src/chains/docker_container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ export abstract class DockerContainer {
this.docker = new Dockerode(options)
}

get id (): string {
return this.requireContainer().id
}

async getIp (name = 'default'): Promise<string> {
const { NetworkSettings: networkSettings } = await this.inspect()
const { Networks: networks } = networkSettings
return networks[name].IPAddress
}

/**
* Try pull docker image if it doesn't already exist.
*/
Expand Down Expand Up @@ -86,6 +96,16 @@ export abstract class DockerContainer {
})
})
}

/**
* Inspect docker container info
*
* @return {Promise<Record<string, any>>}
*/
async inspect (): Promise<Record<string, any>> {
const container = this.requireContainer()
return await container.inspect()
}
}

async function hasImageLocally (image: string, docker: Dockerode): Promise<boolean> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GenesisKeys, MasterNodeKey } from '../../testkeys'
import { waitForCondition } from '../../wait_for_condition'
import { DockerOptions } from 'dockerode'
import { DeFiDContainer, StartOptions } from '../defid_container'
import { RegTestContainer } from './index'
Expand Down Expand Up @@ -54,7 +55,7 @@ export class MasterNodeRegTestContainer extends RegTestContainer {
async waitForGenerate (nblocks: number, timeout: number = 590000, address: string = this.masternodeKey.operator.address): Promise<void> {
const target = await this.getBlockCount() + nblocks

return await this.waitForCondition(async () => {
return await waitForCondition(async () => {
const count = await this.getBlockCount()
if (count > target) {
return true
Expand All @@ -81,7 +82,7 @@ export class MasterNodeRegTestContainer extends RegTestContainer {
* @param {number} [timeout=90000] in ms
*/
async waitForBlockHeight (height: number, timeout = 590000): Promise<void> {
return await this.waitForCondition(async () => {
return await waitForCondition(async () => {
const count = await this.getBlockCount()
if (count > height) {
return true
Expand Down Expand Up @@ -114,7 +115,7 @@ export class MasterNodeRegTestContainer extends RegTestContainer {
* @see waitForWalletCoinbaseMaturity
*/
async waitForWalletBalanceGTE (balance: number, timeout = 30000): Promise<void> {
return await this.waitForCondition(async () => {
return await waitForCondition(async () => {
const getbalance = await this.call('getbalance')
if (getbalance >= balance) {
return true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Dockerode, { DockerOptions, Network } from 'dockerode'
import { waitForCondition } from '../../wait_for_condition'
import { MasterNodeRegTestContainer } from '../../chains/reg_test_container/masternode'

export class MasternodeGroup {
protected readonly docker: Dockerode
protected network?: Network

public constructor (
protected readonly containers: MasterNodeRegTestContainer[] = [],
protected readonly name = `testcontainers-${Math.floor(Math.random() * 10000000)}`,
options?: DockerOptions
) {
this.docker = new Dockerode(options)
}

get (i: number): MasterNodeRegTestContainer {
return this.containers[i]
}

async start (): Promise<void> {
this.network = await new Promise((resolve, reject) => {
return this.docker.createNetwork({
Name: this.name,
IPAM: {
Driver: 'default',
Config: []
}
}, (err, data) => {
if (err instanceof Error) {
return reject(err)
}
return resolve(data)
})
})

// Removing all predefined containers and adding it to group
for (const container of this.containers.splice(0)) {
await container.start()
await this.add(container)
}
}

/**
* Require network, else error exceptionally.
* Not a clean design, but it keep the complexity of this implementation low.
*/
protected requireNetwork (): Network {
if (this.network !== undefined) {
return this.network
}
throw new Error('network not yet started')
}

/**
* @param {DeFiDContainer} container to add into container group with addnode
*/
async add (container: MasterNodeRegTestContainer): Promise<void> {
await this.requireNetwork().connect({ Container: container.id })

for (const each of this.containers) {
await container.addNode(await each.getIp(this.name))
}

this.containers.push(container)
}

/**
* Wait for all container to sync up
* @param {number} [timeout=150000] in millis
*/
async waitForSync (timeout: number = 15000): Promise<void> {
await waitForCondition(async () => {
const hashes = await Promise.all(Object.values(this.containers).map(async container => {
return await container.getBestBlockHash()
}))

return hashes.every(value => value === hashes[0])
}, timeout)
}

/**
* Stop container group and all containers associated with it
*/
async stop (): Promise<void> {
for (const container of this.containers) {
await container.stop()
}
await this.requireNetwork().remove()
}
}
1 change: 1 addition & 0 deletions packages/testcontainers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './chains/test_net_container'
export * from './chains/reg_test_container/index'
export * from './chains/reg_test_container/masternode'
export * from './chains/reg_test_container/persistent'
export * from './chains/reg_test_container/masternode_group'
Loading

0 comments on commit 7b48f0f

Please sign in to comment.