diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fa30b9d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'weekly' + - package-ecosystem: npm + directory: '/' + schedule: + interval: 'weekly' + groups: + eslint: + patterns: + - 'eslint' + - '@eslint/*' diff --git a/.github/renovate.json b/.github/renovate.json index 7b23606..ed907df 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -21,10 +21,6 @@ "matchPackageNames": ["node", "typescript", "@types/node"], "enabled": false }, - { - "matchPackagePatterns": ["^@contentedjs/"], - "groupName": "@contentedjs" - }, { "matchPackagePatterns": ["^@eslint/", "^eslint$"], "groupName": "@eslint" diff --git a/README.md b/README.md index 1b4035a..7a39f00 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,10 @@ to accelerate the adoption of blockchain technology. ```js import hardhat from '@chainfile/eip-155-31337/hardhat.json'; -let testcontainers: ChainfileTestcontainers; +const testcontainers = new ChainfileTestcontainers(hardhat); beforeAll(async () => { - testcontainers = await ChainfileTestcontainers.start(hardhat); + await testcontainers.start(); }); afterAll(async () => { @@ -67,14 +67,11 @@ afterAll(async () => { }); it('should rpc(eth_blockNumber)', async () => { - const hardhat = testcontainers.get('hardhat'); - - const response = await hardhat.rpc({ + const response = await testcontainers.get('hardhat').rpc({ method: 'eth_blockNumber', }); expect(response.status).toStrictEqual(200); - expect(await response.json()).toMatchObject({ result: '0x0', }); diff --git a/package.json b/package.json index 3d27682..da7ba3b 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "jest": "29.7.0", "lint-staged": "^15.2.5", "prettier": "^3.3.0", - "turbo": "^2.0.1", + "turbo": "^2.0.3", "typescript": "5.4.5", "wait-for-expect": "^3.0.2" }, diff --git a/packages/chainfile-agent/Dockerfile b/packages/chainfile-agent/Dockerfile index 17a0c38..3dd4e7c 100644 --- a/packages/chainfile-agent/Dockerfile +++ b/packages/chainfile-agent/Dockerfile @@ -1,25 +1,23 @@ FROM node:20-alpine as base -RUN apk add --no-cache libc6-compat python3 make g++ && \ - apk update WORKDIR /app - RUN corepack enable pnpm -ENV PNPM_HOME="/root/.local/share/pnpm/global" \ - PATH="$PATH:/root/.local/share/pnpm/global" +ENV PNPM_HOME="/pnpm" \ + PATH="/pnpm:$PATH" COPY package.json package.json RUN pnpm -v -ENV COREPACK_ENABLE_NETWORK=0 -RUN pnpm config set store-dir /root/.local/share/pnpm/global/store/v3 +ENV COREPACK_ENABLE_NETWORK=0 \ + TURBO_TELEMETRY_DISABLED=1 + +RUN pnpm config set store-dir /root/.local/share/pnpm/global/store/v3 \ + pnpm config set update-notifier false # Isolate workspace by pruning non-related services. FROM base AS pruner -# renovate: datasource=npm depName=turbo -ENV TURBO_VERSION=2.0.1 -RUN pnpm add -g turbo@${TURBO_VERSION} +RUN pnpm add -g turbo@2 COPY . . @@ -33,25 +31,20 @@ COPY .gitignore pnpm-workspace.yaml ./ COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml -# Mount cache to copy from content-addressable store -RUN --mount=type=cache,target=/root/.local/share/pnpm/global/store/v3\ - pnpm install --frozen-lockfile +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile COPY --from=pruner /app/out/full/ . -# To relink dependencies with ./bin links -RUN pnpm install --frozen-lockfile --offline - RUN pnpm turbo run build --filter=@chainfile/agent # Run agent FROM node:20-alpine AS runner +WORKDIR /app/packages/chainfile-agent + RUN addgroup --system --gid 1001 chainfile && \ adduser --system --uid 1001 chainfile USER chainfile EXPOSE 1569 -WORKDIR /app/packages/chainfile-agent - COPY --from=builder --chown=chainfile:chainfile /app /app CMD node "dist/server.js" diff --git a/packages/chainfile-agent/package.json b/packages/chainfile-agent/package.json index 3d0a1ef..978638b 100644 --- a/packages/chainfile-agent/package.json +++ b/packages/chainfile-agent/package.json @@ -31,7 +31,13 @@ "@trpc/server": "^10.45.2", "ajv": "^8.16.0", "ajv-formats": "^3.0.1", + "debug": "^4.3.4", + "lodash": "^4.17.21", "trpc-openapi": "^1.2.0", "zod": "^3.23.8" + }, + "devDependencies": { + "@types/debug": "^4.1.12", + "@types/lodash": "^4.17.4" } } diff --git a/packages/chainfile-agent/src/routers/_app.ts b/packages/chainfile-agent/src/routers/_app.ts index 2616d51..391138d 100644 --- a/packages/chainfile-agent/src/routers/_app.ts +++ b/packages/chainfile-agent/src/routers/_app.ts @@ -1,10 +1,7 @@ -import { createCallerFactory, router } from '../trpc'; +import { createCallerFactory, mergeRouters } from '../trpc'; import { agentRouter } from './agent'; import { probesRouter } from './probes'; -export const appRouter = router({ - Probes: probesRouter, - Agent: agentRouter, -}); +export const appRouter = mergeRouters(probesRouter, agentRouter); export const createCaller = createCallerFactory(appRouter); diff --git a/packages/chainfile-agent/src/routers/_context.ts b/packages/chainfile-agent/src/routers/_context.ts index e55523e..c83e947 100644 --- a/packages/chainfile-agent/src/routers/_context.ts +++ b/packages/chainfile-agent/src/routers/_context.ts @@ -26,11 +26,21 @@ function getChainfile(): Chainfile { throw new Error(`Invalid Chainfile: ${ajv.errorsText(validateFunction.errors)}`); } +function getValues() { + const CHAINFILE_VALUES = process.env.CHAINFILE_VALUES; + if (CHAINFILE_VALUES === undefined) { + throw new Error('CHAINFILE_VALUES is not defined, cannot start @chainfile/agent.'); + } + return JSON.parse(CHAINFILE_VALUES); +} + const chainfile = getChainfile(); +const values = getValues(); export const createContext = async () => { return { chainfile: chainfile, + values: values, }; }; diff --git a/packages/chainfile-agent/src/routers/agent.test.ts b/packages/chainfile-agent/src/routers/agent.test.ts index 7388d4a..27451f8 100644 --- a/packages/chainfile-agent/src/routers/agent.test.ts +++ b/packages/chainfile-agent/src/routers/agent.test.ts @@ -7,19 +7,14 @@ const chainfile: Chainfile = { $schema: 'https://chainfile.org/schema.json', caip2: 'bip122:0f9188f13cb7b2c71f2a335e3a4fc328', name: 'Bitcoin Regtest', - env: { - RPC_USER: { - type: 'Value', - value: 'agent', - }, - RPC_PASSWORD: { - type: 'Value', - value: 'agent', - }, + values: { + rpc_user: 'agent', + rpc_password: 'agent', }, containers: { bitcoind: { - image: 'docker.io/kylemanna/bitcoind@sha256:1492fa0306cb7eb5de8d50ba60367cff8d29b00b516e45e93e05f8b54fa2970e', + image: 'docker.io/kylemanna/bitcoind', + tag: 'latest', source: 'https://github.com/kylemanna/docker-bitcoind', endpoints: { rpc: { @@ -64,9 +59,10 @@ const chainfile: Chainfile = { const caller = createCaller({ chainfile: chainfile, + values: {}, }); -it('should call GetChainfile', async () => { - const result = await caller.Agent.GetChainfile(); +it('should getChainfile', async () => { + const result = await caller.getChainfile(); expect(result).toEqual(JSON.parse(JSON.stringify(chainfile))); }); diff --git a/packages/chainfile-agent/src/routers/agent.ts b/packages/chainfile-agent/src/routers/agent.ts index 98ffbb1..cb740a7 100644 --- a/packages/chainfile-agent/src/routers/agent.ts +++ b/packages/chainfile-agent/src/routers/agent.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { publicProcedure, router } from '../trpc'; export const agentRouter = router({ - GetChainfile: publicProcedure + getChainfile: publicProcedure .meta({ openapi: { method: 'GET', path: '/chainfile', tags: ['agent'] } }) .input(z.void()) .output( @@ -11,7 +11,7 @@ export const agentRouter = router({ $schema: z.string(), caip2: z.string(), name: z.string(), - env: z.any().optional(), + values: z.any().optional(), containers: z.any(), }), ) diff --git a/packages/chainfile-agent/src/routers/probes.ts b/packages/chainfile-agent/src/routers/probes.ts index e4bbc20..e04c9c5 100644 --- a/packages/chainfile-agent/src/routers/probes.ts +++ b/packages/chainfile-agent/src/routers/probes.ts @@ -7,239 +7,257 @@ import { EndpointHttpAuthorization, EndpointHttpJsonRpc, EndpointHttpRest, - EnvReference, + ValueReference, } from '@chainfile/schema'; import Ajv from 'ajv'; import addFormats from 'ajv-formats'; +import debug0 from 'debug'; import { z } from 'zod'; import { publicProcedure, router } from '../trpc'; -const probeProcedure = publicProcedure.input(z.void()).output( - z.object({ - ok: z.boolean(), - containers: z.record(z.object({ ok: z.boolean(), raw: z.any() })), - }), -); +const debug = debug0('chainfile:agent'); + +const probeProcedure = publicProcedure + .input(z.void()) + .output( + z.object({ + ok: z.boolean(), + containers: z.record(z.object({ ok: z.boolean(), raw: z.any() })), + }), + ) + .use((opts) => { + return opts.next({ + ctx: { + probes: new Probes(opts.ctx.chainfile, opts.ctx.values) as any, + }, + }); + }); export const probesRouter = router({ - ProbeStartup: probeProcedure + probeStartup: probeProcedure .meta({ openapi: { method: 'GET', path: '/probes/startup', tags: ['probes'] } }) - .query(async ({ ctx }) => { - return query(ctx.chainfile, ProbeType.startup); + .query(async ({ ctx: { probes } }) => { + return probes.get(ProbeType.startup); }), - ProbeLiveness: probeProcedure + probeLiveness: probeProcedure .meta({ openapi: { method: 'GET', path: '/probes/liveness', tags: ['probes'] } }) - .query(async ({ ctx }) => { - return query(ctx.chainfile, ProbeType.liveness); + .query(async ({ ctx: { probes } }) => { + return probes.get(ProbeType.liveness); }), - ProbeReadiness: probeProcedure + probeReadiness: probeProcedure .meta({ openapi: { method: 'GET', path: '/probes/readiness', tags: ['probes'] } }) - .query(async ({ ctx }) => { - return query(ctx.chainfile, ProbeType.readiness); + .query(async ({ ctx: { probes } }) => { + return probes.get(ProbeType.readiness); }), }); -async function query(chainfile: Chainfile, probeType: ProbeType) { - const probeFunctions = Object.entries(chainfile.containers).flatMap( - ([name, container]): [string, ProbeFunction][] => { - return Object.values(container.endpoints ?? {}) - .map((endpoint): [string, ProbeFunction] | undefined => { - const func = createProbeFunction(name, endpoint, probeType); - if (func === undefined) { - return undefined; - } - return [name, func]; - }) - .filter((x): x is [string, ProbeFunction] => x !== undefined); - }, - ); - - const containers = await Promise.all( - probeFunctions.map(async ([name, probeFunc]) => { - const res = await probeFunc(); - return { - name: name, - ok: res.ok, - raw: res.raw, - }; - }), - ); - - const ok = containers.every(({ ok }) => ok); - - return { - ok: ok, - containers: containers.reduce( - (acc, { name, ok, raw }) => { - acc[name] = { - ok: ok, - raw: raw, - }; - return acc; - }, - {} as Record, - ), - }; -} - -const ajv = new Ajv(); -addFormats(ajv); - enum ProbeType { startup = 'startup', liveness = 'liveness', readiness = 'readiness', } -type ProbeFunction = () => Promise<{ +interface ProbeResponse { ok: boolean; raw?: any; -}>; - -function createProbeFunction( - containerName: string, - endpoint: Endpoint, - probeType: ProbeType, -): ProbeFunction | undefined { - const protocol = (endpoint as any).protocol; - if (protocol === undefined) { - return undefined; - } - - switch (protocol) { - case 'HTTP JSON-RPC 1.0': - case 'HTTPS JSON-RPC 1.0': - case 'HTTP JSON-RPC 2.0': - case 'HTTPS JSON-RPC 2.0': - return createProbeFunctionHttpJsonRpc(containerName, endpoint as EndpointHttpJsonRpc, probeType); - case 'HTTP REST': - case 'HTTPS REST': - return createProbeFunctionHttp(containerName, endpoint as EndpointHttpRest, probeType); - default: - throw new Error(`Unknown protocol: ${protocol}`); - } } -function createProbeFunctionHttp( - containerName: string, - endpoint: EndpointHttpRest, - probeType: ProbeType, -): ProbeFunction | undefined { - const probe = endpoint.probes?.[probeType]; - if (probe === undefined) { - return undefined; +type ProbeFunction = () => Promise; + +class Probes { + constructor( + private readonly chainfile: Chainfile, + private readonly values: Record, + private readonly ajv: Ajv = new Ajv(), + ) { + addFormats(this.ajv); } - const scheme = endpoint.protocol.startsWith('HTTPS') ? 'https' : 'http'; - const url = `${scheme}://${containerName}:${endpoint.port}${probe.path ?? ''}`; - const headers: Record = { - 'Content-Type': 'application/json', - ...(endpoint.authorization ? getHttpAuthorizationHeaders(endpoint.authorization) : {}), - }; - const body = probe.body ? JSON.stringify(probe.body) : undefined; - - const validateBody = probe.match.body ? ajv.compile(probe.match.body) : () => true; - const validateStatus = (status: number) => { - if (Array.isArray(probe.match.status)) { - return probe.match.status.includes(status); - } - return status === probe.match.status; - }; - - return async (): Promise<{ ok: boolean; raw?: any }> => { - return fetch(url, { - method: probe.method, - headers: headers, - body: body, - }) - .then(async (response) => { - const body = await response.json(); + async get(probeType: ProbeType) { + const probeFunctions = Object.entries(this.chainfile.containers).flatMap( + ([name, container]): [string, ProbeFunction][] => { + return Object.values(container.endpoints ?? {}) + .map((endpoint): [string, ProbeFunction] | undefined => { + const func = this.createProbeFunction(name, endpoint, probeType); + if (func === undefined) { + return undefined; + } + return [name, func]; + }) + .filter((x): x is [string, ProbeFunction] => x !== undefined); + }, + ); + + const containers = await Promise.all( + probeFunctions.map(async ([name, probeFunc]) => { + const res = await probeFunc(); return { - ok: validateStatus(response.status) && validateBody(body), - raw: { - status: response.status, - body: body, - }, + name: name, + ok: res.ok, + raw: res.raw, }; - }) - .catch(() => ({ ok: false })); - }; -} + }), + ); + + const ok = containers.every(({ ok }) => ok); + + return { + ok: ok, + containers: containers.reduce>((acc, { name, ok, raw }) => { + acc[name] = { + ok: ok, + raw: raw, + }; + return acc; + }, {}), + }; + } + + private createProbeFunction( + containerName: string, + endpoint: Endpoint, + probeType: ProbeType, + ): ProbeFunction | undefined { + const protocol = (endpoint as any).protocol; + if (protocol === undefined) { + return undefined; + } -function createProbeFunctionHttpJsonRpc( - containerName: string, - endpoint: EndpointHttpJsonRpc, - probeType: ProbeType, -): ProbeFunction | undefined { - const probe = endpoint.probes?.[probeType]; - if (probe === undefined) { - return undefined; + switch (protocol) { + case 'HTTP JSON-RPC 1.0': + case 'HTTPS JSON-RPC 1.0': + case 'HTTP JSON-RPC 2.0': + case 'HTTPS JSON-RPC 2.0': + return this.createProbeFunctionHttpJsonRpc(containerName, endpoint as EndpointHttpJsonRpc, probeType); + case 'HTTP REST': + case 'HTTPS REST': + return this.createProbeFunctionHttp(containerName, endpoint as EndpointHttpRest, probeType); + default: + throw new Error(`Unknown protocol: ${protocol}`); + } } - const scheme = endpoint.protocol.startsWith('HTTPS') ? 'https' : 'http'; - const url = `${scheme}://${containerName}:${endpoint.port}${endpoint.path ?? ''}`; - const version = endpoint.protocol.endsWith('2.0') ? '2.0' : '1.0'; - const headers: Record = { - 'Content-Type': 'application/json', - ...(endpoint.authorization ? getHttpAuthorizationHeaders(endpoint.authorization) : {}), - }; - - const validate = ajv.compile(probe.match.result); - - return async (): Promise<{ ok: boolean; raw?: any }> => { - return fetch(url, { - method: 'POST', - headers: headers, - body: JSON.stringify({ - jsonrpc: version, + private createProbeFunctionHttp( + containerName: string, + endpoint: EndpointHttpRest, + probeType: ProbeType, + ): ProbeFunction | undefined { + const probe = endpoint.probes?.[probeType]; + if (probe === undefined) { + return undefined; + } + + const scheme = endpoint.protocol.startsWith('HTTPS') ? 'https' : 'http'; + const url = `${scheme}://${containerName}:${endpoint.port}${probe.path ?? ''}`; + const headers: Record = { + 'Content-Type': 'application/json', + ...(endpoint.authorization ? this.getHttpAuthorizationHeaders(endpoint.authorization) : {}), + }; + const body = probe.body ? JSON.stringify(probe.body) : undefined; + + const validateBody = probe.match.body ? this.ajv.compile(probe.match.body) : () => true; + const validateStatus = (status: number) => { + if (Array.isArray(probe.match.status)) { + return probe.match.status.includes(status); + } + return status === probe.match.status; + }; + + return async (): Promise => { + return fetch(url, { method: probe.method, - params: probe.params, - id: randomInt(0, 999999999999), - }), - }) - .then(async (response) => { - const body: any = await response.json(); - return { - ok: validate(body.result), - raw: { - status: response.status, - body: body, - }, - }; + headers: headers, + body: body, }) - .catch(() => ({ ok: false })); - }; -} + .then(async (response) => { + const body = await response.json(); + return { + ok: validateStatus(response.status) && validateBody(body), + raw: { + status: response.status, + body: body, + }, + }; + }) + .catch((error) => { + debug('Error probing %s: %o', url, error); + return { ok: false }; + }); + }; + } -function getHttpAuthorizationHeaders(auth: EndpointHttpAuthorization): Record { - const type = auth.type; - if (type === 'HttpBasic') { - const username = resolveValue(auth.username); - const password = resolveValue(auth.password); - return { - Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, + private createProbeFunctionHttpJsonRpc( + containerName: string, + endpoint: EndpointHttpJsonRpc, + probeType: ProbeType, + ): ProbeFunction | undefined { + const probe = endpoint.probes?.[probeType]; + if (probe === undefined) { + return undefined; + } + + const scheme = endpoint.protocol.startsWith('HTTPS') ? 'https' : 'http'; + const url = `${scheme}://${containerName}:${endpoint.port}${endpoint.path ?? ''}`; + const version = endpoint.protocol.endsWith('2.0') ? '2.0' : '1.0'; + const headers: Record = { + 'Content-Type': 'application/json', + ...(endpoint.authorization ? this.getHttpAuthorizationHeaders(endpoint.authorization) : {}), }; - } else if (type === 'HttpBearer') { - const token = resolveValue(auth.token); - return { - Authorization: `Bearer ${token}`, + + const validate = this.ajv.compile(probe.match.result); + + return async (): Promise => { + return fetch(url, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + jsonrpc: version, + method: probe.method, + params: probe.params, + id: randomInt(0, 999999999999), + }), + }) + .then(async (response) => { + const body: any = await response.json(); + return { + ok: validate(body.result), + raw: { + status: response.status, + body: body, + }, + }; + }) + .catch((error) => { + debug('Error probing %s: %o', url, error); + return { ok: false }; + }); }; - } else { - throw new Error(`Unknown authorization type: ${type}`); } -} -/** - * Resolve value that can be a reference to an environment variable. - * Look for `${KEY}` injected through `CHAINFILE_ENVIRONMENT_KEY`. - * - * If the value is not a reference, it is returned as is. - */ -function resolveValue(value: string | EnvReference): string { - if (typeof value === 'string') { - return value; + private getHttpAuthorizationHeaders(auth: EndpointHttpAuthorization): Record { + const type = auth.type; + if (type === 'HttpBasic') { + const username = this.resolve(auth.username); + const password = this.resolve(auth.password); + return { + Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, + }; + } else if (type === 'HttpBearer') { + const token = this.resolve(auth.token); + return { + Authorization: `Bearer ${token}`, + }; + } else { + throw new Error(`Unknown authorization type: ${type}`); + } } - return process.env[`CHAINFILE_ENVIRONMENT_${value.key}`] ?? ''; + private resolve(value: string | ValueReference): string { + if (typeof value === 'string') { + return value; + } + + return this.values[value.$value] ?? ''; + } } diff --git a/packages/chainfile-agent/src/trpc.ts b/packages/chainfile-agent/src/trpc.ts index f21185c..dd3e005 100644 --- a/packages/chainfile-agent/src/trpc.ts +++ b/packages/chainfile-agent/src/trpc.ts @@ -3,10 +3,12 @@ import type { OpenApiMeta } from 'trpc-openapi'; import type { Context } from './routers/_context'; -const t = initTRPC.meta().context().create(); +const t = initTRPC.meta().context().create({}); export const router = t.router; +export const mergeRouters = t.mergeRouters; + export const createCallerFactory = t.createCallerFactory; export const publicProcedure = t.procedure; diff --git a/packages/chainfile-agent/tsconfig.build.json b/packages/chainfile-agent/tsconfig.build.json index eb37f42..b50453c 100644 --- a/packages/chainfile-agent/tsconfig.build.json +++ b/packages/chainfile-agent/tsconfig.build.json @@ -1,4 +1,5 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "extends": "@workspace/tsconfig", "compilerOptions": { "lib": ["ES2021", "DOM"], diff --git a/packages/chainfile-docker/package.json b/packages/chainfile-docker/package.json index d917570..bca0677 100644 --- a/packages/chainfile-docker/package.json +++ b/packages/chainfile-docker/package.json @@ -33,9 +33,11 @@ "@chainfile/schema": "workspace:^", "ajv": "^8.16.0", "ajv-formats": "^3.0.1", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "lodash": "^4.17.21" }, "devDependencies": { - "@types/js-yaml": "^4.0.9" + "@types/js-yaml": "^4.0.9", + "@types/lodash": "^4.17.4" } } diff --git a/packages/chainfile-docker/src/__snapshots__/compose.test.ts.snap b/packages/chainfile-docker/src/__snapshots__/compose.test.ts.snap deleted file mode 100644 index e094ca8..0000000 --- a/packages/chainfile-docker/src/__snapshots__/compose.test.ts.snap +++ /dev/null @@ -1,162 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should synth compose Bitcoin Mainnet 1`] = ` -"# Generated by @chainfile/docker:0.0.0, do not edit manually. -# Version: 0.0.0 -# Chainfile Name: Bitcoin Mainnet -# Chainfile CAIP-2: bip122:000000000019d6689c085ae165831e93 - -name: bitcoin_mainnet -services: - agent: - container_name: agent-suffix - image: ghcr.io/vetumorg/chainfile-agent:0.0.0 - ports: - - '0:1569' - environment: - CHAINFILE_JSON: >- - {"$$schema":"https://chainfile.org/schema.json","caip2":"bip122:000000000019d6689c085ae165831e93","name":"Bitcoin - Mainnet","env":{"RPC_USER":{"type":"RandomBytes","length":16,"encoding":"hex"},"RPC_PASSWORD":{"type":"RandomBytes","length":16,"encoding":"hex"}},"containers":{"bitcoind":{"image":"docker.io/kylemanna/bitcoind@sha256:1492fa0306cb7eb5de8d50ba60367cff8d29b00b516e45e93e05f8b54fa2970e","source":"https://github.com/kylemanna/docker-bitcoind","endpoints":{"p2p":{"port":8333},"rpc":{"port":8332,"protocol":"HTTP - JSON-RPC - 2.0","authorization":{"type":"HttpBasic","username":{"key":"RPC_USER"},"password":{"key":"RPC_PASSWORD"}},"probes":{"readiness":{"method":"getblockchaininfo","params":[],"match":{"result":{"type":"object","properties":{"blocks":{"type":"number"}},"required":["blocks"]}}}}}},"resources":{"cpu":1,"memory":2048},"environment":{"DISABLEWALLET":"1","RPCUSER":{"key":"RPC_USER"},"RPCPASSWORD":{"key":"RPC_PASSWORD"}},"volumes":{"persistent":{"paths":["/bitcoin/.bitcoin"],"size":{"initial":"600G","from":"2024-01-01","growth":"20G","rate":"monthly"}}}}}} - CHAINFILE_ENVIRONMENT_RPC_USER: \${RPC_USER} - CHAINFILE_ENVIRONMENT_RPC_PASSWORD: \${RPC_PASSWORD} - volumes: - - type: volume - source: chainfile - target: /var/chainfile - networks: - chainfile: {} - bitcoind: - container_name: bitcoind-suffix - image: docker.io/kylemanna/bitcoind@sha256:1492fa0306cb7eb5de8d50ba60367cff8d29b00b516e45e93e05f8b54fa2970e - environment: - DISABLEWALLET: '1' - RPCUSER: \${RPC_USER} - RPCPASSWORD: \${RPC_PASSWORD} - ports: - - '0:8333' - - '0:8332' - volumes: - - type: volume - source: chainfile - target: /var/chainfile - - type: volume - target: /bitcoin/.bitcoin - networks: - chainfile: {} -networks: - chainfile: {} -volumes: - chainfile: {} -" -`; - -exports[`should synth compose Bitcoin Regtest 1`] = ` -"# Generated by @chainfile/docker:0.0.0, do not edit manually. -# Version: 0.0.0 -# Chainfile Name: Bitcoin Regtest -# Chainfile CAIP-2: bip122:0f9188f13cb7b2c71f2a335e3a4fc328 - -name: bitcoin_regtest -services: - agent: - container_name: agent-suffix - image: ghcr.io/vetumorg/chainfile-agent:0.0.0 - ports: - - '0:1569' - environment: - CHAINFILE_JSON: >- - {"$$schema":"https://chainfile.org/schema.json","caip2":"bip122:0f9188f13cb7b2c71f2a335e3a4fc328","name":"Bitcoin - Regtest","env":{"RPC_USER":{"type":"Value","value":"user"},"RPC_PASSWORD":{"type":"Value","value":"password"}},"containers":{"bitcoind":{"image":"docker.io/kylemanna/bitcoind@sha256:1492fa0306cb7eb5de8d50ba60367cff8d29b00b516e45e93e05f8b54fa2970e","source":"https://github.com/kylemanna/docker-bitcoind","command":["btc_oneshot","-fallbackfee=0.00000200","-rpcbind=:8332","-rpcallowip=0.0.0.0/0"],"endpoints":{"p2p":{"port":18445},"rpc":{"port":8332,"protocol":"HTTP - JSON-RPC - 2.0","authorization":{"type":"HttpBasic","username":{"key":"RPC_USER"},"password":{"key":"RPC_PASSWORD"}},"probes":{"readiness":{"method":"getblockchaininfo","params":[],"match":{"result":{"type":"object","properties":{"blocks":{"type":"number"}},"required":["blocks"]}}}}}},"resources":{"cpu":0.25,"memory":256},"environment":{"REGTEST":"1","DISABLEWALLET":"0","RPCUSER":{"key":"RPC_USER"},"RPCPASSWORD":{"key":"RPC_PASSWORD"}},"volumes":{"persistent":{"paths":["/bitcoin/.bitcoin"],"size":"250M"}}}}} - CHAINFILE_ENVIRONMENT_RPC_USER: \${RPC_USER} - CHAINFILE_ENVIRONMENT_RPC_PASSWORD: \${RPC_PASSWORD} - volumes: - - type: volume - source: chainfile - target: /var/chainfile - networks: - chainfile: {} - bitcoind: - container_name: bitcoind-suffix - image: docker.io/kylemanna/bitcoind@sha256:1492fa0306cb7eb5de8d50ba60367cff8d29b00b516e45e93e05f8b54fa2970e - command: - - btc_oneshot - - '-fallbackfee=0.00000200' - - '-rpcbind=:8332' - - '-rpcallowip=0.0.0.0/0' - environment: - REGTEST: '1' - DISABLEWALLET: '0' - RPCUSER: \${RPC_USER} - RPCPASSWORD: \${RPC_PASSWORD} - ports: - - '0:18445' - - '0:8332' - volumes: - - type: volume - source: chainfile - target: /var/chainfile - - type: volume - target: /bitcoin/.bitcoin - networks: - chainfile: {} -networks: - chainfile: {} -volumes: - chainfile: {} -" -`; - -exports[`should synth compose Ganache 1`] = ` -"# Generated by @chainfile/docker:0.0.0, do not edit manually. -# Version: 0.0.0 -# Chainfile Name: Ganache -# Chainfile CAIP-2: eip155:1337 - -name: ganache -services: - agent: - container_name: agent-suffix - image: ghcr.io/vetumorg/chainfile-agent:0.0.0 - ports: - - '0:1569' - environment: - CHAINFILE_JSON: >- - {"$$schema":"https://chainfile.org/schema.json","caip2":"eip155:1337","name":"Ganache","env":{"RPC_USER":{"type":"RandomBytes","length":16,"encoding":"hex"},"RPC_PASSWORD":{"type":"RandomBytes","length":16,"encoding":"hex"},"URL":{"type":"Value","value":"http://$\${RPC_USER}:$\${RPC_PASSWORD}@ganache:8554"}},"containers":{"ganache":{"image":"docker.io/trufflesuite/ganache@sha256:c62c58290c28e24b427f74c6f597ff696257bd2d8e8d517ce4cf46b29b304a3f","source":"https://github.com/trufflesuite/ganache","environment":{"RPCUSER":{"key":"RPC_USER"},"RPCPASSWORD":{"key":"RPC_PASSWORD"}},"resources":{"cpu":0.25,"memory":256},"endpoints":{"p2p":{"port":8555},"rpc":{"port":8545,"protocol":"HTTP - JSON-RPC - 2.0","authorization":{"type":"HttpBasic","username":{"key":"RPC_USER"},"password":{"key":"RPC_PASSWORD"}},"probes":{"readiness":{"params":[],"method":"eth_blockNumber","match":{"result":{"type":"string"}}}}}},"volumes":{"persistent":{"paths":["/.ganache"],"size":{"initial":"1G","from":"2024-01-01","growth":"1G","rate":"yearly"}}}}}} - CHAINFILE_ENVIRONMENT_RPC_USER: \${RPC_USER} - CHAINFILE_ENVIRONMENT_RPC_PASSWORD: \${RPC_PASSWORD} - CHAINFILE_ENVIRONMENT_URL: \${URL} - volumes: - - type: volume - source: chainfile - target: /var/chainfile - networks: - chainfile: {} - ganache: - container_name: ganache-suffix - image: docker.io/trufflesuite/ganache@sha256:c62c58290c28e24b427f74c6f597ff696257bd2d8e8d517ce4cf46b29b304a3f - environment: - RPCUSER: \${RPC_USER} - RPCPASSWORD: \${RPC_PASSWORD} - ports: - - '0:8555' - - '0:8545' - volumes: - - type: volume - source: chainfile - target: /var/chainfile - - type: volume - target: /.ganache - networks: - chainfile: {} -networks: - chainfile: {} -volumes: - chainfile: {} -" -`; diff --git a/packages/chainfile-docker/src/compose.test.ts b/packages/chainfile-docker/src/compose.test.ts index bc38f89..511a15d 100644 --- a/packages/chainfile-docker/src/compose.test.ts +++ b/packages/chainfile-docker/src/compose.test.ts @@ -1,285 +1,41 @@ import { Chainfile } from '@chainfile/schema'; -import { expect, it } from '@jest/globals'; +import { describe, expect, it } from '@jest/globals'; import { Compose } from './compose'; -it.each([ - { +describe('ganache.json', () => { + const chainfile: Chainfile = { $schema: 'https://chainfile.org/schema.json', caip2: 'eip155:1337', name: 'Ganache', - env: { - RPC_USER: { - type: 'RandomBytes', - length: 16, - encoding: 'hex', - }, - RPC_PASSWORD: { - type: 'RandomBytes', - length: 16, - encoding: 'hex', - }, - URL: { - type: 'Value', - value: 'http://${RPC_USER}:${RPC_PASSWORD}@ganache:8554', - }, - }, - containers: { - ganache: { - image: 'docker.io/trufflesuite/ganache@sha256:c62c58290c28e24b427f74c6f597ff696257bd2d8e8d517ce4cf46b29b304a3f', - source: 'https://github.com/trufflesuite/ganache', - environment: { - RPCUSER: { - key: 'RPC_USER', - }, - RPCPASSWORD: { - key: 'RPC_PASSWORD', - }, - }, - // Orchestration - resources: { - cpu: 0.25, - memory: 256, - }, - // Endpoints - endpoints: { - p2p: { - port: 8555, - }, - rpc: { - port: 8545, - protocol: 'HTTP JSON-RPC 2.0', - authorization: { - type: 'HttpBasic', - username: { - key: 'RPC_USER', - }, - password: { - key: 'RPC_PASSWORD', - }, - }, - probes: { - readiness: { - params: [], - method: 'eth_blockNumber', - match: { - result: { - type: 'string', - }, - }, - }, - }, - }, - }, - volumes: { - persistent: { - paths: ['/.ganache'], - size: { - initial: '1G', - from: '2024-01-01', - growth: '1G', - rate: 'yearly', - }, - }, + values: { + url: 'http://${rpc_user}:${rpc_password}@ganache:8554', + rpc_user: { + random: { + type: 'bytes', + length: 16, + encoding: 'hex', }, }, - }, - }, - { - $schema: 'https://chainfile.org/schema.json', - caip2: 'bip122:0f9188f13cb7b2c71f2a335e3a4fc328', - name: 'Bitcoin Regtest', - env: { - RPC_USER: { - type: 'Value', - value: 'user', - }, - RPC_PASSWORD: { - type: 'Value', - value: 'password', - }, - }, - containers: { - bitcoind: { - image: 'docker.io/kylemanna/bitcoind@sha256:1492fa0306cb7eb5de8d50ba60367cff8d29b00b516e45e93e05f8b54fa2970e', - source: 'https://github.com/kylemanna/docker-bitcoind', - command: ['btc_oneshot', '-fallbackfee=0.00000200', '-rpcbind=:8332', '-rpcallowip=0.0.0.0/0'], - endpoints: { - p2p: { - port: 18445, - }, - rpc: { - port: 8332, - protocol: 'HTTP JSON-RPC 2.0', - authorization: { - type: 'HttpBasic', - username: { - key: 'RPC_USER', - }, - password: { - key: 'RPC_PASSWORD', - }, - }, - probes: { - readiness: { - method: 'getblockchaininfo', - params: [], - match: { - result: { - type: 'object', - properties: { - blocks: { - type: 'number', - }, - }, - required: ['blocks'], - }, - }, - }, - }, - }, - }, - resources: { - cpu: 0.25, - memory: 256, + rpc_password: { + random: { + type: 'bytes', + length: 16, + encoding: 'hex', }, - environment: { - REGTEST: '1', - DISABLEWALLET: '0', - RPCUSER: { - key: 'RPC_USER', - }, - RPCPASSWORD: { - key: 'RPC_PASSWORD', - }, - }, - volumes: { - persistent: { - paths: ['/bitcoin/.bitcoin'], - size: '250M', - }, - }, - }, - }, - }, - { - $schema: 'https://chainfile.org/schema.json', - caip2: 'bip122:000000000019d6689c085ae165831e93', - name: 'Bitcoin Mainnet', - env: { - RPC_USER: { - type: 'RandomBytes', - length: 16, - encoding: 'hex', - }, - RPC_PASSWORD: { - type: 'RandomBytes', - length: 16, - encoding: 'hex', - }, - }, - containers: { - bitcoind: { - image: 'docker.io/kylemanna/bitcoind@sha256:1492fa0306cb7eb5de8d50ba60367cff8d29b00b516e45e93e05f8b54fa2970e', - source: 'https://github.com/kylemanna/docker-bitcoind', - endpoints: { - p2p: { - port: 8333, - }, - rpc: { - port: 8332, - protocol: 'HTTP JSON-RPC 2.0', - authorization: { - type: 'HttpBasic', - username: { - key: 'RPC_USER', - }, - password: { - key: 'RPC_PASSWORD', - }, - }, - probes: { - readiness: { - method: 'getblockchaininfo', - params: [], - match: { - result: { - type: 'object', - properties: { - blocks: { - type: 'number', - }, - }, - required: ['blocks'], - }, - }, - }, - }, - }, - }, - resources: { - cpu: 1, - memory: 2048, - }, - environment: { - DISABLEWALLET: '1', - RPCUSER: { - key: 'RPC_USER', - }, - RPCPASSWORD: { - key: 'RPC_PASSWORD', - }, - }, - volumes: { - persistent: { - paths: ['/bitcoin/.bitcoin'], - size: { - initial: '600G', - from: '2024-01-01', - growth: '20G', - rate: 'monthly', - }, - }, - }, - }, - }, - }, -])('should synth compose $name', async (chainfile: any) => { - const compose = new Compose(chainfile, 'suffix'); - expect(compose.synthCompose()).toMatchSnapshot(); -}); - -it('should synth env', async () => { - const chainfile: Chainfile = { - $schema: 'https://chainfile.org/schema.json', - caip2: 'eip155:1337', - name: 'Ganache', - env: { - RPC_USER: { - type: 'RandomBytes', - length: 16, - encoding: 'hex', - }, - RPC_PASSWORD: { - type: 'RandomBytes', - length: 16, - encoding: 'hex', - }, - URL: { - type: 'Value', - value: 'http://${RPC_USER}:${RPC_PASSWORD}@ganache:8554', }, }, containers: { ganache: { - image: 'docker.io/trufflesuite/ganache@sha256:c62c58290c28e24b427f74c6f597ff696257bd2d8e8d517ce4cf46b29b304a3f', + image: 'docker.io/trufflesuite/ganache', + tag: 'v7.9.2', source: 'https://github.com/trufflesuite/ganache', environment: { RPCUSER: { - key: 'RPC_USER', + $value: 'rpc_user', }, RPCPASSWORD: { - key: 'RPC_PASSWORD', + $value: 'rpc_password', }, }, resources: { @@ -289,16 +45,67 @@ it('should synth env', async () => { }, }, }; - const compose = new Compose(chainfile, 'suffix'); - expect(compose.synthEnv().split('\n')).toStrictEqual([ - expect.stringMatching(/^RPC_USER=[0-9a-f]{32}$/), - expect.stringMatching(/^RPC_PASSWORD=[0-9a-f]{32}$/), - expect.stringMatching(/^URL=http:\/\/[0-9a-f]{32}:[0-9a-f]{32}@ganache:8554$/), - ]); + + const compose = new Compose(chainfile, {}, 'suffix'); + + it('should synth .env', async () => { + expect(compose.synthDotEnv().split('\n')).toStrictEqual([ + expect.stringMatching(/^url=http:\/\/[0-9a-f]{32}:[0-9a-f]{32}@ganache:8554$/), + expect.stringMatching(/^rpc_user=[0-9a-f]{32}$/), + expect.stringMatching(/^rpc_password=[0-9a-f]{32}$/), + expect.stringMatching(/^CHAINFILE_VALUES=\{.+}$/), + ]); + }); + + it('should synth compose.yml', async () => { + expect(compose.synthCompose().split('\n')).toStrictEqual([ + '# Generated by @chainfile/docker:0.0.0, do not edit manually.', + '# Version: 0.0.0', + '# Chainfile Name: Ganache', + '# Chainfile CAIP-2: eip155:1337', + '', + 'name: ganache', + 'services:', + ' agent:', + ' container_name: agent-suffix', + ' image: ghcr.io/vetumorg/chainfile-agent:0.0.0', + ' ports:', + " - '0:1569'", + ' environment:', + ' CHAINFILE_JSON: >-', + ` ${JSON.stringify(chainfile).replaceAll('$', '$$$')}`, + ' CHAINFILE_VALUES: ${CHAINFILE_VALUES}', + expect.stringMatching(' DEBUG: '), + ' volumes:', + ' - type: volume', + ' source: chainfile', + ' target: /var/chainfile', + ' networks:', + ' chainfile: {}', + ' ganache:', + ' container_name: ganache-suffix', + ' image: docker.io/trufflesuite/ganache:v7.9.2', + ' environment:', + ' RPCUSER: ${rpc_user}', + ' RPCPASSWORD: ${rpc_password}', + ' ports: []', + ' volumes:', + ' - type: volume', + ' source: chainfile', + ' target: /var/chainfile', + ' networks:', + ' chainfile: {}', + 'networks:', + ' chainfile: {}', + 'volumes:', + ' chainfile: {}', + '', + ]); + }); }); it('should fail to synth with invalid chainfile', async () => { - expect(() => new Compose({} as any)).toThrowError(); + expect(() => new Compose({} as any, {})).toThrowError(); }); it('should have different suffix when using different Compose', async () => { @@ -308,7 +115,8 @@ it('should have different suffix when using different Compose', async () => { name: 'Ganache', containers: { ganache: { - image: 'docker.io/trufflesuite/ganache@sha256:c62c58290c28e24b427f74c6f597ff696257bd2d8e8d517ce4cf46b29b304a3f', + image: 'docker.io/trufflesuite/ganache', + tag: 'v7.9.2', source: 'https://github.com/trufflesuite/ganache', resources: { cpu: 0.25, @@ -319,7 +127,7 @@ it('should have different suffix when using different Compose', async () => { }, }; - const compose1 = new Compose(file); - const compose2 = new Compose(file); + const compose1 = new Compose(file, {}); + const compose2 = new Compose(file, {}); expect(compose1.suffix).not.toEqual(compose2.suffix); }); diff --git a/packages/chainfile-docker/src/compose.ts b/packages/chainfile-docker/src/compose.ts index 33fd36d..d089a00 100644 --- a/packages/chainfile-docker/src/compose.ts +++ b/packages/chainfile-docker/src/compose.ts @@ -1,36 +1,45 @@ import { randomBytes } from 'node:crypto'; -import schema, { Chainfile, Container } from '@chainfile/schema'; +import schema, { Chainfile, Container, ValueOptions, ValueReference } from '@chainfile/schema'; import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import yaml from 'js-yaml'; +import mapValues from 'lodash/mapValues'; import { version } from '../package.json'; -import { synthDotEnvFile } from './dotenv'; - -const ajv = new Ajv(); -addFormats(ajv); - -const validateFunction = ajv.compile(schema); /** * Synthesize a Chainfile into `docker.*.yml` & `.env` files. */ export class Compose { + public readonly chainfile: Chainfile; + public readonly values: Record; + public readonly suffix: string; + + /** + * @param chainfile definition to synthesize. + * @param overrideValues to override the chainfile values. + * @param suffix for the container names to prevent conflicts. + */ constructor( - private readonly chainfile: Chainfile, - /** - * Suffix for the container names to prevent conflicts. - */ - public readonly suffix: string = randomBytes(8).toString('hex'), + chainfile: Chainfile, + overrideValues: Record, + suffix: string = randomBytes(4).toString('hex'), ) { - if (!validateFunction(chainfile)) { - throw new Error(ajv.errorsText(validateFunction.errors)); - } + validate(chainfile); + this.chainfile = chainfile; + this.values = initValues(chainfile, overrideValues); + this.suffix = suffix; } - public synthEnv(): string { - return synthDotEnvFile(this.chainfile.env ?? {}); + public synthDotEnv(): string { + return Object.entries({ + // TODO(?): this.values should filter out values that are not used in the compose file + ...this.values, + CHAINFILE_VALUES: JSON.stringify(this.values), + }) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); } public synthCompose(): string { @@ -45,7 +54,7 @@ export class Compose { name: this.chainfile.name.toLowerCase().replaceAll(/[^a-z0-9_-]/g, '_'), services: { ...this.createAgent(), - ...this.createContainers(), + ...this.createServices(), }, networks: { chainfile: {}, @@ -62,16 +71,6 @@ export class Compose { } private createAgent(): Record<'agent', object> { - const chainfileJson = JSON.stringify(this.chainfile).replaceAll('$', '$$$'); - - const EnvMapping = Object.keys(this.chainfile.env ?? {}).reduce( - (env, key) => { - env[`CHAINFILE_ENVIRONMENT_${key}`] = `$\{${key}}`; - return env; - }, - {} as Record, - ); - return { agent: { container_name: `agent-${this.suffix}`, @@ -79,8 +78,9 @@ export class Compose { ports: ['0:1569'], environment: { // Docker compose automatically evaluate environment literals here - CHAINFILE_JSON: chainfileJson, - ...EnvMapping, + CHAINFILE_JSON: JSON.stringify(this.chainfile).replaceAll('$', '$$$'), + CHAINFILE_VALUES: '${CHAINFILE_VALUES}', + DEBUG: process.env.DEBUG ?? 'false', }, volumes: [ { @@ -96,8 +96,8 @@ export class Compose { }; } - private createContainers(): Record { - // TODO: resources (cpu, memory) is not supported yet for docker-compose + private createServices(): Record { + // TODO: resources (cpu, memory) is not supported for this runtime: // https://docs.docker.com/compose/compose-file/compose-file-v3/#resources // I'm not sure if we should since docker-compose typically runs on a single machine // and utilizes the host's resources. @@ -144,30 +144,75 @@ export class Compose { return volumes; } - return Object.entries(this.chainfile.containers).reduce( - (services, [name, container]) => { - services[name] = { - container_name: `${name}-${this.suffix}`, - image: container.image, - command: container.command, - environment: Object.entries(container.environment ?? {}).reduce( - (env, [key, value]) => { - return { - ...env, - [key]: typeof value === 'string' ? value : `$\{${value.key}}`, - }; - }, - {} as Record, - ), - ports: createPorts(container), - volumes: createVolumes(container), - networks: { - chainfile: {}, - }, - }; - return services; - }, - {} as Record, - ); + return mapValues(this.chainfile.containers, (container, name) => { + return { + container_name: `${name}-${this.suffix}`, + image: container.image + ':' + this.resolveValue(container.tag), + command: container.command, + environment: mapValues(container.environment ?? {}, (value: string | ValueReference) => { + return this.resolveValue(value); + }), + ports: createPorts(container), + volumes: createVolumes(container), + networks: { + chainfile: {}, + }, + }; + }); } + + private resolveValue(value: string | ValueReference): string { + if (typeof value === 'string') { + return value; + } + return `$\{${value.$value}}`; + } +} + +function validate(chainfile: Chainfile) { + const ajv = new Ajv(); + addFormats(ajv); + const validateFunction = ajv.compile(schema); + + if (!validateFunction(chainfile)) { + throw new Error(ajv.errorsText(validateFunction.errors)); + } +} + +function initValues(chainfile: Chainfile, overrideValues: Record) { + const values = mapValues(chainfile.values ?? {}, (options: string | ValueOptions, name) => { + if (overrideValues[name] !== undefined) { + return overrideValues[name]; + } + + if (typeof options === 'string') { + return options; + } + + if (options.default !== undefined) { + return options.default; + } + + if (options.random !== undefined && options.random.type === 'bytes') { + return randomBytes(options.random.length).toString(options.random.encoding); + } + + if (options.required === true) { + throw new Error(`Missing Value: ${name}`); + } + + throw new Error(`Unsupported Value: ${JSON.stringify(options)}`); + }); + + let updated: boolean; + do { + updated = false; + for (const [name, value] of Object.entries(values)) { + values[name] = value.replace(/\$\{([a-z]+(_[a-z0-9]+)*)}/g, (_, key) => { + updated = true; + return values[key]; + }); + } + } while (updated); + return values; } diff --git a/packages/chainfile-docker/src/dotenv.ts b/packages/chainfile-docker/src/dotenv.ts deleted file mode 100644 index e6f62c8..0000000 --- a/packages/chainfile-docker/src/dotenv.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -import { Env } from '@chainfile/schema'; - -// Dotenv Expansion Implementation -// -// Copyright (c) 2016, Scott Motte -// All rights reserved. -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// -// * Redistributions of source code must retain the above copyright notice, this -// list of conditions and the following disclaimer. -// -// * Redistributions in binary form must reproduce the above copyright notice, -// this list of conditions and the following disclaimer in the documentation -// and/or other materials provided with the distribution. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -const dotenv = { - INTERPOLATE_SUBSTITUTION_REGEX: - /(\\)?(\$)(?!\()(\{?)([\w.]+)(?::?-((?:\$\{(?:\$\{(?:\$\{[^}]*\}|[^}])*}|[^}])*}|[^}])+))?(\}?)/gi, - resolveEscapeSequences: (value: string): string => { - return value.replace(/\\\$/g, '$'); - }, - interpolate: (value: string, parsed: Record): string => { - return value.replace( - dotenv.INTERPOLATE_SUBSTITUTION_REGEX, - (match, escaped, dollarSign, openBrace, key, defaultValue) => { - if (escaped === '\\') return match.slice(1); - - if (parsed[key]) { - // avoid recursion from EXPAND_SELF=$EXPAND_SELF - if (parsed[key] === value) { - return parsed[key]; - } else { - return dotenv.interpolate(parsed[key], parsed); - } - } - - if (defaultValue) { - if (defaultValue.startsWith('$')) { - return dotenv.interpolate(defaultValue, parsed); - } else { - return defaultValue; - } - } - - return ''; - }, - ); - }, - expand: (env: Record): Record => { - const copied = { ...env }; - - for (const key in copied) { - const value = dotenv.interpolate(copied[key], copied); - copied[key] = dotenv.resolveEscapeSequences(value); - } - - return copied; - }, -}; - -export function synthDotEnvFile(env: Env): string { - const expanded = dotenv.expand( - Object.entries(env).reduce( - (env, [key, factory]) => { - if (factory.type === 'RandomBytes') { - env[key] = randomBytes(factory.length).toString(factory.encoding); - return env; - } - if (factory.type === 'Value') { - env[key] = factory.value; - return env; - } - - // if (factory.type === 'Injection') { - // TODO: Prompt if CLI, inject if constructs. - // To allow for simple configuration, e.g. Masternode Keys. - - // @ts-expect-error so that we error out if we forget to handle a new factory type - throw new Error(`Unsupported Environment Factory: ${factory.type}`); - }, - {} as Record, - ), - ); - - return Object.entries(expanded) - .map(([key, value]) => `${key}=${value}`) - .join('\n'); -} diff --git a/packages/chainfile-docker/tsconfig.build.json b/packages/chainfile-docker/tsconfig.build.json index 81f5810..8fa05e3 100644 --- a/packages/chainfile-docker/tsconfig.build.json +++ b/packages/chainfile-docker/tsconfig.build.json @@ -1,4 +1,5 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "extends": "@workspace/tsconfig", "compilerOptions": { "rootDir": "./src", diff --git a/packages/chainfile-schema/schema.json b/packages/chainfile-schema/schema.json index ba97556..e3b905e 100644 --- a/packages/chainfile-schema/schema.json +++ b/packages/chainfile-schema/schema.json @@ -1,13 +1,16 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "allOf": [{ "$ref": "#/definitions/Chainfile" }], + "allOf": [ + { + "$ref": "#/definitions/Chainfile" + } + ], "definitions": { "Chainfile": { "type": "object", "properties": { "$schema": { - "type": "string", - "const": "https://chainfile.org/schema.json" + "type": "string" }, "caip2": { "type": "string", @@ -20,8 +23,8 @@ "minLength": 1, "maxLength": 128 }, - "env": { - "$ref": "#/definitions/Env" + "values": { + "$ref": "#/definitions/Values" }, "containers": { "type": "object", @@ -38,23 +41,37 @@ "required": ["$schema", "caip2", "name", "containers"], "additionalProperties": false }, - "Env": { + "Values": { "type": "object", "maxProperties": 30, "patternProperties": { - "^[a-zA-Z_]+[a-zA-Z0-9_]*$": { - "$ref": "#/definitions/EnvFactory" + "^[a-z]+(_[a-z0-9]+)*$": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/ValueOptions" + } + ] } }, "additionalProperties": false }, - "EnvFactory": { - "oneOf": [ - { + "ValueOptions": { + "type": "object", + "properties": { + "required": { + "type": "boolean" + }, + "default": { + "type": "string" + }, + "random": { "type": "object", "properties": { "type": { - "const": "RandomBytes" + "enum": ["bytes"] }, "length": { "type": "number", @@ -66,32 +83,27 @@ "enum": ["hex", "base64", "base64url"] } }, - "required": ["type", "length", "encoding"], + "required": ["length", "encoding"], "additionalProperties": false - }, + } + }, + "allOf": [ { - "type": "object", - "properties": { - "type": { - "const": "Value" - }, - "value": { - "type": "string" - } - }, - "required": ["type", "value"], - "additionalProperties": false + "not": { + "required": ["default", "random"] + } } - ] + ], + "additionalProperties": false }, - "EnvReference": { + "ValueReference": { "type": "object", "properties": { - "key": { + "$value": { "type": "string" } }, - "required": ["key"], + "required": ["$value"], "additionalProperties": false }, "Container": { @@ -100,6 +112,17 @@ "image": { "type": "string" }, + "tag": { + "description": "Tag of the container image.", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/ValueReference" + } + ] + }, "source": { "type": "string", "description": "Source of the container image.", @@ -141,7 +164,14 @@ "maxProperties": 30, "patternProperties": { "^[a-zA-Z_]+[a-zA-Z0-9_]*$": { - "oneOf": [{ "type": "string" }, { "$ref": "#/definitions/EnvReference" }] + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/ValueReference" + } + ] } }, "additionalProperties": false @@ -165,7 +195,7 @@ "additionalProperties": false } }, - "required": ["image", "source", "resources"], + "required": ["image", "tag", "source", "resources"], "additionalProperties": false }, "Endpoint": { @@ -356,10 +386,24 @@ "const": "HttpBasic" }, "username": { - "oneOf": [{ "type": "string" }, { "$ref": "#/definitions/EnvReference" }] + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/ValueReference" + } + ] }, "password": { - "oneOf": [{ "type": "string" }, { "$ref": "#/definitions/EnvReference" }] + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/ValueReference" + } + ] } }, "required": ["type", "username", "password"], @@ -372,7 +416,14 @@ "const": "HttpBearer" }, "token": { - "oneOf": [{ "type": "string" }, { "$ref": "#/definitions/EnvReference" }] + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/ValueReference" + } + ] } }, "required": ["type", "token"], diff --git a/packages/chainfile-testcontainers/src/agent.ts b/packages/chainfile-testcontainers/src/agent.ts index 2e75033..68c95fb 100644 --- a/packages/chainfile-testcontainers/src/agent.ts +++ b/packages/chainfile-testcontainers/src/agent.ts @@ -12,7 +12,8 @@ export class AgentContainer extends AbstractStartedContainer { public async getChainfile(): Promise { const response = await fetch(`${this.endpoint}/chainfile`); - return (await response.json()) as Chainfile; + const json = await response.json(); + return json as Chainfile; } public async probe(type: 'startup' | 'liveness' | 'readiness'): Promise { diff --git a/packages/chainfile-testcontainers/src/container.ts b/packages/chainfile-testcontainers/src/container.ts index 48dc181..b7d508d 100644 --- a/packages/chainfile-testcontainers/src/container.ts +++ b/packages/chainfile-testcontainers/src/container.ts @@ -7,7 +7,7 @@ import { EndpointHttpAuthorization, EndpointHttpJsonRpc, EndpointHttpRest, - EnvReference, + ValueReference, } from '@chainfile/schema'; import { AbstractStartedContainer, StartedTestContainer } from 'testcontainers'; @@ -15,7 +15,7 @@ export class ChainfileContainer extends AbstractStartedContainer { constructor( started: StartedTestContainer, protected container: Container, - protected environment: Record, + protected values: Record, ) { super(started); } @@ -196,10 +196,10 @@ export class ChainfileContainer extends AbstractStartedContainer { }); } - private resolveValue(value: string | EnvReference): string { + private resolveValue(value: string | ValueReference): string { if (typeof value === 'string') { return value; } - return this.environment[value.key] ?? ''; + return this.values[value.$value] ?? ''; } } diff --git a/packages/chainfile-testcontainers/src/index.ts b/packages/chainfile-testcontainers/src/index.ts index 4142b1d..d3c72f7 100644 --- a/packages/chainfile-testcontainers/src/index.ts +++ b/packages/chainfile-testcontainers/src/index.ts @@ -1,78 +1,3 @@ -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import { Compose } from '@chainfile/docker'; -import { Chainfile } from '@chainfile/schema'; -import { DockerComposeEnvironment, StartedDockerComposeEnvironment as ComposeStarted, Wait } from 'testcontainers'; - -import { AgentContainer } from './agent'; -import { ChainfileContainer } from './container'; - -export class ChainfileTestcontainers { - protected cwd: string = join(process.cwd(), '.chainfile'); - protected filename: string; - protected compose: Compose; - - protected environment: Record; - protected composeStarted!: ComposeStarted; - - private constructor(protected readonly chainfile: Chainfile) { - this.compose = new Compose(chainfile); - this.filename = `compose.${this.compose.suffix}.yml`; - this.environment = this.compose - .synthEnv() - .split('\n') - .reduce( - (acc, line) => { - const [key, value] = line.split('='); - acc[key] = value; - return acc; - }, - {} as Record, - ); - } - - static async start(reference: Chainfile | any): Promise { - const testcontainers = new ChainfileTestcontainers(reference); - await testcontainers.start(); - return testcontainers; - } - - private async start(): Promise { - mkdirSync(this.cwd, { recursive: true }); - writeFileSync(join(this.cwd, this.filename), this.compose.synthCompose()); - - this.composeStarted = await new DockerComposeEnvironment(this.cwd, this.filename) - .withEnvironment(this.environment) - // The readiness probe of @chainfile/agent is to determine if the deployment is ready to accept requests. - .withWaitStrategy(`agent-${this.compose.suffix}`, Wait.forHttp('/probes/readiness', 1569)) - .up(); - } - - async stop(): Promise { - await this.composeStarted.down(); - rmSync(join(this.cwd, this.filename)); - } - - get(name: string): ChainfileContainer { - const containerDef = this.chainfile.containers[name]; - if (containerDef === undefined) { - throw new Error(`Container ${name} not found`); - } - return new ChainfileContainer( - this.composeStarted.getContainer(`${name}-${this.compose.suffix}`), - containerDef, - this.environment, - ); - } - - getEnv(): Record { - return this.environment; - } - - getAgent(): AgentContainer { - return new AgentContainer(this.composeStarted.getContainer(`agent-${this.compose.suffix}`)); - } -} - -export { AgentContainer, ChainfileContainer }; +export * from './agent'; +export * from './container'; +export * from './testcontainers'; diff --git a/packages/chainfile-testcontainers/src/index.test.ts b/packages/chainfile-testcontainers/src/testcontainers.test.ts similarity index 75% rename from packages/chainfile-testcontainers/src/index.test.ts rename to packages/chainfile-testcontainers/src/testcontainers.test.ts index f582e31..c295a3c 100644 --- a/packages/chainfile-testcontainers/src/index.test.ts +++ b/packages/chainfile-testcontainers/src/testcontainers.test.ts @@ -1,25 +1,21 @@ import { Chainfile } from '@chainfile/schema'; import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; -import { AgentContainer, ChainfileContainer, ChainfileTestcontainers } from './index'; +import { AgentContainer } from './agent'; +import { ChainfileTestcontainers } from './testcontainers'; const chainfile: Chainfile = { $schema: 'https://chainfile.org/schema.json', caip2: 'bip122:0f9188f13cb7b2c71f2a335e3a4fc328', name: 'Bitcoin Regtest', - env: { - RPC_USER: { - type: 'Value', - value: 'chainfile', - }, - RPC_PASSWORD: { - type: 'Value', - value: 'chainfile', - }, + values: { + rpc_user: 'user', + rpc_password: 'password', }, containers: { bitcoind: { - image: 'docker.io/kylemanna/bitcoind@sha256:1492fa0306cb7eb5de8d50ba60367cff8d29b00b516e45e93e05f8b54fa2970e', + image: 'docker.io/kylemanna/bitcoind', + tag: 'latest', source: 'https://github.com/kylemanna/docker-bitcoind', endpoints: { rpc: { @@ -27,8 +23,12 @@ const chainfile: Chainfile = { protocol: 'HTTP JSON-RPC 2.0', authorization: { type: 'HttpBasic', - username: 'RPC_USER', - password: 'RPC_PASSWORD', + username: { + $value: 'rpc_user', + }, + password: { + $value: 'rpc_password', + }, }, probes: { readiness: { @@ -55,37 +55,35 @@ const chainfile: Chainfile = { }, environment: { REGTEST: '1', - RPCUSER: 'RPC_USER', - RPCPASSWORD: 'RPC_PASSWORD', + RPCUSER: { + $value: 'rpc_user', + }, + RPCPASSWORD: { + $value: 'rpc_password', + }, }, }, }, }; -let testcontainers: ChainfileTestcontainers; +const testcontainers = new ChainfileTestcontainers(chainfile); beforeAll(async () => { - testcontainers = await ChainfileTestcontainers.start(chainfile); + await testcontainers.start(); }); afterAll(async () => { await testcontainers.stop(); }); -describe('bitcoind', () => { - let container: ChainfileContainer; - - beforeAll(() => { - container = testcontainers.get('bitcoind'); - }); - +describe('container', () => { it('should get rpc port', async () => { - const port = container.getHostPort('rpc'); + const port = testcontainers.get('bitcoind').getHostPort('rpc'); expect(port).toStrictEqual(expect.any(Number)); }); - it('should rpc getblockchaininfo', async () => { - const response = await container.rpc({ + it('should rpc(getblockchaininfo)', async () => { + const response = await testcontainers.get('bitcoind').rpc({ method: 'getblockchaininfo', }); diff --git a/packages/chainfile-testcontainers/src/testcontainers.ts b/packages/chainfile-testcontainers/src/testcontainers.ts new file mode 100644 index 0000000..0c0cf50 --- /dev/null +++ b/packages/chainfile-testcontainers/src/testcontainers.ts @@ -0,0 +1,64 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { Compose } from '@chainfile/docker'; +import { Chainfile } from '@chainfile/schema'; +import { DockerComposeEnvironment, StartedDockerComposeEnvironment as ComposeStarted, Wait } from 'testcontainers'; + +import { AgentContainer } from './agent'; +import { ChainfileContainer } from './container'; + +export class ChainfileTestcontainers { + protected cwd: string = join(process.cwd(), '.chainfile'); + protected filename: string; + protected compose: Compose; + protected composeStarted!: ComposeStarted; + + public constructor( + protected readonly chainfile: Chainfile | any, + protected readonly values: Record = {}, + ) { + this.compose = new Compose(chainfile, values); + this.filename = `compose.${this.compose.suffix}.yml`; + } + + public async start(): Promise { + mkdirSync(this.cwd, { recursive: true }); + writeFileSync(join(this.cwd, this.filename), this.compose.synthCompose()); + + const environment = this.compose + .synthDotEnv() + .split('\n') + .reduce>((acc, line) => { + const [key, value] = line.split('='); + acc[key] = value; + return acc; + }, {}); + this.composeStarted = await new DockerComposeEnvironment(this.cwd, this.filename) + .withEnvironment(environment) + // The readiness probe of @chainfile/agent is to determine if the deployment is ready to accept requests. + .withWaitStrategy(`agent-${this.compose.suffix}`, Wait.forHttp('/probes/readiness', 1569)) + .up(); + } + + async stop(): Promise { + await this.composeStarted.down(); + rmSync(join(this.cwd, this.filename)); + } + + get(name: string): ChainfileContainer { + const containerDef = this.chainfile.containers[name]; + if (containerDef === undefined) { + throw new Error(`Container ${name} not found`); + } + return new ChainfileContainer( + this.composeStarted.getContainer(`${name}-${this.compose.suffix}`), + containerDef, + this.compose.values, + ); + } + + getAgent(): AgentContainer { + return new AgentContainer(this.composeStarted.getContainer(`agent-${this.compose.suffix}`)); + } +} diff --git a/packages/chainfile-testcontainers/tests/LICENSE b/packages/chainfile-testcontainers/tests/LICENSE new file mode 100644 index 0000000..fa0086a --- /dev/null +++ b/packages/chainfile-testcontainers/tests/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. \ No newline at end of file diff --git a/packages/chainfile-testcontainers/tests/bitcoin-mainnet.json b/packages/chainfile-testcontainers/tests/bitcoin-mainnet.json new file mode 100644 index 0000000..c93ce58 --- /dev/null +++ b/packages/chainfile-testcontainers/tests/bitcoin-mainnet.json @@ -0,0 +1,87 @@ +{ + "$schema": "../node_modules/@chainfile/schema/schema.json", + "caip2": "bip122:000000000019d6689c085ae165831e93", + "name": "Bitcoin Mainnet", + "values": { + "rpc_user": { + "random": { + "type": "bytes", + "length": 16, + "encoding": "hex" + } + }, + "rpc_password": { + "random": { + "type": "bytes", + "length": 16, + "encoding": "hex" + } + } + }, + "containers": { + "bitcoind": { + "image": "docker.io/kylemanna/bitcoind", + "tag": "latest", + "source": "https://github.com/kylemanna/docker-bitcoind", + "endpoints": { + "p2p": { + "port": 8333 + }, + "rpc": { + "port": 8332, + "protocol": "HTTP JSON-RPC 2.0", + "authorization": { + "type": "HttpBasic", + "username": { + "$value": "rpc_user" + }, + "password": { + "$value": "rpc_password" + } + }, + "probes": { + "readiness": { + "method": "getblockchaininfo", + "params": [], + "match": { + "result": { + "type": "object", + "properties": { + "blocks": { + "type": "number" + } + }, + "required": ["blocks"] + } + } + } + } + } + }, + "resources": { + "cpu": 1, + "memory": 2048 + }, + "environment": { + "DISABLEWALLET": "1", + "RPCUSER": { + "$value": "rpc_user" + }, + "RPCPASSWORD": { + "$value": "rpc_password" + } + }, + "volumes": { + "persistent": { + "paths": ["/bitcoin/.bitcoin"], + "size": { + "initial": "600G", + "from": "2024-01-01", + "growth": "20G", + "rate": "monthly" + } + } + } + } + } +} diff --git a/packages/chainfile-testcontainers/tests/bitcoin-mainnet.test.ts b/packages/chainfile-testcontainers/tests/bitcoin-mainnet.test.ts new file mode 100644 index 0000000..b349f20 --- /dev/null +++ b/packages/chainfile-testcontainers/tests/bitcoin-mainnet.test.ts @@ -0,0 +1,138 @@ +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +import waitForExpect from 'wait-for-expect'; + +import { ChainfileContainer, ChainfileTestcontainers } from '../src'; +import mainnet from './bitcoin-mainnet.json'; + +const testcontainers = new ChainfileTestcontainers(mainnet); + +beforeAll(async () => { + await testcontainers.start(); +}); + +afterAll(async () => { + await testcontainers.stop(); +}); + +describe('bitcoind', () => { + let bitcoind: ChainfileContainer; + + beforeAll(() => { + bitcoind = testcontainers.get('bitcoind'); + }); + + it('should rpc(getblockchaininfo)', async () => { + const response = await bitcoind.rpc({ + method: 'getblockchaininfo', + }); + + expect(response.status).toStrictEqual(200); + + expect(await response.json()).toMatchObject({ + result: { + bestblockhash: '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f', + chain: 'main', + }, + }); + }); + + it('should rpc(getblockcount)', async () => { + const response = await bitcoind.rpc({ + method: 'getblockcount', + }); + + expect(response.status).toStrictEqual(200); + + expect(await response.json()).toMatchObject({ + result: 0, + }); + }); + + it('should rpc(getblock)', async () => { + const response = await bitcoind.rpc({ + method: 'getblock', + params: ['000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f', 2], + }); + + expect(response.status).toStrictEqual(200); + + expect(await response.json()).toEqual({ + error: null, + id: expect.any(Number), + result: { + bits: '1d00ffff', + chainwork: '0000000000000000000000000000000000000000000000000000000100010001', + confirmations: 1, + difficulty: 1, + hash: '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f', + height: 0, + mediantime: 1231006505, + merkleroot: '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b', + nTx: 1, + nonce: 2083236893, + size: 285, + strippedsize: 285, + time: 1231006505, + tx: [ + { + hash: '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b', + hex: '01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000', + locktime: 0, + size: 204, + txid: '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b', + version: 1, + vin: [ + { + coinbase: + '04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73', + sequence: 4294967295, + }, + ], + vout: [ + { + n: 0, + scriptPubKey: { + asm: '04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f OP_CHECKSIG', + desc: 'pk(04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f)#vlz6ztea', + hex: '4104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac', + type: 'pubkey', + }, + value: 50, + }, + ], + vsize: 204, + weight: 816, + }, + ], + version: 1, + versionHex: '00000001', + weight: 1140, + }, + }); + }); + + describe.skip('synchronization', () => { + // Takes too long to run and is not deterministic + beforeAll(async () => { + await waitForExpect(async () => { + const response = await bitcoind.rpc({ + method: 'getblockcount', + params: [], + }); + + const result = ((await response.json()) as any).result; + expect(result).toBeGreaterThan(1); + }, 30000); + }); + + it('should bitcoind.rpc getblock(1)', async () => { + const response = await bitcoind.rpc({ + method: 'getblock', + params: ['00000000839a8e6886ab5951d76f411475428afc90947ee320161bbf18eb6048'], + }); + + expect(response.status).toStrictEqual(200); + expect(await response.json()).toEqual({}); + }); + }); +}); diff --git a/packages/chainfile-testcontainers/tests/bitcoin-regtest.json b/packages/chainfile-testcontainers/tests/bitcoin-regtest.json new file mode 100644 index 0000000..9d93c6e --- /dev/null +++ b/packages/chainfile-testcontainers/tests/bitcoin-regtest.json @@ -0,0 +1,72 @@ +{ + "$schema": "../node_modules/@chainfile/schema/schema.json", + "caip2": "bip122:0f9188f13cb7b2c71f2a335e3a4fc328", + "name": "Bitcoin Regtest", + "values": { + "rpc_user": "user", + "rpc_password": "password" + }, + "containers": { + "bitcoind": { + "image": "docker.io/kylemanna/bitcoind", + "tag": "latest", + "source": "https://github.com/kylemanna/docker-bitcoind", + "command": ["btc_oneshot", "-fallbackfee=0.00000200", "-rpcbind=:8332", "-rpcallowip=0.0.0.0/0"], + "endpoints": { + "p2p": { + "port": 18445 + }, + "rpc": { + "port": 8332, + "protocol": "HTTP JSON-RPC 2.0", + "authorization": { + "type": "HttpBasic", + "username": { + "$value": "rpc_user" + }, + "password": { + "$value": "rpc_password" + } + }, + "probes": { + "readiness": { + "method": "getblockchaininfo", + "params": [], + "match": { + "result": { + "type": "object", + "properties": { + "blocks": { + "type": "number" + } + }, + "required": ["blocks"] + } + } + } + } + } + }, + "resources": { + "cpu": 0.25, + "memory": 256 + }, + "environment": { + "REGTEST": "1", + "DISABLEWALLET": "0", + "RPCUSER": { + "$value": "rpc_user" + }, + "RPCPASSWORD": { + "$value": "rpc_password" + } + }, + "volumes": { + "persistent": { + "paths": ["/bitcoin/.bitcoin"], + "size": "250M" + } + } + } + } +} diff --git a/packages/chainfile-testcontainers/tests/bitcoin-regtest.test.ts b/packages/chainfile-testcontainers/tests/bitcoin-regtest.test.ts new file mode 100644 index 0000000..3a09dec --- /dev/null +++ b/packages/chainfile-testcontainers/tests/bitcoin-regtest.test.ts @@ -0,0 +1,143 @@ +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; + +import { ChainfileContainer, ChainfileTestcontainers } from '../src'; +import regtest from './bitcoin-regtest.json'; + +const testcontainers = new ChainfileTestcontainers(regtest); + +beforeAll(async () => { + await testcontainers.start(); +}); + +afterAll(async () => { + await testcontainers.stop(); +}); + +describe('bitcoind', () => { + let bitcoind: ChainfileContainer; + + beforeAll(() => { + bitcoind = testcontainers.get('bitcoind'); + }); + + it('should rpc(getblockchaininfo)', async () => { + const response = await bitcoind.rpc({ + method: 'getblockchaininfo', + }); + + expect(response.status).toStrictEqual(200); + + expect(await response.json()).toMatchObject({ + result: { + bestblockhash: '0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206', + chain: 'regtest', + blocks: 0, + }, + }); + }); + + it('should rpc(getblockcount)', async () => { + const response = await bitcoind.rpc({ + method: 'getblockcount', + }); + + expect(response.status).toStrictEqual(200); + + expect(await response.json()).toMatchObject({ + result: 0, + }); + }); + + it('should rpc(getblock)', async () => { + const response = await bitcoind.rpc({ + method: 'getblock', + params: ['0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206', 2], + }); + + expect(response.status).toStrictEqual(200); + + expect(await response.json()).toEqual({ + error: null, + id: expect.any(Number), + result: { + bits: '207fffff', + chainwork: '0000000000000000000000000000000000000000000000000000000000000002', + confirmations: 1, + difficulty: 4.656542373906925e-10, + hash: '0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206', + height: 0, + mediantime: 1296688602, + merkleroot: '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b', + nTx: 1, + nonce: 2, + size: 285, + strippedsize: 285, + time: 1296688602, + tx: [ + { + hash: '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b', + hex: '01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000', + locktime: 0, + size: 204, + txid: '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b', + version: 1, + vin: [ + { + coinbase: + '04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73', + sequence: 4294967295, + }, + ], + vout: [ + { + n: 0, + scriptPubKey: { + asm: '04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f OP_CHECKSIG', + desc: 'pk(04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f)#vlz6ztea', + hex: '4104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac', + type: 'pubkey', + }, + value: 50, + }, + ], + vsize: 204, + weight: 816, + }, + ], + version: 1, + versionHex: '00000001', + weight: 1140, + }, + }); + }); + + it('should rpc(sendtoaddress)', async () => { + const createwallet: any = await bitcoind + .rpc({ method: 'createwallet', params: ['test'] }) + .then((res) => res.json()); + expect(createwallet).toMatchObject({ error: null }); + + const getnewaddress: any = await bitcoind.rpc({ method: 'getnewaddress' }).then((res) => res.json()); + expect(getnewaddress).toMatchObject({ error: null, result: expect.any(String) }); + + // Wait for coinbase maturity + const generatetoaddress = await bitcoind + .rpc({ + method: 'generatetoaddress', + params: [101, getnewaddress.result], + }) + .then((res) => res.json()); + expect(generatetoaddress).toMatchObject({ error: null }); + + const sendtoaddress: any = await bitcoind + .rpc({ + method: 'sendtoaddress', + params: ['bcrt1q4u4nsgk6ug0sqz7r3rj9tykjxrsl0yy4d0wwte', 1.23456789], + }) + .then((res) => res.json()); + + expect(sendtoaddress).toMatchObject({ + error: null, + }); + }); +}); diff --git a/packages/chainfile-testcontainers/tests/ganache.json b/packages/chainfile-testcontainers/tests/ganache.json new file mode 100644 index 0000000..3660193 --- /dev/null +++ b/packages/chainfile-testcontainers/tests/ganache.json @@ -0,0 +1,38 @@ +{ + "$schema": "../node_modules/@chainfile/schema/schema.json", + "caip2": "eip155:1337", + "name": "Ganache", + "values": { + "version": "v7.9.2" + }, + "containers": { + "ganache": { + "image": "docker.io/trufflesuite/ganache", + "tag": { + "$value": "version" + }, + "source": "https://github.com/trufflesuite/ganache", + "resources": { + "cpu": 0.25, + "memory": 256 + }, + "endpoints": { + "rpc": { + "port": 8545, + "protocol": "HTTP JSON-RPC 2.0", + "probes": { + "readiness": { + "params": [], + "method": "eth_blockNumber", + "match": { + "result": { + "type": "string" + } + } + } + } + } + } + } + } +} diff --git a/packages/chainfile-testcontainers/tests/ganache.test.ts b/packages/chainfile-testcontainers/tests/ganache.test.ts new file mode 100644 index 0000000..c3a731a --- /dev/null +++ b/packages/chainfile-testcontainers/tests/ganache.test.ts @@ -0,0 +1,25 @@ +import { afterAll, beforeAll, expect, it } from '@jest/globals'; + +import { ChainfileTestcontainers } from '../src'; +import localhost from './ganache.json'; + +const testcontainers = new ChainfileTestcontainers(localhost); + +beforeAll(async () => { + await testcontainers.start(); +}); + +afterAll(async () => { + await testcontainers.stop(); +}); + +it('should rpc(eth_blockNumber)', async () => { + const response = await testcontainers.get('ganache').rpc({ + method: 'eth_blockNumber', + }); + + expect(response.status).toStrictEqual(200); + expect(await response.json()).toMatchObject({ + result: '0x0', + }); +}); diff --git a/packages/chainfile-testcontainers/tests/hardhat.json b/packages/chainfile-testcontainers/tests/hardhat.json new file mode 100644 index 0000000..df6363b --- /dev/null +++ b/packages/chainfile-testcontainers/tests/hardhat.json @@ -0,0 +1,38 @@ +{ + "$schema": "../node_modules/@chainfile/schema/schema.json", + "caip2": "eip155:31337", + "name": "Hardhat", + "values": { + "version": "2.22.1" + }, + "containers": { + "hardhat": { + "image": "ghcr.io/fuxingloh/hardhat-container", + "tag": { + "$value": "version" + }, + "source": "https://github.com/fuxingloh/hardhat-container", + "resources": { + "cpu": 0.25, + "memory": 256 + }, + "endpoints": { + "rpc": { + "port": 8545, + "protocol": "HTTP JSON-RPC 2.0", + "probes": { + "readiness": { + "params": [], + "method": "eth_blockNumber", + "match": { + "result": { + "type": "string" + } + } + } + } + } + } + } + } +} diff --git a/packages/chainfile-testcontainers/tests/hardhat.test.ts b/packages/chainfile-testcontainers/tests/hardhat.test.ts new file mode 100644 index 0000000..58dec23 --- /dev/null +++ b/packages/chainfile-testcontainers/tests/hardhat.test.ts @@ -0,0 +1,26 @@ +import { afterAll, beforeAll, expect, it } from '@jest/globals'; + +import { ChainfileTestcontainers } from '../src'; +import localhost from './hardhat.json'; + +const testcontainers = new ChainfileTestcontainers(localhost); + +beforeAll(async () => { + await testcontainers.start(); +}); + +afterAll(async () => { + await testcontainers.stop(); +}); + +it('should rpc(eth_blockNumber)', async () => { + const response = await testcontainers.get('hardhat').rpc({ + method: 'eth_blockNumber', + }); + + expect(response.status).toStrictEqual(200); + + expect(await response.json()).toMatchObject({ + result: '0x0', + }); +}); diff --git a/packages/chainfile-testcontainers/tests/solana-test-validator.json b/packages/chainfile-testcontainers/tests/solana-test-validator.json new file mode 100644 index 0000000..a3c5d89 --- /dev/null +++ b/packages/chainfile-testcontainers/tests/solana-test-validator.json @@ -0,0 +1,38 @@ +{ + "$schema": "../node_modules/@chainfile/schema/schema.json", + "caip2": "solana:00000000000000000000000000000000", + "name": "Solana Test Validator", + "values": { + "version": "1.17.26" + }, + "containers": { + "solana-test-validator": { + "image": "ghcr.io/fuxingloh/solana-container", + "tag": { + "$value": "version" + }, + "source": "https://github.com/fuxingloh/solana-container", + "resources": { + "cpu": 0.25, + "memory": 256 + }, + "endpoints": { + "rpc": { + "port": 8899, + "protocol": "HTTP JSON-RPC 2.0", + "probes": { + "readiness": { + "params": [], + "method": "getBlockHeight", + "match": { + "result": { + "type": "number" + } + } + } + } + } + } + } + } +} diff --git a/packages/chainfile-testcontainers/tests/solana-test-validator.test.ts b/packages/chainfile-testcontainers/tests/solana-test-validator.test.ts new file mode 100644 index 0000000..349f402 --- /dev/null +++ b/packages/chainfile-testcontainers/tests/solana-test-validator.test.ts @@ -0,0 +1,34 @@ +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; + +import { ChainfileContainer, ChainfileTestcontainers } from '../src'; +import solana from './solana-test-validator.json'; + +const testcontainers = new ChainfileTestcontainers(solana); + +beforeAll(async () => { + await testcontainers.start(); +}); + +afterAll(async () => { + await testcontainers.stop(); +}); + +describe('solana-test-validator', () => { + let validator: ChainfileContainer; + + beforeAll(() => { + validator = testcontainers.get('solana-test-validator'); + }); + + it('should rpc(getBlockHeight)', async () => { + const response = await validator.rpc({ + method: 'getBlockHeight', + }); + + expect(response.status).toStrictEqual(200); + + expect(await response.json()).toMatchObject({ + result: 0, + }); + }); +}); diff --git a/packages/chainfile-testcontainers/tsconfig.build.json b/packages/chainfile-testcontainers/tsconfig.build.json index d8a1ea0..5395b56 100644 --- a/packages/chainfile-testcontainers/tsconfig.build.json +++ b/packages/chainfile-testcontainers/tsconfig.build.json @@ -1,4 +1,5 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "extends": "@workspace/tsconfig", "compilerOptions": { "rootDir": "./src", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10b95f7..bad413d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,8 +48,8 @@ importers: specifier: ^3.3.0 version: 3.3.0 turbo: - specifier: ^2.0.1 - version: 2.0.1 + specifier: ^2.0.3 + version: 2.0.3 typescript: specifier: 5.4.5 version: 5.4.5 @@ -71,12 +71,25 @@ importers: ajv-formats: specifier: ^3.0.1 version: 3.0.1(ajv@8.16.0) + debug: + specifier: ^4.3.4 + version: 4.3.4 + lodash: + specifier: ^4.17.21 + version: 4.17.21 trpc-openapi: specifier: ^1.2.0 version: 1.2.0(@trpc/server@10.45.2)(zod@3.23.8) zod: specifier: ^3.23.8 version: 3.23.8 + devDependencies: + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 + '@types/lodash': + specifier: ^4.17.4 + version: 4.17.4 packages/chainfile-docker: dependencies: @@ -92,10 +105,16 @@ importers: js-yaml: specifier: ^4.1.0 version: 4.1.0 + lodash: + specifier: ^4.17.21 + version: 4.17.21 devDependencies: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + '@types/lodash': + specifier: ^4.17.4 + version: 4.17.4 packages/chainfile-schema: devDependencies: @@ -1430,7 +1449,6 @@ packages: resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} dependencies: '@types/ms': 0.7.34 - dev: false /@types/docker-modem@3.0.6: resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} @@ -1554,7 +1572,6 @@ packages: /@types/ms@0.7.34: resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} - dev: false /@types/node@18.19.31: resolution: {integrity: sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==} @@ -5379,7 +5396,6 @@ packages: /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - dev: true /log-update@6.0.0: resolution: {integrity: sha512-niTvB4gqvtof056rRIrTZvjNYE4rCUzO6X/X+kYjd7WFxXeJ0NwEFnRxX6ehkvv3jTwrXnNdtAak5XYZuIyPFw==} @@ -7910,64 +7926,64 @@ packages: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} dev: false - /turbo-darwin-64@2.0.1: - resolution: {integrity: sha512-GO391pUmI6c6l/EpUIaXNzwbVDWRvYahm5oLB176dAWRYKYO+Osqs/XBdOM0G3l7ZFdR6nUtRJc8qinJp7qDUQ==} + /turbo-darwin-64@2.0.3: + resolution: {integrity: sha512-v7ztJ8sxdHw3SLfO2MhGFeeU4LQhFii1hIGs9uBiXns/0YTGOvxLeifnfGqhfSrAIIhrCoByXO7nR9wlm10n3Q==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-darwin-arm64@2.0.1: - resolution: {integrity: sha512-rmjJoxeq7nmH/F2aWKapahrDE2zE2Uc15rvs4Rz6qHOzSqC8R5uyLpQyTKIPIZ95O/z9nKfLfVPyiRENuk5vpw==} + /turbo-darwin-arm64@2.0.3: + resolution: {integrity: sha512-LUcqvkV9Bxtng6QHbevp8IK8zzwbIxM6HMjCE7FEW6yJBN1KwvTtRtsGBwwmTxaaLO0wD1Jgl3vgkXAmQ4fqUw==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-linux-64@2.0.1: - resolution: {integrity: sha512-vwTOc4v4jm6tM+9WlsiDlN+zwHP8A2wlsAYiNqz2u0DZL55aCWaVdivh2VpVLN36Mr9HgREGH0Fw+jx6ObcNRg==} + /turbo-linux-64@2.0.3: + resolution: {integrity: sha512-xpdY1suXoEbsQsu0kPep2zrB8ijv/S5aKKrntGuQ62hCiwDFoDcA/Z7FZ8IHQ2u+dpJARa7yfiByHmizFE0r5Q==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-arm64@2.0.1: - resolution: {integrity: sha512-DkVt76fjwY940DfmqznWhpYIlKYduvKAoTtylkERrDlcWUpDYWwqNbcf9PRRIbnjnv9lIxvuom1KZmMY+cw/Ig==} + /turbo-linux-arm64@2.0.3: + resolution: {integrity: sha512-MBACTcSR874L1FtLL7gkgbI4yYJWBUCqeBN/iE29D+8EFe0d3fAyviFlbQP4K/HaDYet1i26xkkOiWr0z7/V9A==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-windows-64@2.0.1: - resolution: {integrity: sha512-XskV34kYuXVIHbRbgH8jr35Y8uA6kJOQ0LJStU4jFk7piiyk0a4n2GNDymMtvIwAxYdbuTe+pKuPCThFdirHBQ==} + /turbo-windows-64@2.0.3: + resolution: {integrity: sha512-zi3YuKPkM9JxMTshZo3excPk37hUrj5WfnCqh4FjI26ux6j/LJK+Dh3SebMHd9mR7wP9CMam4GhmLCT+gDfM+w==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /turbo-windows-arm64@2.0.1: - resolution: {integrity: sha512-R2/RmKr2uQxkOCtXK5LNxdD3Iv7lUm56iy2FrDwTDgPI7X7K6WRjrxdirmFIu/fABYE5n6EampU3ejbG5mmGtg==} + /turbo-windows-arm64@2.0.3: + resolution: {integrity: sha512-wmed4kkenLvRbidi7gISB4PU77ujBuZfgVGDZ4DXTFslE/kYpINulwzkVwJIvNXsJtHqyOq0n6jL8Zwl3BrwDg==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /turbo@2.0.1: - resolution: {integrity: sha512-sJhxfBaN14pYj//xxAG6zAyStkE2j4HI9JVXVMob35SGob6dz/HuSqV/4QlVqw0uKAkwc1lXIsnykbe8RLmOOw==} + /turbo@2.0.3: + resolution: {integrity: sha512-jF1K0tTUyryEWmgqk1V0ALbSz3VdeZ8FXUo6B64WsPksCMCE48N5jUezGOH2MN0+epdaRMH8/WcPU0QQaVfeLA==} hasBin: true optionalDependencies: - turbo-darwin-64: 2.0.1 - turbo-darwin-arm64: 2.0.1 - turbo-linux-64: 2.0.1 - turbo-linux-arm64: 2.0.1 - turbo-windows-64: 2.0.1 - turbo-windows-arm64: 2.0.1 + turbo-darwin-64: 2.0.3 + turbo-darwin-arm64: 2.0.3 + turbo-linux-64: 2.0.3 + turbo-linux-arm64: 2.0.3 + turbo-windows-64: 2.0.3 + turbo-windows-arm64: 2.0.3 dev: true /tweetnacl@0.14.5: diff --git a/tsconfig.json b/tsconfig.json index 1799e74..437a469 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,4 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "extends": "@workspace/tsconfig" } diff --git a/website/pages/core-concepts/schema.mdx b/website/pages/core-concepts/schema.mdx index a61c281..2050151 100644 --- a/website/pages/core-concepts/schema.mdx +++ b/website/pages/core-concepts/schema.mdx @@ -35,10 +35,10 @@ import { Callout } from 'nextra/components'; "authorization": { "type": "HttpBasic", "username": { - "key": "RPC_USER" + "$value": "RPC_USER" }, "password": { - "key": "RPC_PASSWORD" + "$value": "RPC_PASSWORD" } }, "probes": { @@ -67,10 +67,10 @@ import { Callout } from 'nextra/components'; "environment": { "DISABLEWALLET": "1", "RPCUSER": { - "key": "RPC_USER" + "$value": "RPC_USER" }, "RPCPASSWORD": { - "key": "RPC_PASSWORD" + "$value": "RPC_PASSWORD" } }, "volumes": { diff --git a/website/pages/definitions/bitcoin-core.mdx b/website/pages/definitions/bitcoin-core.mdx deleted file mode 100644 index a206a0c..0000000 --- a/website/pages/definitions/bitcoin-core.mdx +++ /dev/null @@ -1 +0,0 @@ -# Bitcoin Core diff --git a/website/pages/definitions/bitcoin.mdx b/website/pages/definitions/bitcoin.mdx new file mode 100644 index 0000000..0166eae --- /dev/null +++ b/website/pages/definitions/bitcoin.mdx @@ -0,0 +1 @@ +# Bitcoin diff --git a/website/pages/definitions/ethereum.mdx b/website/pages/definitions/ethereum.mdx new file mode 100644 index 0000000..0846e0c --- /dev/null +++ b/website/pages/definitions/ethereum.mdx @@ -0,0 +1 @@ +# Ethereum