feat: replace cli framework furious-commander with oclif

This commit is contained in:
Adam Uhlíř 2025-05-07 16:37:54 +02:00
parent d566c52045
commit 221587b0e4
No known key found for this signature in database
GPG Key ID: 1D17A9E81F76155B
51 changed files with 10048 additions and 11240 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
PATH_add ./node_modules/.bin/

View File

@ -1 +0,0 @@
dist/**

View File

@ -1,109 +0,0 @@
module.exports = {
extends: ['plugin:@typescript-eslint/recommended', 'prettier', 'plugin:prettier/recommended'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
project: './tsconfig.test.json',
},
env: {
jest: true,
},
globals: {
browser: true,
page: true,
},
plugins: ['jest', 'unused-imports'],
rules: {
'array-bracket-newline': ['error', 'consistent'],
strict: ['error', 'safe'],
'block-scoped-var': 'error',
complexity: 'warn',
'default-case': 'error',
'dot-notation': 'warn',
eqeqeq: 'error',
'guard-for-in': 'warn',
'linebreak-style': ['warn', 'unix'],
'no-alert': 'error',
'no-case-declarations': 'error',
'no-console': 'error',
'no-constant-condition': 'error',
'no-continue': 'warn',
'no-div-regex': 'error',
'no-empty': 'warn',
'no-empty-pattern': 'error',
'no-implicit-coercion': 'error',
'prefer-arrow-callback': 'warn',
'no-labels': 'error',
'no-loop-func': 'error',
'no-nested-ternary': 'warn',
'no-script-url': 'error',
'no-warning-comments': 'warn',
'quote-props': ['error', 'as-needed'],
'require-yield': 'error',
'max-nested-callbacks': ['error', 4],
'max-depth': ['error', 4],
'space-before-function-paren': [
'error',
{
anonymous: 'never',
named: 'never',
asyncArrow: 'always',
},
],
'padding-line-between-statements': [
'error',
{ blankLine: 'always', prev: '*', next: 'if' },
{ blankLine: 'always', prev: '*', next: 'function' },
{ blankLine: 'always', prev: '*', next: 'return' },
],
'no-useless-constructor': 'off',
'no-dupe-class-members': 'off',
'no-unused-expressions': 'off',
curly: ['error', 'multi-line'],
'object-curly-spacing': ['error', 'always'],
'comma-dangle': ['error', 'always-multiline'],
'@typescript-eslint/no-useless-constructor': 'error',
'@typescript-eslint/no-unused-expressions': 'error',
'@typescript-eslint/member-delimiter-style': [
'error',
{
multiline: {
delimiter: 'none',
requireLast: true,
},
singleline: {
delimiter: 'semi',
requireLast: false,
},
},
],
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-expect-error': 'allow-with-description',
'ts-ignore': 'allow-with-description',
'ts-nocheck': 'allow-with-description',
'ts-check': 'allow-with-description',
minimumDescriptionLength: 6,
},
],
'require-await': 'off',
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'warn',
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
],
},
overrides: [
{
files: ['*.spec.ts'],
rules: {
'max-nested-callbacks': ['error', 10], // allow describe/it nesting
},
},
],
}

1
.gitattributes vendored
View File

@ -1 +0,0 @@
* text=auto

View File

@ -1,13 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
# Always increase the version in package.json as well (for patch versions by default only package-lock.json i updated)
versioning-strategy: increase

View File

@ -1,54 +0,0 @@
name: Check
on:
push:
branches:
- 'master'
pull_request:
branches:
- '**'
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x]
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
## Try getting the node modules from cache, if failed npm ci
- uses: actions/cache@v3
id: cache-npm
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.OS }}-node-${{ env.cache-name }}-
${{ runner.OS }}-node-
${{ runner.OS }}-
- name: Install npm deps
if: steps.cache-npm.outputs.cache-hit != 'true'
run: npm ci
- name: Commit linting
uses: wagoid/commitlint-github-action@v5
- name: Code linting
run: npm run lint:check
env:
CI: true
- name: Types check
run: npm run types:check
- name: Build nodejs code
run: npm run build

View File

@ -1,4 +1,4 @@
name: Tests
name: Check
on:
push:
@ -7,42 +7,32 @@ on:
pull_request:
branches:
- '**'
jobs:
node-tests:
runs-on: ubuntu-latest
jobs:
check:
runs-on: 'ubuntu-latest'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run build
tests:
strategy:
matrix:
node-version: [14.x, 16.x, 18.x]
os: ['ubuntu-latest', 'windows-latest']
node_version: [lts/-1, lts/*, latest]
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
fetch-depth: 1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
## Try getting the node modules from cache, if failed npm ci
- uses: actions/cache@v3
id: cache-npm
with:
path: node_modules
key: ${{ runner.os }}-node-${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.OS }}-node-${{ matrix.node }}-${{ env.cache-name }}-
${{ runner.OS }}-node-${{ matrix.node }}-
- name: Install npm deps
if: steps.cache-npm.outputs.cache-hit != 'true'
run: npm ci
- name: Run tests for older versions
if: matrix.node-version != '18.x'
run: npm test -- --bail true
- name: Run tests for Node 18
if: matrix.node-version == '18.x'
env:
NODE_OPTIONS: "--no-experimental-fetch"
run: npm test -- --bail true
node-version: ${{ matrix.node_version }}
cache: npm
- run: npm ci
- run: npm test

36
.gitignore vendored
View File

@ -1,30 +1,14 @@
# Logs
logs
*.log
*-debug.log
*-error.log
**/.DS_Store
/.idea
/dist
/tmp
/node_modules
oclif.manifest.json
# OS files
.DS_Store
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
yarn.lock
pnpm-lock.yaml
# Coverage directory used by tools like istanbul
coverage
.nyc_output
# node-waf configuration
.lock-wscript
build
dist
# Dependency directory
node_modules
# Generated files
docs

15
.mocharc.json Normal file
View File

@ -0,0 +1,15 @@
{
"require": [
"ts-node/register"
],
"watch-extensions": [
"ts"
],
"recursive": true,
"reporter": "spec",
"timeout": 60000,
"node-option": [
"loader=ts-node/esm",
"experimental-specifier-resolution=node"
]
}

View File

@ -1,13 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"semi": false,
"singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "all",
"endOfLine": "lf",
"arrowParens": "avoid",
"proseWrap": "always"
}

1
.prettierrc.json Normal file
View File

@ -0,0 +1 @@
"@oclif/prettier-config"

View File

View File

@ -1 +0,0 @@
* @AuHau

View File

@ -1,15 +1,14 @@
# Codex Factory
[![oclif](https://img.shields.io/badge/cli-oclif-brightgreen.svg)](https://oclif.io)
[![Version](https://img.shields.io/npm/v/@codex-storage/codex-factory.svg)](https://npmjs.org/package/@codex-storage/codex-factory)
[![Downloads/week](https://img.shields.io/npm/dw/@codex-storage/codex-factory.svg)](https://npmjs.org/package/@codex-storage/codex-factory)
[![Tests](https://github.com/codex-storage/codex-factory/actions/workflows/test.yaml/badge.svg)](https://github.com/codex-storage/codex-factory/actions/workflows/test.yaml)
[![standard-readme compliant](https://img.shields.io/badge/standard--readme-OK-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme)
[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/feross/standard)
![](https://img.shields.io/badge/npm-%3E%3D10.0.0-orange.svg?style=flat-square)
![](https://img.shields.io/badge/Node.js-%3E%3D18.0.0-orange.svg?style=flat-square)
![](https://img.shields.io/badge/Node.js-%3E%3D20.0.0-orange.svg?style=flat-square)
> CLI tool to spin up Docker cluster of Codex nodes for advanced testing and/or development
**Warning: This project is in beta state. There might (and most probably will) be changes in the future to its API and working. Also, no guarantees can be made about its stability, efficiency, and security at this stage.**
## Table of Contents
- [Install](#install)
@ -58,9 +57,9 @@ For more details see the `--help` page of the CLI and its commands.
You can omit the Codex version argument when running `codex-factory start` command if you specify it in one of the expected places:
- `CODEX_FACTORY_VERSION` env. variable
- `package.json` placed in current working directory (cwd) under the `engines.codex` property.
- `.codexfactory.json` placed in current working directory (cwd) with property `version`.
- `CODEX_FACTORY_VERSION` env. variable
- `package.json` placed in current working directory (cwd) under the `engines.codex` property.
- `.codexfactory.json` placed in current working directory (cwd) with property `version`.
#### Build versions
@ -94,6 +93,6 @@ You can run the CLI while developing using `npm start -- <command> ...`.
## License
[BSD-3-Clause](./LICENSE)
[BSD-3-Clause](LICENSE)
Originally written for Swarm's Bee client: https://github.com/ethersphere/bee-factory

3
bin/dev.cmd Normal file
View File

@ -0,0 +1,3 @@
@echo off
node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %*

5
bin/dev.js Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env -S node --loader ts-node/esm --no-warnings
import {execute} from '@oclif/core'
await execute({development: true, dir: import.meta.url})

3
bin/run.cmd Normal file
View File

@ -0,0 +1,3 @@
@echo off
node "%~dp0\run" %*

5
bin/run.js Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env node
import {execute} from '@oclif/core'
await execute({dir: import.meta.url})

View File

@ -1,6 +0,0 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'body-max-line-length': [0, 'always', Infinity], // disable commit body length restriction
},
}

9
eslint.config.mjs Normal file
View File

@ -0,0 +1,9 @@
import {includeIgnoreFile} from '@eslint/compat'
import oclif from 'eslint-config-oclif'
import prettier from 'eslint-config-prettier'
import path from 'node:path'
import {fileURLToPath} from 'node:url'
const gitignorePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '.gitignore')
export default [includeIgnoreFile(gitignorePath), ...oclif, prettier]

View File

@ -1,35 +0,0 @@
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/en/configuration.html
*/
import type { Config } from '@jest/types'
export default async (): Promise<Config.InitialOptions> => {
return {
preset: 'ts-jest',
runner: '@ethersphere/jest-serial-runner',
testRegex: 'test/integration/.*\\.spec\\.ts',
testEnvironment: 'node',
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
// An array of regexp pattern strings used to skip coverage collection
coveragePathIgnorePatterns: ['/node_modules/'],
// An array of directory names to be searched recursively up from the requiring module's location
moduleDirectories: ['node_modules'],
// The root directory that Jest should scan for tests and modules within
rootDir: 'test',
// Increase timeout since we are spinning Codex containers
testTimeout: 4 * 60 * 1000,
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ['/node_modules/'],
}
}

18544
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,58 @@
{
"name": "@codex-storage/codex-factory",
"version": "0.1.0",
"description": "Orchestration CLI for spinning up local development Codex cluster with Docker",
"version": "0.1.0",
"author": "Codex Authors",
"bin": {
"codex-factory": "bin/run.js"
},
"bugs": "https://github.com/codex-storage/codex-factory/issues",
"dependencies": {
"@codex-storage/sdk-js": "^0.1.2",
"@oclif/core": "^4",
"@oclif/plugin-help": "^6",
"chalk": "^4.1.2",
"dockerode": "^4",
"node-fetch": "^3",
"ora": "^8",
"semver": "^7"
},
"devDependencies": {
"@eslint/compat": "^1",
"@oclif/prettier-config": "^0.2.1",
"@oclif/test": "^4",
"@types/chai": "^4",
"@types/chai-as-promised": "^8.0.2",
"@types/dockerode": "^3.3.38",
"@types/mocha": "^10",
"@types/node": "^18",
"@types/node-fetch": "^2.6.12",
"@types/semver": "^7.7.0",
"chai": "^4",
"chai-as-promised": "^8.0.1",
"eslint": "^9",
"eslint-config-oclif": "^6",
"eslint-config-prettier": "^10",
"mocha": "^10",
"oclif": "^4",
"shx": "^0.3.3",
"ts-node": "^10",
"typescript": "^5"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=6.0.0",
"codex": "0.2.1",
"supportedCodex": ">0.2.0"
},
"files": [
"bin",
"./dist",
"./oclif.manifest.json"
],
"homepage": "https://github.com/codex-storage/codex-factory",
"keywords": [
"oclif",
"codex",
"codex-storage",
"decentralised",
@ -11,70 +61,28 @@
"p2p",
"docker"
],
"homepage": "https://github.com/codex-storage/codex-factory",
"bugs": {
"url": "https://github.com/codex-storage/codex-factory/issues/"
},
"license": "BSD-3-Clause",
"repository": {
"type": "git",
"url": "https://github.com/codex-storage/codex-factory.git"
"main": "./dist/index.js",
"type": "module",
"oclif": {
"bin": "codex-factory",
"dirname": "codex-factory",
"commands": "./dist/commands",
"plugins": [
"@oclif/plugin-help"
],
"topicSeparator": " ",
"topics": {}
},
"bin": {
"codex-factory": "./dist/src/index.js"
},
"main": "dist/src/index.js",
"files": [
"dist"
],
"repository": "https://github.com/codex-storage/codex-factory/",
"scripts": {
"prepublishOnly": "npm run build",
"build": "rimraf dist && tsc",
"start": "ts-node src/index.ts",
"test": "jest --verbose --config=jest.config.ts",
"types:check": "tsc --project tsconfig.test.json",
"lint": "eslint --fix \"src/**/*.ts\" \"test/**/*.ts\" && prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"lint:check": "eslint \"src/**/*.ts\" \"test/**/*.ts\" && prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"depcheck": "depcheck ."
"start": "bin/dev.js",
"build": "shx rm -rf dist && tsc -b",
"lint": "eslint",
"postpack": "shx rm -f oclif.manifest.json",
"prepack": "oclif manifest && oclif readme",
"test": "mocha --forbid-only \"test/**/*.test.ts\"",
"version": "oclif readme && git add README.md"
},
"dependencies": {
"@codex-storage/sdk-js": "^0.1.2",
"chalk": "^4.1.2",
"dockerode": "^3.3.4",
"furious-commander": "^1.7.1",
"node-fetch": "3.0.0-beta.9",
"ora": "^5.3.0",
"semver": "^7.3.8"
},
"devDependencies": {
"@commitlint/cli": "^17.1.2",
"@commitlint/config-conventional": "^17.1.0",
"@ethersphere/jest-serial-runner": "^1.0.0",
"@fluffy-spoon/substitute": "^1.208.0",
"@jest/types": "^29.2.1",
"@types/dockerode": "^3.3.11",
"@types/jest": "^29.2.2",
"@types/node": "^18.11.3",
"@typescript-eslint/eslint-plugin": "^5.40.1",
"@typescript-eslint/parser": "^5.40.1",
"depcheck": "^1.4.3",
"eslint": "^8.27.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-jest": "^27.1.3",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unused-imports": "^2.0.0",
"jest": "^29.3.0",
"jest-runner": "^29.2.1",
"prettier": "^2.7.1",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1",
"typescript": "^4.8.4"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=6.0.0",
"codex": "4ace774",
"supportedCodex": ">=0.2.0"
}
"types": "dist/index.d.ts"
}

View File

@ -1,10 +0,0 @@
import { Application } from 'furious-commander/dist/application'
import PackageJson from '../package.json'
export const application: Application = {
name: 'Codex Factory',
command: 'codex-factory',
description: 'Orchestration CLI for spinning up local development Codex cluster with Docker',
version: PackageJson.version,
autocompletion: 'fromOption',
}

48
src/base.ts Normal file
View File

@ -0,0 +1,48 @@
import { Command, Flags, Interfaces } from "@oclif/core";
import { Logging, VerbosityLevel } from "./utils/logging.js";
export type Flags<T extends typeof Command> = Interfaces.InferredFlags<T["flags"] & typeof BaseCommand["baseFlags"]>
export type Args<T extends typeof Command> = Interfaces.InferredArgs<T["args"]>
export abstract class BaseCommand<T extends typeof Command> extends Command {
static baseFlags = {
quiet: Flags.boolean({
default: false,
description: "Does not print anything.",
}),
verbose: Flags.boolean({
default: false,
description: "Display logs.",
}),
};
protected args!: Args<T>;
public console!: Logging;
protected flags!: Flags<T>;
public verbosity!: VerbosityLevel;
public async init(): Promise<void> {
await super.init();
const { args, flags } = await this.parse({
args: this.ctor.args,
baseFlags: (super.ctor as typeof BaseCommand).baseFlags,
enableJsonFlag: this.ctor.enableJsonFlag,
flags: this.ctor.flags,
strict: this.ctor.strict,
});
this.flags = flags as Flags<T>;
this.args = args as Args<T>;
this.verbosity = VerbosityLevel.Normal;
if (flags.quiet) {
this.verbosity = VerbosityLevel.Quiet;
}
if (flags.verbose) {
this.verbosity = VerbosityLevel.Verbose;
}
this.console = new Logging(this.verbosity);
}
}

View File

@ -1,68 +0,0 @@
import { Argument, GroupCommand, LeafCommand, Option } from 'furious-commander'
import { RootCommand } from './root-command'
import { ContainerType, DEFAULT_ENV_PREFIX, DEFAULT_IMAGE_PREFIX, Docker } from '../utils/docker'
class ClientRequestCommand extends RootCommand implements LeafCommand {
public readonly name = 'request'
public readonly description = `Prints logs for given container. Valid container's names are: ${Object.values(
ContainerType,
).join(', ')}`
@Option({
key: 'image-prefix',
type: 'string',
description: 'Docker image name prefix',
envKey: 'FACTORY_IMAGE_PREFIX',
default: DEFAULT_IMAGE_PREFIX,
})
public imagePrefix!: string
@Option({
key: 'env-prefix',
type: 'string',
description: "Docker container's names prefix",
envKey: 'FACTORY_ENV_PREFIX',
default: DEFAULT_ENV_PREFIX,
})
public envPrefix!: string
@Option({
key: 'follow',
alias: 'f',
type: 'boolean',
description: 'Stays attached to the container and output any new logs.',
default: false,
})
public follow!: boolean
@Option({
key: 'tail',
alias: 't',
type: 'number',
description: 'Prints specified number of last log lines.',
})
public tail!: number
@Argument({ key: 'container', description: 'Container name as described above', required: true })
public container!: ContainerType
public async run(): Promise<void> {
await super.init()
if (!Object.values(ContainerType).includes(this.container)) {
throw new Error(`Passed container name is not valid! Valid values: ${Object.values(ContainerType).join(', ')}`)
}
const docker = new Docker(this.console, this.envPrefix, this.imagePrefix)
await docker.logs(this.container, process.stdout, this.follow, this.tail)
}
}
export class ClientGroupCommand implements GroupCommand {
public readonly name = 'client'
public subCommandClasses = [ClientRequestCommand]
public readonly description = 'Group of commands to operate client node'
}

View File

@ -1,60 +0,0 @@
import { Argument, LeafCommand, Option } from 'furious-commander'
import { RootCommand } from './root-command'
import { ContainerType, DEFAULT_ENV_PREFIX, DEFAULT_IMAGE_PREFIX, Docker } from '../utils/docker'
export class Logs extends RootCommand implements LeafCommand {
public readonly name = 'logs'
public readonly description = `Prints logs for given container. Valid container's names are: ${Object.values(
ContainerType,
).join(', ')}`
@Option({
key: 'image-prefix',
type: 'string',
description: 'Docker image name prefix',
envKey: 'FACTORY_IMAGE_PREFIX',
default: DEFAULT_IMAGE_PREFIX,
})
public imagePrefix!: string
@Option({
key: 'env-prefix',
type: 'string',
description: "Docker container's names prefix",
envKey: 'FACTORY_ENV_PREFIX',
default: DEFAULT_ENV_PREFIX,
})
public envPrefix!: string
@Option({
key: 'follow',
alias: 'f',
type: 'boolean',
description: 'Stays attached to the container and output any new logs.',
default: false,
})
public follow!: boolean
@Option({
key: 'tail',
alias: 't',
type: 'number',
description: 'Prints specified number of last log lines.',
})
public tail!: number
@Argument({ key: 'container', description: 'Container name as described above', required: true })
public container!: ContainerType
public async run(): Promise<void> {
await super.init()
if (!Object.values(ContainerType).includes(this.container)) {
throw new Error(`Passed container name is not valid! Valid values: ${Object.values(ContainerType).join(', ')}`)
}
const docker = new Docker(this.console, this.envPrefix, this.imagePrefix)
await docker.logs(this.container, process.stdout, this.follow, this.tail)
}
}

View File

@ -1,29 +0,0 @@
import { ExternalOption } from 'furious-commander'
import { Logging, VerbosityLevel } from './logging'
export class RootCommand {
@ExternalOption('quiet')
public quiet!: boolean
@ExternalOption('verbose')
public verbose!: boolean
public verbosity!: VerbosityLevel
public console!: Logging
public readonly appName = 'codex-factory'
protected async init(): Promise<void> {
this.verbosity = VerbosityLevel.Normal
if (this.quiet) {
this.verbosity = VerbosityLevel.Quiet
}
if (this.verbose) {
this.verbosity = VerbosityLevel.Verbose
}
this.console = new Logging(this.verbosity)
}
}

View File

@ -1,25 +0,0 @@
/* eslint-disable no-console */
import chalk from 'chalk'
export const FORMATTED_ERROR = chalk.red.bold('ERROR')
export const Printer = {
emptyFunction: (): void => {
return
},
divider: (char = '-'): void => {
console.log(char.repeat(process.stdout.columns))
},
error: (message: string, ...args: unknown[]): void => {
console.error(message, ...args)
},
log: (message: string, ...args: unknown[]): void => {
console.log(message, ...args)
},
info: (message: string, ...args: unknown[]): void => {
console.log(chalk.dim(message), ...args)
},
dimFunction: (message: string, ...args: unknown[]): void => {
console.log(chalk.dim(message), ...args)
},
}

View File

@ -1,235 +0,0 @@
import { Argument, LeafCommand, Option } from 'furious-commander'
import { RootCommand } from './root-command'
import {
ContainerType,
DEFAULT_ENV_PREFIX,
DEFAULT_IMAGE_PREFIX,
Docker,
RunOptions,
HOST_COUNT,
} from '../utils/docker'
import { waitForBlockchain, waitForClient, waitForHosts } from '../utils/wait'
import ora from 'ora'
import { VerbosityLevel } from './root-command/logging'
import { findCodexVersion, validateVersion } from '../utils/config-sources'
const DEFAULT_REPO = 'codexstorage'
export const ENV_ENV_PREFIX_KEY = 'FACTORY_ENV_PREFIX'
const ENV_IMAGE_PREFIX_KEY = 'FACTORY_IMAGE_PREFIX'
const ENV_REPO_KEY = 'FACTORY_DOCKER_REPO'
const ENV_DETACH_KEY = 'FACTORY_DETACH'
const ENV_HOSTS_KEY = 'FACTORY_HOSTS'
const ENV_FRESH_KEY = 'FACTORY_FRESH'
export class Start extends RootCommand implements LeafCommand {
public readonly name = 'start'
public readonly description = 'Spin up the Codex Factory cluster'
@Option({
key: 'fresh',
alias: 'f',
type: 'boolean',
description: 'The cluster data will be purged before start',
envKey: ENV_FRESH_KEY,
default: false,
})
public fresh!: boolean
@Option({
key: 'detach',
alias: 'd',
type: 'boolean',
description: 'Spin up the cluster and exit. No logging is outputted.',
envKey: ENV_DETACH_KEY,
default: false,
})
public detach!: boolean
@Option({
key: 'hosts',
alias: 'h',
type: 'number',
description: `Number of hosts to spin. Value between 0 and ${HOST_COUNT} including.`,
envKey: ENV_HOSTS_KEY,
default: HOST_COUNT,
})
public hosts!: number
@Option({
key: 'repo',
type: 'string',
description: 'Docker repo',
envKey: ENV_REPO_KEY,
default: DEFAULT_REPO,
})
public repo!: string
@Option({
key: 'image-prefix',
type: 'string',
description: 'Docker image name prefix',
envKey: ENV_IMAGE_PREFIX_KEY,
default: DEFAULT_IMAGE_PREFIX,
})
public imagePrefix!: string
@Option({
key: 'env-prefix',
type: 'string',
description: "Docker container's names prefix",
envKey: ENV_ENV_PREFIX_KEY,
default: DEFAULT_ENV_PREFIX,
})
public envPrefix!: string
@Argument({ key: 'codex-version', description: 'Codex image version', required: false })
public codexVersion!: string
public async run(): Promise<void> {
await super.init()
if (this.hosts < 0 || this.hosts > HOST_COUNT) {
throw new Error(`Worker count has to be between 0 and ${HOST_COUNT} including.`)
}
if (!this.codexVersion) {
this.codexVersion = await findCodexVersion()
this.console.log('Codex version not specified. Found it configured externally.')
this.console.log(`Spinning up cluster with Codex version ${this.codexVersion}.`)
}
this.codexVersion = validateVersion(this.codexVersion)
const dockerOptions = await this.buildDockerOptions()
const docker = new Docker(this.console, this.envPrefix, this.imagePrefix, this.repo)
const status = await docker.getAllStatus()
if (Object.values(status).every(st => st === 'running')) {
this.console.log('All containers are up and running')
if (this.detach) {
return
}
await docker.logs(ContainerType.CLIENT, process.stdout)
}
let clientDockerIpAddress: string
process.on('SIGINT', async () => {
try {
await docker.stopAll(false)
} catch (e) {
this.console.error(`Error: ${e}`)
}
process.exit()
})
const networkSpinner = ora({
text: 'Spawning network...',
spinner: 'point',
color: 'yellow',
isSilent: this.verbosity === VerbosityLevel.Quiet,
}).start()
try {
await docker.createNetwork()
networkSpinner.succeed('Network is up')
} catch (e) {
networkSpinner.fail(`It was not possible to spawn network!`)
throw e
}
const blockchainSpinner = ora({
text: 'Getting blockchain image version...',
spinner: 'point',
color: 'yellow',
isSilent: this.verbosity === VerbosityLevel.Quiet,
}).start()
try {
const blockchainImage = await docker.getBlockchainImage(this.codexVersion)
blockchainSpinner.text = 'Starting blockchain node...'
await docker.startBlockchainNode(blockchainImage, dockerOptions)
blockchainSpinner.text = 'Waiting until blockchain is ready...'
await waitForBlockchain()
blockchainSpinner.succeed('Blockchain node is up and listening')
} catch (e) {
blockchainSpinner.fail(`It was not possible to start blockchain node!`)
await this.stopDocker(docker)
throw e
}
const clientSpinner = ora({
text: 'Starting Codex client node...',
spinner: 'point',
color: 'yellow',
isSilent: this.verbosity === VerbosityLevel.Quiet,
}).start()
async function clientStatus(): Promise<boolean> {
return (await docker.getStatusForContainer(ContainerType.CLIENT)) === 'running'
}
try {
await docker.startClientNode(this.codexVersion, dockerOptions)
clientSpinner.text = 'Waiting until client node is ready...'
clientDockerIpAddress = await waitForClient(clientStatus)
clientSpinner.succeed('Client boot node is up and listening')
} catch (e) {
clientSpinner.fail(`It was not possible to start client node!`)
await this.stopDocker(docker)
throw e
}
if (this.hosts > 0) {
const hostSpinner = ora({
text: 'Starting Codex host nodes...',
spinner: 'point',
color: 'yellow',
isSilent: this.verbosity === VerbosityLevel.Quiet,
}).start()
try {
for (let i = 1; i <= this.hosts; i++) {
await docker.startHostNode(this.codexVersion, i, clientDockerIpAddress, dockerOptions)
}
hostSpinner.text = 'Waiting until all host nodes connect to client...'
await waitForHosts(this.hosts, docker.getAllStatus.bind(docker))
hostSpinner.succeed('Host nodes are up and listening')
} catch (e) {
hostSpinner.fail(`It was not possible to start host nodes!`)
await this.stopDocker(docker)
throw e
}
}
if (!this.detach) {
await docker.logs(ContainerType.CLIENT, process.stdout, true)
}
}
private async stopDocker(docker: Docker) {
const dockerSpinner = ora({
text: 'Stopping all containers...',
spinner: 'point',
color: 'red',
isSilent: this.verbosity === VerbosityLevel.Quiet,
}).start()
await docker.stopAll(false)
dockerSpinner.stop()
}
private async buildDockerOptions(): Promise<RunOptions> {
return {
fresh: this.fresh,
}
}
}

View File

@ -1,53 +0,0 @@
import { LeafCommand, Option } from 'furious-commander'
import { RootCommand } from './root-command'
import { DEFAULT_ENV_PREFIX, DEFAULT_IMAGE_PREFIX, Docker } from '../utils/docker'
import ora from 'ora'
import { VerbosityLevel } from './root-command/logging'
export class Stop extends RootCommand implements LeafCommand {
public readonly name = 'stop'
public readonly description = 'Stops the Codex Factory cluster'
@Option({
key: 'env-prefix',
type: 'string',
description: "Docker container's names prefix",
envKey: 'FACTORY_DOCKER_PREFIX',
default: DEFAULT_ENV_PREFIX,
})
public envPrefix!: string
@Option({
key: 'image-prefix',
type: 'string',
description: 'Docker image name prefix',
envKey: 'FACTORY_DOCKER_PREFIX',
default: DEFAULT_IMAGE_PREFIX,
})
public imagePrefix!: string
@Option({
key: 'rm',
type: 'boolean',
description: 'Remove the containers',
})
public deleteContainers!: boolean
public async run(): Promise<void> {
await super.init()
const docker = new Docker(this.console, this.envPrefix, this.imagePrefix)
const dockerSpinner = ora({
text: 'Stopping all containers...',
spinner: 'point',
color: 'yellow',
isSilent: this.verbosity === VerbosityLevel.Quiet,
}).start()
await docker.stopAll(true, this.deleteContainers)
dockerSpinner.succeed('Containers stopped')
}
}

59
src/commands/logs.ts Normal file
View File

@ -0,0 +1,59 @@
import { Args, Flags } from '@oclif/core'
import { BaseCommand } from '../base.js'
import {
ContainerType,
DEFAULT_ENV_PREFIX,
DEFAULT_IMAGE_PREFIX,
Docker
} from '../utils/docker.js'
const ENV_ENV_PREFIX_KEY = 'FACTORY_ENV_PREFIX'
const ENV_IMAGE_PREFIX_KEY = 'FACTORY_IMAGE_PREFIX'
export default class Logs extends BaseCommand<typeof Logs> {
static override args = {
container: Args.string({ description: "Container name as described above", required: true }),
};
static override description = `Prints logs for given container. Valid container's names are: ${Object.values(
ContainerType
).join(', ')}`
static override examples = [
'<%= config.bin %> <%= command.id %>'
]
static override flags = {
envPrefix: Flags.string({
default: DEFAULT_ENV_PREFIX,
description: 'Docker container\'s names prefix',
env: ENV_ENV_PREFIX_KEY
}),
follow: Flags.boolean({
char: 'f',
default: false,
description: 'Stays attached to the container and output any new logs'
}),
imagePrefix: Flags.string({
default: DEFAULT_IMAGE_PREFIX,
description: 'Docker image name prefix',
env: ENV_IMAGE_PREFIX_KEY
}),
tail: Flags.integer({
char: 't',
description: 'Prints specified number of last log lines.',
required: false
})
}
public async run (): Promise<void> {
const { args, flags } = await this.parse(Logs)
if (!Object.values(ContainerType).includes(args.container as ContainerType)) {
this.console.error(`Passed container name is not valid! Valid values: ${Object.values(ContainerType).join(', ')}`)
// eslint-disable-next-line n/no-process-exit,unicorn/no-process-exit
process.exit(1)
}
const docker = new Docker(this.console, flags.envPrefix, flags.imagePrefix)
await docker.logs(args.container as ContainerType, process.stdout, flags.follow, flags.tail)
}
}

222
src/commands/start.ts Normal file
View File

@ -0,0 +1,222 @@
import { Args, Flags } from "@oclif/core";
import { OutputFlags } from "@oclif/core/interfaces";
import ora from "ora";
import { BaseCommand } from "../base.js";
import { findCodexVersion, validateVersion } from "../utils/config-sources.js";
import {
ContainerType,
DEFAULT_ENV_PREFIX,
DEFAULT_IMAGE_PREFIX,
Docker,
HOST_COUNT,
RunOptions,
} from "../utils/docker.js";
import { VerbosityLevel } from "../utils/logging.js";
import { waitForBlockchain, waitForClient, waitForHosts } from "../utils/wait.js";
const DEFAULT_REPO = "codexstorage";
export const ENV_ENV_PREFIX_KEY = "FACTORY_ENV_PREFIX";
const ENV_IMAGE_PREFIX_KEY = "FACTORY_IMAGE_PREFIX";
const ENV_REPO_KEY = "FACTORY_DOCKER_REPO";
const ENV_DETACH_KEY = "FACTORY_DETACH";
const ENV_HOSTS_KEY = "FACTORY_HOSTS";
const ENV_FRESH_KEY = "FACTORY_FRESH";
export default class Start extends BaseCommand<typeof Start> {
static override args = {
codexVersion: Args.string({ description: "Codex image version", required: false }),
};
static override description = "Spin up the Codex Factory cluster";
static override examples = [
"<%= config.bin %> <%= command.id %>",
"<%= config.bin %> <%= command.id %> 0.2.0",
"<%= config.bin %> <%= command.id %> latest",
];
static override flags = {
detach: Flags.boolean({
char: "d",
default: false,
description: "Spin up the cluster and exit. No logging is outputted.",
env: ENV_DETACH_KEY,
}),
envPrefix: Flags.string({
default: DEFAULT_ENV_PREFIX,
description: "Docker container's names prefix",
env: ENV_ENV_PREFIX_KEY,
}),
fresh: Flags.boolean({
char: "f",
default: false,
description: "The cluster data will be purged before start.",
env: ENV_FRESH_KEY,
}),
hosts: Flags.integer({
char: "h",
default: HOST_COUNT,
description: `Number of hosts to spin. Value between 0 and ${HOST_COUNT} including.`,
env: ENV_HOSTS_KEY,
}),
imagePrefix: Flags.string({
default: DEFAULT_IMAGE_PREFIX,
description: "Docker image name prefix",
env: ENV_IMAGE_PREFIX_KEY,
}),
repo: Flags.string({
default: DEFAULT_REPO,
description: "Docker repo where images are published",
env: ENV_REPO_KEY,
}),
};
public async run(): Promise<void> {
const { args, flags } = await this.parse(Start);
if (flags.hosts < 0 || flags.hosts > HOST_COUNT) {
throw new Error(`Worker count has to be between 0 and ${HOST_COUNT} including.`)
}
if (!args.codexVersion) {
args.codexVersion = await findCodexVersion()
this.console.log('Codex version not specified. Found it configured externally.')
this.console.log(`Spinning up cluster with Codex version ${args.codexVersion}.`)
}
args.codexVersion = validateVersion(args.codexVersion, this.config.pjson)
const dockerOptions = await this.buildDockerOptions(flags)
const docker = new Docker(this.console, flags.envPrefix, flags.imagePrefix, flags.repo)
const status = await docker.getAllStatus()
if (Object.values(status).every(st => st === 'running')) {
this.console.log('All containers are up and running')
if (flags.detach) {
return
}
await docker.logs(ContainerType.CLIENT, process.stdout)
}
let clientDockerIpAddress: string
process.on('SIGINT', async () => {
try {
await docker.stopAll(false)
} catch (error) {
this.console.error(`Error: ${error}`)
}
// eslint-disable-next-line n/no-process-exit
process.exit()
})
const networkSpinner = ora({
color: 'yellow',
isSilent: this.verbosity === VerbosityLevel.Quiet,
spinner: 'point',
text: 'Spawning network...',
}).start()
try {
await docker.createNetwork()
networkSpinner.succeed('Network is up')
} catch (error) {
networkSpinner.fail(`It was not possible to spawn network!`)
throw error
}
const blockchainSpinner = ora({
color: 'yellow',
isSilent: this.verbosity === VerbosityLevel.Quiet,
spinner: 'point',
text: 'Getting blockchain image version...',
}).start()
try {
const blockchainImage = await docker.getBlockchainImage(args.codexVersion)
blockchainSpinner.text = 'Starting blockchain node...'
await docker.startBlockchainNode(blockchainImage, dockerOptions)
blockchainSpinner.text = 'Waiting until blockchain is ready...'
await waitForBlockchain()
blockchainSpinner.succeed('Blockchain node is up and listening')
} catch (error) {
blockchainSpinner.fail(`It was not possible to start blockchain node!`)
await this.stopDocker(docker)
throw error
}
const clientSpinner = ora({
color: 'yellow',
isSilent: this.verbosity === VerbosityLevel.Quiet,
spinner: 'point',
text: 'Starting Codex client node...',
}).start()
async function clientStatus(): Promise<boolean> {
return (await docker.getStatusForContainer(ContainerType.CLIENT)) === 'running'
}
try {
await docker.startClientNode(args.codexVersion, dockerOptions)
clientSpinner.text = 'Waiting until client node is ready...'
clientDockerIpAddress = await waitForClient(clientStatus)
clientSpinner.succeed('Client boot node is up and listening')
} catch (error) {
clientSpinner.fail(`It was not possible to start client node!`)
await this.stopDocker(docker)
throw error
}
if (flags.hosts > 0) {
const hostSpinner = ora({
color: 'yellow',
isSilent: this.verbosity === VerbosityLevel.Quiet,
spinner: 'point',
text: 'Starting Codex host nodes...',
}).start()
try {
for (let i = 1; i <= flags.hosts; i++) {
// eslint-disable-next-line no-await-in-loop
await docker.startHostNode(args.codexVersion, i, clientDockerIpAddress, dockerOptions)
}
hostSpinner.text = 'Waiting until all host nodes connect to client...'
await waitForHosts(flags.hosts, docker.getAllStatus.bind(docker))
hostSpinner.succeed('Host nodes are up and listening')
} catch (error) {
hostSpinner.fail(`It was not possible to start host nodes!`)
await this.stopDocker(docker)
throw error
}
}
if (!flags.detach) {
await docker.logs(ContainerType.CLIENT, process.stdout, true)
}
}
private async buildDockerOptions(flags: OutputFlags<typeof Start.flags>): Promise<RunOptions> {
return {
fresh: flags.fresh,
}
}
private async stopDocker(docker: Docker) {
const dockerSpinner = ora({
color: 'red',
isSilent: this.verbosity === VerbosityLevel.Quiet,
spinner: 'point',
text: 'Stopping all containers...',
}).start()
await docker.stopAll(false)
dockerSpinner.stop()
}
}

56
src/commands/stop.ts Normal file
View File

@ -0,0 +1,56 @@
import { Flags } from '@oclif/core'
import ora from 'ora'
import { BaseCommand } from '../base.js'
import {
DEFAULT_ENV_PREFIX,
DEFAULT_IMAGE_PREFIX,
Docker,
} from '../utils/docker.js'
import { VerbosityLevel } from '../utils/logging.js'
const ENV_ENV_PREFIX_KEY = 'FACTORY_ENV_PREFIX'
const ENV_IMAGE_PREFIX_KEY = 'FACTORY_IMAGE_PREFIX'
const ENV_RM = 'FACTORY_RM'
export default class Stop extends BaseCommand<typeof Stop> {
static override description = 'Stops the Codex Factory cluster'
static override examples = [
'<%= config.bin %> <%= command.id %>',
]
static override flags = {
envPrefix: Flags.string({
default: DEFAULT_ENV_PREFIX,
description: 'Docker container\'s names prefix',
env: ENV_ENV_PREFIX_KEY
}),
imagePrefix: Flags.string({
default: DEFAULT_IMAGE_PREFIX,
description: 'Docker image name prefix',
env: ENV_IMAGE_PREFIX_KEY
}),
rm: Flags.boolean({
default: false,
description: 'Remove the containers',
env: ENV_RM
})
}
public async run (): Promise<void> {
const { flags } = await this.parse(Stop)
const docker = new Docker(this.console, flags.envPrefix, flags.imagePrefix)
const dockerSpinner = ora({
color: 'yellow',
isSilent: this.verbosity === VerbosityLevel.Quiet,
spinner: 'point',
text: 'Stopping all containers...'
}).start()
await docker.stopAll(true, flags.rm)
dockerSpinner.succeed('Containers stopped')
}
}

View File

@ -1,46 +0,0 @@
import { IOption } from 'furious-commander'
import PackageJson from '../package.json'
import { Start } from './command/start'
import { Stop } from './command/stop'
import { Logs } from './command/logs'
export const quiet: IOption<boolean> = {
key: 'quiet',
alias: 'q',
description: 'Does not print anything',
type: 'boolean',
default: false,
conflicts: 'verbose',
}
export const verbose: IOption<boolean> = {
key: 'verbose',
alias: 'v',
description: 'Display logs',
type: 'boolean',
default: false,
conflicts: 'quiet',
}
export const help: IOption<boolean> = {
key: 'help',
alias: 'h',
description: 'Print context specific help and exit',
type: 'boolean',
default: false,
}
export const version: IOption<boolean> = {
key: 'version',
alias: 'V',
description: 'Print version and exit',
type: 'boolean',
default: false,
handler: () => {
process.stdout.write(PackageJson.version + '\n')
},
}
export const optionParameters: IOption<unknown>[] = [quiet, verbose, help, version]
export const rootCommandClasses = [Start, Stop, Logs]

View File

@ -1,13 +1 @@
#!/usr/bin/env node
import { cli } from 'furious-commander'
import { application } from './application'
import { optionParameters, rootCommandClasses } from './config'
import { printer } from './printer'
cli({
rootCommandClasses,
optionParameters,
printer,
application,
})
export {run} from '@oclif/core'

View File

@ -1,12 +0,0 @@
import chalk from 'chalk'
import { Printer } from 'furious-commander/dist/printer'
import { Printer as SwarmPrinter } from './command/root-command/printer'
export const printer: Printer = {
print: SwarmPrinter.log,
printError: SwarmPrinter.error,
printHeading: (text: string) => SwarmPrinter.log(chalk.bold('█ ' + text)),
formatDim: (text: string) => chalk.dim(text),
formatImportant: (text: string) => chalk.bold(text),
getGenericErrorMessage: () => 'Failed to run command!',
}

View File

@ -1,60 +1,65 @@
import { readFile as readFileCb } from 'fs'
import * as path from 'path'
import { promisify } from 'util'
import PackageJson from '../../package.json'
import type { PJSON } from '@oclif/core/interfaces'
import { readFile as readFileCb } from 'node:fs'
import { join } from 'node:path'
import { promisify } from 'node:util'
import semver from 'semver'
const readFile = promisify(readFileCb)
const VERSION_REGEX = /^\d+\.\d+\.\d+$/
const COMMIT_HASH_REGEX = /^([a-f0-9]{7,10})$/i
export function validateVersion(version: string): string {
export function validateVersion (version: string, pkgJson: PJSON): string {
if (version === 'latest') {
return version
}
if (VERSION_REGEX.test(version)) {
const supportedCodexVersion = PackageJson.engines.supportedCodex
const supportedCodexVersion = pkgJson.engines.supportedCodex
if (!semver.satisfies(version, supportedCodexVersion, { includePrerelease: true })) {
throw new Error(
`Unsupported Codex version!\nThis version of Codex Factory supports versions: ${supportedCodexVersion}, but you have requested start of ${version}`,
`Unsupported Codex version!\nThis version of Codex Factory supports versions: ${supportedCodexVersion}, but you have requested start of ${version}`
)
}
return version
} else if (COMMIT_HASH_REGEX.test(version)) {
return `sha-${version}`
} else {
throw new Error('The version does not have expected format!')
}
if (COMMIT_HASH_REGEX.test(version)) {
return `sha-${version}`
}
throw new Error('The version does not have expected format!')
}
async function searchPackageJson(): Promise<string | undefined> {
const expectedPath = path.join(process.cwd(), 'package.json')
async function searchPackageJson (): Promise<string | undefined> {
const expectedPath = join(process.cwd(), 'package.json')
try {
const pkgJson = JSON.parse(await readFile(expectedPath, { encoding: 'utf8' }))
return pkgJson?.engines?.codex
} catch (e) {
} catch {
return undefined
}
}
async function searchCodexFactory(): Promise<string | undefined> {
const expectedPath = path.join(process.cwd(), '.codexfactory.json')
async function searchCodexFactory (): Promise<string | undefined> {
const expectedPath = join(process.cwd(), '.codexfactory.json')
try {
const pkgJson = JSON.parse(await readFile(expectedPath, { encoding: 'utf8' }))
return pkgJson?.version
} catch (e) {
} catch {
return undefined
}
}
export async function findCodexVersion(): Promise<string> {
export async function findCodexVersion (): Promise<string> {
if (process.env.CODEX_FACTORY_VERSION) {
return process.env.CODEX_FACTORY_VERSION
}

View File

@ -1,6 +1,8 @@
/* eslint-disable camelcase */
import Dockerode, { Container, ContainerCreateOptions } from 'dockerode'
import { Logging } from '../command/root-command/logging'
import { ContainerImageConflictError } from './error'
import { ContainerImageConflictError } from './error.js'
import { Logging } from './logging.js'
export const DEFAULT_ENV_PREFIX = 'codex-factory'
export const DEFAULT_IMAGE_PREFIX = 'nim-codex'
@ -40,15 +42,15 @@ export interface RunOptions {
}
export enum ContainerType {
CLIENT = 'client',
BLOCKCHAIN = 'blockchain',
CLIENT = 'client',
HOST = 'host',
HOST_2 = 'host2',
HOST_3 = 'host3',
HOST_4 = 'host4',
}
export type Status = 'running' | 'exists' | 'not-found'
export type Status = 'exists' | 'not-found' | 'running'
type FindResult = { container?: Container; image?: string }
export interface AllStatus {
@ -66,34 +68,12 @@ export interface DockerError extends Error {
}
export class Docker {
private docker: Dockerode
private console: Logging
private runningContainers: Container[]
private docker: Dockerode
private envPrefix: string
private imagePrefix: string
private repo?: string
private get networkName() {
return `${this.envPrefix}${NETWORK_NAME_SUFFIX}`
}
private get blockchainName() {
return `${this.envPrefix}${BLOCKCHAIN_IMAGE_NAME_SUFFIX}`
}
private hostName(index: number) {
return `${this.envPrefix}${HOST_IMAGE_NAME_SUFFIX}-${index}`
}
private get clientName() {
return `${this.envPrefix}${CLIENT_IMAGE_NAME_SUFFIX}`
}
private codexImage(codexVersion: string) {
if (!this.repo) throw new TypeError('Repo has to be defined!')
return `${this.repo}/${this.imagePrefix}:${codexVersion}`
}
private runningContainers: Container[]
constructor(console: Logging, envPrefix: string, imagePrefix: string, repo?: string) {
this.docker = new Dockerode()
@ -104,6 +84,18 @@ export class Docker {
this.repo = repo
}
private get blockchainName() {
return `${this.envPrefix}${BLOCKCHAIN_IMAGE_NAME_SUFFIX}`
}
private get clientName() {
return `${this.envPrefix}${CLIENT_IMAGE_NAME_SUFFIX}`
}
private get networkName() {
return `${this.envPrefix}${NETWORK_NAME_SUFFIX}`
}
public async createNetwork(): Promise<void> {
const networks = await this.docker.listNetworks({ filters: { name: [this.networkName] } })
@ -112,164 +104,14 @@ export class Docker {
}
}
public async startBlockchainNode(blockchainImage: string, options: RunOptions): Promise<void> {
if (options.fresh) await this.removeContainer(this.blockchainName)
await this.pullImageIfNotFound(blockchainImage)
const container = await this.findOrCreateContainer(this.blockchainName, {
Image: blockchainImage,
name: this.blockchainName,
ExposedPorts: {
'8545/tcp': {},
},
AttachStderr: false,
AttachStdout: false,
HostConfig: {
PortBindings: { '8545/tcp': [{ HostPort: '8545' }] },
NetworkMode: this.networkName,
},
})
this.runningContainers.push(container)
const state = await container.inspect()
// If it is already running (because of whatever reason) we are not spawning new node
if (!state.State.Running) {
await container.start()
} else {
this.console.info('The blockchain container was already running, so not starting it again.')
}
}
public async startClientNode(codexVersion: string, options: RunOptions): Promise<void> {
if (options.fresh) await this.removeContainer(this.clientName)
await this.pullImageIfNotFound(this.codexImage(codexVersion))
const container = await this.findOrCreateContainer(this.clientName, {
Image: this.codexImage(codexVersion),
name: this.clientName,
ExposedPorts: {
'8070/tcp': {},
'8080/tcp': {},
'8090/udp': {},
},
Tty: true,
Cmd: ['codex', 'persistence'],
Env: this.createCodexEnvParameters(HARDHAT_ACCOUNTS[1], 0),
AttachStderr: false,
AttachStdout: false,
HostConfig: {
NetworkMode: this.networkName,
PortBindings: {
'8070/tcp': [{ HostPort: '8070' }],
'8080/tcp': [{ HostPort: '8080' }],
'8090/udp': [{ HostPort: '8090' }],
},
},
})
this.runningContainers.push(container)
const state = await container.inspect()
// If it is already running (because of whatever reason) we are not spawning new node.
// Already in `findOrCreateContainer` the container is verified that it was spawned with expected version.
if (!state.State.Running) {
await container.start()
} else {
this.console.info('The Client node container was already running, so not starting it again.')
}
}
public async startHostNode(
codexVersion: string,
hostNumber: number,
bootstrapAddress: string,
options: RunOptions,
): Promise<void> {
if (options.fresh) await this.removeContainer(this.hostName(hostNumber))
await this.pullImageIfNotFound(this.codexImage(codexVersion))
const container = await this.findOrCreateContainer(this.hostName(hostNumber), {
Image: this.codexImage(codexVersion),
name: this.hostName(hostNumber),
ExposedPorts: {
[`${8070 + hostNumber}/tcp`]: {},
[`${8080 + hostNumber}/tcp`]: {},
[`${8090 + hostNumber}/udp`]: {},
},
Env: this.createCodexEnvParameters(HARDHAT_ACCOUNTS[hostNumber + 1], hostNumber, bootstrapAddress),
Cmd: ['codex', 'persistence', 'prover'],
AttachStderr: false,
AttachStdout: false,
HostConfig: {
NetworkMode: this.networkName,
PortBindings: {
[`${8070 + hostNumber}/tcp`]: [{ HostPort: (8070 + hostNumber).toString() }],
[`${8080 + hostNumber}/tcp`]: [{ HostPort: (8080 + hostNumber).toString() }],
[`${8090 + hostNumber}/udp`]: [{ HostPort: (8090 + hostNumber).toString() }],
},
},
})
this.runningContainers.push(container)
const state = await container.inspect()
// If it is already running (because of whatever reason) we are not spawning new node
if (!state.State.Running) {
await container.start()
} else {
this.console.info('The client node container was already running, so not starting it again.')
}
}
public async logs(
target: ContainerType,
outputStream: NodeJS.WriteStream,
follow = false,
tail?: number,
): Promise<void> {
const { container } = await this.findContainer(this.getContainerName(target))
if (!container) {
throw new Error('Client container does not exists, even though it should have had!')
}
const logs = await container.logs({ stdout: true, stderr: true, follow, tail })
if (!follow) {
outputStream.write(logs as unknown as Buffer)
} else {
logs.pipe(outputStream)
}
}
public async stopAll(allWithPrefix = false, deleteContainers = false): Promise<void> {
const containerProcessor = async (container: Container) => {
try {
await container.stop()
} catch (e) {
// We ignore 304 that represents that the container is already stopped
if ((e as DockerError).statusCode !== 304) {
throw e
}
}
if (deleteContainers) {
await container.remove()
}
}
this.console.info('Stopping all containers')
await Promise.all(this.runningContainers.map(containerProcessor))
if (allWithPrefix) {
const containers = await this.docker.listContainers({ all: true })
await Promise.all(
containers
.filter(container => container.Names.filter(n => n.startsWith('/' + this.envPrefix)).length >= 1)
.map(container => this.docker.getContainer(container.Id))
.map(containerProcessor),
)
public async getAllStatus(): Promise<AllStatus> {
return {
blockchain: await this.getStatusForContainer(ContainerType.BLOCKCHAIN),
client: await this.getStatusForContainer(ContainerType.CLIENT),
host: await this.getStatusForContainer(ContainerType.HOST),
host_2: await this.getStatusForContainer(ContainerType.HOST_2),
host_3: await this.getStatusForContainer(ContainerType.HOST_3),
host_4: await this.getStatusForContainer(ContainerType.HOST_4),
}
}
@ -287,27 +129,236 @@ export class Docker {
return blockchainImage
}
public async getAllStatus(): Promise<AllStatus> {
return {
client: await this.getStatusForContainer(ContainerType.CLIENT),
blockchain: await this.getStatusForContainer(ContainerType.BLOCKCHAIN),
host: await this.getStatusForContainer(ContainerType.HOST),
host_2: await this.getStatusForContainer(ContainerType.HOST_2),
host_3: await this.getStatusForContainer(ContainerType.HOST_3),
host_4: await this.getStatusForContainer(ContainerType.HOST_4),
public async getStatusForContainer(name: ContainerType): Promise<Status> {
const foundContainer = await this.findContainer(this.getContainerName(name))
if (!foundContainer.container) {
return 'not-found'
}
const inspectStatus = await foundContainer.container.inspect()
if (inspectStatus.State.Running) {
return 'running'
}
return 'exists'
}
public async logs(
target: ContainerType,
outputStream: NodeJS.WriteStream,
follow = false,
tail?: number,
): Promise<void> {
const { container } = await this.findContainer(this.getContainerName(target))
if (!container) {
throw new Error('Client container does not exists, even though it should have had!')
}
// @ts-expect-error: Follow is not in typings
const logs = await container.logs({ follow, stderr: true, stdout: true, tail })
if (follow) {
// @ts-expect-error: Pipe not defined
logs.pipe(outputStream)
} else {
outputStream.write(logs as unknown as Buffer)
}
}
private async removeContainer(name: string): Promise<void> {
this.console.info(`Removing container with name "${name}"`)
const { container } = await this.findContainer(name)
public async startBlockchainNode(blockchainImage: string, options: RunOptions): Promise<void> {
if (options.fresh) await this.removeContainer(this.blockchainName)
await this.pullImageIfNotFound(blockchainImage)
// Container does not exist so nothing to delete
if (!container) {
return
const container = await this.findOrCreateContainer(this.blockchainName, {
AttachStderr: false,
AttachStdout: false,
ExposedPorts: {
'8545/tcp': {},
},
HostConfig: {
NetworkMode: this.networkName,
PortBindings: { '8545/tcp': [{ HostPort: '8545' }] },
},
Image: blockchainImage,
name: this.blockchainName,
})
this.runningContainers.push(container)
const state = await container.inspect()
// If it is already running (because of whatever reason) we are not spawning new node
if (state.State.Running) {
this.console.info('The blockchain container was already running, so not starting it again.')
} else {
await container.start()
}
}
public async startClientNode(codexVersion: string, options: RunOptions): Promise<void> {
if (options.fresh) await this.removeContainer(this.clientName)
await this.pullImageIfNotFound(this.codexImage(codexVersion))
const container = await this.findOrCreateContainer(this.clientName, {
AttachStderr: false,
AttachStdout: false,
Cmd: ['codex', 'persistence'],
Env: this.createCodexEnvParameters(HARDHAT_ACCOUNTS[1], 0),
ExposedPorts: {
'8070/tcp': {},
'8080/tcp': {},
'8090/udp': {},
},
HostConfig: {
NetworkMode: this.networkName,
PortBindings: {
'8070/tcp': [{ HostPort: '8070' }],
'8080/tcp': [{ HostPort: '8080' }],
'8090/udp': [{ HostPort: '8090' }],
},
},
Image: this.codexImage(codexVersion),
name: this.clientName,
Tty: true,
})
this.runningContainers.push(container)
const state = await container.inspect()
// If it is already running (because of whatever reason) we are not spawning new node.
// Already in `findOrCreateContainer` the container is verified that it was spawned with expected version.
if (state.State.Running) {
this.console.info('The Client node container was already running, so not starting it again.')
} else {
await container.start()
}
}
public async startHostNode(
codexVersion: string,
hostNumber: number,
bootstrapAddress: string,
options: RunOptions,
): Promise<void> {
if (options.fresh) await this.removeContainer(this.hostName(hostNumber))
await this.pullImageIfNotFound(this.codexImage(codexVersion))
const container = await this.findOrCreateContainer(this.hostName(hostNumber), {
AttachStderr: false,
AttachStdout: false,
Cmd: ['codex', 'persistence', 'prover'],
Env: this.createCodexEnvParameters(HARDHAT_ACCOUNTS[hostNumber + 1], hostNumber, bootstrapAddress),
ExposedPorts: {
[`${8070 + hostNumber}/tcp`]: {},
[`${8080 + hostNumber}/tcp`]: {},
[`${8090 + hostNumber}/udp`]: {},
},
HostConfig: {
NetworkMode: this.networkName,
PortBindings: {
[`${8070 + hostNumber}/tcp`]: [{ HostPort: (8070 + hostNumber).toString() }],
[`${8080 + hostNumber}/tcp`]: [{ HostPort: (8080 + hostNumber).toString() }],
[`${8090 + hostNumber}/udp`]: [{ HostPort: (8090 + hostNumber).toString() }],
},
},
Image: this.codexImage(codexVersion),
name: this.hostName(hostNumber),
})
this.runningContainers.push(container)
const state = await container.inspect()
// If it is already running (because of whatever reason) we are not spawning new node
if (state.State.Running) {
this.console.info('The client node container was already running, so not starting it again.')
} else {
await container.start()
}
}
public async stopAll(allWithPrefix = false, deleteContainers = false): Promise<void> {
const containerProcessor = async (container: Container) => {
try {
await container.stop()
} catch (error) {
// We ignore 304 that represents that the container is already stopped
if ((error as DockerError).statusCode !== 304) {
throw error
}
}
if (deleteContainers) {
await container.remove()
}
}
await container.remove({ v: true, force: true })
this.console.info('Stopping all containers')
await Promise.all(this.runningContainers.map((element) => containerProcessor(element)))
if (allWithPrefix) {
const containers = await this.docker.listContainers({ all: true })
await Promise.all(
containers
.filter(container => container.Names.some(n => n.startsWith('/' + this.envPrefix)))
.map(container => this.docker.getContainer(container.Id))
.map((element) => containerProcessor(element)),
)
}
}
private codexImage(codexVersion: string) {
if (!this.repo) throw new TypeError('Repo has to be defined!')
return `${this.repo}/${this.imagePrefix}:${codexVersion}`
}
private createCodexEnvParameters(ethAccount: string, portIndex: number, bootnode?: string): string[] {
const options: Record<string, string> = {
'api-bindaddr': '0.0.0.0',
'api-cors-origin': '*',
'api-port': `${8080 + portIndex}`,
'disc-port': `${8090 + portIndex}`,
'eth-account': ethAccount,
'eth-provider': `http://${this.blockchainName}:8545`,
'listen-addrs': `/ip4/0.0.0.0/tcp/${8070 + portIndex}`,
'log-level': 'NOTICE; TRACE: marketplace,sales,node,restapi',
'marketplace-address': '0x322813Fd9A801c5507c9de605d63CEA4f2CE6c44',
validator: 'true',
'validator-max-slots': '1000',
}
// Env variables for Codex has form of `CODEX_LOG_LEVEL`, so we need to transform it.
// eslint-disable-next-line unicorn/no-array-reduce
const envVariables = Object.entries(options).reduce<string[]>((previous, current) => {
const keyName = `CODEX_${current[0].toUpperCase().replaceAll('-', '_')}`
previous.push(`${keyName}=${current[1]}`)
return previous
}, [])
if (bootnode) {
envVariables.push(`BOOTSTRAP_NODE_URL=${bootnode}:8080`)
}
envVariables.push('NAT_IP_AUTO=true')
return envVariables
}
private async findContainer(name: string): Promise<FindResult> {
const containers = await this.docker.listContainers({ all: true, filters: { name: [name] } })
if (containers.length === 0) {
return {}
}
if (containers.length > 1) {
throw new Error(`Found ${containers.length} containers for name "${name}". Expected only one.`)
}
return { container: this.docker.getContainer(containers[0].Id), image: containers[0].Image }
}
private async findOrCreateContainer(name: string, createOptions: ContainerCreateOptions): Promise<Container> {
@ -331,108 +382,75 @@ export class Docker {
try {
return await this.docker.createContainer(createOptions)
} catch (e) {
} catch (error) {
// 404 is Image Not Found ==> pull the image
if ((e as DockerError).statusCode !== 404) {
throw e
if ((error as DockerError).statusCode !== 404) {
throw error
}
this.console.info(`Image ${createOptions.Image} not found. Pulling it.`)
await this.pullImageIfNotFound(createOptions.Image!)
return await this.docker.createContainer(createOptions)
return this.docker.createContainer(createOptions)
}
}
private async findContainer(name: string): Promise<FindResult> {
const containers = await this.docker.listContainers({ all: true, filters: { name: [name] } })
if (containers.length === 0) {
return {}
}
if (containers.length > 1) {
throw new Error(`Found ${containers.length} containers for name "${name}". Expected only one.`)
}
return { container: this.docker.getContainer(containers[0].Id), image: containers[0].Image }
}
public async getStatusForContainer(name: ContainerType): Promise<Status> {
const foundContainer = await this.findContainer(this.getContainerName(name))
if (!foundContainer.container) {
return 'not-found'
}
const inspectStatus = await foundContainer.container.inspect()
if (inspectStatus.State.Running) {
return 'running'
}
return 'exists'
}
private getContainerName(name: ContainerType) {
switch (name) {
case ContainerType.BLOCKCHAIN:
case ContainerType.BLOCKCHAIN: {
return this.blockchainName
case ContainerType.CLIENT:
}
case ContainerType.CLIENT: {
return this.clientName
case ContainerType.HOST:
}
case ContainerType.HOST: {
return this.hostName(1)
case ContainerType.HOST_2:
}
case ContainerType.HOST_2: {
return this.hostName(2)
case ContainerType.HOST_3:
}
case ContainerType.HOST_3: {
return this.hostName(3)
case ContainerType.HOST_4:
}
case ContainerType.HOST_4: {
return this.hostName(4)
default:
}
default: {
throw new Error('Unknown container!')
}
}
}
private createCodexEnvParameters(ethAccount: string, portIndex: number, bootnode?: string): string[] {
const options: Record<string, string> = {
'eth-provider': `http://${this.blockchainName}:8545`,
'eth-account': ethAccount,
'disc-port': `${8090 + portIndex}`,
'listen-addrs': `/ip4/0.0.0.0/tcp/${8070 + portIndex}`,
'api-port': `${8080 + portIndex}`,
'api-bindaddr': '0.0.0.0',
'api-cors-origin': '*',
validator: 'true',
'validator-max-slots': '1000',
'marketplace-address': '0x322813Fd9A801c5507c9de605d63CEA4f2CE6c44',
'log-level': 'NOTICE; TRACE: marketplace,sales,node,restapi',
}
// Env variables for Codex has form of `CODEX_LOG_LEVEL`, so we need to transform it.
const envVariables = Object.entries(options).reduce<string[]>((previous, current) => {
const keyName = `CODEX_${current[0].toUpperCase().replace(/-/g, '_')}`
previous.push(`${keyName}=${current[1]}`)
return previous
}, [])
if (bootnode) {
envVariables.push(`BOOTSTRAP_NODE_URL=${bootnode}:8080`)
}
envVariables.push('NAT_IP_AUTO=true')
return envVariables
private hostName(index: number) {
return `${this.envPrefix}${HOST_IMAGE_NAME_SUFFIX}-${index}`
}
private async pullImageIfNotFound(name: string): Promise<void> {
try {
await this.docker.getImage(name).inspect()
} catch (e) {
} catch {
this.console.info(`Image ${name} not found locally, pulling it.`)
const pullStream = await this.docker.pull(name)
await new Promise(res => this.docker.modem.followProgress(pullStream, res))
await new Promise(res => {this.docker.modem.followProgress(pullStream, res)})
}
}
private async removeContainer(name: string): Promise<void> {
this.console.info(`Removing container with name "${name}"`)
const { container } = await this.findContainer(name)
// Container does not exist so nothing to delete
if (!container) {
return
}
await container.remove({ force: true, v: true })
}
}

View File

@ -4,5 +4,5 @@
* @param ms Number of miliseconds to sleep
*/
export async function sleep(ms: number): Promise<void> {
return new Promise<void>(resolve => setTimeout(() => resolve(), ms))
return new Promise<void>(resolve => {setTimeout(() => resolve(), ms)})
}

View File

@ -1,4 +1,25 @@
import { Printer } from './printer'
import chalk from 'chalk'
const Printer = {
dimFunction(message: string, ...args: unknown[]): void {
console.log(chalk.dim(message), ...args)
},
divider(char = '-'): void {
console.log(char.repeat(process.stdout.columns))
},
emptyFunction(): void {
},
error(message: string, ...args: unknown[]): void {
console.error(chalk.red(message), ...args)
},
info(message: string, ...args: unknown[]): void {
console.log(chalk.dim(message), ...args)
},
log(message: string, ...args: unknown[]): void {
console.log(message, ...args)
},
}
export enum VerbosityLevel {
/** No output message, only at errors or result strings (e.g. hash of uploaded file) */
@ -12,34 +33,38 @@ export enum VerbosityLevel {
type PrinterFnc = (message: string, ...args: unknown[]) => void
export class Logging {
public readonly verbosityLevel: VerbosityLevel
// Callable logging functions (instead of console.log)
/** Error messages */
public error: PrinterFnc
// Callable logging functions (instead of console.log)
/** Informal messages (e.g. Tips) */
public info: PrinterFnc
/** Identical with console.log */
public log: PrinterFnc
/** Informal messages (e.g. Tips) */
public info: PrinterFnc
public readonly verbosityLevel: VerbosityLevel
constructor(verbosityLevel: VerbosityLevel) {
this.verbosityLevel = verbosityLevel
switch (verbosityLevel) {
case VerbosityLevel.Verbose:
case VerbosityLevel.Normal: {
this.error = Printer.error
this.log = Printer.log
this.info = Printer.emptyFunction
break
}
case VerbosityLevel.Verbose: {
this.error = Printer.error
this.log = Printer.log
this.info = Printer.info
break
case VerbosityLevel.Normal:
this.error = Printer.error
this.log = Printer.log
this.info = Printer.emptyFunction
break
default:
}
default: {
// quiet
this.error = Printer.error
this.log = Printer.emptyFunction
this.info = Printer.emptyFunction
}
}
}
}

View File

@ -1,19 +1,20 @@
import fetch, { FetchError } from 'node-fetch'
import { sleep } from './index'
import { TimeoutError } from './error'
import { AllStatus } from './docker'
/* eslint-disable no-await-in-loop */
import { Codex } from '@codex-storage/sdk-js'
import fetch, { FetchError } from 'node-fetch'
const AWAIT_SLEEP = 3_000
import { AllStatus } from './docker.js'
import { TimeoutError } from './error.js'
import { sleep } from './index.js'
const BLOCKCHAIN_BODY_REQUEST = JSON.stringify({ jsonrpc: '2.0', method: 'eth_chainId', id: 1 })
const AWAIT_SLEEP = 3000
const BLOCKCHAIN_BODY_REQUEST = JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' })
const EXPECTED_CHAIN_ID = '0x7a69'
const ALLOWED_ERRORS = ['ECONNREFUSED', 'ECONNRESET', 'UND_ERR_SOCKET']
function isAllowedError(e: FetchError): boolean {
//@ts-ignore: Node 18 native fetch returns error where the underlying error is wrapped and placed in e.cause
if (e.cause) {
//@ts-ignore: Node 18 native fetch returns error where the underlying error is wrapped and placed in e.cause
// @ts-expect-error: Node 18 native fetch returns error where the underlying error is wrapped and placed in e.cause
e = e.cause
}
@ -35,27 +36,28 @@ function extractIpFromMultiaddr(multiaddr: string): string {
if (match) {
return match[1]
} else {
throw new Error('Unsupported multiaddr')
}
throw new Error('Unsupported multiaddr')
}
export async function waitForBlockchain(waitingIterations = 30): Promise<void> {
for (let i = 0; i < waitingIterations; i++) {
try {
const request = await fetch('http://127.0.0.1:8545', {
method: 'POST',
body: BLOCKCHAIN_BODY_REQUEST,
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
const response = (await request.json()) as { result: string }
if (response.result === EXPECTED_CHAIN_ID) {
return
}
} catch (e) {
if (!isAllowedError(e as FetchError)) {
throw e
} catch (error) {
if (!isAllowedError(error as FetchError)) {
throw error
}
}
@ -90,9 +92,9 @@ export async function waitForClient(
return extractIpFromMultiaddr(addr)
}
}
} catch (e) {
if (!isAllowedError(e as FetchError)) {
throw e
} catch (error) {
if (!isAllowedError(error as FetchError)) {
throw error
}
}
@ -114,6 +116,7 @@ export async function waitForHosts(
if (status[`host` as keyof AllStatus] !== 'running') {
throw new Error('Some of the hosts node is not running!')
}
for (let i = 2; i <= hostCount; i++) {
if (status[`host_${i}` as keyof AllStatus] !== 'running') {
throw new Error('Some of the hosts node is not running!')
@ -131,9 +134,9 @@ export async function waitForHosts(
if (info.data.table.nodes.length >= hostCount) {
return
}
} catch (e) {
if (!isAllowedError(e as FetchError)) {
throw e
} catch (error) {
if (!isAllowedError(error as FetchError)) {
throw error
}
}

182
test/commands/start.test.ts Normal file
View File

@ -0,0 +1,182 @@
import { Codex } from '@codex-storage/sdk-js'
import { runCommand } from '@oclif/test'
import { use as chaiUse, expect } from 'chai'
import chaiAsPromised from 'chai-as-promised'
import Dockerode from 'dockerode'
import { randomBytes } from 'node:crypto'
import { ENV_ENV_PREFIX_KEY } from '../../src/commands/start'
import { DockerError } from '../../src/utils/docker'
import { deleteNetwork, findContainer } from '../utils/docker'
chaiUse(chaiAsPromised)
let testFailed = false
function wrapper (fn: () => Promise<unknown>): () => Promise<unknown> {
return async () => {
try {
const result = await fn()
testFailed = false
return result
} catch (error) {
testFailed = true
throw error
}
}
}
process.on('SIGINT', async () => {
try {
console.log('SIGINT received, stopping the cluster')
if (process.env[ENV_ENV_PREFIX_KEY]) {
console.log('Cleaning up containers')
await runCommand('stop --rm') // Cleanup the testing containers
}
} catch (error) {
console.error(`Error: ${error}`)
}
// eslint-disable-next-line n/no-process-exit
process.exit()
})
describe('start command', () => {
let docker: Dockerode
let codexClient: Codex
const envPrefix = `codex-factory-test-${randomBytes(4).toString('hex')}`
before(() => {
docker = new Dockerode()
codexClient = new Codex('http://127.0.0.1:8080')
// This will force Codex Factory to create fresh images
process.env[ENV_ENV_PREFIX_KEY] = envPrefix
})
afterEach(async () => {
if (testFailed) {
console.log('List of containers:')
const containers = await docker.listContainers()
for (const c of containers) console.log(` - ${c.Names.join(', ')}`)
await runCommand('logs client')
}
await runCommand('stop')
})
after(async () => {
await runCommand('stop --rm') // Cleanup the testing containers
await deleteNetwork(docker, `${envPrefix}-network`)
})
it(
'should start cluster',
wrapper(async () => {
// As spinning the cluster with --detach the command will exit once the cluster is up and running
await runCommand('start --detach')
await expect(findContainer(docker, 'client')).to.eventually.be.not.undefined
await expect(findContainer(docker, 'blockchain')).to.eventually.be.not.undefined
await expect(findContainer(docker, 'host-1')).to.eventually.be.not.undefined
await expect(findContainer(docker, 'host-2')).to.eventually.be.not.undefined
await expect(findContainer(docker, 'host-3')).to.eventually.be.not.undefined
await expect(findContainer(docker, 'host-4')).to.eventually.be.not.undefined
expect((await codexClient.debug.info()).data).to.have.property('id')
})
)
describe('should start cluster with just few hosts', () => {
before(async () => {
await runCommand('stop --rm') // Cleanup the testing containers
})
it(
'test',
wrapper(async () => {
// As spinning the cluster with --detach the command will exit once the cluster is up and running
await runCommand('start --hosts 2')
await expect(findContainer(docker, 'client')).to.eventually.be.not.undefined
await expect(findContainer(docker, 'blockchain')).to.eventually.be.not.undefined
await expect(findContainer(docker, 'host-1')).to.eventually.be.not.undefined
await expect(findContainer(docker, 'host-2')).to.eventually.be.not.undefined
await expect(findContainer(docker, 'host-3')).to.be.rejected
await expect(findContainer(docker, 'host-4')).to.be.rejected
expect((await codexClient.debug.info()).data).to.have.property('id')
})
)
})
describe('should create docker network', () => {
before(async () => {
await runCommand('stop --rm') // Cleanup the testing containers
try {
// Make sure the network does not exists
await (await docker.getNetwork(`${envPrefix}-network`)).remove()
} catch (error) {
if ((error as DockerError).statusCode !== 404) {
throw error
}
}
})
it(
'test',
wrapper(async () => {
await runCommand('start --detach')
expect(docker.getNetwork(`${envPrefix}-network`)).to.be.not.undefined
})
)
})
describe('should remove containers with --fresh option', () => {
let availabilityId: string
before(async () => {
console.log('(before) Starting up Codex Factory')
await runCommand('start --detach')
const result = await codexClient.marketplace.createAvailability({
duration: 100,
minPricePerBytePerSecond: 100,
totalCollateral: 1,
totalSize: 3000
})
if (result.error) {
throw result.data
}
availabilityId = result.data.id
console.log('(before) Stopping the Codex Factory')
await runCommand('stop') // Cleanup the testing containers
})
it(
'test',
wrapper(async () => {
console.log('(test) Starting the Codex Factory')
await runCommand('start --fresh --detach')
console.log('(test) Trying to fetch the data')
const availabilities = await codexClient.marketplace.availabilities()
if (availabilities.error) {
throw availabilities.data
}
if (availabilities.data.some(({ id }) => id === availabilityId)) {
throw new Error('Availability was not removed!')
}
})
)
})
})

View File

@ -0,0 +1,92 @@
import { runCommand } from '@oclif/test'
import { use as chaiUse, expect } from 'chai'
import chaiAsPromised from 'chai-as-promised'
import Dockerode from 'dockerode'
import {randomBytes} from 'node:crypto'
import { ENV_ENV_PREFIX_KEY } from '../../src/commands/start'
import { deleteNetwork, findContainer } from '../utils/docker'
chaiUse(chaiAsPromised)
describe('stop command', () => {
let docker: Dockerode
const envPrefix = `codex-factory-test-${randomBytes(4).toString('hex')}`
before(() => {
docker = new Dockerode()
// This will force Codex Factory to create fresh images
process.env[ENV_ENV_PREFIX_KEY] = envPrefix
})
after(async () => {
await runCommand('stop --rm') // Cleanup the testing containers
await deleteNetwork(docker, `${envPrefix}-network`)
})
describe('should stop cluster', () => {
before(async () => {
// As spinning the cluster with --detach the command will exit once the cluster is up and running
await runCommand('start --detach')
})
it('test', async () => {
await expect(findContainer(docker, 'client')).to.eventually.have.nested.property('State.Status', 'running')
await expect(findContainer(docker, 'blockchain')).to.eventually.have.nested.property('State.Status', 'running')
await expect(findContainer(docker, 'host-1')).to.eventually.have.nested.property('State.Status', 'running')
await expect(findContainer(docker, 'host-2')).to.eventually.have.nested.property('State.Status', 'running')
await expect(findContainer(docker, 'host-3')).to.eventually.have.nested.property('State.Status', 'running')
await expect(findContainer(docker, 'host-4')).to.eventually.have.nested.property('State.Status', 'running')
await runCommand('stop') // Cleanup the testing containers
await expect(findContainer(docker, 'client')).to.eventually.have.nested.property('State.Status', 'exited')
await expect(findContainer(docker, 'blockchain')).to.eventually.have.nested.property('State.Status', 'exited')
await expect(findContainer(docker, 'host-1')).to.eventually.have.nested.property('State.Status', 'exited')
await expect(findContainer(docker, 'host-2')).to.eventually.have.nested.property('State.Status', 'exited')
await expect(findContainer(docker, 'host-3')).to.eventually.have.nested.property('State.Status', 'exited')
await expect(findContainer(docker, 'host-4')).to.eventually.have.nested.property('State.Status', 'exited')
})
})
describe('should stop cluster and remove containers', () => {
before(async () => {
// As spinning the cluster with --detach the command will exit once the cluster is up and running
await runCommand('start --detach')
})
it('test', async () => {
await expect(findContainer(docker, 'client')).to.eventually.have.nested.property('State.Status', 'running')
await expect(findContainer(docker, 'blockchain')).to.eventually.have.nested.property('State.Status', 'running')
await expect(findContainer(docker, 'host-1')).to.eventually.have.nested.property('State.Status', 'running')
await expect(findContainer(docker, 'host-2')).to.eventually.have.nested.property('State.Status', 'running')
await expect(findContainer(docker, 'host-3')).to.eventually.have.nested.property('State.Status', 'running')
await expect(findContainer(docker, 'host-4')).to.eventually.have.nested.property('State.Status', 'running')
await runCommand('stop --rm') // Cleanup the testing containers
await expect(findContainer(docker, 'client')).to.be.rejected
await expect(findContainer(docker, 'blockchain')).to.be.rejected
await expect(findContainer(docker, 'host-1')).to.be.rejected
await expect(findContainer(docker, 'host-2')).to.be.rejected
await expect(findContainer(docker, 'host-3')).to.be.rejected
await expect(findContainer(docker, 'host-4')).to.be.rejected
})
})
})
process.on('SIGINT', async () => {
try {
if (process.env[ENV_ENV_PREFIX_KEY]) {
console.log('Cleaning up containers')
await runCommand('stop --rm') // Cleanup the testing containers
}
} catch (error) {
console.error(`Error: ${error}`)
}
// eslint-disable-next-line n/no-process-exit
process.exit()
})

View File

@ -1,162 +0,0 @@
/* eslint-disable no-console */
import Dockerode from 'dockerode'
import crypto from 'crypto'
import { Codex } from '@codex-storage/sdk-js'
import { run } from '../utils/run'
import { ENV_ENV_PREFIX_KEY } from '../../src/command/start'
import { DockerError } from '../../src/utils/docker'
import { findContainer } from '../utils/docker'
let testFailed = false
function wrapper(fn: () => Promise<unknown>): () => Promise<unknown> {
return async () => {
try {
const result = await fn()
testFailed = false
return result
} catch (e) {
testFailed = true
throw e
}
}
}
describe('start command', () => {
let docker: Dockerode
let codexClient: Codex, codexHost: Codex
const envPrefix = `codex-factory-test-${crypto.randomBytes(4).toString('hex')}`
beforeAll(() => {
docker = new Dockerode()
codexClient = new Codex('http://127.0.0.1:8080')
codexHost = new Codex('http://127.0.0.1:8081')
// This will force Codex Factory to create
process.env[ENV_ENV_PREFIX_KEY] = envPrefix
})
afterEach(async () => {
if (testFailed) {
console.log('List of containers:')
const containers = await docker.listContainers()
containers.forEach(c => console.log(` - ${c.Names.join(', ')}`))
await run(['logs', 'client'])
}
await run(['stop'])
})
afterAll(async () => {
await run(['stop', '--rm']) // Cleanup the testing containers
})
it(
'should start cluster',
wrapper(async () => {
// As spinning the cluster with --detach the command will exit once the cluster is up and running
await run(['start', '--detach'])
await expect(findContainer(docker, 'client')).resolves.toBeDefined()
await expect(findContainer(docker, 'blockchain')).resolves.toBeDefined()
await expect(findContainer(docker, 'host-1')).resolves.toBeDefined()
await expect(findContainer(docker, 'host-2')).resolves.toBeDefined()
await expect(findContainer(docker, 'host-3')).resolves.toBeDefined()
await expect(findContainer(docker, 'host-4')).resolves.toBeDefined()
expect((await codexClient.debug.info()).data).toHaveProperty('id')
}),
)
describe('should start cluster with just few hosts', () => {
beforeAll(async () => {
await run(['stop', '--rm']) // Cleanup the testing containers
})
it(
'',
wrapper(async () => {
// As spinning the cluster with --detach the command will exit once the cluster is up and running
await run(['start', '--hosts', '2'])
await expect(findContainer(docker, 'client')).resolves.toBeDefined()
await expect(findContainer(docker, 'blockchain')).resolves.toBeDefined()
await expect(findContainer(docker, 'host-1')).resolves.toBeDefined()
await expect(findContainer(docker, 'host-2')).resolves.toBeDefined()
await expect(findContainer(docker, 'host-3')).rejects.toHaveProperty('statusCode', 404)
await expect(findContainer(docker, 'host-4')).rejects.toHaveProperty('statusCode', 404)
expect((await codexClient.debug.info()).data).toHaveProperty('id')
}),
)
})
describe('should create docker network', () => {
beforeAll(async () => {
await run(['stop', '--rm']) // Cleanup the testing containers
try {
// Make sure the network does not exists
await (await docker.getNetwork(`${envPrefix}-network`)).remove()
} catch (e) {
if ((e as DockerError).statusCode !== 404) {
throw e
}
}
})
it(
'',
wrapper(async () => {
await run(['start', '--detach'])
expect(docker.getNetwork(`${envPrefix}-network`)).toBeDefined()
}),
)
})
describe('should remove containers with --fresh option', () => {
let availabilityId: string
beforeAll(async () => {
console.log('(before) Starting up Codex Factory')
await run(['start', '--detach'])
const result = await codexClient.marketplace.createAvailability({
totalCollateral: 1,
totalSize: 3000,
minPricePerBytePerSecond: 100,
duration: 100,
})
if (result.error) {
throw result.data
}
availabilityId = result.data.id
console.log('(before) Stopping the Codex Factory')
await run(['stop'])
})
it(
'',
wrapper(async () => {
console.log('(test) Starting the Codex Factory')
await run(['start', '--fresh', '--detach'])
console.log('(test) Trying to fetch the data')
expect((await codexClient.marketplace.availabilities()).data).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.stringContaining(availabilityId),
}),
]),
)
}),
)
})
})

View File

@ -1,73 +0,0 @@
/* eslint-disable no-console */
import Dockerode from 'dockerode'
import crypto from 'crypto'
import { run } from '../utils/run'
import { ENV_ENV_PREFIX_KEY } from '../../src/command/start'
import { findContainer } from '../utils/docker'
describe('stop command', () => {
let docker: Dockerode
const envPrefix = `codex-factory-test-${crypto.randomBytes(4).toString('hex')}`
beforeAll(() => {
docker = new Dockerode()
// This will force Codex Factory to create
process.env[ENV_ENV_PREFIX_KEY] = envPrefix
})
afterAll(async () => {
await run(['stop', '--rm']) // Cleanup the testing containers
})
describe('should stop cluster', () => {
beforeAll(async () => {
// As spinning the cluster with --detach the command will exit once the cluster is up and running
await run(['start', '--detach'])
})
it('', async () => {
await expect(findContainer(docker, 'client')).resolves.toHaveProperty('State.Status', 'running')
await expect(findContainer(docker, 'blockchain')).resolves.toHaveProperty('State.Status', 'running')
await expect(findContainer(docker, 'host-1')).resolves.toHaveProperty('State.Status', 'running')
await expect(findContainer(docker, 'host-2')).resolves.toHaveProperty('State.Status', 'running')
await expect(findContainer(docker, 'host-3')).resolves.toHaveProperty('State.Status', 'running')
await expect(findContainer(docker, 'host-4')).resolves.toHaveProperty('State.Status', 'running')
await run(['stop'])
await expect(findContainer(docker, 'client')).resolves.toHaveProperty('State.Status', 'exited')
await expect(findContainer(docker, 'blockchain')).resolves.toHaveProperty('State.Status', 'exited')
await expect(findContainer(docker, 'host-1')).resolves.toHaveProperty('State.Status', 'exited')
await expect(findContainer(docker, 'host-2')).resolves.toHaveProperty('State.Status', 'exited')
await expect(findContainer(docker, 'host-3')).resolves.toHaveProperty('State.Status', 'exited')
await expect(findContainer(docker, 'host-4')).resolves.toHaveProperty('State.Status', 'exited')
})
})
describe('should stop cluster and remove containers', () => {
beforeAll(async () => {
// As spinning the cluster with --detach the command will exit once the cluster is up and running
await run(['start', '--detach'])
})
it('', async () => {
await expect(findContainer(docker, 'client')).resolves.toHaveProperty('State.Status', 'running')
await expect(findContainer(docker, 'blockchain')).resolves.toHaveProperty('State.Status', 'running')
await expect(findContainer(docker, 'host-1')).resolves.toHaveProperty('State.Status', 'running')
await expect(findContainer(docker, 'host-2')).resolves.toHaveProperty('State.Status', 'running')
await expect(findContainer(docker, 'host-3')).resolves.toHaveProperty('State.Status', 'running')
await expect(findContainer(docker, 'host-4')).resolves.toHaveProperty('State.Status', 'running')
await run(['stop', '--rm'])
await expect(findContainer(docker, 'client')).rejects.toHaveProperty('statusCode', 404)
await expect(findContainer(docker, 'blockchain')).rejects.toHaveProperty('statusCode', 404)
await expect(findContainer(docker, 'host-1')).rejects.toHaveProperty('statusCode', 404)
await expect(findContainer(docker, 'host-2')).rejects.toHaveProperty('statusCode', 404)
await expect(findContainer(docker, 'host-3')).rejects.toHaveProperty('statusCode', 404)
await expect(findContainer(docker, 'host-4')).rejects.toHaveProperty('statusCode', 404)
})
})
})

9
test/tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"references": [
{"path": "../../codex-factory-new"}
]
}

View File

@ -1,10 +1,18 @@
import Dockerode from 'dockerode'
import { ENV_ENV_PREFIX_KEY } from '../../src/command/start'
import * as Dockerode from 'dockerode'
import { ENV_ENV_PREFIX_KEY } from '../../src/commands/start'
export async function findContainer(docker: Dockerode, name: string): Promise<Dockerode.ContainerInspectInfo> {
return docker.getContainer(`${process.env[ENV_ENV_PREFIX_KEY]}-${name}`).inspect()
}
export async function sleep(ms: number): Promise<void> {
return new Promise<void>(resolve => setTimeout(() => resolve(), ms))
export async function deleteNetwork(docker: Dockerode, name: string): Promise<void> {
const network = (await docker.listNetworks()).find(n => n.Name === name)
if (network) {
await docker.getNetwork(network.Id).remove()
}
}
export async function sleep(ms: number): Promise<void> {
return new Promise<void>(resolve => {setTimeout(() => resolve(), ms)})
}

View File

@ -1,12 +0,0 @@
import { cli } from 'furious-commander'
import { optionParameters, rootCommandClasses } from '../../src/config'
export async function run(argv: string[]): ReturnType<typeof cli> {
const commandBuilder = await cli({
rootCommandClasses,
optionParameters,
testArguments: argv,
})
return commandBuilder
}

View File

@ -1,20 +1,15 @@
{
"include": ["src"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"alwaysStrict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"declaration": true,
"module": "Node16",
"outDir": "./dist",
"rootDir": "src",
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"typeRoots": ["./src/types", "node_modules/@types"],
"target": "es2015",
"rootDirs": ["src"],
"module": "commonjs",
"outDir": "dist",
"resolveJsonModule": true,
"useDefineForClassFields": true
"target": "es2022",
"moduleResolution": "node16"
},
"include": ["./src/**/*"],
"ts-node": {
"esm": true
}
}

View File

@ -1,11 +0,0 @@
{
"extends": "./tsconfig.json",
"include": [
"src",
"test",
"jest.config.ts"
],
"compilerOptions": {
"noEmit": true
}
}