Skip to content

Commit

Permalink
🗞️ Add docker compose spec generation utilities for localnet [4/N] (#478
Browse files Browse the repository at this point in the history
)
  • Loading branch information
janjakubnanista authored Mar 14, 2024
1 parent 9b2ef9f commit 622fd00
Show file tree
Hide file tree
Showing 20 changed files with 411 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .changeset/chilly-glasses-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@layerzerolabs/test-devtools": patch
---

Add mnemonic arbitrary to test-devtools
5 changes: 5 additions & 0 deletions .changeset/perfect-monkeys-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@layerzerolabs/test-devtools": patch
---

Add optionalArbitrary
5 changes: 5 additions & 0 deletions .changeset/violet-hornets-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@layerzerolabs/devtools-evm-hardhat": patch
---

Add docker compose utilities for simulation
2 changes: 2 additions & 0 deletions packages/devtools-evm-hardhat/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
module.exports = {
cache: false,
reporters: [['github-actions', { silent: false }], 'default'],
testTimeout: 15_000,
testEnvironment: 'node',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
transform: {
'^.+\\.(t|j)sx?$': '@swc/jest',
'^.+\\.conf$': '<rootDir>/jest.transformer.raw.js',
},
};
6 changes: 6 additions & 0 deletions packages/devtools-evm-hardhat/jest.transformer.raw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// This is a quick, up-to-date version of https://www.npmjs.com/package/jest-raw-loader
//
// We need this in order to be able to statically import/embed simulation Dockerfile & nginx.conf
module.exports = {
process: (content) => ({ code: `module.exports = ${JSON.stringify(content)}` }),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
ARG FOUNDRY_VERSION=nightly-156cb1396b7076c6f9cb56f3719f8c90f7f52064
ARG ALPINE_VERSION=3.18

# .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-
# / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \
# `-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-'
#
# Image that gives us the foundry tools
#
# .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-
# / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \
# `-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-'
FROM ghcr.io/foundry-rs/foundry:$FOUNDRY_VERSION AS foundry

# .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-
# / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \
# `-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-'
#
# Image that starts an EVM node
#
# .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-
# / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \
# `-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-'
FROM alpine:$ALPINE_VERSION AS node-evm

STOPSIGNAL SIGINT

# We will provide a default healthcheck (that assumes that the netowrk is running on the default port 8545)
HEALTHCHECK --timeout=2s --interval=2s --retries=20 CMD cast block --rpc-url http://localhost:8545/ latest

# Get anvil
COPY --from=foundry /usr/local/bin/anvil /usr/local/bin/anvil

# Get cast for healthcheck
COPY --from=foundry /usr/local/bin/cast /usr/local/bin/cast

# .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-
# / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \
# `-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-'
#
# Image that starts an nginx proxy server
#
# .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-. .-.-
# / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \
# `-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-'
FROM nginx:alpine$ALPINE_VERSION AS proxy-evm

COPY ./nginx.conf /etc/nginx/nginx.conf

HEALTHCHECK --timeout=2s --interval=2s --retries=20 CMD curl -f http://0.0.0.0:8545/health-check

7 changes: 7 additions & 0 deletions packages/devtools-evm-hardhat/src/simulation/assets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Even though it would be nice to just call this file Dockerfile instead of Dockerfile.conf,
// esbuild (or tsup) have issues with specifying loaders for files without extensions.
//
// And since we already have a file with .conf extension,
// we add the same extension to the Dockerfile to use the same d.ts file and the same loader
export { default as dockerfile } from './Dockerfile.conf'
export { default as nginxConf } from './nginx.conf'
56 changes: 56 additions & 0 deletions packages/devtools-evm-hardhat/src/simulation/assets/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
events {}

http {
# We will modify the log format to include the target_network
log_format proxied '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'Network: "$target_network"';

server {
# This proxy server will listen on port 8545
#
# Even though it's not ideal to have this hardcoded, this port
# will be remapped to a desired host port using docker compose,
# the only issue this hardcoding brings is the fact that this port
# needs to match the container port in the compose spec
listen 8545;
listen [::]:8545;

# We will add a simple endpoint for healthcheck
location /health-check {
access_log off;
error_log off;
return 200 'ok';
}

# In this section we'll proxy all the requests to this server
# to the respective network nodes
#
# The requests are proxied based on the first path segment:
#
# http://localhost/fuji -> http://fuji:8545/
#
# For now the remaining path segments are not being preserved:
#
# # http://localhost/fuji/some/url -> http://fuji:8545/
location / {
# Set the log format to be our custom 'proxied' log format
access_log /var/log/nginx/access.log proxied;

resolver 127.0.0.11;
autoindex off;

# This variable will hold the name of the network to proxy to
set $target_network '';

# Extract the first path segment from the request URI
if ($request_uri ~* ^/(?<target_network>[^/]+)(/.*)?$) {
set $target_network $1;
}

# Proxy the request to the appropriate network
proxy_pass http://$target_network:8545/;
}
}
}
93 changes: 93 additions & 0 deletions packages/devtools-evm-hardhat/src/simulation/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { pipe } from 'fp-ts/lib/function'
import * as RR from 'fp-ts/ReadonlyRecord'
import type { ComposeSpec, ComposeSpecService } from '@layerzerolabs/devtools'
import { type AnvilOptions, createAnvilCliOptions } from '@layerzerolabs/devtools-evm'
import type { ComposeSpecServices } from '@layerzerolabs/devtools'
import type { SimulationConfig } from './types'

/**
* Creates a docker compose service specification for an anvil-based EVM node
*
* @param {AnvilOptions} anvilOptions
* @returns {ComposeSpecService}
*/
export const createEvmNodeServiceSpec = (anvilOptions: AnvilOptions): ComposeSpecService => ({
// This service references a Dockerfile that is copied
// next to the resulting docker-compose.yaml
//
// The source for this Dockerfile is located in src/simulation/assets/Dockerfile.conf
build: {
dockerfile: 'Dockerfile',
target: 'node-evm',
},
command: ['anvil', ...createAnvilCliOptions(anvilOptions)],
})

/**
* Creates a docker compose service specification for an nginx-based proxy service
* that proxies requests to underlying EVM nodes (or their RPC URLs to be mor precise)
*
* @param {number} port
* @param {ComposeSpecServices} networkServices
* @returns {ComposeSpecService}
*/
export const createEvmNodeProxyServiceSpec = (
port: number,
networkServices: ComposeSpecServices
): ComposeSpecService => ({
// This service references a Dockerfile that is copied
// next to the resulting docker-compose.yaml
//
// The source for this Dockerfile is located in src/simulation/assets/Dockerfile.conf
build: {
dockerfile: 'Dockerfile',
target: 'proxy-evm',
},
// This service will expose its internal 8545 port to a host port
//
// The internal 8545 port is hardcoded both here and in the nginx.conf file,
// the source for which is located in src/simulation/assets/nginx.conf
ports: [`${port}:8545`],
depends_on: pipe(
networkServices,
// This service will depend on the RPCs to be healthy
// so we'll take the networkServices object and replace
// the values with service_healthy condition
RR.map(() => ({
condition: 'service_healthy',
}))
),
})

/**
* Creates a docker compose spec with a set of anvil-based EVM nodes
* and a single proxy server that proxies requests to these nodes.
*
* @param {SimulationConfig} config
* @param {Record<string, AnvilOptions>} networks
* @returns {ComposeSpec}
*/
export const createSimulationComposeSpec = (
config: SimulationConfig,
networks: Record<string, AnvilOptions>
): ComposeSpec => ({
version: '3.9',
services: pipe(
networks,
// First we turn the networks into docker compose specs for EVM nodes
RR.map(createEvmNodeServiceSpec),
(networkServiceSpecs) =>
// Then we add the RPC proxy server
//
// There is a small edge case here that we can address
// if it ever comes up: if a network is called 'rpc', this compose file
// will not work.
//
// The fix for this is to prefix all networks with something like network-xxx
// but we can do that if ever this usecase comes up
pipe(
networkServiceSpecs,
RR.upsertAt('rpc', createEvmNodeProxyServiceSpec(config.port, networkServiceSpecs))
)
),
})
1 change: 1 addition & 0 deletions packages/devtools-evm-hardhat/src/simulation/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './compose'
export * from './config'
export * from './types'
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`simulation/compose createEvmNodeServiceSpec() should work when there are no anvil options 1`] = `
"version: '3.9'
services:
anvil:
build:
dockerfile: Dockerfile
target: node-evm
command:
- anvil
"
`;
130 changes: 130 additions & 0 deletions packages/devtools-evm-hardhat/test/simulation/compose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import hre from 'hardhat'
import { resolveSimulationConfig } from '@/simulation'
import {
createEvmNodeProxyServiceSpec,
createEvmNodeServiceSpec,
createSimulationComposeSpec,
} from '@/simulation/compose'
import { serializeDockerComposeSpec } from '@layerzerolabs/devtools'
import { AnvilOptions } from '@layerzerolabs/devtools-evm'
import { mnemonicArbitrary, optionalArbitrary } from '@layerzerolabs/test-devtools'
import { spawnSync } from 'child_process'
import fc from 'fast-check'
import { rm, writeFile } from 'fs/promises'
import { join } from 'path'

describe('simulation/compose', () => {
// The available docker port range
const portArbitrary = fc.integer({ min: 1, max: 65535 })

const anvilOptionsArbitrary: fc.Arbitrary<AnvilOptions> = fc.record({
host: optionalArbitrary(fc.ipV4()),
port: optionalArbitrary(portArbitrary),
count: optionalArbitrary(fc.integer()),
mnemonic: optionalArbitrary(mnemonicArbitrary),
blockTime: optionalArbitrary(fc.integer()),
forkUrl: optionalArbitrary(fc.webUrl()),
})

// We are aware of shortcomings of the simulation spec generation process:
//
// - whitespace service names will generate invalid compose files. This should not be a problem in a real hardhat project
// - network named rpc will collide with the RPC proxy container defined in the spec
//
// Because of this we'll filter down on the possibilities when it comes to service names
const serviceNameArbitrary = fc.constantFrom('mumbai', 'sepolia-testnet', 'someNetwork', 'someNetwork.V2')

const SPEC_FILE_PATH = join(__dirname, 'docker-compose.yaml')

// We add --log-level to suppress any warnings - for example warnings about environment variables not being defined
//
// This allows us to easily test the stderr for being empty (otherwise we would need to test it for not containing errors)
const validateSpec = () => spawnSync('docker', ['--log-level', 'ERROR', 'compose', '-f', SPEC_FILE_PATH, 'config'])

afterEach(async () => {
await rm(SPEC_FILE_PATH, { force: true })
})

describe('createEvmNodeServiceSpec()', () => {
it('should work when there are no anvil options', async () => {
const spec = serializeDockerComposeSpec({
version: '3.9',
services: {
anvil: createEvmNodeServiceSpec({}),
},
})

await writeFile(SPEC_FILE_PATH, spec)

expect(validateSpec().stderr.toString('utf8')).toBe('')
expect(validateSpec().status).toBe(0)

expect(spec).toMatchSnapshot()
})

it('should work with anvil options', async () => {
await fc.assert(
fc.asyncProperty(anvilOptionsArbitrary, async (anvilOptions) => {
const spec = serializeDockerComposeSpec({
version: '3.9',
services: {
anvil: createEvmNodeServiceSpec(anvilOptions),
},
})

await writeFile(SPEC_FILE_PATH, spec)

expect(validateSpec().stderr.toString('utf8')).toBe('')
expect(validateSpec().status).toBe(0)
}),
{ numRuns: 20 }
)
})
})

describe('createEvmNodeProxyServiceSpec()', () => {
const servicesArbitrary = fc.dictionary(
serviceNameArbitrary,
anvilOptionsArbitrary.map(createEvmNodeServiceSpec)
)

it('should work with anvil services', async () => {
await fc.assert(
fc.asyncProperty(portArbitrary, servicesArbitrary, async (port, services) => {
const spec = serializeDockerComposeSpec({
version: '3.9',
services: {
...services,
rpc: createEvmNodeProxyServiceSpec(port, services),
},
})

await writeFile(SPEC_FILE_PATH, spec)

expect(validateSpec().stderr.toString('utf8')).toBe('')
expect(validateSpec().status).toBe(0)
}),
{ numRuns: 20 }
)
})
})

describe('createSimulationComposeSpec()', () => {
const anvilOptionsRecordArbitrary = fc.dictionary(serviceNameArbitrary, anvilOptionsArbitrary)

it('should work goddammit', async () => {
await fc.assert(
fc.asyncProperty(portArbitrary, anvilOptionsRecordArbitrary, async (port, anvilOptions) => {
const simulationConfig = resolveSimulationConfig({ port }, hre.config)
const spec = serializeDockerComposeSpec(createSimulationComposeSpec(simulationConfig, anvilOptions))

await writeFile(SPEC_FILE_PATH, spec)

expect(validateSpec().stderr.toString('utf8')).toBe('')
expect(validateSpec().status).toBe(0)
}),
{ numRuns: 20 }
)
})
})
})
Loading

0 comments on commit 622fd00

Please sign in to comment.