diff --git a/.github/workflows/python_libraries_ci.yaml b/.github/workflows/python_libraries_ci.yaml index 365a151..21c3570 100644 --- a/.github/workflows/python_libraries_ci.yaml +++ b/.github/workflows/python_libraries_ci.yaml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - library: [ "infernet_client", "infernet_ml", "ritual_arweave" ] + library: [ "infernet_cli", "infernet_client", "infernet_ml", "ritual_arweave" ] python-version: [ "3.10", "3.11" ] fail-fast: false diff --git a/infernet_services/requirements-e2e-tests.lock b/infernet_services/requirements-e2e-tests.lock index c69fdbb..c167dfd 100644 --- a/infernet_services/requirements-e2e-tests.lock +++ b/infernet_services/requirements-e2e-tests.lock @@ -40,7 +40,7 @@ humanfriendly==10.0 hypercorn==0.17.3 hyperframe==6.0.1 idna==3.10 -infernet-client==1.0.3.9 +infernet-client==1.4.0 infernet-ml==2.0.0 iniconfig==2.0.0 itsdangerous==2.2.0 diff --git a/infernet_services/requirements-e2e-tests.txt b/infernet_services/requirements-e2e-tests.txt index c2eb8a3..d8d7a2c 100644 --- a/infernet_services/requirements-e2e-tests.txt +++ b/infernet_services/requirements-e2e-tests.txt @@ -5,10 +5,10 @@ web3>=6.15.1,<7.0.0 reretry>=0.11.8,<1.0.0 retry2>=0.9.5,<1.0.0 pydantic>=2.5.3,<3.0.0 -infernet-ml>=2.0.0.81,<3.0.0 -infernet-ml[torch_inference]>=2.0.0.81,<3.0.0 -infernet-ml[onnx_inference]>=2.0.0.81,<3.0.0 -infernet-ml[ezkl]>=2.0.0.81,<3.0.0 +infernet-ml>=2.0.0,<3.0.0 +infernet-ml[torch_inference]>=2.0.0,<3.0.0 +infernet-ml[onnx_inference]>=2.0.0,<3.0.0 +infernet-ml[ezkl]>=2.0.0,<3.0.0 infernet-client>=1.0.3.9,<2.0.0 torch>=2.0.1,<3.0.0 reretry>=0.11.8,<1.0.0 diff --git a/infernet_services/requirements-precommit.lock b/infernet_services/requirements-precommit.lock index 6942e3b..e6a5b5a 100644 --- a/infernet_services/requirements-precommit.lock +++ b/infernet_services/requirements-precommit.lock @@ -46,7 +46,7 @@ hypercorn==0.17.3 hyperframe==6.0.1 identify==2.6.1 idna==3.10 -infernet-client==1.0.3.9 +infernet-client==1.4.0 infernet-ml==2.0.0 iniconfig==2.0.0 isort==5.13.2 diff --git a/infernet_services/requirements-precommit.txt b/infernet_services/requirements-precommit.txt index 034e57a..f5d3cee 100644 --- a/infernet_services/requirements-precommit.txt +++ b/infernet_services/requirements-precommit.txt @@ -9,7 +9,7 @@ mypy>=1.9.0,<2.0.0 quart>=0.19.4,<1.0.0 pre-commit>=3.6.2,<4.0.0 pandas>=2.2.2,<3.0.0 -infernet-ml>=2.0.0.78,<3.0.0 +infernet-ml>=2.0.0,<3.0.0 infernet-ml[onnx_inference]>=2.0.0.78,<3.0.0 infernet-ml[torch_inference]>=2.0.0.78,<3.0.0 infernet-ml[css_inference]>=2.0.0.78,<3.0.0 diff --git a/infernet_services/services/css_inference_service/Dockerfile b/infernet_services/services/css_inference_service/Dockerfile index 03837b3..3407e75 100644 --- a/infernet_services/services/css_inference_service/Dockerfile +++ b/infernet_services/services/css_inference_service/Dockerfile @@ -12,7 +12,7 @@ RUN apt-get update RUN apt-get install -y git curl # install uv -RUN curl -LsSf https://astral.sh/uv/0.2.33/install.sh | sh +RUN curl -LsSf https://astral.sh/uv/0.4.28/install.sh | sh COPY requirements.lock . ARG index_url diff --git a/infernet_services/services/css_inference_service/requirements.txt b/infernet_services/services/css_inference_service/requirements.txt index c0c3c3f..53dd816 100644 --- a/infernet_services/services/css_inference_service/requirements.txt +++ b/infernet_services/services/css_inference_service/requirements.txt @@ -1,6 +1,6 @@ eth-abi>=5.1.0,<6.0.0 -infernet-ml>=2.0.0.81,<3.0.0 -infernet-ml[css_inference]>=2.0.0.81,<3.0.0 +infernet-ml>=2.0.0,<3.0.0 +infernet-ml[css_inference]>=2.0.0,<3.0.0 quart>=0.19.4,<1.0.0 python-dotenv>=1.0.1,<2.0.0 pydantic>=2.5.3,<3.0.0 diff --git a/infernet_services/services/ezkl_proof_service/Dockerfile b/infernet_services/services/ezkl_proof_service/Dockerfile index 3836426..d7c59ed 100644 --- a/infernet_services/services/ezkl_proof_service/Dockerfile +++ b/infernet_services/services/ezkl_proof_service/Dockerfile @@ -12,7 +12,7 @@ RUN apt-get update RUN apt-get install -y git curl # install uv -RUN curl -LsSf https://astral.sh/uv/0.2.33/install.sh | sh +RUN curl -LsSf https://astral.sh/uv/0.4.28/install.sh | sh COPY requirements.lock . ARG index_url diff --git a/infernet_services/services/ezkl_proof_service/base.Dockerfile b/infernet_services/services/ezkl_proof_service/base.Dockerfile index c5fcdbf..49916f5 100644 --- a/infernet_services/services/ezkl_proof_service/base.Dockerfile +++ b/infernet_services/services/ezkl_proof_service/base.Dockerfile @@ -11,7 +11,7 @@ ENV PYTHONPATH src RUN apt-get update && apt-get install -y curl # install uv -RUN curl -LsSf https://astral.sh/uv/0.2.33/install.sh | sh +RUN curl -LsSf https://astral.sh/uv/0.4.28/install.sh | sh COPY requirements.lock . ARG index_url diff --git a/infernet_services/services/hf_inference_client_service/Dockerfile b/infernet_services/services/hf_inference_client_service/Dockerfile index 03837b3..3407e75 100644 --- a/infernet_services/services/hf_inference_client_service/Dockerfile +++ b/infernet_services/services/hf_inference_client_service/Dockerfile @@ -12,7 +12,7 @@ RUN apt-get update RUN apt-get install -y git curl # install uv -RUN curl -LsSf https://astral.sh/uv/0.2.33/install.sh | sh +RUN curl -LsSf https://astral.sh/uv/0.4.28/install.sh | sh COPY requirements.lock . ARG index_url diff --git a/infernet_services/services/hf_inference_client_service/requirements.txt b/infernet_services/services/hf_inference_client_service/requirements.txt index c84cdf7..48ba0a9 100644 --- a/infernet_services/services/hf_inference_client_service/requirements.txt +++ b/infernet_services/services/hf_inference_client_service/requirements.txt @@ -1,6 +1,6 @@ click>=8.1.7,<9.0.0 eth-abi>=5.1.0,<6.0.0 -infernet-ml>=2.0.0.81,<3.0.0 -infernet-ml[hf_inference]>=2.0.0.81,<3.0.0 +infernet-ml>=2.0.0,<3.0.0 +infernet-ml[hf_inference]>=2.0.0,<3.0.0 quart>=0.19.4,<1.0.0 pydantic>=2.5.3,<3.0.0 diff --git a/infernet_services/services/onnx_inference_service/Dockerfile b/infernet_services/services/onnx_inference_service/Dockerfile index 03837b3..3407e75 100644 --- a/infernet_services/services/onnx_inference_service/Dockerfile +++ b/infernet_services/services/onnx_inference_service/Dockerfile @@ -12,7 +12,7 @@ RUN apt-get update RUN apt-get install -y git curl # install uv -RUN curl -LsSf https://astral.sh/uv/0.2.33/install.sh | sh +RUN curl -LsSf https://astral.sh/uv/0.4.28/install.sh | sh COPY requirements.lock . ARG index_url diff --git a/infernet_services/services/onnx_inference_service/requirements.txt b/infernet_services/services/onnx_inference_service/requirements.txt index 469a7f3..69e9150 100644 --- a/infernet_services/services/onnx_inference_service/requirements.txt +++ b/infernet_services/services/onnx_inference_service/requirements.txt @@ -1,6 +1,6 @@ click>=8.1.7,<9.0.0 eth-abi>=5.1.0,<6.0.0 quart>=0.19.4,<1.0.0 -infernet-ml>=2.0.0.81,<3.0.0 -infernet-ml[onnx_inference]>=2.0.0.81,<3.0.0 -infernet-ml[onnx_inference_gpu]>=2.0.0.81,<3.0.0; platform_system != "Darwin" +infernet-ml>=2.0.0,<3.0.0 +infernet-ml[onnx_inference]>=2.0.0,<3.0.0 +infernet-ml[onnx_inference_gpu]>=2.0.0,<3.0.0; platform_system != "Darwin" diff --git a/infernet_services/services/tgi_client_inference_service/Dockerfile b/infernet_services/services/tgi_client_inference_service/Dockerfile index 03837b3..3407e75 100644 --- a/infernet_services/services/tgi_client_inference_service/Dockerfile +++ b/infernet_services/services/tgi_client_inference_service/Dockerfile @@ -12,7 +12,7 @@ RUN apt-get update RUN apt-get install -y git curl # install uv -RUN curl -LsSf https://astral.sh/uv/0.2.33/install.sh | sh +RUN curl -LsSf https://astral.sh/uv/0.4.28/install.sh | sh COPY requirements.lock . ARG index_url diff --git a/infernet_services/services/tgi_client_inference_service/requirements.txt b/infernet_services/services/tgi_client_inference_service/requirements.txt index 6d54f36..6507140 100644 --- a/infernet_services/services/tgi_client_inference_service/requirements.txt +++ b/infernet_services/services/tgi_client_inference_service/requirements.txt @@ -1,5 +1,5 @@ eth-abi>=5.1.0,<6.0.0 -infernet-ml>=2.0.0.81,<3.0.0 -infernet-ml[tgi_inference]>=2.0.0.81,<3.0.0 +infernet-ml>=2.0.0,<3.0.0 +infernet-ml[tgi_inference]>=2.0.0,<3.0.0 quart>=0.19.4,<1.0.0 pydantic>=2.5.3,<3.0.0 diff --git a/infernet_services/services/torch_inference_service/Dockerfile b/infernet_services/services/torch_inference_service/Dockerfile index 03837b3..3407e75 100644 --- a/infernet_services/services/torch_inference_service/Dockerfile +++ b/infernet_services/services/torch_inference_service/Dockerfile @@ -12,7 +12,7 @@ RUN apt-get update RUN apt-get install -y git curl # install uv -RUN curl -LsSf https://astral.sh/uv/0.2.33/install.sh | sh +RUN curl -LsSf https://astral.sh/uv/0.4.28/install.sh | sh COPY requirements.lock . ARG index_url diff --git a/infernet_services/services/torch_inference_service/requirements.txt b/infernet_services/services/torch_inference_service/requirements.txt index 26cf483..82ffc6a 100644 --- a/infernet_services/services/torch_inference_service/requirements.txt +++ b/infernet_services/services/torch_inference_service/requirements.txt @@ -1,3 +1,3 @@ -infernet-ml>=2.0.0.81,<3.0.0 -infernet-ml[torch_inference]>=2.0.0.81,<3.0.0 +infernet-ml>=2.0.0,<3.0.0 +infernet-ml[torch_inference]>=2.0.0,<3.0.0 quart>=0.19.4,<1.0.0 diff --git a/infernet_services/test_services/echo/Dockerfile b/infernet_services/test_services/echo/Dockerfile index 3082c85..2af1079 100644 --- a/infernet_services/test_services/echo/Dockerfile +++ b/infernet_services/test_services/echo/Dockerfile @@ -12,7 +12,7 @@ RUN apt-get update RUN apt-get install -y git curl # install uv -RUN curl -LsSf https://astral.sh/uv/0.2.33/install.sh | sh +RUN curl -LsSf https://astral.sh/uv/0.4.28/install.sh | sh COPY requirements.lock . ARG index_url diff --git a/infernet_services/test_services/echo/requirements.txt b/infernet_services/test_services/echo/requirements.txt index 6d112a0..bb189b9 100644 --- a/infernet_services/test_services/echo/requirements.txt +++ b/infernet_services/test_services/echo/requirements.txt @@ -1,4 +1,4 @@ Flask>=3.0.0,<4.0.0 gunicorn>=21.2.0,<22.0.0 web3>=6.17.1,<7.0.0 -infernet-ml>=2.0.0.81,<3.0.0 +infernet-ml>=2.0.0,<3.0.0 diff --git a/libraries/infernet_cli/.gitignore b/libraries/infernet_cli/.gitignore new file mode 100644 index 0000000..b159098 --- /dev/null +++ b/libraries/infernet_cli/.gitignore @@ -0,0 +1,7 @@ +**/deploy/** +**/backup/** + +# docs reference +docs/reference/ + +mkdocs.yml diff --git a/libraries/infernet_cli/.python-version b/libraries/infernet_cli/.python-version new file mode 100644 index 0000000..b6d8b76 --- /dev/null +++ b/libraries/infernet_cli/.python-version @@ -0,0 +1 @@ +3.11.8 diff --git a/libraries/infernet_cli/CHANGELOG.md b/libraries/infernet_cli/CHANGELOG.md new file mode 100644 index 0000000..f1e7ee9 --- /dev/null +++ b/libraries/infernet_cli/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +- ##### The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +- ##### This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - XXXX-XX-XX + +### Added +- Initial release of Infernet CLI. diff --git a/libraries/infernet_cli/Makefile b/libraries/infernet_cli/Makefile new file mode 100644 index 0000000..86f6df4 --- /dev/null +++ b/libraries/infernet_cli/Makefile @@ -0,0 +1,4 @@ +update-lockfile: + uv venv -p 3.11 && source .venv/bin/activate && \ + uv pip install -r pyproject.toml && \ + uv pip freeze | grep -v infernet_cli > requirements.lock && rm -rf .venv diff --git a/libraries/infernet_cli/README.md b/libraries/infernet_cli/README.md new file mode 100644 index 0000000..e97d694 --- /dev/null +++ b/libraries/infernet_cli/README.md @@ -0,0 +1,3 @@ +# Infernet CLI + +See [infernet-cli.docs.ritual.net](infernet-cli.docs.ritual.net) or refer to `infernet_cli/docs`. diff --git a/libraries/infernet_cli/docs/assets/favicon.webp b/libraries/infernet_cli/docs/assets/favicon.webp new file mode 100644 index 0000000..757ec62 Binary files /dev/null and b/libraries/infernet_cli/docs/assets/favicon.webp differ diff --git a/libraries/infernet_cli/docs/assets/logo.svg b/libraries/infernet_cli/docs/assets/logo.svg new file mode 100644 index 0000000..bf07d17 --- /dev/null +++ b/libraries/infernet_cli/docs/assets/logo.svg @@ -0,0 +1,14 @@ + diff --git a/libraries/infernet_cli/docs/assets/meta.png b/libraries/infernet_cli/docs/assets/meta.png new file mode 100644 index 0000000..260f060 Binary files /dev/null and b/libraries/infernet_cli/docs/assets/meta.png differ diff --git a/libraries/infernet_cli/docs/config.yml b/libraries/infernet_cli/docs/config.yml new file mode 100644 index 0000000..e493ba7 --- /dev/null +++ b/libraries/infernet_cli/docs/config.yml @@ -0,0 +1,5 @@ +site_name: Infernet CLI +site_description: Infernet CLI Documentation + +nav: +- Usage: usage.md diff --git a/libraries/infernet_cli/docs/index.md b/libraries/infernet_cli/docs/index.md new file mode 100644 index 0000000..f95436d --- /dev/null +++ b/libraries/infernet_cli/docs/index.md @@ -0,0 +1,83 @@ + +# Infernet CLI + +Infernet CLI is a tool that simplifies configuration and deployment of an [Infernet Node](https://github.com/ritual-net/infernet-node). Specifically, it enables: + +1. Pulling plug-and-play [node configurations](https://github.com/ritual-net/infernet-recipes/tree/main/node) for different chains, with the ability to further configure and customize them. +2. Adding plug-and-play [service configurations](https://github.com/ritual-net/infernet-recipes/tree/main/services) to your node. +3. Creating, managing, and destroying a node. + +## Prerequisites + +- [Python >= 3.9](https://www.python.org/downloads/) +- [Docker Desktop](https://docs.docker.com/get-started/get-docker/) or ([Docker Engine](https://docs.docker.com/engine/install/) + [Docker Compose](https://docs.docker.com/compose/install/)). + +## Installation +You can either install `infernet-cli` via [`uv`](https://astral.sh/blog/uv) (recommended) or via `pip`. + +=== "uv" + + ``` bash + uv pip install infernet-cli + ``` + +=== "pip" + + ``` bash + pip install infernet-cli + ``` + +## Quickstart + +Here's how you can **configure** a node connected to a local Anvil chain: + +```bash +export DEPLOY_DIR=deploy/ + +infernet-cli config anvil --skip +``` + +The output will look something like this: + +``` +No version specified. Using latest: v1.3.0 +Using configurations: + Chain = 'anvil' + Version = '1.3.0' + GPU support = disabled + Output dir = 'deploy' + +Stored base configurations to '/root/deploy'. +To configure services: + - Use `infernet-cli add-service` + - Or edit config.json directly +``` + +You can add an ML service, e.g. `onnx-inference`, as follows: + +```bash +infernet-cli add-service onnx-inference --skip +``` + +The output will look something like this: + +``` +Version not provided. Using latest version '2.0.0'. +Successfully added service 'onnx-inference-2.0.0' to config.json. +``` + +You can then **deploy** the node: + +```bash +infernet-cli start +``` + +and check that it's **healthy**: + +```bash +infernet-cli health +``` + +## More Options + +To see all the available commands and options, head over to the [Usage](usage.md) documentation. diff --git a/libraries/infernet_cli/docs/overrides/main.html b/libraries/infernet_cli/docs/overrides/main.html new file mode 100644 index 0000000..9bd3792 --- /dev/null +++ b/libraries/infernet_cli/docs/overrides/main.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block extrahead %} + {% set title = config.site_name %} + {% if page and page.meta and page.meta.title %} + {% set title = title ~ " - " ~ page.meta.title %} + {% elif page and page.title and not page.is_homepage %} + {% set title = title ~ " - " ~ page.title | striptags %} + {% endif %} + + + + + + + + + + + + + + + +{% endblock %} diff --git a/libraries/infernet_cli/docs/stylesheets/extra.css b/libraries/infernet_cli/docs/stylesheets/extra.css new file mode 100644 index 0000000..cedb3cd --- /dev/null +++ b/libraries/infernet_cli/docs/stylesheets/extra.css @@ -0,0 +1,199 @@ +/* extra.css */ + +@import url("https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900"); +@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap"); + +/* Add brand fonts */ +p, +h1, +h2, +h3, +h4, +h5, +h6, +div, +label, +span, +input { + font-family: "Barlow", sans-serif !important; +} + +/* Increase table font size */ +td { + font-size: 14px; +} + +/* Change monospace font */ +code, +code span, +pre, +pre span { + font-family: "JetBrains Mono", monospace !important; +} + +/* Increase font weight of headers */ +h1, +h2, +h3, +h4, +h5, +h6 { + font-weight: 600 !important; +} + +/* Change body background */ +body { + background-color: #0e0e0e; + padding-bottom: 3rem; +} + +/* Set color variables */ +[data-md-color-scheme="slate"] { + --md-primary-fg-color: #66ff99; + --md-accent-fg-color: #66ff99; + /* Ritual green for other primary elements */ + --md-primary-bg-color: #66ff99; + /* Darker green for background */ + --md-typeset-a-color: #66ff99; + --md-primary-border-color: #66ff99; + /* Ritual green */ + --md-primary-light-color: #66ff99; + /* Lighter green for hover, etc. */ + /* Customize the navbar specifically */ + --md-header-fg-color: #ffffff; + /* Navbar text color */ + --md-header-bg-color: #0e0e0e; + /* Navbar background color */ + --md-code-bg-color: #202022; + --md-accent-fg-color--transparent: #66ff9915; +} + +:root { + --md-code-bg-color: #202022; + /* Solarized dark background */ + --md-code-fg-color: #839496; + /* Solarized dark foreground */ + --md-code-hl-color: #586e75; + /* Solarized dark highlight */ + --md-default-bg-color: #1e2129; +} + +/* Additional styling for navbar if needed */ +.md-header { + background-color: var(--md-header-bg-color); + color: var(--md-header-fg-color); +} + +/* Adjust header color */ +.md-header__title, +.md-header__nav, +.md-header__button { + color: var(--md-header-fg-color); +} + +.md-header__title:focus, +.md-header__title:hover, +.md-header__nav a:focus, +.md-header__nav a:hover, +.md-header__button:focus, +.md-header__button:hover { + color: var(--md-primary-light-color) !important; +} + +.md-nav--primary .md-nav__title[for="__drawer"] { + background-color: #1e2129; +} + +.md-nav__link[for]:focus, +.md-nav__link[for]:hover, +.md-nav__link[href]:focus, +.md-nav__link[href]:hover { + color: var(--md-primary-fg-color) !important; +} + +.md-search-result mark { + color: var(--md-primary-fg-color) !important; +} + +/* Remove drop shadow from header */ +header.md-header { + box-shadow: none !important; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +/* Change border for quotes */ +details.quote { + border-color: rgba(255, 255, 255, 0.1) !important; +} + +/* Change logo size */ +.md-logo > img { + height: 38px !important; +} + +/* Remove box shadow from navigation */ +.md-sidebar__inner .md-nav__title { + background-color: #0e0e0e; + box-shadow: none !important; +} + +.md-nav__title { + color: white !important; +} + +/* Remove mkDocs footer */ +.md-footer { + display: none; +} + +/* Bump font size in quotes */ +details.quote summary { + font-size: 14px; + font-weight: normal; +} + +details.quote code { + font-weight: 500; +} + +.md-nav__title[for="__drawer"] { + background-color: unset !important; +} + +/* Add border-radius to
*/ +pre code { + border-radius: 7px !important; +} + +/* Change header colors */ +.doc.doc-heading code, +.doc.doc-heading span { + color: white; +} + +.md-content__inner.md-typeset code { + color: white; +} + +.md-header__title { + border-left: 1px solid rgba(255, 255, 255, 0.1); + padding-left: 24px; +} + +/* Restyle search dropdown */ +@media only screen and (min-width: 960px) { + .md-search__output { + border-radius: 7px !important; + margin-top: 8px !important; + } + + .md-search form { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 7px !important; + } +} + +/* Change font weight */ +.md-header__topic { + font-weight: 600 !important; +} diff --git a/libraries/infernet_cli/docs/usage.md b/libraries/infernet_cli/docs/usage.md new file mode 100644 index 0000000..389bf3f --- /dev/null +++ b/libraries/infernet_cli/docs/usage.md @@ -0,0 +1,429 @@ +# Usage + +The following examples assume a target directory `deploy/`. All files related to node configuration and deployment will be stored here. + +You can set the target directory with every command using `--dir`, or you can set it once as an ENV variable: + +```bash +export DEPLOY_DIR=deploy +``` + +## Node Configuration + +To pull plug-and-play node configurations, you can use `config`. + +++ +**Example**: + +```bash +infernet-cli config anvil --skip +``` + +The output will look something like this: + +``` +No version specified. Using latest: v1.3.0 +Using configurations: + Chain = 'anvil' + Version = '1.3.0' + GPU support = disabled + Output dir = 'deploy' + +Stored base configurations to '/root/deploy'. +To configure services: + - Use `infernet-cli add-service` + - Or edit config.json directly +``` + +Notice that for demonstration purposes, we are using the `--skip` flag to default optional inputs. + +### Inputs + +Depending on your chain selection, some configuration will need user input in real time. For example, when configuring a node for `Base Mainnet`, user will be prompted for their wallet's `private_key` and an optional `payment_address`. + +```bash +infernet-cli config base +``` + +The output will look something like the following. First, you will be prompted for a **required** input, the `private_key`: + +``` +No version specified. Using latest: v1.3.0 +Using configurations: + Chain = 'base' + Version = '1.3.0' + GPU support = disabled + Output dir = 'deploy' +"private_key" (string): Private key for the wallet (Required): + Enter value: +``` + +You should enter your private key followed by the `return` key. Next, you'll be prompted for an optional `payment_address`: + +``` +"payment_address" (string): Payment address for the wallet (RETURN to skip): + Enter value: +``` + +Assuming you don't need to accept payments just yet, you can simply skip it by hitting `return`. You should then see output similar to the following: + +``` +Stored base configurations to '/root/deploy'. +To configure services: + - Use `infernet-cli add-service` + - Or edit config.json directly +``` + +**Alternatively**, the same inputs can be provided **non-interactively** as a JSON string, using the `--inputs` option: + +```bash +infernet-cli config base -v "1.3.0" --inputs '{"private_key": "0xxxxxxxxxx"}' +# Same as above +``` + +### GPU + +To deploy a GPU-enabled Infernet Node, just use the `--gpu` flag. This assumes your machine is GPU-enabled. + +```bash +infernet-cli config base --gpu --inputs '{"private_key": "0xxxxxxxxxx"}' +``` + +The output will look something like this: + +``` +Using configurations: + Chain = 'base' + Version = '1.3.0' + GPU support = enabled + Output dir = 'deploy' + +Stored base configurations to '/root/deploy'. +To configure services: + - Use `infernet-cli add-service` + - Or edit config.json directly +``` + +## Service Configuration + +To add service containers to the node, you can use `add-service`. + +You can configure a service either [manually](#manually) by providing a complete [container specification](https://docs.ritual.net/infernet/node/configuration/v1_2_0#container_spec-object), or using [recipes](#recipes). + +Usage
+ ``` + Usage: infernet-cli config [OPTIONS] {anvil|base|base-sepolia|eth|other} + + Pull node configurations. + + Options: + -v, --version TEXT The version of the node to configure. + -d, --dir TEXT The directory to store and retrieve configuration files. + Can also set DEPLOY_DIR environment variable. + --gpu Enable GPU support for the node. + -i, --inputs TEXT The inputs to fill in the recipe. Should be a JSON + string of key-value pairs. If not provided, the user + will be prompted for inputs via the CLI. + -y, --yes Force overwrite of existing configurations. + --skip Skip optional inputs. + ``` +++ +### Recipes + +You can configure one or more [official Ritual services](https://infernet-services.docs.ritual.net) using our pre-configured [service recipes](https://github.com/ritual-net/infernet-recipes/tree/main/services). + +```bash +infernet-cli add-service hf-client-inference:1.0.0 +``` + +Similar to config [inputs](#inputs), you will be prompted for configuration parameters: + +``` +"HF_TOKEN" (string): The Hugging Face API token. (Required): + Enter value: +``` + +and + +``` +"NUM_WORKERS" (integer): The number of workers to use with the server. (RETURN to skip): + Enter value: +``` + +Notice that `HF_TOKEN` is required, but `NUM_WORKERS` can be skipped. You should expect to see the following output: + +``` +Successfully added service 'hf-client-inference:1.0.0' to config.json. +``` + +**Alternatively**, the same inputs can be provided **non-interactively** as a JSON string, using the `--inputs` option: + +```bash +infernet-cli add-service hf-client-inference:1.0.0 --inputs '{"HF_TOKEN": "a0xxxxxxxxxxxxx"}' +``` + +### Manually + +You can also add custom service configurations via command-line: + +```bash +infernet-cli add-service +``` + +You will be prompted to paste an entire service configuration: + +```bash +Enter service configuration JSON, followed by EOF: +``` + +To configure an identical service as [above](#recipes), you can paste the following: + +``` +{ + "id": "hf-client-inference", + "image": "ritualnetwork/torch_inference_service:1.0.0", + "env": {"HF_TOKEN": "a0xxxxxxxxxxxxx"}, + "command": "--bind=0.0.0.0:3000 --workers=2" +} +``` + +followed by EOF (`Ctrl+D` on Linux / MacOS). You should see output similar to this: + +``` +Successfully added service 'hf-client-inference' to config.json. +``` + +### Remove + +You can remove a service configuration with `remove-service`. + +Usage
+ ``` + Usage: infernet-cli add-service [OPTIONS] [RECIPE_ID] + + Add a service to the node configuration. + + Options: + -d, --dir TEXT The directory to store and retrieve configuration files. + Can also set DEPLOY_DIR environment variable. + -i, --inputs TEXT The inputs to fill in the recipe. Should be a JSON string + of key-value pairs. If not provided, the user will be + prompted for inputs via the CLI. + --skip Skip optional inputs. + ``` +++ +You can remove services **by ID**: + +```bash +infernet-cli remove-service hf-client-inference:1.0.0 +``` + +or remove all services: + +```bash +infernet-cli remove-service +``` + +## Node Deployment + +After [configuring a node](#node-configuration) and [adding some services](#service-configuration), you can manage its lifecycle as follows: + +### Deploy + +To **create** or **start** the node, use `start`. + +Usage
+ ``` + Usage: infernet-cli remove-service [OPTIONS] [SERVICE_ID] + + Remove a service from the node configuration. + + Options: + -d, --dir TEXT The directory to store and retrieve configuration files. Can + also set DEPLOY_DIR environment variable. + ``` +++ +**Example:** + +```bash +infernet-cli start +``` + +If successful, you should see: + +``` +# Starting Infernet Node... +# Containers started successfully. +``` + +### Health + +To check the **health** of the node and containers, use `health`. + +Usage
+ ``` + Usage: infernet-cli start [OPTIONS] + + Start the Infernet Node. + + Options: + -d, --dir TEXT The directory to store and retrieve configuration files. Can + also set DEPLOY_DIR environment variable. + ``` +++ +**Example:** + +```bash +infernet-cli health +``` + +If successful, you should see: + +``` +All containers are up and running. +``` + +### Stop + +To stop the node, use `stop`. + +Usage
+ ``` + Usage: infernet-cli health [OPTIONS] + + Check health of the Infernet Node. + + Options: + -d, --dir TEXT The directory to store and retrieve configuration files. Can + also set DEPLOY_DIR environment variable. + ``` +++ +**Example:** + +```bash +infernet-cli stop +``` + +If successful, you should see: + +``` +Stopping Infernet Node... +Containers stopped successfully. +``` + +### Reset + +To reset the node, use `reset`. + +Usage
+ ``` + Usage: infernet-cli stop [OPTIONS] + + Stop the Infernet Node. + + Options: + -d, --dir TEXT The directory to store and retrieve configuration files. Can + also set DEPLOY_DIR environment variable. + ``` +++ +**Example:** + +```bash +infernet-cli reset +``` + +If successful, you should see: + +``` +Resetting Infernet Node... +Containers stopped successfully. +Containers started successfully. +``` + +By default, service containers are **not** reset when the node is stopped or destroyed. This is intended behavior to ensure pre-processing-heavy services are not repeatedly initialized. To **force** reset all service containers, use the `--services` flag. This is a destructive operation: + +```bash +infernet-cli reset --services +``` + +If successful, you should see: + +``` +Resetting Infernet Node... +Containers stopped successfully. +Destroying service containers... +Containers started successfully. +``` + +### Destroy + +To destroy the node, use `destroy`. + +Usage
+ ``` + Usage: infernet-cli reset [OPTIONS] + + Reset Infernet Node. + + Options: + -d, --dir TEXT The directory to store and retrieve configuration files. Can + also set DEPLOY_DIR environment variable. + --services Force removal of service containers. Destructive operation. + ``` +++ +**Example:** + +```bash +infernet-cli destroy -y +``` + +If successful, you should see: + +``` +Destroying Infernet Node... +Containers stopped successfully. +Containers destroyed successfully. +``` + +By default, service containers are **not** destroyed when the node is stopped or destroyed. This is intended behavior to ensure pre-processing-heavy services are not repeatedly initialized. To **force** destroy all service containers, use the `--services` flag. This is a destructive operation: + +```bash +infernet-cli destroy --services -y +``` + +If successful, you should see: + +``` +Destroying Infernet Node... +Containers stopped successfully. +Containers destroyed successfully. +Destroying service containers... +``` diff --git a/libraries/infernet_cli/pyproject.toml b/libraries/infernet_cli/pyproject.toml new file mode 100644 index 0000000..1b7224e --- /dev/null +++ b/libraries/infernet_cli/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "infernet-cli" +version = "1.0.0" +description = "Infernet Node CLI" +authors = [ + { name = "Stelios Rousoglou", email = "stelios@ritual.net" } +] +dependencies = [ + "click>=8.1.7,<9.0.0", + "requests>=2.31.0,<3.0.0", + "typing-extensions>=4.12.0,<5.0.0" +] +readme = "README.md" +requires-python = ">= 3.9" + +[project.scripts] +"infernet-cli" = "infernet_cli:main" + +[project.optional-dependencies] +development = [ + "isort>=5.13.2,<6.0.0", + "mypy>=1.9.0,<2.0.0", + "pre-commit>=3.7.0,<4.0.0", + "pytest>=8.1.1,<9.0.0", + "pytest-asyncio>=0.21.1", + "types-requests>=2.31.0.20240406,<3.0" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.rye] +managed = true +dev-dependencies = [] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["src/infernet_cli"] + +[tool.mypy] +exclude = ['**/venv', '**/.venv'] + +[tool.isort] +profile = "black" diff --git a/libraries/infernet_cli/requirements.lock b/libraries/infernet_cli/requirements.lock new file mode 100644 index 0000000..5be64ce --- /dev/null +++ b/libraries/infernet_cli/requirements.lock @@ -0,0 +1,25 @@ +certifi==2024.8.30 +cfgv==3.4.0 +charset-normalizer==3.4.0 +click==8.1.7 +distlib==0.3.9 +filelock==3.16.1 +identify==2.6.1 +idna==3.10 +iniconfig==2.0.0 +isort==5.13.2 +mypy==1.13.0 +mypy-extensions==1.0.0 +nodeenv==1.9.1 +packaging==24.1 +platformdirs==4.3.6 +pluggy==1.5.0 +pre-commit==3.8.0 +pytest==8.3.3 +pytest-asyncio==0.24.0 +pyyaml==6.0.2 +requests==2.32.3 +types-requests==2.32.0.20241016 +typing-extensions==4.12.2 +urllib3==2.2.3 +virtualenv==20.27.0 diff --git a/libraries/infernet_cli/src/infernet_cli/__init__.py b/libraries/infernet_cli/src/infernet_cli/__init__.py new file mode 100644 index 0000000..e3cba50 --- /dev/null +++ b/libraries/infernet_cli/src/infernet_cli/__init__.py @@ -0,0 +1,6 @@ +from infernet_cli.cli import cli + + +def main() -> int: + cli() + return 0 diff --git a/libraries/infernet_cli/src/infernet_cli/cli/__init__.py b/libraries/infernet_cli/src/infernet_cli/cli/__init__.py new file mode 100644 index 0000000..e28d664 --- /dev/null +++ b/libraries/infernet_cli/src/infernet_cli/cli/__init__.py @@ -0,0 +1,160 @@ +from typing import Any, Optional + +import click + +from infernet_cli.cli.docker import ( + destroy_services, + docker_destroy, + docker_start, + docker_stop, + health_check, +) +from infernet_cli.node import get_configs +from infernet_cli.service import add_service_container, remove_service_container + +from .options import ( + config_inputs_option, + config_skip_option, + deploy_dir_option, + destroy_services_option, +) + + +@click.group() +def cli() -> None: + pass + + +@config_skip_option +@click.option( + "-y", + "--yes", + is_flag=True, + help="Force overwrite of existing configurations.", +) +@config_inputs_option +@click.option( + "--gpu", + is_flag=True, + default=False, + help="Enable GPU support for the node.", +) +@deploy_dir_option +@click.option( + "-v", + "--version", + type=str, + required=False, + help="The version of the node to configure.", +) +@click.argument( + "chain", + type=click.Choice( + ["anvil", "base", "base-sepolia", "eth", "other"], case_sensitive=False + ), + required=True, +) +@cli.command( + "config", + help="Pull node configurations.", +) +def config( + chain: str, + version: str, + dir: str, + gpu: bool, + inputs: Optional[dict[str, Any]] = None, + yes: bool = False, + skip: bool = False, +) -> None: + """Pull node configurations for a specific chain.""" + + get_configs(chain, dir, gpu, version, inputs, force=yes, skip=skip) + + +@deploy_dir_option +@cli.command("start", help="Start the Infernet Node.") +def start(dir: str) -> None: + click.echo("Starting Infernet Node...") + docker_start(dir) + + +@deploy_dir_option +@cli.command("health", help="Check health of the Infernet Node.") +def health(dir: str) -> None: + health_check(dir) + + +@deploy_dir_option +@cli.command("stop", help="Stop the Infernet Node.") +def stop(dir: str) -> None: + click.echo("Stopping Infernet Node...") + docker_stop(dir) + + +def abort_if_false(ctx: Any, param: Any, value: Optional[str]) -> None: + if not value: + click.Abort() + + +@click.option( + "-y", + "--yes", + is_flag=True, + callback=abort_if_false, + expose_value=False, + help="No manual y/n confirmation required.", + prompt="Are you sure you want to destroy the Infernet Node?", +) +@destroy_services_option +@deploy_dir_option +@cli.command("destroy", help="Destroy the Infernet Node.") +def destroy(dir: str, services: bool = False) -> None: + click.echo("Destroying Infernet Node...") + docker_stop(dir) + docker_destroy(dir) + + if services: + click.echo("Destroying service containers...") + destroy_services(dir) + + +@destroy_services_option +@deploy_dir_option +@cli.command("reset", help="Reset Infernet Node.") +def reset(dir: str, services: bool = False) -> None: + click.echo("Resetting Infernet Node...") + docker_stop(dir) + + if services: + click.echo("Destroying service containers...") + destroy_services(dir) + + docker_start(dir) + + +@config_skip_option +@config_inputs_option +@deploy_dir_option +@click.argument("recipe_id", type=str, required=False) +@cli.command( + "add-service", + help="Add a service to the node configuration.", +) +def add_service( + recipe_id: Optional[str], + dir: str, + inputs: Optional[dict[str, Any]] = None, + skip: bool = False, +) -> None: + add_service_container(recipe_id, dir, inputs, skip) + + +@deploy_dir_option +@click.argument("service_id", type=str, required=False) +@cli.command( + "remove-service", + help="Remove a service from the node configuration.", +) +def remove_service(service_id: Optional[str], dir: str) -> None: + remove_service_container(service_id, dir) diff --git a/libraries/infernet_cli/src/infernet_cli/cli/docker.py b/libraries/infernet_cli/src/infernet_cli/cli/docker.py new file mode 100644 index 0000000..5501d56 --- /dev/null +++ b/libraries/infernet_cli/src/infernet_cli/cli/docker.py @@ -0,0 +1,135 @@ +import json +import os +import subprocess + +import click + + +def run_command(command: list[str]) -> subprocess.CompletedProcess[str]: + """Helper function to run shell commands.""" + return subprocess.run( + command, + stdout=subprocess.PIPE, # Suppress standard output + stderr=subprocess.PIPE, # Suppress standard error + text=True, # Get the output as text + ) + + +def docker_start(dir: str) -> None: + """Start the containers.""" + try: + # Run the docker compose command without showing the output + result = run_command( + ["docker", "compose", "-f", f"{dir}/docker-compose.yaml", "up", "-d"] + ) + + # Check if the command was successful + if result.returncode == 0: + click.echo("Containers started successfully.") + else: + click.echo(f"Failed to start containers. Error: {result.stderr}") + + except Exception as e: + click.echo(f"An error occurred: {e}") + + +def docker_stop(dir: str) -> None: + """Stop the containers.""" + try: + # Run the docker compose command without showing the output + result = run_command( + ["docker", "compose", "-f", f"{dir}/docker-compose.yaml", "stop"], + ) + + # Check if the command was successful + if result.returncode == 0: + click.echo("Containers stopped successfully.") + else: + click.echo(f"Failed to stop containers. Error: {result.stderr}") + + except Exception as e: + click.echo(f"An error occurred while stopping containers: {e}") + + +def docker_destroy(dir: str) -> None: + """Destroy the containers.""" + try: + # Run the docker compose command without showing the output + result = run_command( + ["docker", "compose", "-f", f"{dir}/docker-compose.yaml", "rm", "-f"], + ) + + # Check if the command was successful + if result.returncode == 0: + click.echo("Containers destroyed successfully.") + else: + click.echo(f"Failed to destroy containers. Error: {result.stderr}") + + except Exception as e: + click.echo(f"An error occurred while destroying containers: {e}") + + +def health_check(dir: str) -> None: + """Check if all containers are up and healthy.""" + try: + # Run `docker compose ps` to get the status of the containers + result = run_command( + [ + "docker", + "compose", + "-f", + f"{dir}/docker-compose.yaml", + "ps", + "-a", + "--format", + "json", + ], + ) + # Parse the result as JSON to inspect each container + containers = ",".join(result.stdout.split("\n")[:-1]) + containers_obj = json.loads(f"[{containers}]") + + if len(containers_obj) == 0: + click.echo("No containers found.") + return + + # Check if the command was successful + if result.returncode != 0: + click.echo(f"Failed to get the container status. Error: {result.stderr}") + return + + # Check if all containers are up and healthy + all_healthy = True + for container in containers_obj: + name = container["Service"] + status = container["State"] # State includes "running", "exited", etc. + + # Simply check if the container is running + if status != "running": + click.echo(f"Container {name} is not running. Status: {status}.") + all_healthy = False + + if all_healthy: + click.echo("\033[92mAll containers are up and running.\033[0m") + else: + click.echo("\033[91mSome containers are not running.\033[0m") + + except Exception as e: + click.echo(f"An error occurred while checking container health: {e}") + + +def destroy_services(dir: str) -> None: + """Stop and remove all service containers found in the config file.""" + + if not os.path.exists(f"{dir}/config.json"): + raise click.ClickException(f"{dir}/config.json not found.") + + # Read container IDs from config.json + config = json.load(open(f"{dir}/config.json")) + container_ids = [container["id"] for container in config["containers"]] + + # Force stop and remove containers + for container_id in container_ids: + # Stop and remove the container + run_command(["docker", "stop", container_id]) + run_command(["docker", "rm", container_id]) diff --git a/libraries/infernet_cli/src/infernet_cli/cli/options.py b/libraries/infernet_cli/src/infernet_cli/cli/options.py new file mode 100644 index 0000000..f8f27ee --- /dev/null +++ b/libraries/infernet_cli/src/infernet_cli/cli/options.py @@ -0,0 +1,68 @@ +import json +from typing import Any, Callable, Optional + +import click + +# Generic callable type for function decorators +GenericCallable = Callable[..., Any] + + +def deploy_dir_option(f: GenericCallable) -> GenericCallable: + """Decorator to specify the deploy directory.""" + return click.option( + "-d", + "--dir", + type=str, + envvar="DEPLOY_DIR", + required=False, + default="deploy", + help=( + "The directory to store and retrieve configuration files. " + "Can also set DEPLOY_DIR environment variable." + ), + )(f) + + +# Define the callback function that transforms the input +def parse_json(ctx: Any, param: Any, value: Optional[str]) -> Any: + """Callback function to parse JSON string into a dictionary.""" + if value is None: + return None + try: + return json.loads(value) + except json.JSONDecodeError as e: + raise click.BadParameter(f"Invalid JSON string: {e}") + + +def config_inputs_option(f: GenericCallable) -> GenericCallable: + """Decorator to specify the inputs for the configuration.""" + return click.option( + "-i", + "--inputs", + type=str, + required=False, + help=( + "The inputs to fill in the recipe. Should be a JSON string of key-value " + "pairs. If not provided, the user will be prompted for inputs via the CLI." + ), + callback=parse_json, + )(f) + + +def config_skip_option(f: GenericCallable) -> GenericCallable: + """Decorator to skip optional inputs.""" + return click.option( + "--skip", + is_flag=True, + default=False, + help="Skip optional inputs.", + )(f) + + +def destroy_services_option(f: GenericCallable) -> GenericCallable: + """Decorator to force removal of service containers.""" + return click.option( + "--services", + is_flag=True, + help="Force removal of service containers. Destructive operation.", + )(f) diff --git a/libraries/infernet_cli/src/infernet_cli/github.py b/libraries/infernet_cli/src/infernet_cli/github.py new file mode 100644 index 0000000..2ba2c83 --- /dev/null +++ b/libraries/infernet_cli/src/infernet_cli/github.py @@ -0,0 +1,63 @@ +from typing import Any + +import requests + + +def github_list_files( + owner: str, repo: str, path: str, branch: str = "main", type: str = "file" +) -> list[str]: + """List all files (or directories) in a GitHub repo path. + + Args: + owner (str): The owner of the repository. + repo (str): The repository name. + path (str): The path to list files from. + branch (str): The branch to list files from. Defaults to "main". + type (str): "dir" or "file". Defaults to "file". + + Returns: + list[str]: A list of file names. + """ + + url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={branch}" + files = [] + + response = requests.get(url) + response.raise_for_status() + data = response.json() + files.extend([file["name"] for file in data if file["type"] == type]) + return files + + +def github_pull_file(owner: str, repo: str, path: str, branch: str = "main") -> Any: + """Retrieve a file's contents from Github + + Args: + owner (str): The owner of the repository. + repo (str): The repository name. + path (str): The path to the file. + branch (str): The branch to pull the file from. Defaults to "main". + + Returns: + Any: The file's contents. + """ + + api_url = ( + f"https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={branch}" + ) + headers = { + "Accept": "application/vnd.github.v3.raw", + } + + try: + response = requests.get(api_url, headers=headers) + response.raise_for_status() + if path.endswith(".json"): + return response.json() + else: + return response.text + except ValueError: + raise ValueError( + f"Unable to fetch {path}." + f" Status code: {response.status_code}: {response.text}" + ) diff --git a/libraries/infernet_cli/src/infernet_cli/node.py b/libraries/infernet_cli/src/infernet_cli/node.py new file mode 100644 index 0000000..53f3c9b --- /dev/null +++ b/libraries/infernet_cli/src/infernet_cli/node.py @@ -0,0 +1,178 @@ +import json +import os +from datetime import datetime +from typing import Any, Optional, cast + +import click +import requests + +from infernet_cli.github import github_list_files, github_pull_file +from infernet_cli.recipe import InfernetRecipe, fill_in_recipe + + +def get_compatible_node_versions() -> list[str]: + """Get all recipe-compatible node versions for the CLI. + + Returns: + list[str]: A list of compatible node versions, sorted in descending order. + """ + return sorted( + set( + [ + tag.split("-")[0] + for tag in get_docker_image_tags("ritualnetwork", "infernet-node") + # Node v1.3.0 is the first compatible with the CLI + if not tag.startswith("latest") and tag.split("-")[0] >= "1.3.0" + ] + ), + reverse=True, + ) + + +def get_docker_image_tags(owner: str, repo: str) -> list[str]: + """Get all tags for a Docker image repository. + + Args: + owner (str): The owner of the repository. + repo (str): The repository name. + + Returns: + list[str]: A list of tags. + """ + url = f"https://hub.docker.com/v2/repositories/{owner}/{repo}/tags/" + tags = [] + + while url: + response = requests.get(url, params={"page_size": 100}) + response.raise_for_status() + data = response.json() + tags.extend([tag["name"] for tag in data["results"]]) + url = data["next"] # If there's a next page, continue + + return tags + + +def can_overwrite_file(file: str, dir: str, force: bool) -> None: + """Whether setup is allowed to (over)write the file + + If the file exists, the user is prompted to overwrite it. + + Args: + file (str): The file to check + dir (str): The directory where the file is located + force (bool): Whether to force overwrite existing file + """ + + # Create backup directory + backup_dir = f"{dir}/backup" + if not os.path.exists(backup_dir): + os.makedirs(backup_dir) + + # Check if file exists + path = os.path.join(dir, file) + if os.path.exists(path): + if not force: + click.confirm(f"File '{path}' exists. Overwrite?", abort=True) + + # Move file to a backup + backup_path = f"{backup_dir}/{file}.{datetime.now().strftime('%Y%m%d%H%M%S')}" + os.rename(path, backup_path) + click.echo(f" Old file moved to '{backup_path}'.") + + +def get_configs( + chain: str, + dir: str, + gpu: bool, + version: Optional[str], + inputs: Optional[dict[str, Any]], + force: bool = False, + skip: bool = False, +) -> None: + """Pull node configurations for a specific chain. + + 1. Touches target directory + 2. Fetches available node tags to validate version + 3. Fetches configuration files + 4. Keeps the correct docker-compose file based on GPU support + 5. Fills in the recipe with input values + 6. Writes the configuration files + + Args: + chain (str): The chain for which to pull the configurations. + dir (str): The directory to write the configurations to. + gpu (bool): Whether to enable GPU support. + version (Optional[str]): The version of the node to configure. + inputs (Optional[dict[str, Any]]): The input values to fill in the recipe. + force (bool): Whether to force overwrite existing files. + skip (bool): Whether to skip optional inputs. + """ + + # Create directory if it doesn't exist + if not os.path.exists(dir): + os.makedirs(dir) + + # Fetch the available node tags + all_tags = get_compatible_node_versions() + + # Default or validate the version + if not version: + version = all_tags[0] + click.echo(f"No version specified. Using latest: v{version}") + elif version not in all_tags: + raise click.BadParameter( + f"Version '{version}' not found. Choose from: {', '.join(all_tags)}" + ) + + click.echo( + f"Using configurations: \n" + f" Chain = '{chain}'\n" + f" Version = '{version}'\n" + f" GPU support = {'enabled' if gpu else 'disabled'}\n" + f" Output dir = '{dir}'" + ) + + deploy_files = github_list_files( + "ritual-net", + "infernet-recipes", + f"node/{chain}/{version}", + ) + + # Only keep one of {GPU, non-GPU} docker-compose files + if gpu: + deploy_files.remove("docker-compose.yaml") + else: + deploy_files.remove("docker-compose-gpu.yaml") + + for file in deploy_files: + contents = github_pull_file( + "ritual-net", + "infernet-recipes", + f"node/{chain}/{version}/{file}", + ) + + # Special handling for config file to get inputs + if file == "config.json": + contents = fill_in_recipe(cast(InfernetRecipe, contents), inputs, skip) + + can_overwrite_file(file, dir, force) + + # Special handling of docker-compose file + if file.startswith("docker-compose"): + path = os.path.join(dir, "docker-compose.yaml") + else: + path = os.path.join(dir, file) + + # Write the file + with open(path, "w") as f: + if file.endswith(".json"): + f.write(json.dumps(contents, indent=4)) + else: + f.write(contents) + + click.echo( + f"\nStored base configurations to '{os.path.abspath(dir)}'." + "\nTo configure services:" + "\n - Use `infernet-cli add-service`" + "\n - Or edit config.json directly" + ) diff --git a/libraries/infernet_cli/src/infernet_cli/recipe.py b/libraries/infernet_cli/src/infernet_cli/recipe.py new file mode 100644 index 0000000..b0eaf4e --- /dev/null +++ b/libraries/infernet_cli/src/infernet_cli/recipe.py @@ -0,0 +1,120 @@ +import json +from copy import deepcopy +from typing import Any, Optional, TypedDict + +import click +from typing_extensions import NotRequired + + +class RecipeConfig(TypedDict, total=False): + """Infernet Recipe Configuration""" + + # Required + id: str + image: str + + # Optional + command: NotRequired[str] + env: NotRequired[dict[str, Any]] + description: NotRequired[str] + + +class RecipeInputs(TypedDict): + """Infernet Recipe Input Variable""" + + # Required + id: str + type: str + path: str + required: bool + + # Optional + description: NotRequired[str] + default: NotRequired[Any] + + +class InfernetRecipe(TypedDict): + """Infernet Recipe (see github.com/ritual-net/infernet-recipes)""" + + # Required + config: RecipeConfig + + # Optional + inputs: NotRequired[list[RecipeInputs]] + + +def fill_in_recipe( + recipe: InfernetRecipe, inputs: Optional[dict[str, Any]] = None, skip: bool = False +) -> Any: + """Fill-in the recipe inputs and return the configuration + + If inputs object is provided, use it to fill in the recipe inputs. Otherwise, + prompt the user for inputs interactively via the CLI. + + Args: + recipe (dict[str, Any]): The recipe containing the inputs and configuration + inputs (Optional[dict[str, Any]]): The inputs to fill in the recipe + skip (bool): Whether to skip optional inputs + + Returns: + Any: The configuration with the user inputs + """ + config = deepcopy(recipe["config"]) + + # Prompt user for inputs if not provided + if inputs is None: + inputs = {} + for var in recipe["inputs"]: + # Skip optional inputs if specified + if skip and var["required"] is False: + continue + + click.echo( + f"\"{var['id']}\" ({var['type']}): {var['description']} " + f"({'Required' if var['required'] is True else 'RETURN to skip'}):" + ) + value = input(" Enter value: ") + inputs[var["id"]] = value + + # Fill in the recipe inputs + for var in recipe["inputs"]: + value = inputs.get(var["id"], None) + + # If skipped, check if required or default value + if not value: + # If value if required, exit with error + if var["required"] is True: + raise click.ClickException( + f"Required value '{var['id']}' not provided." + ) + # If value is defaulted, use default value + elif "default" in var: + value = var["default"] + # Otherwise, skip + else: + continue + + # Handle mid-string substitutions with # notation + pound_index = var["path"].find("#") + pound_substr = None + if pound_index != -1: + pound_substr = var["path"][pound_index + 1 :] + keys = var["path"][:pound_index].split(".") + else: + keys = var["path"].split(".") + + # Traverse the configuration path + ptr = config + for key in keys[:-1]: + ptr = ptr[key] # type: ignore + + # Store value in the configuration + if not pound_substr: + ptr[keys[-1]] = value # type: ignore + else: + # make the mid-string substitution + string = json.dumps(ptr[keys[-1]]) # type: ignore + string = string.replace("${" + pound_substr + "}", str(value)) + ptr[keys[-1]] = json.loads(string) # type: ignore + + return config diff --git a/libraries/infernet_cli/src/infernet_cli/service.py b/libraries/infernet_cli/src/infernet_cli/service.py new file mode 100644 index 0000000..e8d7e00 --- /dev/null +++ b/libraries/infernet_cli/src/infernet_cli/service.py @@ -0,0 +1,152 @@ +import json +import sys +from pathlib import Path +from typing import Any, Optional, cast + +import click + +from infernet_cli.github import github_list_files, github_pull_file +from infernet_cli.recipe import InfernetRecipe, fill_in_recipe + + +def add_service_container( + recipe_id: Optional[str], + dir: str, + inputs: Optional[dict[str, Any]], + skip: bool = False, +) -> None: + """Add container configuration to the node config.json. + + If no recipe ID is provided, the user will be prompted to enter the service + configuration JSON manually. Otherwise, the recipe will be pulled from the services + registry and inputs will be filled in, either from the provided JSON string or + interactively via the CLI. + + Args: + recipe_id (Optional[str]): The service ID, optionally followed by a version. + dir (str): The directory containing the node configuration. + inputs (Optional[dict[str, Any]]): The inputs to fill in the recipe. + skip (bool): Whether to skip optional inputs. + """ + # Pull all recipe IDs + recipe_ids = github_list_files( + "ritual-net", + "infernet-recipes", + "services", + type="dir", + ) + + if not recipe_id: + # Take entire object from stdin - stop at EOF + click.echo("Enter service configuration JSON, followed by EOF:") + try: + config = json.loads(sys.stdin.read()) + except json.JSONDecodeError as e: + raise click.ClickException(f"Decoding JSON error: {e}") + else: + service, version = ( + recipe_id.split(":") if ":" in recipe_id else (recipe_id, None) + ) + + if service not in recipe_ids: + raise click.ClickException(f"Service '{recipe_id}' not found.") + + versions = github_list_files( + "ritual-net", + "infernet-recipes", + f"services/{service}", + type="dir", + ) + + if version and version not in versions: + raise click.ClickException( + f"Version {version} not found for service '{service}'." + ) + + if not version: + version = sorted(versions)[-1] + click.echo(f"Version not provided. Using latest version '{version}'.") + + # Pull the recipe file + recipe = cast( + InfernetRecipe, + github_pull_file( + "ritual-net", + "infernet-recipes", + f"services/{service}/{version}/recipe.json", + ), + ) + + # Prompt user for inputs + config = cast(dict[str, Any], fill_in_recipe(recipe, inputs, skip=skip)) + + # Check that config.json exists + path = Path(f"{dir}/config.json") + if not path.exists(): + raise click.ClickException(f"File {dir}/config.json does not exist.") + + try: + full_config = json.loads(path.read_bytes()) + except json.JSONDecodeError as e: + raise click.ClickException( + f"Error decoding config.json: {e}. \nTry running `infernet-cli config`." + ) + + # Ensure service ID does not already exist + if any(service["id"] == config["id"] for service in full_config["containers"]): + click.confirm( + f"Service '{config['id']}' already exists. Overwrite?", abort=True + ) + + full_config["containers"] = [ + service + for service in full_config["containers"] + if service["id"] != config["id"] + ] + + # Append service and rewrite config.json + full_config["containers"].append(config) + path.write_text(json.dumps(full_config, indent=4)) + + click.echo(f"Successfully added service '{config['id']}' to config.json.") + + +def remove_service_container(service_id: Optional[str], dir: str) -> None: + """Remove a service from the node. + + If no service ID is provided, all services will be removed. + + Args: + service_id (Optional[str]): The service ID to remove. + dir (str): The directory containing the configuration. + """ + + # Check that config.json exists + path = Path(f"{dir}/config.json") + if not path.exists(): + raise click.ClickException(f"File {dir}/config.json does not exist.") + + try: + full_config = json.loads(path.read_bytes()) + except json.JSONDecodeError as e: + raise click.ClickException(f"Error decoding config.json: {e}.") + + if not service_id: + click.confirm("Are you sure you want to remove all services?", abort=True) + full_config["containers"] = [] + else: + # Ensure service ID exists + if not any( + service["id"] == service_id for service in full_config["containers"] + ): + raise click.ClickException(f"Service '{service_id}' does not exist.") + + # Remove service and rewrite config.json + full_config["containers"] = [ + service + for service in full_config["containers"] + if service["id"] != service_id + ] + + path.write_text(json.dumps(full_config, indent=4)) + click.echo("Successfully removed service(s).") diff --git a/libraries/infernet_cli/tests/test_cli.py b/libraries/infernet_cli/tests/test_cli.py new file mode 100644 index 0000000..299f4c0 --- /dev/null +++ b/libraries/infernet_cli/tests/test_cli.py @@ -0,0 +1,147 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from infernet_cli.cli import cli + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@patch("infernet_cli.cli.get_configs") +def test_config_command(mock_get_configs: MagicMock, runner: CliRunner) -> None: + result = runner.invoke( + cli, ["config", "eth", "--version", "1.3.0", "--dir", "/test/dir"] + ) + assert result.exit_code == 0 + mock_get_configs.assert_called_once_with( + "eth", "/test/dir", False, "1.3.0", None, force=False, skip=False + ) + + +@patch("infernet_cli.cli.get_configs") +def test_config_command_with_noninteractive_inputs( + mock_get_configs: MagicMock, runner: CliRunner +) -> None: + user_input = '{"input1": "test_value1", "input2": "test_value2"}' + + result = runner.invoke( + cli, ["config", "eth", "--dir", "/test/dir", "-i", user_input, "--skip"] + ) + + assert result.exit_code == 0 + + mock_get_configs.assert_called_once() + args, kwargs = mock_get_configs.call_args + + mock_get_configs.assert_called_once_with( + "eth", "/test/dir", False, None, json.loads(user_input), force=False, skip=True + ) + + assert kwargs.get("force") is False + + +@patch("infernet_cli.cli.docker_start") +def test_start_command(mock_docker_start: MagicMock, runner: CliRunner) -> None: + result = runner.invoke(cli, ["start", "--dir", "/test/dir"]) + assert result.exit_code == 0 + mock_docker_start.assert_called_once_with("/test/dir") + + +@patch("infernet_cli.cli.docker_stop") +def test_stop_command(mock_docker_stop: MagicMock, runner: CliRunner) -> None: + result = runner.invoke(cli, ["stop", "--dir", "/test/dir"]) + assert result.exit_code == 0 + mock_docker_stop.assert_called_once_with("/test/dir") + + +@patch("infernet_cli.cli.docker_destroy") +@patch("infernet_cli.cli.docker_stop") +def test_destroy_command( + mock_docker_stop: MagicMock, + mock_docker_destroy: MagicMock, + runner: CliRunner, +) -> None: + result = runner.invoke(cli, ["destroy", "--dir", "/test/dir", "--yes"]) + assert result.exit_code == 0 + mock_docker_stop.assert_called_once_with("/test/dir") + mock_docker_destroy.assert_called_once_with("/test/dir") + + +@patch("infernet_cli.cli.docker_destroy") +@patch("infernet_cli.cli.docker_stop") +@patch("infernet_cli.cli.destroy_services") +def test_destroy_command_2( + mock_docker_stop: MagicMock, + mock_docker_destroy: MagicMock, + mock_destroy_services: MagicMock, + runner: CliRunner, +) -> None: + result = runner.invoke( + cli, ["destroy", "--dir", "/test/dir", "--yes", "--services"] + ) + assert result.exit_code == 0 + mock_docker_stop.assert_called_once_with("/test/dir") + mock_docker_destroy.assert_called_once_with("/test/dir") + mock_destroy_services.assert_called_once_with("/test/dir") + + +@patch("infernet_cli.cli.docker_start") +@patch("infernet_cli.cli.docker_stop") +def test_reset_command( + mock_docker_stop: MagicMock, + mock_docker_start: MagicMock, + runner: CliRunner, +) -> None: + result = runner.invoke(cli, ["reset", "--dir", "/test/dir"]) + assert result.exit_code == 0 + mock_docker_stop.assert_called_once_with("/test/dir") + mock_docker_start.assert_called_once_with("/test/dir") + + +@patch("infernet_cli.cli.docker_start") +@patch("infernet_cli.cli.docker_stop") +@patch("infernet_cli.cli.destroy_services") +def test_reset_command_2( + mock_docker_stop: MagicMock, + mock_docker_start: MagicMock, + mock_destroy_services: MagicMock, + runner: CliRunner, +) -> None: + result = runner.invoke(cli, ["reset", "--dir", "/test/dir", "--services"]) + assert result.exit_code == 0 + mock_docker_stop.assert_called_once_with("/test/dir") + mock_docker_start.assert_called_once_with("/test/dir") + mock_destroy_services.assert_called_once_with("/test/dir") + + +@patch("infernet_cli.cli.add_service_container") +def test_add_service_command( + mock_add_service_container: MagicMock, runner: CliRunner +) -> None: + result = runner.invoke( + cli, ["add-service", "recipe_id", "--dir", "/test/dir", "--skip"] + ) + assert result.exit_code == 0 + mock_add_service_container.assert_called_once_with( + "recipe_id", "/test/dir", None, True + ) + + +@patch("infernet_cli.cli.remove_service_container") +def test_remove_service_command( + mock_remove_service_container: MagicMock, runner: CliRunner +) -> None: + result = runner.invoke(cli, ["remove-service", "service_id", "--dir", "/test/dir"]) + assert result.exit_code == 0 + mock_remove_service_container.assert_called_once_with("service_id", "/test/dir") + + +def test_cli_group(runner: CliRunner) -> None: + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "Usage: cli [OPTIONS] COMMAND [ARGS]..." in result.output diff --git a/libraries/infernet_cli/tests/test_docker.py b/libraries/infernet_cli/tests/test_docker.py new file mode 100644 index 0000000..07ab53c --- /dev/null +++ b/libraries/infernet_cli/tests/test_docker.py @@ -0,0 +1,53 @@ +import subprocess +from typing import Any, Callable, Generator, List +from unittest.mock import MagicMock, patch + +import pytest + +from infernet_cli.cli.docker import ( + docker_destroy, + docker_start, + docker_stop, + run_command, +) + + +@pytest.fixture +def mock_subprocess_run() -> Generator[MagicMock, None, None]: + with patch("subprocess.run") as mock_run: + yield mock_run + + +def test_run_command_success(mock_subprocess_run: MagicMock) -> None: + run_command(["test", "command"]) + mock_subprocess_run.assert_called_once_with( + ["test", "command"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + +@pytest.mark.parametrize( + "function,expected_command", + [ + ( + docker_start, + ["docker", "compose", "-f", "/test/dir/docker-compose.yaml", "up", "-d"], + ), + ( + docker_stop, + ["docker", "compose", "-f", "/test/dir/docker-compose.yaml", "stop"], + ), + ( + docker_destroy, + ["docker", "compose", "-f", "/test/dir/docker-compose.yaml", "rm", "-f"], + ), + ], +) +def test_service_functions( + function: Callable[..., Any], + expected_command: List[str], + mock_subprocess_run: MagicMock, +) -> None: + function("/test/dir") + mock_subprocess_run.assert_called_once_with( + expected_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) diff --git a/libraries/infernet_cli/tests/test_github.py b/libraries/infernet_cli/tests/test_github.py new file mode 100644 index 0000000..d78ebdf --- /dev/null +++ b/libraries/infernet_cli/tests/test_github.py @@ -0,0 +1,104 @@ +from typing import Dict, List +from unittest.mock import Mock, patch + +import pytest + +from infernet_cli.github import github_list_files, github_pull_file + + +@patch("infernet_cli.github.requests.get") +def test_github_list_files(mock_get: Mock) -> None: + # Mock the API response + mock_response: Mock = Mock() + mock_response.json.return_value = [ + {"name": "file1.txt", "type": "file"}, + {"name": "file2.py", "type": "file"}, + {"name": "dir1", "type": "dir"}, + ] + mock_get.return_value = mock_response + + # Test listing files + files: List[str] = github_list_files("owner", "repo", "path") + assert files == ["file1.txt", "file2.py"] + mock_get.assert_called_once_with( + "https://api.github.com/repos/owner/repo/contents/path?ref=main" + ) + + # Test listing directories + mock_get.reset_mock() + dirs: List[str] = github_list_files("owner", "repo", "path", type="dir") + assert dirs == ["dir1"] + + +@patch("infernet_cli.github.requests.get") +def test_github_list_files_custom_branch(mock_get: Mock) -> None: + mock_response: Mock = Mock() + mock_response.json.return_value = [{"name": "file1.txt", "type": "file"}] + mock_get.return_value = mock_response + + github_list_files("owner", "repo", "path", branch="dev") + mock_get.assert_called_once_with( + "https://api.github.com/repos/owner/repo/contents/path?ref=dev" + ) + + +@patch("infernet_cli.github.requests.get") +def test_github_list_files_error(mock_get: Mock) -> None: + mock_response: Mock = Mock() + mock_response.raise_for_status.side_effect = Exception("API Error") + mock_get.return_value = mock_response + + with pytest.raises(Exception, match="API Error"): + github_list_files("owner", "repo", "path") + + +@patch("infernet_cli.github.requests.get") +def test_github_pull_file_text(mock_get: Mock) -> None: + mock_response: Mock = Mock() + mock_response.text = "file content" + mock_get.return_value = mock_response + + content: str = github_pull_file("owner", "repo", "path/to/file.txt") + assert content == "file content" + mock_get.assert_called_once_with( + "https://api.github.com/repos/owner/repo/contents/path/to/file.txt?ref=main", + headers={"Accept": "application/vnd.github.v3.raw"}, + ) + + +@patch("infernet_cli.github.requests.get") +def test_github_pull_file_json(mock_get: Mock) -> None: + mock_response: Mock = Mock() + mock_response.json.return_value = {"key": "value"} + mock_get.return_value = mock_response + + content: Dict[str, str] = github_pull_file("owner", "repo", "path/to/file.json") + assert content == {"key": "value"} + + +@patch("infernet_cli.github.requests.get") +def test_github_pull_file_custom_branch(mock_get: Mock) -> None: + mock_response: Mock = Mock() + mock_response.text = "file content" + mock_get.return_value = mock_response + + github_pull_file("owner", "repo", "path/to/file.txt", branch="dev") + mock_get.assert_called_once_with( + "https://api.github.com/repos/owner/repo/contents/path/to/file.txt?ref=dev", + headers={"Accept": "application/vnd.github.v3.raw"}, + ) + + +@patch("infernet_cli.github.requests.get") +def test_github_pull_file_error(mock_get: Mock) -> None: + mock_response: Mock = Mock() + mock_response.status_code = 404 + mock_response.text = "Not Found" + mock_get.return_value = mock_response + mock_response.raise_for_status.side_effect = ValueError("Not Found") + + with pytest.raises( + ValueError, + match="Unable to fetch path/to/file.txt. Status code: 404: Not Found", + ): + github_pull_file("owner", "repo", "path/to/file.txt") diff --git a/libraries/infernet_cli/tests/test_node.py b/libraries/infernet_cli/tests/test_node.py new file mode 100644 index 0000000..e2a1f6d --- /dev/null +++ b/libraries/infernet_cli/tests/test_node.py @@ -0,0 +1,214 @@ +from typing import List +from unittest.mock import MagicMock, call, mock_open, patch + +import click +import pytest +import requests + +from infernet_cli.node import ( + can_overwrite_file, + get_compatible_node_versions, + get_configs, + get_docker_image_tags, +) + +# Test get_compatible_node_versions + + +@patch("infernet_cli.node.get_docker_image_tags") +def test_get_compatible_node_versions(mock_get_tags: MagicMock) -> None: + mock_get_tags.return_value = ["latest", "1.2.0", "1.3.0", "1.4.0", "2.0.0", "0.9.0"] + result: List[str] = get_compatible_node_versions() + assert result == ["2.0.0", "1.4.0", "1.3.0"] + + +# Test get_docker_image_tags + + +@patch("requests.get") +def test_get_docker_image_tags(mock_get: MagicMock) -> None: + mock_response = MagicMock() + mock_response.json.side_effect = [ + {"results": [{"name": "tag1"}, {"name": "tag2"}], "next": "next_url"}, + {"results": [{"name": "tag3"}, {"name": "latest"}], "next": None}, + ] + mock_get.return_value = mock_response + + result: List[str] = get_docker_image_tags("owner", "repo") + assert result == ["tag1", "tag2", "tag3", "latest"] + assert mock_get.call_count == 2 + + +@patch("requests.get") +def test_get_docker_image_tags_error(mock_get: MagicMock) -> None: + mock_get.side_effect = requests.RequestException() + with pytest.raises(requests.RequestException): + get_docker_image_tags("owner", "repo") + + +# Test can_overwrite_file + + +@patch("os.path.exists") +@patch("os.makedirs") +@patch("os.rename") +@patch("click.confirm") +def test_can_overwrite_file_existing( + mock_confirm: MagicMock, + mock_rename: MagicMock, + mock_makedirs: MagicMock, + mock_exists: MagicMock, +) -> None: + mock_exists.side_effect = [False, True] # First for backup dir, then for file + mock_confirm.return_value = True + + can_overwrite_file("test.txt", "/test/dir", False) + + mock_makedirs.assert_called_once_with("/test/dir/backup") + mock_confirm.assert_called_once() + mock_rename.assert_called_once() + + +@patch("os.path.exists") +@patch("os.makedirs") +@patch("os.rename") +def test_can_overwrite_file_force( + mock_rename: MagicMock, mock_makedirs: MagicMock, mock_exists: MagicMock +) -> None: + mock_exists.side_effect = [True, True] # Both backup dir and file exist + + can_overwrite_file("test.txt", "/test/dir", True) + + mock_makedirs.assert_not_called() + mock_rename.assert_called_once() + + +@patch("os.path.exists") +@patch("click.confirm") +def test_can_overwrite_file_abort( + mock_confirm: MagicMock, mock_exists: MagicMock +) -> None: + mock_exists.return_value = True + mock_confirm.side_effect = click.Abort() + + with pytest.raises(click.Abort): + can_overwrite_file("test.txt", "/test/dir", False) + mock_confirm.assert_called_once() + + +# Test get_configs + + +@patch("infernet_cli.node.get_compatible_node_versions") +@patch("infernet_cli.node.github_list_files") +@patch("infernet_cli.node.github_pull_file") +@patch("infernet_cli.node.fill_in_recipe") +@patch("infernet_cli.node.can_overwrite_file") +@patch("builtins.open", new_callable=mock_open) +@patch("click.echo") +@patch("os.makedirs") +@patch("os.path.exists", return_value=False) +def test_get_configs( + mock_exists: MagicMock, + mock_makedirs: MagicMock, + mock_echo: MagicMock, + mock_file: MagicMock, + mock_can_overwrite: MagicMock, + mock_fill: MagicMock, + mock_pull: MagicMock, + mock_list: MagicMock, + mock_versions: MagicMock, +) -> None: + mock_versions.return_value = ["2.0.0", "1.9.0"] + mock_list.return_value = [ + "config.json", + "docker-compose.yaml", + "docker-compose-gpu.yaml", + ] + mock_pull.return_value = {"key0": "value0"} + mock_fill.return_value = {"filled": "recipe"} + + get_configs("testchain", "/test/dir", False, None, {"key1": "value1"}, skip=True) + + mock_can_overwrite.assert_called() + mock_makedirs.assert_called_once() + mock_echo.assert_has_calls( + [ + call("No version specified. Using latest: v2.0.0"), + call( + "Using configurations: \n Chain = 'testchain'\n Version = '2.0.0'\n" + " GPU support = disabled\n Output dir = '/test/dir'" + ), + call( + "\nStored base configurations to '/test/dir'.\nTo configure services:\n" + " - Use `infernet-cli add-service`\n - Or edit config.json directly" + ), + ], + any_order=False, + ) + + mock_fill.assert_called_once_with({"key0": "value0"}, {"key1": "value1"}, True) + assert mock_file.call_count == 2 # config.json and docker-compose.yaml + mock_pull.assert_has_calls( + [ + call("ritual-net", "infernet-recipes", "node/testchain/2.0.0/config.json"), + call( + "ritual-net", + "infernet-recipes", + "node/testchain/2.0.0/docker-compose.yaml", + ), + ] + ) + + +@patch("infernet_cli.node.get_compatible_node_versions") +@patch("os.path.exists", return_value=True) +def test_get_configs_invalid_version( + mock_exists: MagicMock, mock_versions: MagicMock +) -> None: + mock_versions.return_value = ["2.0.0", "1.9.0"] + + with pytest.raises(click.BadParameter): + get_configs("testchain", "/test/dir", False, "1.0.0", None) + + +@patch("infernet_cli.node.get_compatible_node_versions") +@patch("infernet_cli.node.github_list_files") +@patch("infernet_cli.node.github_pull_file") +@patch("infernet_cli.node.fill_in_recipe") +@patch("infernet_cli.node.can_overwrite_file") +@patch("builtins.open", new_callable=mock_open) +@patch("os.path.exists", return_value=True) +def test_get_configs_gpu( + mock_exists: MagicMock, + mock_file: MagicMock, + mock_can_overwrite: MagicMock, + mock_fill: MagicMock, + mock_pull: MagicMock, + mock_list: MagicMock, + mock_versions: MagicMock, +) -> None: + mock_versions.return_value = ["2.0.0"] + mock_list.return_value = [ + "config.json", + "docker-compose.yaml", + "docker-compose-gpu.yaml", + ] + mock_pull.return_value = '{"key": "value"}' + mock_fill.return_value = {"filled": "recipe"} + + get_configs("testchain", "/test/dir", True, None, {"input": "value"}) + + assert mock_file.call_count == 2 # config.json and docker-compose-gpu.yaml + mock_can_overwrite.assert_called() + mock_fill.assert_called_once() + mock_pull.assert_has_calls( + [ + call("ritual-net", "infernet-recipes", "node/testchain/2.0.0/config.json"), + call( + "ritual-net", + "infernet-recipes", + "node/testchain/2.0.0/docker-compose-gpu.yaml", + ), + ] + ) diff --git a/libraries/infernet_cli/tests/test_recipe.py b/libraries/infernet_cli/tests/test_recipe.py new file mode 100644 index 0000000..4c48b53 --- /dev/null +++ b/libraries/infernet_cli/tests/test_recipe.py @@ -0,0 +1,93 @@ +from typing import Any, Dict +from unittest.mock import patch + +import click +import pytest + +from infernet_cli.recipe import InfernetRecipe, fill_in_recipe + + +@pytest.fixture +def sample_recipe() -> InfernetRecipe: + return InfernetRecipe( + config={ + "id": "sample-recipe", + "image": "sample-image:latest", + "command": "python main.py ${ARG}", + "env": {"KEY": "value"}, + }, + inputs=[ + { + "id": "input1", + "type": "string", + "path": "env.INPUT1", + "required": True, + "description": "First input", + }, + { + "id": "input2", + "type": "integer", + "path": "env.INPUT2", + "required": False, + "description": "Second input", + }, + { + "id": "input3", + "type": "string", + "path": "command#ARG", + "required": False, + "description": "Third input", + "default": "default_arg", + }, + ], + ) + + +def test_fill_in_recipe_with_provided_inputs(sample_recipe: InfernetRecipe) -> None: + inputs: Dict[str, Any] = { + "input1": "test_value", + "input2": 100, + "input3": "custom_arg", + } + result: Dict[str, Any] = fill_in_recipe(sample_recipe, inputs) + + assert result["env"]["INPUT1"] == "test_value" + assert result["env"]["INPUT2"] == 100 + assert result["command"] == "python main.py custom_arg" + + +def test_fill_in_recipe_with_default_values(sample_recipe: InfernetRecipe) -> None: + inputs: Dict[str, str] = {"input1": "test_value"} + result: Dict[str, Any] = fill_in_recipe(sample_recipe, inputs) + + assert result["env"]["INPUT1"] == "test_value" + assert "INPUT2" not in result["env"] + assert result["command"] == "python main.py default_arg" + + +def test_fill_in_recipe_with_empty_values(sample_recipe: InfernetRecipe) -> None: + inputs: Dict[str, Any] = {"input1": "test_value", "input2": "", "input3": ""} + result: Dict[str, Any] = fill_in_recipe(sample_recipe, inputs) + + assert result["env"]["INPUT1"] == "test_value" + assert "INPUT2" not in result["env"] + assert result["command"] == "python main.py default_arg" + + +def test_fill_in_recipe_missing_required_input(sample_recipe: InfernetRecipe) -> None: + inputs: Dict[str, Any] = {} + with pytest.raises(click.ClickException) as exc_info: + fill_in_recipe(sample_recipe, inputs) + assert str(exc_info.value) == "Required value 'input1' not provided." + + +@patch("builtins.input", side_effect=["test_value", 42, "custom_arg"]) +def test_fill_in_recipe_interactive( + mock_input: Any, sample_recipe: InfernetRecipe +) -> None: + with patch("click.echo"): + result: Dict[str, Any] = fill_in_recipe(sample_recipe) + + assert result["env"]["INPUT1"] == "test_value" + assert result["env"]["INPUT2"] == 42 + assert result["command"] == "python main.py custom_arg" diff --git a/libraries/infernet_cli/tests/test_service.py b/libraries/infernet_cli/tests/test_service.py new file mode 100644 index 0000000..35e016d --- /dev/null +++ b/libraries/infernet_cli/tests/test_service.py @@ -0,0 +1,288 @@ +import json +from typing import Any, Dict +from unittest.mock import MagicMock, mock_open, patch + +import click +import pytest + +from infernet_cli.service import add_service_container, remove_service_container + + +@pytest.fixture +def mock_config() -> Dict[str, Any]: + return { + "id": "test-node", + "containers": [{"id": "test-service", "image": "test-image:latest"}], + } + + +@pytest.fixture +def mock_recipe() -> Dict[str, Any]: + return { + "config": {"id": "test-service", "image": "test-image:latest"}, + "inputs": [ + {"id": "input1", "type": "string", "path": "env.INPUT1", "required": True} + ], + } + + +# Tests for add_service_container + + +@patch("infernet_cli.service.github_list_files") +@patch("infernet_cli.service.github_pull_file") +@patch("infernet_cli.service.fill_in_recipe") +@patch("infernet_cli.service.Path") +def test_add_service_container_with_recipe( + mock_path: MagicMock, + mock_fill: MagicMock, + mock_pull: MagicMock, + mock_list: MagicMock, + mock_config: Dict[str, Any], + mock_recipe: Dict[str, Any], +) -> None: + mock_list.side_effect = [["new-service"], ["1.0.0", "2.0.0"]] + mock_pull.return_value = mock_recipe + mock_fill.return_value = {"id": "new-service", "image": "new-image:latest"} + + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = True + mock_path_instance.read_bytes.return_value = json.dumps(mock_config).encode() + mock_path.return_value = mock_path_instance + + with patch("builtins.open", mock_open()): + add_service_container("new-service", "/test/dir", None) + + mock_path_instance.write_text.assert_called_once() + written_config = json.loads(mock_path_instance.write_text.call_args[0][0]) + assert len(written_config["containers"]) == 2 + assert written_config["containers"][1]["id"] == "new-service" + + +@patch("infernet_cli.service.github_pull_file") +@patch("infernet_cli.service.github_list_files") +@patch("infernet_cli.service.Path") +def test_add_service_container_invalid_service( + mock_path: MagicMock, mock_list: MagicMock, mock_pull: MagicMock +) -> None: + mock_list.return_value = ["other-service"] + + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = True + mock_path_instance.read_bytes.return_value = json.dumps({"containers": []}).encode() + mock_path.return_value = mock_path_instance + + with pytest.raises(click.ClickException) as exc_info: + add_service_container("invalid-service", "/test/dir", None) + assert str(exc_info.value) == "Service 'invalid-service' not found." + + +@patch("infernet_cli.service.github_pull_file") +@patch("infernet_cli.service.github_list_files") +@patch("infernet_cli.service.fill_in_recipe") +@patch("infernet_cli.service.Path") +def test_add_service_container_invalid_version( + mock_path: MagicMock, + mock_fill: MagicMock, + mock_list: MagicMock, + mock_pull: MagicMock, + mock_recipe: Dict[str, Any], +) -> None: + mock_list.side_effect = [["test-service"], ["1.0.0", "2.0.0"]] + mock_pull.return_value = mock_recipe + mock_fill.return_value = {"id": "test-service", "image": "test-image:latest"} + + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = True + mock_path_instance.read_bytes.return_value = json.dumps({"containers": []}).encode() + mock_path.return_value = mock_path_instance + + with pytest.raises(click.ClickException) as exc_info: + add_service_container("test-service:3.0.0", "/test/dir", None) + assert str(exc_info.value) == "Version 3.0.0 not found for service 'test-service'." + + +@patch("infernet_cli.service.sys.stdin") +@patch("infernet_cli.service.Path") +def test_add_service_container_manual_input( + mock_path: MagicMock, mock_stdin: MagicMock, mock_config: Dict[str, Any] +) -> None: + mock_stdin.read.return_value = json.dumps( + {"id": "manual-service", "image": "manual-image:latest"} + ) + + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = True + mock_path_instance.read_bytes.return_value = json.dumps(mock_config).encode() + mock_path.return_value = mock_path_instance + + with patch("builtins.open", mock_open()): + add_service_container(None, "/test/dir", None) + + mock_path_instance.write_text.assert_called_once() + written_config = json.loads(mock_path_instance.write_text.call_args[0][0]) + assert len(written_config["containers"]) == 2 + assert written_config["containers"][1]["id"] == "manual-service" + + +@patch("infernet_cli.service.github_pull_file") +@patch("infernet_cli.service.github_list_files") +@patch("infernet_cli.service.fill_in_recipe") +@patch("infernet_cli.service.Path") +def test_add_service_container_config_not_found( + mock_path: MagicMock, + mock_fill: MagicMock, + mock_list: MagicMock, + mock_pull: MagicMock, + mock_recipe: Dict[str, Any], +) -> None: + mock_list.side_effect = [["test-service"], ["1.0.0", "2.0.0"]] + mock_pull.return_value = mock_recipe + mock_fill.return_value = {"id": "test-service", "image": "test-image:latest"} + + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = False + mock_path.return_value = mock_path_instance + + with pytest.raises(click.ClickException) as exc_info: + add_service_container("test-service", "/test/dir", None) + assert str(exc_info.value) == "File /test/dir/config.json does not exist." + + +@patch("infernet_cli.service.github_pull_file") +@patch("infernet_cli.service.github_list_files") +@patch("infernet_cli.service.fill_in_recipe") +@patch("infernet_cli.service.Path") +def test_add_service_container_invalid_json( + mock_path: MagicMock, + mock_fill: MagicMock, + mock_list: MagicMock, + mock_pull: MagicMock, +) -> None: + mock_list.side_effect = [["test-service"], ["1.0.0", "2.0.0"]] + mock_pull.return_value = mock_recipe + + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = True + mock_path_instance.read_bytes.return_value = b"invalid json" + mock_path.return_value = mock_path_instance + + with pytest.raises(click.ClickException) as exc_info: + add_service_container("test-service", "/test/dir", None) + assert str(exc_info.value) == ( + "Error decoding config.json: Expecting value: line 1 column 1 (char 0). " + "\nTry running `infernet-cli config`." + ) + + +@patch("infernet_cli.service.github_pull_file") +@patch("infernet_cli.service.github_list_files") +@patch("infernet_cli.service.fill_in_recipe") +@patch("infernet_cli.service.click.confirm") +@patch("infernet_cli.service.Path") +def test_add_service_container_overwrite_existing( + mock_path: MagicMock, + mock_confirm: MagicMock, + mock_fill: MagicMock, + mock_list: MagicMock, + mock_pull: MagicMock, + mock_config: Dict[str, Any], + mock_recipe: Dict[str, Any], +) -> None: + mock_list.side_effect = [["test-service"], ["1.0.0", "2.0.0"]] + mock_pull.return_value = mock_recipe + mock_fill.return_value = {"id": "test-service", "image": "new-image:latest"} + + mock_confirm.return_value = True + + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = True + mock_path_instance.read_bytes.return_value = json.dumps(mock_config).encode() + mock_path.return_value = mock_path_instance + + add_service_container("test-service", "/test/dir", None) + + mock_confirm.assert_called_once() + mock_path_instance.write_text.assert_called_once() + written_config = json.loads(mock_path_instance.write_text.call_args[0][0]) + assert len(written_config["containers"]) == 1 + assert written_config["containers"][0]["image"] == "new-image:latest" + + +# Tests for remove_service_container + + +@patch("infernet_cli.service.Path") +def test_remove_service_container_specific_service( + mock_path: MagicMock, mock_config: Dict[str, Any] +) -> None: + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = True + mock_path_instance.read_bytes.return_value = json.dumps(mock_config).encode() + mock_path.return_value = mock_path_instance + + remove_service_container("test-service", "/test/dir") + + mock_path_instance.write_text.assert_called_once() + written_config = json.loads(mock_path_instance.write_text.call_args[0][0]) + assert len(written_config["containers"]) == 0 + + +@patch("infernet_cli.service.click.confirm") +@patch("infernet_cli.service.Path") +def test_remove_service_container_all_services( + mock_path: MagicMock, mock_confirm: MagicMock, mock_config: Dict[str, Any] +) -> None: + mock_confirm.return_value = True + + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = True + mock_path_instance.read_bytes.return_value = json.dumps(mock_config).encode() + mock_path.return_value = mock_path_instance + + remove_service_container(None, "/test/dir") + + mock_confirm.assert_called_once() + mock_path_instance.write_text.assert_called_once() + written_config = json.loads(mock_path_instance.write_text.call_args[0][0]) + assert len(written_config["containers"]) == 0 + + +@patch("infernet_cli.service.Path") +def test_remove_service_container_config_not_found(mock_path: MagicMock) -> None: + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = False + mock_path.return_value = mock_path_instance + + with pytest.raises(click.ClickException) as exc_info: + remove_service_container("test-service", "/test/dir") + assert str(exc_info.value) == "File /test/dir/config.json does not exist." + + +@patch("infernet_cli.service.Path") +def test_remove_service_container_invalid_json(mock_path: MagicMock) -> None: + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = True + mock_path_instance.read_bytes.return_value = b"invalid json" + mock_path.return_value = mock_path_instance + + with pytest.raises(click.ClickException) as exc_info: + remove_service_container("test-service", "/test/dir") + assert ( + str(exc_info.value) + == "Error decoding config.json: Expecting value: line 1 column 1 (char 0)." + ) + + +@patch("infernet_cli.service.Path") +def test_remove_service_container_nonexistent_service( + mock_path: MagicMock, mock_config: Dict[str, Any] +) -> None: + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = True + mock_path_instance.read_bytes.return_value = json.dumps(mock_config).encode() + mock_path.return_value = mock_path_instance + + with pytest.raises(click.ClickException) as exc_info: + remove_service_container("nonexistent-service", "/test/dir") + assert str(exc_info.value) == "Service 'nonexistent-service' does not exist." diff --git a/libraries/infernet_client/pyproject.toml b/libraries/infernet_client/pyproject.toml index 3024f5b..9cc295a 100644 --- a/libraries/infernet_client/pyproject.toml +++ b/libraries/infernet_client/pyproject.toml @@ -8,7 +8,7 @@ authors = [ dependencies = [ "aiohttp>=3.9.2,<4.0.0", "click>=8.1.7,<9.0.0", - "infernet-ml>=2.0.0.81,<3.0.0", + "infernet-ml>=2.0.0,<3.0.0", "quart>=0.19.0,<1.0.0", "web3>=6.19.0,<7.0.0" ]Usage
+ ``` + Usage: infernet-cli destroy [OPTIONS] + + Destroy the Infernet Node. + + Options: + -d, --dir TEXT The directory to store and retrieve configuration files. Can + also set DEPLOY_DIR environment variable. + --services Force removal of service containers. Destructive operation. + -y, --yes No manual y/n confirmation required. + ``` +