From 09b68a82041a95a4f9d1d8e9483d45396f3473bc Mon Sep 17 00:00:00 2001 From: Dinko Zdravac <173912580+dynco-nym@users.noreply.github.com> Date: Fri, 1 Nov 2024 01:24:41 +0100 Subject: [PATCH] Cherry pick NS API from develop (#5074) * Revert "NS API with directory v2 (#5068)" This reverts commit cf4fe5f875d0e235117d52737504b37405391668. * Merge pull request #5050 from nymtech/dz-node-status-api Node Status API * Ns agent workflow (#5055) * feat: add dockerfile * add github workflow for node status agent --------- Co-authored-by: Fran Arbanas * NS API with directory v2 (#5058) * Use unstable explorer client * Clean up stale testruns & logging - log gw identity key - better agent testrun logging - log responses - change response code for agents * Better logging on agent * Testrun stores gw identity key instead of gw pk * Agent 0.1.3 * Agent 0.1.4 * Sqlx offline query data + clippy * Compatible with directory v2 * Point to internal deps + rebase + v0.1.5 * self described field not null * Fix build.rs typo * Fix clippy --------- Co-authored-by: Fran Arbanas --- .github/workflows/ci-build.yml | 12 +- .github/workflows/push-node-status-agent.yaml | 56 ++++ .github/workflows/push-node-status-api.yaml | 54 +++- Cargo.lock | 242 +++++++++++++++++- Cargo.toml | 14 +- common/bin-common/Cargo.toml | 1 + .../client-libs/validator-client/Cargo.toml | 2 +- .../validator-client/src/client.rs | 7 + .../validator-client/src/connection_tester.rs | 18 +- .../validator-client/src/nym_api/mod.rs | 46 ++++ .../nyxd/contract_traits/dkg_query_client.rs | 2 +- .../client_traits/query_client.rs | 7 +- .../client_traits/signing_client.rs | 2 +- .../src/nyxd/cosmwasm_client/helpers.rs | 2 +- common/http-api-client/src/lib.rs | 23 +- common/models/Cargo.toml | 14 + common/models/src/lib.rs | 1 + envs/canary.env | 4 +- envs/mainnet.env | 6 +- envs/qa.env | 4 +- envs/sandbox.env | 6 +- explorer-api/explorer-client/Cargo.toml | 2 +- explorer-api/explorer-client/src/lib.rs | 22 +- nym-data-observatory/README_SQLX.md | 1 + nym-node-status-agent/.gitignore | 1 + nym-node-status-agent/Dockerfile | 28 ++ nym-node-status-agent/src/main.rs | 78 ++++++ nym-node-status-api/.gitignore | 6 + nym-node-status-api/Dockerfile | 15 ++ nym-node-status-api/Dockerfile.dev | 8 + nym-node-status-api/src/cli/mod.rs | 77 ++++++ nym-node-status-api/src/db/mod.rs | 35 +++ nym-node-status-api/src/db/queries/misc.rs | 88 +++++++ .../src/db/queries/mixnodes.rs | 177 +++++++++++++ nym-node-status-api/src/db/queries/summary.rs | 209 +++++++++++++++ nym-node-status-api/src/http/api/gateways.rs | 110 ++++++++ nym-node-status-api/src/http/api/mixnodes.rs | 91 +++++++ .../src/http/api/services/json_path.rs | 58 +++++ .../src/http/api/services/mod.rs | 134 ++++++++++ nym-node-status-api/src/http/api/summary.rs | 43 ++++ nym-node-status-api/src/http/api_docs.rs | 15 ++ nym-node-status-api/src/http/mod.rs | 71 +++++ nym-node-status-api/src/http/server.rs | 93 +++++++ nym-node-status-api/src/http/state.rs | 223 ++++++++++++++++ nym-node-status-api/src/logging.rs | 69 +++++ nym-node-status-api/src/main.rs | 53 ++++ nym-node-status-api/src/monitor/mod.rs | 2 + nym-node-status-api/src/testruns/models.rs | 16 ++ 48 files changed, 2187 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/push-node-status-agent.yaml create mode 100644 common/models/Cargo.toml create mode 100644 common/models/src/lib.rs create mode 100644 nym-node-status-agent/.gitignore create mode 100644 nym-node-status-agent/Dockerfile create mode 100644 nym-node-status-agent/src/main.rs create mode 100644 nym-node-status-api/.gitignore create mode 100644 nym-node-status-api/Dockerfile create mode 100644 nym-node-status-api/Dockerfile.dev create mode 100644 nym-node-status-api/src/cli/mod.rs create mode 100644 nym-node-status-api/src/db/mod.rs create mode 100644 nym-node-status-api/src/db/queries/misc.rs create mode 100644 nym-node-status-api/src/db/queries/mixnodes.rs create mode 100644 nym-node-status-api/src/db/queries/summary.rs create mode 100644 nym-node-status-api/src/http/api/gateways.rs create mode 100644 nym-node-status-api/src/http/api/mixnodes.rs create mode 100644 nym-node-status-api/src/http/api/services/json_path.rs create mode 100644 nym-node-status-api/src/http/api/services/mod.rs create mode 100644 nym-node-status-api/src/http/api/summary.rs create mode 100644 nym-node-status-api/src/http/api_docs.rs create mode 100644 nym-node-status-api/src/http/mod.rs create mode 100644 nym-node-status-api/src/http/server.rs create mode 100644 nym-node-status-api/src/http/state.rs create mode 100644 nym-node-status-api/src/logging.rs create mode 100644 nym-node-status-api/src/main.rs create mode 100644 nym-node-status-api/src/testruns/models.rs diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 77d220e633..d7a76f2673 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -57,6 +57,12 @@ jobs: command: fmt args: --all -- --check + - name: Clippy + uses: actions-rs/cargo@v1 + with: + command: clippy + args: --workspace --all-targets -- -D warnings + - name: Build all binaries uses: actions-rs/cargo@v1 with: @@ -82,9 +88,3 @@ jobs: with: command: test args: --workspace -- --ignored - - - name: Clippy - uses: actions-rs/cargo@v1 - with: - command: clippy - args: --workspace --all-targets -- -D warnings diff --git a/.github/workflows/push-node-status-agent.yaml b/.github/workflows/push-node-status-agent.yaml new file mode 100644 index 0000000000..a66d4dee41 --- /dev/null +++ b/.github/workflows/push-node-status-agent.yaml @@ -0,0 +1,56 @@ +name: Build and upload Node Status agent container to harbor.nymte.ch + +on: + workflow_dispatch: + +env: + WORKING_DIRECTORY: "nym-node-status-agent" + CONTAINER_NAME: "node-status-agent" + +jobs: + build-container: + runs-on: arc-ubuntu-22.04-dind + steps: + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: harbor.nymte.ch + username: ${{ secrets.HARBOR_ROBOT_USERNAME }} + password: ${{ secrets.HARBOR_ROBOT_SECRET }} + + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Configure git identity + run: | + git config --global user.email "lawrence@nymtech.net" + git config --global user.name "Lawrence Stalder" + + - name: Get version from cargo.toml + uses: mikefarah/yq@v4.44.3 + id: get_version + with: + cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml + + - name: Check if tag exists + run: | + if git rev-parse ${{ steps.get_version.outputs.value }} >/dev/null 2>&1; then + echo "Tag ${{ steps.get_version.outputs.value }} already exists" + fi + + - name: Remove existing tag if exists + run: | + if git rev-parse ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} >/dev/null 2>&1; then + git push --delete origin ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} + git tag -d ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} + fi + + - name: Create tag + run: | + git tag -a ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} -m "Version ${{ steps.get_version.outputs.result }}" + git push origin ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} + + - name: BuildAndPushImageOnHarbor + run: | + docker build -f ${{ env.WORKING_DIRECTORY }}/Dockerfile . -t harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }}:${{ steps.get_version.outputs.result }} -t harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }}:latest + docker push harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }} --all-tags diff --git a/.github/workflows/push-node-status-api.yaml b/.github/workflows/push-node-status-api.yaml index 1a082a1ad2..941a8619f0 100644 --- a/.github/workflows/push-node-status-api.yaml +++ b/.github/workflows/push-node-status-api.yaml @@ -1,11 +1,55 @@ name: Build and upload Node Status API container to harbor.nymte.ch - on: workflow_dispatch: +env: + WORKING_DIRECTORY: "nym-node-status-api" + CONTAINER_NAME: "node-status-api" + jobs: - my-job: - runs-on: arc-ubuntu-22.04 + build-container: + runs-on: arc-ubuntu-22.04-dind steps: - - name: my-step - run: echo "Hello World!" + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: harbor.nymte.ch + username: ${{ secrets.HARBOR_ROBOT_USERNAME }} + password: ${{ secrets.HARBOR_ROBOT_SECRET }} + + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Configure git identity + run: | + git config --global user.email "lawrence@nymtech.net" + git config --global user.name "Lawrence Stalder" + + - name: Get version from cargo.toml + uses: mikefarah/yq@v4.44.3 + id: get_version + with: + cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml + + - name: Check if tag exists + run: | + if git rev-parse ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} >/dev/null 2>&1; then + echo "Tag ${{ steps.get_version.outputs.result }} already exists" + fi + + - name: Remove existing tag if exists + run: | + if git rev-parse ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} >/dev/null 2>&1; then + git push --delete origin ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} + git tag -d ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} + fi + + - name: Create tag + run: | + git tag -a ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} -m "Version ${{ steps.get_version.outputs.result }}" + git push origin ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} + + - name: BuildAndPushImageOnHarbor + run: | + docker build -f ${{ env.WORKING_DIRECTORY }}/Dockerfile . -t harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }}:${{ steps.get_version.outputs.result }} -t harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }}:latest + docker push harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }} --all-tags diff --git a/Cargo.lock b/Cargo.lock index aaffaa5570..65bf102040 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -318,10 +318,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" dependencies = [ "concurrent-queue", - "event-listener", + "event-listener 2.5.3", "futures-core", ] +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener 5.3.1", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-stream" version = "0.3.5" @@ -455,6 +466,7 @@ checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae" dependencies = [ "async-trait", "axum-core 0.4.5", + "axum-macros", "bytes", "futures-util", "http 1.1.0", @@ -553,6 +565,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "axum-test" version = "16.2.0" @@ -2331,6 +2354,15 @@ dependencies = [ "termcolor", ] +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -2373,6 +2405,27 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener 5.3.1", + "pin-project-lite", +] + [[package]] name = "explorer-api" version = "1.1.41" @@ -3593,7 +3646,7 @@ dependencies = [ "crossbeam-utils", "curl", "curl-sys", - "event-listener", + "event-listener 2.5.3", "futures-lite", "http 0.2.12", "log", @@ -4075,6 +4128,30 @@ dependencies = [ "wasm-utils", ] +[[package]] +name = "moka" +version = "0.12.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cf62eb4dd975d2dde76432fb1075c49e3ee2331cf36f1f8fd4b66550d32b6f" +dependencies = [ + "async-lock", + "async-trait", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "event-listener 5.3.1", + "futures-util", + "once_cell", + "parking_lot", + "quanta", + "rustc_version 0.4.0", + "smallvec", + "tagptr", + "thiserror", + "triomphe", + "uuid", +] + [[package]] name = "multer" version = "2.1.0" @@ -4920,6 +4997,13 @@ dependencies = [ "nym-multisig-contract-common", ] +[[package]] +name = "nym-common-models" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "nym-compact-ecash" version = "0.1.0" @@ -5235,12 +5319,12 @@ dependencies = [ name = "nym-explorer-client" version = "0.1.0" dependencies = [ - "log", "nym-explorer-api-requests", "reqwest 0.12.4", "serde", "thiserror", "tokio", + "tracing", "url", ] @@ -5894,6 +5978,61 @@ dependencies = [ "utoipa", ] +[[package]] +name = "nym-node-status-agent" +version = "0.1.4" +dependencies = [ + "anyhow", + "clap 4.5.18", + "nym-bin-common", + "nym-common-models", + "reqwest 0.12.4", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "nym-node-status-api" +version = "0.1.5" +dependencies = [ + "anyhow", + "axum 0.7.7", + "chrono", + "clap 4.5.18", + "cosmwasm-std", + "envy", + "futures-util", + "moka", + "nym-bin-common", + "nym-common-models", + "nym-explorer-client", + "nym-network-defaults", + "nym-node-requests", + "nym-task", + "nym-validator-client", + "regex", + "reqwest 0.12.4", + "serde", + "serde_json", + "serde_json_path", + "sqlx", + "strum 0.26.3", + "strum_macros 0.26.4", + "thiserror", + "tokio", + "tokio-util", + "tower-http", + "tracing", + "tracing-log 0.2.0", + "tracing-subscriber", + "utoipa", + "utoipa-swagger-ui", + "utoipauto", +] + [[package]] name = "nym-node-tester-utils" version = "0.1.0" @@ -6488,7 +6627,6 @@ dependencies = [ "flate2", "futures", "itertools 0.13.0", - "log", "nym-api-requests", "nym-coconut-bandwidth-contract-common", "nym-coconut-dkg-common", @@ -6512,6 +6650,7 @@ dependencies = [ "thiserror", "time", "tokio", + "tracing", "ts-rs", "url", "wasmtimer", @@ -7421,6 +7560,21 @@ dependencies = [ "psl-types", ] +[[package]] +name = "quanta" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5167a477619228a0b284fac2674e3c388cba90631d7b7de620e6f1fcd08da5" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -7512,6 +7666,15 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "raw-cpuid" +version = "11.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ab240315c661615f2ee9f0f2cd32d5a7343a84d5ebcccb99d46e6637565e7b0" +dependencies = [ + "bitflags 2.5.0", +] + [[package]] name = "rayon" version = "1.10.0" @@ -8361,9 +8524,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.128" +version = "1.0.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" dependencies = [ "itoa", "memchr", @@ -8371,6 +8534,59 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_json_path" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc0207b6351893eafa1e39aa9aea452abb6425ca7b02dd64faf29109e7a33ba" +dependencies = [ + "inventory", + "nom", + "once_cell", + "regex", + "serde", + "serde_json", + "serde_json_path_core", + "serde_json_path_macros", + "thiserror", +] + +[[package]] +name = "serde_json_path_core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d64fe53ce1aaa31bea2b2b46d3b6ab6a37e61854bedcbd9f174e188f3f7d79" +dependencies = [ + "inventory", + "once_cell", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "serde_json_path_macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a31e8177a443fd3e94917f12946ae7891dfb656e6d4c5e79b8c5d202fbcb723" +dependencies = [ + "inventory", + "once_cell", + "serde_json_path_core", + "serde_json_path_macros_internal", +] + +[[package]] +name = "serde_json_path_macros_internal" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75dde5a1d2ed78dfc411fc45592f72d3694436524d3353683ecb3d22009731dc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "serde_path_to_error" version = "0.1.16" @@ -8735,7 +8951,7 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 2.5.3", "futures-channel", "futures-core", "futures-intrusive", @@ -9145,6 +9361,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tap" version = "1.0.1" @@ -9917,6 +10139,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "triomphe" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859eb650cfee7434994602c3a68b25d77ad9e68c8a6cd491616ef86661382eb3" + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index fd480fa59e..1dc988d673 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ members = [ "common/ip-packet-requests", "common/ledger", "common/mixnode-common", + "common/models", "common/network-defaults", "common/node-tester-utils", "common/nonexhaustive-delayqueue", @@ -119,6 +120,8 @@ members = [ "nym-node", "nym-node/nym-node-http-api", "nym-node/nym-node-requests", + "nym-node-status-api", + "nym-node-status-agent", "nym-outfox", "nym-validator-rewarder", "tools/echo-server", @@ -146,13 +149,16 @@ members = [ default-members = [ "clients/native", "clients/socks5", + "common/models", "explorer-api", "gateway", "mixnode", "nym-api", "nym-data-observatory", "nym-node", + "nym-node-status-api", "nym-validator-rewarder", + "nym-node-status-api", "service-providers/authenticator", "service-providers/ip-packet-router", "service-providers/network-requester", @@ -234,10 +240,12 @@ dotenvy = "0.15.6" ecdsa = "0.16" ed25519-dalek = "2.1" etherparse = "0.13.0" +envy = "0.4" eyre = "0.6.9" fastrand = "2.1.1" flate2 = "1.0.34" futures = "0.3.28" +futures-util = "0.3" generic-array = "0.14.7" getrandom = "0.2.10" getset = "0.1.3" @@ -267,6 +275,7 @@ ledger-transport-hid = "0.10.0" log = "0.4" maxminddb = "0.23.0" mime = "0.3.17" +moka = { version = "0.12", features = ["future"] } nix = "0.27.1" notify = "5.1.0" okapi = "0.7.0" @@ -299,7 +308,8 @@ semver = "1.0.23" serde = "1.0.210" serde_bytes = "0.11.15" serde_derive = "1.0" -serde_json = "1.0.128" +serde_json = "1.0.132" +serde_json_path = "0.6.7" serde_repr = "0.1" serde_with = "3.9.0" serde_yaml = "0.9.25" @@ -308,6 +318,7 @@ si-scale = "0.2.3" sphinx-packet = "0.1.1" sqlx = "0.7.4" strum = "0.26" +strum_macros = "0.26" subtle-encoding = "0.5" syn = "1" sysinfo = "0.30.13" @@ -329,6 +340,7 @@ tracing = "0.1.37" tracing-opentelemetry = "0.19.0" tracing-subscriber = "0.3.16" tracing-tree = "0.2.2" +tracing-log = "0.2" ts-rs = "10.0.0" tungstenite = { version = "0.20.1", default-features = false } url = "2.5" diff --git a/common/bin-common/Cargo.toml b/common/bin-common/Cargo.toml index b63631ddaa..11a2c76f2b 100644 --- a/common/bin-common/Cargo.toml +++ b/common/bin-common/Cargo.toml @@ -45,3 +45,4 @@ tracing = [ "opentelemetry", ] clap = [ "dep:clap", "dep:clap_complete", "dep:clap_complete_fig" ] +models = [] diff --git a/common/client-libs/validator-client/Cargo.toml b/common/client-libs/validator-client/Cargo.toml index d17b100672..5cd86e2dba 100644 --- a/common/client-libs/validator-client/Cargo.toml +++ b/common/client-libs/validator-client/Cargo.toml @@ -25,7 +25,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } nym-http-api-client = { path = "../../../common/http-api-client" } thiserror = { workspace = true } -log = { workspace = true } +tracing = { workspace = true } url = { workspace = true, features = ["serde"] } tokio = { workspace = true, features = ["sync", "time"] } time = { workspace = true, features = ["formatting"] } diff --git a/common/client-libs/validator-client/src/client.rs b/common/client-libs/validator-client/src/client.rs index 6a13e8f186..071fb597bf 100644 --- a/common/client-libs/validator-client/src/client.rs +++ b/common/client-libs/validator-client/src/client.rs @@ -323,6 +323,13 @@ impl NymApiClient { NymApiClient { nym_api } } + #[cfg(not(target_arch = "wasm32"))] + pub fn new_with_timeout(api_url: Url, timeout: std::time::Duration) -> Self { + let nym_api = nym_api::Client::new(api_url, Some(timeout)); + + NymApiClient { nym_api } + } + pub fn new_with_user_agent(api_url: Url, user_agent: UserAgent) -> Self { let nym_api = nym_api::Client::builder::<_, ValidatorClientError>(api_url) .expect("invalid api url") diff --git a/common/client-libs/validator-client/src/connection_tester.rs b/common/client-libs/validator-client/src/connection_tester.rs index 7876fd1918..3e71a3c000 100644 --- a/common/client-libs/validator-client/src/connection_tester.rs +++ b/common/client-libs/validator-client/src/connection_tester.rs @@ -121,36 +121,36 @@ async fn test_nyxd_connection( { Ok(Err(NyxdError::TendermintErrorRpc(e))) => { // If we get a tendermint-rpc error, we classify the node as not contactable - log::warn!("Checking: nyxd url: {url}: {}: {}", "failed".red(), e); + tracing::warn!("Checking: nyxd url: {url}: {}: {}", "failed".red(), e); false } Ok(Err(NyxdError::AbciError { code, log, .. })) => { // We accept the mixnet contract not found as ok from a connection standpoint. This happens // for example on a pre-launch network. - log::debug!( + tracing::debug!( "Checking: nyxd url: {url}: {}, but with abci error: {code}: {log}", "success".green() ); code == 18 } Ok(Err(error @ NyxdError::NoContractAddressAvailable(_))) => { - log::warn!("Checking: nyxd url: {url}: {}: {error}", "failed".red()); + tracing::warn!("Checking: nyxd url: {url}: {}: {error}", "failed".red()); false } Ok(Err(e)) => { // For any other error, we're optimistic and just try anyway. - log::warn!( + tracing::warn!( "Checking: nyxd_url: {url}: {}, but with error: {e}", "success".green() ); true } Ok(Ok(_)) => { - log::debug!("Checking: nyxd_url: {url}: {}", "success".green()); + tracing::debug!("Checking: nyxd_url: {url}: {}", "success".green()); true } Err(e) => { - log::warn!("Checking: nyxd_url: {url}: {}: {e}", "failed".red()); + tracing::warn!("Checking: nyxd_url: {url}: {}: {e}", "failed".red()); false } }; @@ -169,15 +169,15 @@ async fn test_nym_api_connection( .await { Ok(Ok(_)) => { - log::debug!("Checking: api_url: {url}: {}", "success".green()); + tracing::debug!("Checking: api_url: {url}: {}", "success".green()); true } Ok(Err(e)) => { - log::debug!("Checking: api_url: {url}: {}: {e}", "failed".red()); + tracing::debug!("Checking: api_url: {url}: {}: {e}", "failed".red()); false } Err(e) => { - log::debug!("Checking: api_url: {url}: {}: {e}", "failed".red()); + tracing::debug!("Checking: api_url: {url}: {}: {e}", "failed".red()); false } }; diff --git a/common/client-libs/validator-client/src/nym_api/mod.rs b/common/client-libs/validator-client/src/nym_api/mod.rs index aaad043ad5..6556a80102 100644 --- a/common/client-libs/validator-client/src/nym_api/mod.rs +++ b/common/client-libs/validator-client/src/nym_api/mod.rs @@ -43,6 +43,7 @@ use nym_mixnet_contract_common::mixnode::MixNodeDetails; use nym_mixnet_contract_common::{GatewayBond, IdentityKeyRef, NodeId, NymNodeDetails}; use time::format_description::BorrowedFormatItem; use time::Date; +use tracing::instrument; pub mod error; pub mod routes; @@ -67,12 +68,14 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_mixnodes(&self) -> Result, NymAPIError> { self.get_json(&[routes::API_VERSION, routes::MIXNODES], NO_PARAMS) .await } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_mixnodes_detailed(&self) -> Result, NymAPIError> { self.get_json( &[ @@ -87,6 +90,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_gateways_detailed(&self) -> Result, NymAPIError> { self.get_json( &[ @@ -101,6 +105,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_mixnodes_detailed_unfiltered( &self, ) -> Result, NymAPIError> { @@ -117,12 +122,14 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_gateways(&self) -> Result, NymAPIError> { self.get_json(&[routes::API_VERSION, routes::GATEWAYS], NO_PARAMS) .await } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_gateways_described(&self) -> Result, NymAPIError> { self.get_json( &[routes::API_VERSION, routes::GATEWAYS, routes::DESCRIBED], @@ -132,6 +139,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_mixnodes_described(&self) -> Result, NymAPIError> { self.get_json( &[routes::API_VERSION, routes::MIXNODES, routes::DESCRIBED], @@ -140,6 +148,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[tracing::instrument(level = "debug", skip_all)] async fn get_nodes_described( &self, page: Option, @@ -159,6 +168,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[tracing::instrument(level = "debug", skip_all)] async fn get_nym_nodes( &self, page: Option, @@ -179,6 +189,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[tracing::instrument(level = "debug", skip_all)] async fn get_basic_mixnodes( &self, semver_compatibility: Option, @@ -203,6 +214,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_basic_gateways( &self, semver_compatibility: Option, @@ -228,6 +240,7 @@ pub trait NymApiClientExt: ApiClient { /// retrieve basic information for nodes are capable of operating as an entry gateway /// this includes legacy gateways and nym-nodes + #[instrument(level = "debug", skip(self))] async fn get_basic_entry_assigned_nodes( &self, semver_compatibility: Option, @@ -269,6 +282,7 @@ pub trait NymApiClientExt: ApiClient { /// retrieve basic information for nodes that got assigned 'mixing' node in this epoch /// this includes legacy mixnodes and nym-nodes + #[instrument(level = "debug", skip(self))] async fn get_basic_active_mixing_assigned_nodes( &self, semver_compatibility: Option, @@ -310,6 +324,7 @@ pub trait NymApiClientExt: ApiClient { /// retrieve basic information for nodes that got assigned 'mixing' node in this epoch /// this includes legacy mixnodes and nym-nodes + #[instrument(level = "debug", skip(self))] async fn get_basic_mixing_capable_nodes( &self, semver_compatibility: Option, @@ -348,6 +363,7 @@ pub trait NymApiClientExt: ApiClient { ) .await } + #[instrument(level = "debug", skip(self))] async fn get_basic_nodes( &self, @@ -382,6 +398,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_active_mixnodes(&self) -> Result, NymAPIError> { self.get_json( &[routes::API_VERSION, routes::MIXNODES, routes::ACTIVE], @@ -391,6 +408,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_active_mixnodes_detailed(&self) -> Result, NymAPIError> { self.get_json( &[ @@ -406,6 +424,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_rewarded_mixnodes(&self) -> Result, NymAPIError> { self.get_json( &[routes::API_VERSION, routes::MIXNODES, routes::REWARDED], @@ -415,6 +434,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_mixnode_report( &self, mix_id: NodeId, @@ -433,6 +453,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_gateway_report( &self, identity: IdentityKeyRef<'_>, @@ -451,6 +472,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_mixnode_history( &self, mix_id: NodeId, @@ -469,6 +491,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_gateway_history( &self, identity: IdentityKeyRef<'_>, @@ -487,6 +510,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_rewarded_mixnodes_detailed( &self, ) -> Result, NymAPIError> { @@ -504,6 +528,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_gateway_core_status_count( &self, identity: IdentityKeyRef<'_>, @@ -536,6 +561,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_mixnode_core_status_count( &self, mix_id: NodeId, @@ -569,6 +595,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_mixnode_status( &self, mix_id: NodeId, @@ -587,6 +614,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_mixnode_reward_estimation( &self, mix_id: NodeId, @@ -605,6 +633,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn compute_mixnode_reward_estimation( &self, mix_id: NodeId, @@ -625,6 +654,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_mixnode_stake_saturation( &self, mix_id: NodeId, @@ -643,6 +673,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_mixnode_inclusion_probability( &self, mix_id: NodeId, @@ -660,6 +691,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self))] async fn get_current_node_performance( &self, node_id: NodeId, @@ -692,6 +724,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_mixnodes_blacklisted(&self) -> Result, NymAPIError> { self.get_json( &[routes::API_VERSION, routes::MIXNODES, routes::BLACKLISTED], @@ -701,6 +734,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn get_gateways_blacklisted(&self) -> Result, NymAPIError> { self.get_json( &[routes::API_VERSION, routes::GATEWAYS, routes::BLACKLISTED], @@ -709,6 +743,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self, request_body))] async fn blind_sign( &self, request_body: &BlindSignRequestBody, @@ -725,6 +760,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self, request_body))] async fn verify_ecash_ticket( &self, request_body: &VerifyEcashTicketBody, @@ -741,6 +777,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self, request_body))] async fn batch_redeem_ecash_tickets( &self, request_body: &BatchRedeemTicketsBody, @@ -758,6 +795,7 @@ pub trait NymApiClientExt: ApiClient { } #[deprecated] + #[instrument(level = "debug", skip(self))] async fn double_spending_filter_v1(&self) -> Result { self.get_json( &[ @@ -770,6 +808,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self))] async fn partial_expiration_date_signatures( &self, expiration_date: Option, @@ -793,6 +832,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self))] async fn partial_coin_indices_signatures( &self, epoch_id: Option, @@ -813,6 +853,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self))] async fn global_expiration_date_signatures( &self, expiration_date: Option, @@ -836,6 +877,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self))] async fn global_coin_indices_signatures( &self, epoch_id: Option, @@ -856,6 +898,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self))] async fn master_verification_key( &self, epoch_id: Option, @@ -875,6 +918,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self))] async fn epoch_credentials( &self, dkg_epoch: EpochId, @@ -891,6 +935,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self))] async fn issued_credential( &self, credential_id: i64, @@ -907,6 +952,7 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self))] async fn issued_credentials( &self, credential_ids: Vec, diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/dkg_query_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/dkg_query_client.rs index 4d8fd3237c..8674d7cb0a 100644 --- a/common/client-libs/validator-client/src/nyxd/contract_traits/dkg_query_client.rs +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/dkg_query_client.rs @@ -8,9 +8,9 @@ use crate::nyxd::CosmWasmClient; use async_trait::async_trait; use cosmrs::AccountId; use cosmwasm_std::Addr; -use log::trace; use nym_coconut_dkg_common::types::{ChunkIndex, NodeIndex, StateAdvanceResponse}; use serde::Deserialize; +use tracing::trace; use nym_coconut_dkg_common::dealer::RegisteredDealerDetails; pub use nym_coconut_dkg_common::{ diff --git a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/client_traits/query_client.rs b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/client_traits/query_client.rs index 5cea0a1ba2..8feceebdbf 100644 --- a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/client_traits/query_client.rs +++ b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/client_traits/query_client.rs @@ -29,7 +29,6 @@ use cosmrs::proto::cosmwasm::wasm::v1::{ }; use cosmrs::tendermint::{block, chain, Hash}; use cosmrs::{AccountId, Coin as CosmosCoin, Tx}; -use log::trace; use prost::Message; use serde::{Deserialize, Serialize}; @@ -68,7 +67,7 @@ pub trait CosmWasmClient: TendermintRpcClient { Res: Message + Default, { if let Some(ref abci_path) = path { - trace!("performing query on abci path {abci_path}") + tracing::trace!("performing query on abci path {abci_path}") } let mut buf = Vec::with_capacity(req.encoded_len()); req.encode(&mut buf)?; @@ -297,7 +296,7 @@ pub trait CosmWasmClient: TendermintRpcClient { let start = Instant::now(); loop { - log::debug!( + tracing::debug!( "Polling for result of including {} in a block...", broadcasted.hash ); @@ -522,7 +521,7 @@ pub trait CosmWasmClient: TendermintRpcClient { .make_abci_query::<_, QuerySmartContractStateResponse>(path, req) .await?; - trace!("raw query response: {}", String::from_utf8_lossy(&res.data)); + tracing::trace!("raw query response: {}", String::from_utf8_lossy(&res.data)); Ok(serde_json::from_slice(&res.data)?) } diff --git a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/client_traits/signing_client.rs b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/client_traits/signing_client.rs index 8d0bb0fb4f..cec29e9c50 100644 --- a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/client_traits/signing_client.rs +++ b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/client_traits/signing_client.rs @@ -25,12 +25,12 @@ use cosmrs::proto::cosmos::tx::signing::v1beta1::SignMode; use cosmrs::staking::{MsgDelegate, MsgUndelegate}; use cosmrs::tx::{self, Msg}; use cosmrs::{cosmwasm, AccountId, Any, Tx}; -use log::debug; use serde::Serialize; use sha2::Digest; use sha2::Sha256; use std::time::SystemTime; use tendermint_rpc::endpoint::broadcast; +use tracing::debug; fn empty_fee() -> tx::Fee { tx::Fee { diff --git a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/helpers.rs b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/helpers.rs index d6e42daac7..559ad434a2 100644 --- a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/helpers.rs +++ b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/helpers.rs @@ -7,9 +7,9 @@ use base64::Engine; use cosmrs::abci::TxMsgData; use cosmrs::cosmwasm::MsgExecuteContractResponse; use cosmrs::proto::cosmos::base::query::v1beta1::{PageRequest, PageResponse}; -use log::error; use prost::bytes::Bytes; use tendermint_rpc::endpoint::broadcast; +use tracing::error; pub use cosmrs::abci::MsgResponse; diff --git a/common/http-api-client/src/lib.rs b/common/http-api-client/src/lib.rs index 84ab2d2cb9..ce502c112a 100644 --- a/common/http-api-client/src/lib.rs +++ b/common/http-api-client/src/lib.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use std::fmt::Display; use std::time::Duration; use thiserror::Error; -use tracing::warn; +use tracing::{instrument, warn}; use url::Url; pub use reqwest::IntoUrl; @@ -205,6 +205,7 @@ impl Client { self.reqwest_client.get(url) } + #[instrument(level = "debug", skip_all, fields(path=?path))] async fn send_get_request( &self, path: PathSegments<'_>, @@ -215,6 +216,7 @@ impl Client { V: AsRef, E: Display, { + tracing::trace!("Sending GET request"); let url = sanitize_url(&self.base_url, path, params); #[cfg(target_arch = "wasm32")] @@ -280,6 +282,7 @@ impl Client { } } + #[instrument(level = "debug", skip_all)] pub async fn get_json( &self, path: PathSegments<'_>, @@ -515,12 +518,14 @@ pub fn sanitize_url, V: AsRef>( url } +#[tracing::instrument(level = "debug", skip_all)] pub async fn parse_response(res: Response, allow_empty: bool) -> Result> where T: DeserializeOwned, E: DeserializeOwned + Display, { let status = res.status(); + tracing::debug!("Status: {} (success: {})", &status, status.is_success()); if !allow_empty { if let Some(0) = res.content_length() { @@ -529,11 +534,19 @@ where } if res.status().is_success() { - let text = res.text().await?; - match serde_json::from_str(&text) { - Ok(res) => Ok(res), - Err(source) => Err(HttpClientError::ResponseDeserialisationFailure { source }), + #[cfg(debug_assertions)] + { + let text = res.text().await.inspect_err(|err| { + tracing::error!("Couldn't even get response text: {err}"); + })?; + tracing::trace!("Result:\n{:#?}", text); + + serde_json::from_str(&text) + .map_err(|err| HttpClientError::GenericRequestFailure(err.to_string())) } + + #[cfg(not(debug_assertions))] + Ok(res.json().await?) } else if res.status() == StatusCode::NOT_FOUND { Err(HttpClientError::NotFound) } else { diff --git a/common/models/Cargo.toml b/common/models/Cargo.toml new file mode 100644 index 0000000000..acb6e35682 --- /dev/null +++ b/common/models/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nym-common-models" +version = "0.1.0" +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +readme.workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } diff --git a/common/models/src/lib.rs b/common/models/src/lib.rs new file mode 100644 index 0000000000..3d85e66947 --- /dev/null +++ b/common/models/src/lib.rs @@ -0,0 +1 @@ +pub mod ns_api; diff --git a/envs/canary.env b/envs/canary.env index c4aac15082..db70a19fd0 100644 --- a/envs/canary.env +++ b/envs/canary.env @@ -19,5 +19,5 @@ MULTISIG_CONTRACT_ADDRESS=n1zwv6feuzhy6a9wekh96cd57lsarmqlwxdypdsplw6zhfncqw6ftq COCONUT_DKG_CONTRACT_ADDRESS=n1aakfpghcanxtc45gpqlx8j3rq0zcpyf49qmhm9mdjrfx036h4z5sy2vfh9 EXPLORER_API=https://canary-explorer.performance.nymte.ch/api -NYXD="https://canary-validator.performance.nymte.ch" -NYM_API="https://canary-api.performance.nymte.ch/api" +NYXD=https://canary-validator.performance.nymte.ch +NYM_API=https://canary-api.performance.nymte.ch/api diff --git a/envs/mainnet.env b/envs/mainnet.env index e40e54a471..419547bf97 100644 --- a/envs/mainnet.env +++ b/envs/mainnet.env @@ -21,8 +21,8 @@ COCONUT_DKG_CONTRACT_ADDRESS=n19604yflqggs9mk2z26mqygq43q2kr3n932egxx630svywd5mp REWARDING_VALIDATOR_ADDRESS=n10yyd98e2tuwu0f7ypz9dy3hhjw7v772q6287gy STATISTICS_SERVICE_DOMAIN_ADDRESS="https://mainnet-stats.nymte.ch:8090" -NYXD="https://rpc.nymtech.net" -NYM_API="https://validator.nymtech.net/api/" +NYXD=https://rpc.nymtech.net +NYM_API=https://validator.nymtech.net/api/ NYXD_WS="wss://rpc.nymtech.net/websocket" -EXPLORER_API="https://explorer.nymtech.net/api/" +EXPLORER_API=https://explorer.nymtech.net/api/ NYM_VPN_API="https://nymvpn.com/api" diff --git a/envs/qa.env b/envs/qa.env index e88f845416..34306a62ea 100644 --- a/envs/qa.env +++ b/envs/qa.env @@ -19,5 +19,5 @@ VESTING_CONTRACT_ADDRESS=n1jlzdxnyces4hrhqz68dqk28mrw5jgwtcfq0c2funcwrmw0dx9l9s8 REWARDING_VALIDATOR_ADDRESS=n1rfvpsynktze6wvn6ldskj8xgwfzzk5v6pnff39 EXPLORER_API=https://qa-network-explorer.qa.nymte.ch/api -NYXD="https://qa-validator.qa.nymte.ch" -NYM_API="https://qa-nym-api.qa.nymte.ch/api" +NYXD=https://qa-validator.qa.nymte.ch +NYM_API=https://qa-nym-api.qa.nymte.ch/api diff --git a/envs/sandbox.env b/envs/sandbox.env index 4763269a6f..6310b8fa89 100644 --- a/envs/sandbox.env +++ b/envs/sandbox.env @@ -20,6 +20,6 @@ ECASH_CONTRACT_ADDRESS=n1v3vydvs2ued84yv3khqwtgldmgwn0elljsdh08dr5s2j9x4rc5fs9jl STATISTICS_SERVICE_DOMAIN_ADDRESS="http://0.0.0.0" EXPLORER_API=https://sandbox-explorer.nymtech.net/api -NYXD="https://rpc.sandbox.nymtech.net" -NYXD_WS="wss://rpc.sandbox.nymtech.net/websocket" -NYM_API="https://sandbox-nym-api1.nymtech.net/api" +NYXD=https://rpc.sandbox.nymtech.net +NYXD_WS=wss://rpc.sandbox.nymtech.net/websocket +NYM_API=https://sandbox-nym-api1.nymtech.net/api diff --git a/explorer-api/explorer-client/Cargo.toml b/explorer-api/explorer-client/Cargo.toml index 2397c83f7b..d32429e9a1 100644 --- a/explorer-api/explorer-client/Cargo.toml +++ b/explorer-api/explorer-client/Cargo.toml @@ -7,12 +7,12 @@ license.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -log.workspace = true nym-explorer-api-requests = { path = "../explorer-api-requests" } reqwest = { workspace = true, features = ["json"] } serde.workspace = true thiserror.workspace = true url.workspace = true +tracing = {workspace = true, features = ["attributes"]} [dev-dependencies] tokio = { workspace = true, features = ["full"] } diff --git a/explorer-api/explorer-client/src/lib.rs b/explorer-api/explorer-client/src/lib.rs index f23d10683d..999f23466b 100644 --- a/explorer-api/explorer-client/src/lib.rs +++ b/explorer-api/explorer-client/src/lib.rs @@ -3,6 +3,7 @@ use std::time::Duration; use reqwest::StatusCode; use thiserror::Error; +use tracing::instrument; use url::Url; // Re-export request types @@ -49,27 +50,28 @@ impl ExplorerClient { Ok(Self { client, url }) } - #[cfg(target_arch = "wasm32")] - pub fn new(url: url::Url) -> Result { - let client = reqwest::Client::builder().build()?; - Ok(Self { client, url }) - } - #[cfg(not(target_arch = "wasm32"))] pub fn new_with_timeout(url: url::Url, timeout: Duration) -> Result { let client = reqwest::Client::builder().timeout(timeout).build()?; Ok(Self { client, url }) } + #[cfg(target_arch = "wasm32")] + pub fn new(url: url::Url) -> Result { + let client = reqwest::Client::builder().build()?; + Ok(Self { client, url }) + } + async fn send_get_request( &self, paths: &[&str], ) -> Result { let url = combine_url(self.url.clone(), paths)?; - log::trace!("Sending GET request {url:?}"); + tracing::debug!("Sending GET request"); Ok(self.client.get(url).send().await?) } + #[instrument(level = "trace", skip_all, fields(paths=?paths))] async fn query_explorer_api(&self, paths: &[&str]) -> Result where T: std::fmt::Debug, @@ -78,12 +80,14 @@ impl ExplorerClient { let response = self.send_get_request(paths).await?; if response.status().is_success() { let res = response.json::().await?; - log::trace!("Got response: {res:?}"); + tracing::trace!("Got response: {res:?}"); Ok(res) } else if response.status() == StatusCode::NOT_FOUND { Err(ExplorerApiError::NotFound) } else { - Err(ExplorerApiError::RequestFailure(response.text().await?)) + let status = response.status(); + let err_msg = format!("{}: {}", response.text().await?, status); + Err(ExplorerApiError::RequestFailure(err_msg)) } } diff --git a/nym-data-observatory/README_SQLX.md b/nym-data-observatory/README_SQLX.md index 3e1021c8eb..72cca4294f 100644 --- a/nym-data-observatory/README_SQLX.md +++ b/nym-data-observatory/README_SQLX.md @@ -68,6 +68,7 @@ warning: no queries found; do you have the `offline` feature enabled ### Possible solutions - does your `sqlx-cli` version match `sqlx` version from `Cargo.toml`? + + `cargo install -f sqlx-cli --version ` ``` cargo install sqlx-cli --version --force ``` diff --git a/nym-node-status-agent/.gitignore b/nym-node-status-agent/.gitignore new file mode 100644 index 0000000000..bf462f3e05 --- /dev/null +++ b/nym-node-status-agent/.gitignore @@ -0,0 +1 @@ +nym-gateway-probe diff --git a/nym-node-status-agent/Dockerfile b/nym-node-status-agent/Dockerfile new file mode 100644 index 0000000000..0280fc5687 --- /dev/null +++ b/nym-node-status-agent/Dockerfile @@ -0,0 +1,28 @@ +FROM rust:latest AS builder + +RUN apt update && apt install -yy libdbus-1-dev pkg-config libclang-dev + +# Install go +RUN wget https://go.dev/dl/go1.22.5.linux-amd64.tar.gz -O go.tar.gz +RUN tar -xzvf go.tar.gz -C /usr/local + +RUN git clone https://github.com/nymtech/nym-vpn-client /usr/src/nym-vpn-client +ENV PATH=/go/bin:/usr/local/go/bin:$PATH +WORKDIR /usr/src/nym-vpn-client/nym-vpn-core +RUN cargo build --release --package nym-gateway-probe + +COPY ./ /usr/src/nym +WORKDIR /usr/src/nym/nym-node-status-agent +RUN cargo build --release + +FROM ubuntu:24.04 + +RUN apt-get update && apt-get install -y ca-certificates + +WORKDIR /nym + +COPY --from=builder /usr/src/nym/target/release/nym-node-status-agent ./ +COPY --from=builder /usr/src/nym-vpn-client/nym-vpn-core/target/release/nym-gateway-probe ./ + +ENV NODE_STATUS_AGENT_PROBE_PATH=/nym/nym-gateway-probe +ENTRYPOINT [ "/nym/nym-node-status-agent" ] diff --git a/nym-node-status-agent/src/main.rs b/nym-node-status-agent/src/main.rs new file mode 100644 index 0000000000..133828b7fa --- /dev/null +++ b/nym-node-status-agent/src/main.rs @@ -0,0 +1,78 @@ +use crate::cli::Args; +use clap::Parser; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::{filter::Directive, EnvFilter}; + +mod cli; +mod probe; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + setup_tracing(); + let args = Args::parse(); + + let server_addr = format!("{}:{}", args.server_address, args.server_port); + test_ns_api_conn(&server_addr).await?; + + args.execute().await?; + + Ok(()) +} + +async fn test_ns_api_conn(server_addr: &str) -> anyhow::Result<()> { + reqwest::get(server_addr) + .await + .map(|res| { + tracing::info!( + "Testing connection to NS API at {server_addr}: {}", + res.status() + ); + }) + .map_err(|err| anyhow::anyhow!("Couldn't connect to server on {}: {}", server_addr, err)) +} + +pub(crate) fn setup_tracing() { + fn directive_checked(directive: impl Into) -> Directive { + directive + .into() + .parse() + .expect("Failed to parse log directive") + } + + let log_builder = tracing_subscriber::fmt() + // Use a more compact, abbreviated log format + .compact() + // Display source code file paths + .with_file(true) + // Display source code line numbers + .with_line_number(true) + .with_thread_ids(true) + // Don't display the event's target (module path) + .with_target(false); + + let mut filter = EnvFilter::builder() + // if RUST_LOG isn't set, set default level + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(); + // these crates are more granularly filtered + let filter_crates = [ + "reqwest", + "rustls", + "hyper", + "sqlx", + "h2", + "tendermint_rpc", + "tower_http", + "axum", + ]; + for crate_name in filter_crates { + filter = filter.add_directive(directive_checked(format!("{}=warn", crate_name))); + } + + filter = filter.add_directive(directive_checked("nym_bin_common=debug")); + filter = filter.add_directive(directive_checked("nym_explorer_client=debug")); + filter = filter.add_directive(directive_checked("nym_network_defaults=debug")); + filter = filter.add_directive(directive_checked("nym_validator_client=debug")); + + log_builder.with_env_filter(filter).init(); +} diff --git a/nym-node-status-api/.gitignore b/nym-node-status-api/.gitignore new file mode 100644 index 0000000000..91459afabe --- /dev/null +++ b/nym-node-status-api/.gitignore @@ -0,0 +1,6 @@ +data/ +enter_db.sh +nym-gateway-probe +nym-node-status-api +*.sqlite +*.sqlite-journal diff --git a/nym-node-status-api/Dockerfile b/nym-node-status-api/Dockerfile new file mode 100644 index 0000000000..ceab7c9392 --- /dev/null +++ b/nym-node-status-api/Dockerfile @@ -0,0 +1,15 @@ +FROM rust:latest AS builder + +COPY ./ /usr/src/nym +WORKDIR /usr/src/nym/nym-node-status-api + +RUN cargo build --release + +FROM ubuntu:24.04 + +RUN apt-get update && apt-get install -y ca-certificates + +WORKDIR /nym + +COPY --from=builder /usr/src/nym/target/release/nym-node-status-api ./ +ENTRYPOINT [ "/nym/nym-node-status-api" ] diff --git a/nym-node-status-api/Dockerfile.dev b/nym-node-status-api/Dockerfile.dev new file mode 100644 index 0000000000..2967a6e605 --- /dev/null +++ b/nym-node-status-api/Dockerfile.dev @@ -0,0 +1,8 @@ +FROM ubuntu:22.04 + +RUN apt-get update && apt-get install -y ca-certificates + +WORKDIR /nym + +COPY nym-node-status-api/nym-node-status-api ./ +ENTRYPOINT [ "/nym/nym-node-status-api" ] diff --git a/nym-node-status-api/src/cli/mod.rs b/nym-node-status-api/src/cli/mod.rs new file mode 100644 index 0000000000..84ee86577f --- /dev/null +++ b/nym-node-status-api/src/cli/mod.rs @@ -0,0 +1,77 @@ +use clap::Parser; +use nym_bin_common::bin_info; +use reqwest::Url; +use std::{sync::OnceLock, time::Duration}; + +// Helper for passing LONG_VERSION to clap +fn pretty_build_info_static() -> &'static str { + static PRETTY_BUILD_INFORMATION: OnceLock = OnceLock::new(); + PRETTY_BUILD_INFORMATION.get_or_init(|| bin_info!().pretty_print()) +} + +#[derive(Clone, Debug, Parser)] +#[clap(author = "Nymtech", version, long_version = pretty_build_info_static(), about)] +pub(crate) struct Cli { + /// Network name for the network to which we're connecting. + #[clap(long, env = "NETWORK_NAME")] + pub(crate) network_name: String, + + /// Explorer api url. + #[clap(short, long, env = "EXPLORER_API")] + pub(crate) explorer_api: String, + + /// Nym api url. + #[clap(short, long, env = "NYM_API")] + pub(crate) nym_api: String, + + /// TTL for the http cache. + #[clap( + long, + default_value_t = 30, + env = "NYM_NODE_STATUS_API_NYM_HTTP_CACHE_TTL" + )] + pub(crate) nym_http_cache_ttl: u64, + + /// HTTP port on which to run node status api. + #[clap(long, default_value_t = 8000, env = "NYM_NODE_STATUS_API_HTTP_PORT")] + pub(crate) http_port: u16, + + /// Nyxd address. + #[clap(long, env = "NYXD")] + pub(crate) nyxd_addr: Url, + + /// Nym api client timeout. + #[clap(long, default_value = "15", env = "NYM_API_CLIENT_TIMEOUT")] + #[arg(value_parser = parse_duration)] + pub(crate) nym_api_client_timeout: Duration, + + /// Explorer api client timeout. + #[clap(long, default_value = "15", env = "EXPLORER_CLIENT_TIMEOUT")] + #[arg(value_parser = parse_duration)] + pub(crate) explorer_client_timeout: Duration, + + /// Connection url for the database. + #[clap(long, env = "DATABASE_URL")] + pub(crate) database_url: String, + + #[clap( + long, + default_value = "600", + env = "NODE_STATUS_API_MONITOR_REFRESH_INTERVAL" + )] + #[arg(value_parser = parse_duration)] + pub(crate) monitor_refresh_interval: Duration, + + #[clap( + long, + default_value = "600", + env = "NODE_STATUS_API_TESTRUN_REFRESH_INTERVAL" + )] + #[arg(value_parser = parse_duration)] + pub(crate) testruns_refresh_interval: Duration, +} + +fn parse_duration(arg: &str) -> Result { + let seconds = arg.parse()?; + Ok(std::time::Duration::from_secs(seconds)) +} diff --git a/nym-node-status-api/src/db/mod.rs b/nym-node-status-api/src/db/mod.rs new file mode 100644 index 0000000000..86e25e9227 --- /dev/null +++ b/nym-node-status-api/src/db/mod.rs @@ -0,0 +1,35 @@ +use anyhow::{anyhow, Result}; +use sqlx::{migrate::Migrator, sqlite::SqliteConnectOptions, ConnectOptions, SqlitePool}; +use std::str::FromStr; + +pub(crate) mod models; +pub(crate) mod queries; + +static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); + +pub(crate) type DbPool = SqlitePool; + +pub(crate) struct Storage { + pool: DbPool, +} + +impl Storage { + pub async fn init(connection_url: String) -> Result { + let connect_options = SqliteConnectOptions::from_str(&connection_url)? + .create_if_missing(true) + .disable_statement_logging(); + + let pool = sqlx::SqlitePool::connect_with(connect_options) + .await + .map_err(|err| anyhow!("Failed to connect to {}: {}", &connection_url, err))?; + + MIGRATOR.run(&pool).await?; + + Ok(Storage { pool }) + } + + /// Cloning pool is cheap, it's the same underlying set of connections + pub fn pool_owned(&self) -> DbPool { + self.pool.clone() + } +} diff --git a/nym-node-status-api/src/db/queries/misc.rs b/nym-node-status-api/src/db/queries/misc.rs new file mode 100644 index 0000000000..2aa6356051 --- /dev/null +++ b/nym-node-status-api/src/db/queries/misc.rs @@ -0,0 +1,88 @@ +use crate::db::{models::NetworkSummary, DbPool}; +use chrono::{DateTime, Utc}; + +/// take `last_updated` instead of calculating it so that `summary` matches +/// `daily_summary` +pub(crate) async fn insert_summaries( + pool: &DbPool, + summaries: &[(&str, &usize)], + summary: &NetworkSummary, + last_updated: DateTime, +) -> anyhow::Result<()> { + insert_summary(pool, summaries, last_updated).await?; + + insert_summary_history(pool, summary, last_updated).await?; + + Ok(()) +} + +async fn insert_summary( + pool: &DbPool, + summaries: &[(&str, &usize)], + last_updated: DateTime, +) -> anyhow::Result<()> { + let timestamp = last_updated.timestamp(); + let mut tx = pool.begin().await?; + + for (kind, value) in summaries { + let value = value.to_string(); + sqlx::query!( + "INSERT INTO summary + (key, value_json, last_updated_utc) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value_json=excluded.value_json, + last_updated_utc=excluded.last_updated_utc;", + kind, + value, + timestamp + ) + .execute(&mut *tx) + .await + .map_err(|err| { + tracing::error!("Failed to insert data for {kind}: {err}, aborting transaction",); + err + })?; + } + + tx.commit().await?; + + Ok(()) +} + +/// For ``, `summary_history` is updated with fresh data on every +/// iteration. +/// +/// After UTC midnight, summary is inserted for `` and last entry for +/// `` stays there forever. +/// +/// This is not aggregate data, it's a set of latest data points +async fn insert_summary_history( + pool: &DbPool, + summary: &NetworkSummary, + last_updated: DateTime, +) -> anyhow::Result<()> { + let mut conn = pool.acquire().await?; + + let value_json = serde_json::to_string(&summary)?; + let timestamp = last_updated.timestamp(); + let now_rfc3339 = last_updated.to_rfc3339(); + // YYYY-MM-DD, without time + let date = &now_rfc3339[..10]; + + sqlx::query!( + "INSERT INTO summary_history + (date, timestamp_utc, value_json) + VALUES (?, ?, ?) + ON CONFLICT(date) DO UPDATE SET + timestamp_utc=excluded.timestamp_utc, + value_json=excluded.value_json;", + date, + timestamp, + value_json + ) + .execute(&mut *conn) + .await?; + + Ok(()) +} diff --git a/nym-node-status-api/src/db/queries/mixnodes.rs b/nym-node-status-api/src/db/queries/mixnodes.rs new file mode 100644 index 0000000000..58af9bd429 --- /dev/null +++ b/nym-node-status-api/src/db/queries/mixnodes.rs @@ -0,0 +1,177 @@ +use futures_util::TryStreamExt; +use nym_validator_client::models::MixNodeBondAnnotated; +use tracing::error; + +use crate::{ + db::{ + models::{BondedStatusDto, MixnodeDto, MixnodeRecord}, + DbPool, + }, + http::models::{DailyStats, Mixnode}, +}; + +pub(crate) async fn insert_mixnodes( + pool: &DbPool, + mixnodes: Vec, +) -> anyhow::Result<()> { + let mut conn = pool.acquire().await?; + + for record in mixnodes.iter() { + // https://www.sqlite.org/lang_upsert.html + sqlx::query!( + "INSERT INTO mixnodes + (mix_id, identity_key, bonded, total_stake, + host, http_api_port, blacklisted, full_details, + self_described, last_updated_utc, is_dp_delegatee) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(mix_id) DO UPDATE SET + bonded=excluded.bonded, + total_stake=excluded.total_stake, host=excluded.host, + http_api_port=excluded.http_api_port,blacklisted=excluded.blacklisted, + full_details=excluded.full_details,self_described=excluded.self_described, + last_updated_utc=excluded.last_updated_utc, + is_dp_delegatee = excluded.is_dp_delegatee;", + record.mix_id, + record.identity_key, + record.bonded, + record.total_stake, + record.host, + record.http_port, + record.blacklisted, + record.full_details, + record.self_described, + record.last_updated_utc, + record.is_dp_delegatee + ) + .execute(&mut *conn) + .await?; + } + + Ok(()) +} + +pub(crate) async fn get_all_mixnodes(pool: &DbPool) -> anyhow::Result> { + let mut conn = pool.acquire().await?; + let items = sqlx::query_as!( + MixnodeDto, + r#"SELECT + mn.mix_id as "mix_id!", + mn.bonded as "bonded: bool", + mn.blacklisted as "blacklisted: bool", + mn.is_dp_delegatee as "is_dp_delegatee: bool", + mn.total_stake as "total_stake!", + mn.full_details as "full_details!", + mn.self_described as "self_described", + mn.last_updated_utc as "last_updated_utc!", + COALESCE(md.moniker, "NA") as "moniker!", + COALESCE(md.website, "NA") as "website!", + COALESCE(md.security_contact, "NA") as "security_contact!", + COALESCE(md.details, "NA") as "details!" + FROM mixnodes mn + LEFT JOIN mixnode_description md ON mn.mix_id = md.mix_id + ORDER BY mn.mix_id"# + ) + .fetch(&mut *conn) + .try_collect::>() + .await?; + + let items = items + .into_iter() + .map(|item| item.try_into()) + .collect::>>() + .map_err(|e| { + error!("Conversion from DTO failed: {e}. Invalidly stored data?"); + e + })?; + Ok(items) +} + +/// We fetch the latest 30 days of data as a subquery and then +/// return it in ascending order, so we don't break existing UI +pub(crate) async fn get_daily_stats(pool: &DbPool) -> anyhow::Result> { + let mut conn = pool.acquire().await?; + let items = sqlx::query_as!( + DailyStats, + r#" + SELECT + date_utc as "date_utc!", + packets_received as "total_packets_received!: i64", + packets_sent as "total_packets_sent!: i64", + packets_dropped as "total_packets_dropped!: i64", + total_stake as "total_stake!: i64" + FROM ( + SELECT + date_utc, + SUM(packets_received) as packets_received, + SUM(packets_sent) as packets_sent, + SUM(packets_dropped) as packets_dropped, + SUM(total_stake) as total_stake + FROM mixnode_daily_stats + GROUP BY date_utc + ORDER BY date_utc DESC + LIMIT 30 + ) + GROUP BY date_utc + ORDER BY date_utc + "# + ) + .fetch(&mut *conn) + .try_collect::>() + .await?; + + Ok(items) +} + +/// Ensure all mixnodes that are set as bonded, are still bonded +pub(crate) async fn ensure_mixnodes_still_bonded( + pool: &DbPool, + mixnodes: &[MixNodeBondAnnotated], +) -> anyhow::Result { + let bonded_mixnodes_rows = get_all_bonded_mixnodes_row_ids_by_status(pool, true).await?; + let unbonded_mixnodes_rows = bonded_mixnodes_rows.iter().filter(|v| { + !mixnodes + .iter() + .any(|bonded| *bonded.mixnode_details.bond_information.identity() == v.identity_key) + }); + + let recently_unbonded_mixnodes = unbonded_mixnodes_rows.to_owned().count(); + let last_updated_utc = chrono::offset::Utc::now().timestamp(); + let mut transaction = pool.begin().await?; + for row in unbonded_mixnodes_rows { + sqlx::query!( + "UPDATE mixnodes + SET bonded = ?, last_updated_utc = ? + WHERE id = ?;", + false, + last_updated_utc, + row.id, + ) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + + Ok(recently_unbonded_mixnodes) +} + +async fn get_all_bonded_mixnodes_row_ids_by_status( + pool: &DbPool, + status: bool, +) -> anyhow::Result> { + let mut conn = pool.acquire().await?; + let items = sqlx::query_as!( + BondedStatusDto, + r#"SELECT + id as "id!", + identity_key as "identity_key!", + bonded as "bonded: bool" + FROM mixnodes + WHERE bonded = ?"#, + status, + ) + .fetch(&mut *conn) + .try_collect::>() + .await?; + + Ok(items) +} diff --git a/nym-node-status-api/src/db/queries/summary.rs b/nym-node-status-api/src/db/queries/summary.rs new file mode 100644 index 0000000000..103712a9a4 --- /dev/null +++ b/nym-node-status-api/src/db/queries/summary.rs @@ -0,0 +1,209 @@ +use chrono::{DateTime, Utc}; +use futures_util::TryStreamExt; +use std::collections::HashMap; +use tracing::error; + +use crate::{ + db::{ + models::{ + gateway::{ + GatewaySummary, GatewaySummaryBlacklisted, GatewaySummaryBonded, + GatewaySummaryExplorer, GatewaySummaryHistorical, + }, + mixnode::{ + MixnodeSummary, MixnodeSummaryBlacklisted, MixnodeSummaryBonded, + MixnodeSummaryHistorical, + }, + NetworkSummary, SummaryDto, SummaryHistoryDto, + }, + DbPool, + }, + http::{ + error::{HttpError, HttpResult}, + models::SummaryHistory, + }, +}; + +pub(crate) async fn get_summary_history(pool: &DbPool) -> anyhow::Result> { + let mut conn = pool.acquire().await?; + let items = sqlx::query_as!( + SummaryHistoryDto, + r#"SELECT + id as "id!", + date as "date!", + timestamp_utc as "timestamp_utc!", + value_json as "value_json!" + FROM summary_history + ORDER BY date DESC + LIMIT 30"#, + ) + .fetch(&mut *conn) + .try_collect::>() + .await?; + + let items = items + .into_iter() + .map(|item| item.try_into()) + .collect::>>() + .map_err(|e| { + error!("Conversion from DTO failed: {e}. Invalidly stored data?"); + e + })?; + Ok(items) +} + +async fn get_summary_dto(pool: &DbPool) -> anyhow::Result> { + let mut conn = pool.acquire().await?; + Ok(sqlx::query_as!( + SummaryDto, + r#"SELECT + key as "key!", + value_json as "value_json!", + last_updated_utc as "last_updated_utc!" + FROM summary"# + ) + .fetch(&mut *conn) + .try_collect::>() + .await?) +} + +pub(crate) async fn get_summary(pool: &DbPool) -> HttpResult { + let items = get_summary_dto(pool).await.map_err(|err| { + tracing::error!("Couldn't get Summary from DB: {err}"); + HttpError::internal() + })?; + from_summary_dto(items).await +} + +async fn from_summary_dto(items: Vec) -> HttpResult { + const MIXNODES_BONDED_COUNT: &str = "mixnodes.bonded.count"; + const MIXNODES_BONDED_ACTIVE: &str = "mixnodes.bonded.active"; + const MIXNODES_BONDED_INACTIVE: &str = "mixnodes.bonded.inactive"; + const MIXNODES_BONDED_RESERVE: &str = "mixnodes.bonded.reserve"; + const MIXNODES_BLACKLISTED_COUNT: &str = "mixnodes.blacklisted.count"; + const GATEWAYS_BONDED_COUNT: &str = "gateways.bonded.count"; + const GATEWAYS_EXPLORER_COUNT: &str = "gateways.explorer.count"; + const GATEWAYS_BLACKLISTED_COUNT: &str = "gateways.blacklisted.count"; + const MIXNODES_HISTORICAL_COUNT: &str = "mixnodes.historical.count"; + const GATEWAYS_HISTORICAL_COUNT: &str = "gateways.historical.count"; + + // convert database rows into a map by key + let mut map = HashMap::new(); + for item in items { + map.insert(item.key.clone(), item); + } + + // check we have all the keys we are expecting, and build up a map of errors for missing one + let keys = [ + GATEWAYS_BONDED_COUNT, + GATEWAYS_EXPLORER_COUNT, + GATEWAYS_HISTORICAL_COUNT, + GATEWAYS_BLACKLISTED_COUNT, + MIXNODES_BLACKLISTED_COUNT, + MIXNODES_BONDED_ACTIVE, + MIXNODES_BONDED_COUNT, + MIXNODES_BONDED_INACTIVE, + MIXNODES_BONDED_RESERVE, + MIXNODES_HISTORICAL_COUNT, + ]; + + let mut errors: Vec<&str> = vec![]; + for key in keys { + if !map.contains_key(key) { + errors.push(key); + } + } + + // return an error if anything is missing, with a nice list + if !errors.is_empty() { + tracing::error!("Summary value missing: {}", errors.join(", ")); + return Err(HttpError::internal()); + } + + // strip the options and use default values (anything missing is trapped above) + let mixnodes_bonded_count: SummaryDto = + map.get(MIXNODES_BONDED_COUNT).cloned().unwrap_or_default(); + let mixnodes_bonded_active: SummaryDto = + map.get(MIXNODES_BONDED_ACTIVE).cloned().unwrap_or_default(); + let mixnodes_bonded_inactive: SummaryDto = map + .get(MIXNODES_BONDED_INACTIVE) + .cloned() + .unwrap_or_default(); + let mixnodes_bonded_reserve: SummaryDto = map + .get(MIXNODES_BONDED_RESERVE) + .cloned() + .unwrap_or_default(); + let mixnodes_blacklisted_count: SummaryDto = map + .get(MIXNODES_BLACKLISTED_COUNT) + .cloned() + .unwrap_or_default(); + let gateways_bonded_count: SummaryDto = + map.get(GATEWAYS_BONDED_COUNT).cloned().unwrap_or_default(); + let gateways_explorer_count: SummaryDto = map + .get(GATEWAYS_EXPLORER_COUNT) + .cloned() + .unwrap_or_default(); + let mixnodes_historical_count: SummaryDto = map + .get(MIXNODES_HISTORICAL_COUNT) + .cloned() + .unwrap_or_default(); + let gateways_historical_count: SummaryDto = map + .get(GATEWAYS_HISTORICAL_COUNT) + .cloned() + .unwrap_or_default(); + let gateways_blacklisted_count: SummaryDto = map + .get(GATEWAYS_BLACKLISTED_COUNT) + .cloned() + .unwrap_or_default(); + + Ok(NetworkSummary { + mixnodes: MixnodeSummary { + bonded: MixnodeSummaryBonded { + count: to_count_i32(&mixnodes_bonded_count), + active: to_count_i32(&mixnodes_bonded_active), + reserve: to_count_i32(&mixnodes_bonded_reserve), + inactive: to_count_i32(&mixnodes_bonded_inactive), + last_updated_utc: to_timestamp(&mixnodes_bonded_count), + }, + blacklisted: MixnodeSummaryBlacklisted { + count: to_count_i32(&mixnodes_blacklisted_count), + last_updated_utc: to_timestamp(&mixnodes_blacklisted_count), + }, + historical: MixnodeSummaryHistorical { + count: to_count_i32(&mixnodes_historical_count), + last_updated_utc: to_timestamp(&mixnodes_historical_count), + }, + }, + gateways: GatewaySummary { + bonded: GatewaySummaryBonded { + count: to_count_i32(&gateways_bonded_count), + last_updated_utc: to_timestamp(&gateways_bonded_count), + }, + blacklisted: GatewaySummaryBlacklisted { + count: to_count_i32(&gateways_blacklisted_count), + last_updated_utc: to_timestamp(&gateways_blacklisted_count), + }, + historical: GatewaySummaryHistorical { + count: to_count_i32(&gateways_historical_count), + last_updated_utc: to_timestamp(&gateways_historical_count), + }, + explorer: GatewaySummaryExplorer { + count: to_count_i32(&gateways_explorer_count), + last_updated_utc: to_timestamp(&gateways_explorer_count), + }, + }, + }) +} + +fn to_count_i32(value: &SummaryDto) -> i32 { + value.value_json.parse::().unwrap_or_default() +} + +fn to_timestamp(value: &SummaryDto) -> String { + timestamp_as_utc(value.last_updated_utc as u64).to_rfc3339() +} + +fn timestamp_as_utc(unix_timestamp: u64) -> DateTime { + let d = std::time::UNIX_EPOCH + std::time::Duration::from_secs(unix_timestamp); + d.into() +} diff --git a/nym-node-status-api/src/http/api/gateways.rs b/nym-node-status-api/src/http/api/gateways.rs new file mode 100644 index 0000000000..c1f4073767 --- /dev/null +++ b/nym-node-status-api/src/http/api/gateways.rs @@ -0,0 +1,110 @@ +use axum::{ + extract::{Path, Query, State}, + Json, Router, +}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::http::{ + error::{HttpError, HttpResult}, + models::{Gateway, GatewaySkinny}, + state::AppState, + PagedResult, Pagination, +}; + +pub(crate) fn routes() -> Router { + Router::new() + .route("/", axum::routing::get(gateways)) + .route("/skinny", axum::routing::get(gateways_skinny)) + .route("/:identity_key", axum::routing::get(get_gateway)) +} + +#[utoipa::path( + tag = "Gateways", + get, + params( + Pagination + ), + path = "/v2/gateways", + responses( + (status = 200, body = PagedGateway) + ) +)] +async fn gateways( + Query(pagination): Query, + State(state): State, +) -> HttpResult>> { + let db = state.db_pool(); + let res = state.cache().get_gateway_list(db).await; + + Ok(Json(PagedResult::paginate(pagination, res))) +} + +#[utoipa::path( + tag = "Gateways", + get, + params( + Pagination + ), + path = "/v2/gateways/skinny", + responses( + (status = 200, body = PagedGatewaySkinny) + ) +)] +async fn gateways_skinny( + Query(pagination): Query, + State(state): State, +) -> HttpResult>> { + let db = state.db_pool(); + let res = state.cache().get_gateway_list(db).await; + let res: Vec = res + .iter() + .filter(|g| g.bonded) + .map(|g| GatewaySkinny { + gateway_identity_key: g.gateway_identity_key.clone(), + self_described: g.self_described.clone(), + performance: g.performance, + explorer_pretty_bond: g.explorer_pretty_bond.clone(), + last_probe_result: g.last_probe_result.clone(), + last_testrun_utc: g.last_testrun_utc.clone(), + last_updated_utc: g.last_updated_utc.clone(), + routing_score: g.routing_score, + config_score: g.config_score, + }) + .collect(); + + Ok(Json(PagedResult::paginate(pagination, res))) +} + +#[derive(Deserialize, IntoParams)] +#[into_params(parameter_in = Path)] +struct IdentityKeyParam { + identity_key: String, +} + +#[utoipa::path( + tag = "Gateways", + get, + params( + IdentityKeyParam + ), + path = "/v2/gateways/{identity_key}", + responses( + (status = 200, body = Gateway) + ) +)] +async fn get_gateway( + Path(IdentityKeyParam { identity_key }): Path, + State(state): State, +) -> HttpResult> { + let db = state.db_pool(); + let res = state.cache().get_gateway_list(db).await; + + match res + .iter() + .find(|item| item.gateway_identity_key == identity_key) + { + Some(res) => Ok(Json(res.clone())), + None => Err(HttpError::invalid_input(identity_key)), + } +} diff --git a/nym-node-status-api/src/http/api/mixnodes.rs b/nym-node-status-api/src/http/api/mixnodes.rs new file mode 100644 index 0000000000..f42d0bf91c --- /dev/null +++ b/nym-node-status-api/src/http/api/mixnodes.rs @@ -0,0 +1,91 @@ +use axum::{ + extract::{Path, Query, State}, + Json, Router, +}; +use serde::Deserialize; +use tracing::instrument; +use utoipa::IntoParams; + +use crate::http::{ + error::{HttpError, HttpResult}, + models::{DailyStats, Mixnode}, + state::AppState, + PagedResult, Pagination, +}; + +pub(crate) fn routes() -> Router { + Router::new() + .route("/", axum::routing::get(mixnodes)) + .route("/:mix_id", axum::routing::get(get_mixnodes)) + .route("/stats", axum::routing::get(get_stats)) +} + +#[utoipa::path( + tag = "Mixnodes", + get, + params( + Pagination + ), + path = "/v2/mixnodes", + responses( + (status = 200, body = PagedMixnode) + ) +)] +#[instrument(level = tracing::Level::DEBUG, skip_all, fields(page=pagination.page, size=pagination.size))] +async fn mixnodes( + Query(pagination): Query, + State(state): State, +) -> HttpResult>> { + let db = state.db_pool(); + let res = state.cache().get_mixnodes_list(db).await; + + Ok(Json(PagedResult::paginate(pagination, res))) +} + +#[derive(Deserialize, IntoParams)] +#[into_params(parameter_in = Path)] +struct MixIdParam { + mix_id: String, +} + +#[utoipa::path( + tag = "Mixnodes", + get, + params( + MixIdParam + ), + path = "/v2/mixnodes/{mix_id}", + responses( + (status = 200, body = Mixnode) + ) +)] +#[instrument(level = tracing::Level::DEBUG, skip_all, fields(mix_id = mix_id))] +async fn get_mixnodes( + Path(MixIdParam { mix_id }): Path, + State(state): State, +) -> HttpResult> { + match mix_id.parse::() { + Ok(parsed_mix_id) => { + let res = state.cache().get_mixnodes_list(state.db_pool()).await; + + match res.iter().find(|item| item.mix_id == parsed_mix_id) { + Some(res) => Ok(Json(res.clone())), + None => Err(HttpError::invalid_input(mix_id)), + } + } + Err(_e) => Err(HttpError::invalid_input(mix_id)), + } +} + +#[utoipa::path( + tag = "Mixnodes", + get, + path = "/v2/mixnodes/stats", + responses( + (status = 200, body = Vec) + ) +)] +async fn get_stats(State(state): State) -> HttpResult>> { + let stats = state.cache().get_mixnode_stats(state.db_pool()).await; + Ok(Json(stats)) +} diff --git a/nym-node-status-api/src/http/api/services/json_path.rs b/nym-node-status-api/src/http/api/services/json_path.rs new file mode 100644 index 0000000000..caefc6489d --- /dev/null +++ b/nym-node-status-api/src/http/api/services/json_path.rs @@ -0,0 +1,58 @@ +use serde_json_path::JsonPath; + +use crate::http::models::Gateway; + +pub(super) struct ParseJsonPaths { + pub(super) path_ip_address: JsonPath, + pub(super) path_hostname: JsonPath, + pub(super) path_service_provider_client_id: JsonPath, +} + +impl ParseJsonPaths { + pub fn new() -> Result { + Ok(ParseJsonPaths { + path_ip_address: JsonPath::parse("$.host_information.ip_address[0]")?, + path_hostname: JsonPath::parse("$.host_information.hostname")?, + path_service_provider_client_id: JsonPath::parse("$.network_requester.address")?, + }) + } +} + +pub(super) struct ParsedDetails { + pub(super) ip_address: Option, + pub(super) hostname: Option, + pub(super) service_provider_client_id: Option, +} + +impl ParsedDetails { + fn get_string_from_json_path( + value: &Option, + path: &JsonPath, + ) -> Option { + match value { + Some(value) => path + .query(value) + .exactly_one() + .map(|v2| v2.as_str().map(|v3| v3.to_string())) + .ok() + .flatten(), + None => None, + } + } + pub fn new(paths: &ParseJsonPaths, g: &Gateway) -> ParsedDetails { + ParsedDetails { + hostname: ParsedDetails::get_string_from_json_path( + &g.self_described, + &paths.path_hostname, + ), + ip_address: ParsedDetails::get_string_from_json_path( + &g.self_described, + &paths.path_ip_address, + ), + service_provider_client_id: ParsedDetails::get_string_from_json_path( + &g.self_described, + &paths.path_service_provider_client_id, + ), + } + } +} diff --git a/nym-node-status-api/src/http/api/services/mod.rs b/nym-node-status-api/src/http/api/services/mod.rs new file mode 100644 index 0000000000..5650684c43 --- /dev/null +++ b/nym-node-status-api/src/http/api/services/mod.rs @@ -0,0 +1,134 @@ +use crate::http::{ + error::{HttpError, HttpResult}, + models::Service, + state::AppState, + PagedResult, Pagination, +}; +use axum::{ + extract::{Query, State}, + Json, Router, +}; +use json_path::{ParseJsonPaths, ParsedDetails}; +use tracing::instrument; + +mod json_path; + +pub(crate) fn routes() -> Router { + Router::new().route("/", axum::routing::get(mixnodes)) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +pub(crate) struct ServicesQueryParams { + size: Option, + page: Option, + wss: Option, + hostname: Option, + entry: Option, +} + +#[utoipa::path( + tag = "Services", + get, + params( + ServicesQueryParams, + ), + path = "/v2/services", + responses( + (status = 200, body = PagedService) + ) +)] +#[instrument(level = tracing::Level::DEBUG, skip(state))] +async fn mixnodes( + Query(ServicesQueryParams { + size, + page, + wss, + hostname, + entry, + }): Query, + State(state): State, +) -> HttpResult>> { + let db = state.db_pool(); + let cache = state.cache(); + + let show_only_wss = wss.unwrap_or(false); + let show_only_with_hostname = hostname.unwrap_or(false); + let show_entry_gateways_only = entry.unwrap_or(false); + + let paths = ParseJsonPaths::new().map_err(|e| { + tracing::error!("Invalidly configured ParseJsonPaths: {e}"); + HttpError::internal() + })?; + let res = cache.get_gateway_list(db).await; + let res: Vec = res + .iter() + .map(|g| { + let details = ParsedDetails::new(&paths, g); + + let s = Service { + gateway_identity_key: g.gateway_identity_key.clone(), + ip_address: details.ip_address, + service_provider_client_id: details.service_provider_client_id, + hostname: details.hostname, + last_successful_ping_utc: g.last_testrun_utc.clone(), + last_updated_utc: g.last_updated_utc.clone(), + // routing_score: g.routing_score, + routing_score: 1f32, + mixnet_websockets: g + .self_described + .clone() + .and_then(|s| s.get("mixnet_websockets").cloned()), + }; + + let f = ServiceFilter::new(&s); + + (s, f) + }) + .filter(|(_, f)| { + let mut keep = f.has_network_requester_sp; + + if show_entry_gateways_only { + keep = true; + } + + if show_only_wss { + keep &= f.has_wss; + } + if show_only_with_hostname { + keep &= f.has_hostname; + } + + keep + }) + .map(|(s, _)| s) + .collect(); + + Ok(Json(PagedResult::paginate(Pagination { size, page }, res))) +} + +struct ServiceFilter { + has_wss: bool, + has_network_requester_sp: bool, + has_hostname: bool, +} + +impl ServiceFilter { + fn new(s: &Service) -> Self { + let has_wss = match &s.mixnet_websockets { + Some(v) => v.get("wss_port").map(|v2| !v2.is_null()).unwrap_or(false), + None => false, + }; + let has_hostname = s.hostname.is_some(); + let has_network_requester_sp = match &s.service_provider_client_id { + Some(v) => !v.is_empty(), + None => false, + }; + + ServiceFilter { + has_wss, + has_hostname, + has_network_requester_sp, + } + } +} diff --git a/nym-node-status-api/src/http/api/summary.rs b/nym-node-status-api/src/http/api/summary.rs new file mode 100644 index 0000000000..729141509c --- /dev/null +++ b/nym-node-status-api/src/http/api/summary.rs @@ -0,0 +1,43 @@ +use axum::{extract::State, Json, Router}; +use tracing::instrument; + +use crate::{ + db::models::NetworkSummary, + http::{error::HttpResult, models::SummaryHistory, state::AppState}, +}; + +pub(crate) fn routes() -> Router { + Router::new() + .route("/", axum::routing::get(summary)) + .route("/history", axum::routing::get(summary_history)) +} + +#[utoipa::path( + tag = "Summary", + get, + path = "/v2/summary", + responses( + (status = 200, body = NetworkSummary) + ) +)] +#[instrument(level = tracing::Level::DEBUG, skip_all)] +async fn summary(State(state): State) -> HttpResult> { + crate::db::queries::get_summary(state.db_pool()) + .await + .map(Json) +} + +#[utoipa::path( + tag = "Summary", + get, + path = "/v2/summary/history", + responses( + (status = 200, body = Vec) + ) +)] +#[instrument(level = tracing::Level::DEBUG, skip_all)] +async fn summary_history(State(state): State) -> HttpResult>> { + Ok(Json( + state.cache().get_summary_history(state.db_pool()).await, + )) +} diff --git a/nym-node-status-api/src/http/api_docs.rs b/nym-node-status-api/src/http/api_docs.rs new file mode 100644 index 0000000000..9ac5238fc4 --- /dev/null +++ b/nym-node-status-api/src/http/api_docs.rs @@ -0,0 +1,15 @@ +use crate::http::{Gateway, GatewaySkinny, Mixnode, Service}; +use utoipa::OpenApi; +use utoipauto::utoipauto; + +// manually import external structs which are behind feature flags because they +// can't be automatically discovered +// https://github.com/ProbablyClem/utoipauto/issues/13#issuecomment-1974911829 +#[utoipauto(paths = "./nym-node-status-api/src")] +#[derive(OpenApi)] +#[openapi( + info(title = "Node Status API"), + tags(), + components(schemas(nym_node_requests::api::v1::node::models::NodeDescription,)) +)] +pub(super) struct ApiDoc; diff --git a/nym-node-status-api/src/http/mod.rs b/nym-node-status-api/src/http/mod.rs new file mode 100644 index 0000000000..1cc317337f --- /dev/null +++ b/nym-node-status-api/src/http/mod.rs @@ -0,0 +1,71 @@ +use models::{Gateway, GatewaySkinny, Mixnode, Service}; + +pub(crate) mod api; +pub(crate) mod api_docs; +pub(crate) mod error; +pub(crate) mod models; +pub(crate) mod server; +pub(crate) mod state; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] +// exclude generic from auto-discovery +#[utoipauto::utoipa_ignore] +// https://docs.rs/utoipa/latest/utoipa/derive.ToSchema.html#generic-schemas-with-aliases +// Generic structs can only be included via aliases, not directly, because they +// it would cause an error in generated Swagger docs. +// Instead, you have to manually monomorphize each generic struct that +// you wish to document +#[aliases( + PagedGateway = PagedResult, + PagedGatewaySkinny = PagedResult, + PagedMixnode = PagedResult, + PagedService = PagedResult, +)] +pub struct PagedResult { + pub page: usize, + pub size: usize, + pub total: usize, + pub items: Vec, +} + +impl PagedResult { + pub fn paginate(pagination: Pagination, res: Vec) -> Self { + let total = res.len(); + let (size, mut page) = pagination.intoto_inner_values(); + + if page * size > total { + page = total / size; + } + + let chunks: Vec<&[T]> = res.chunks(size).collect(); + + PagedResult { + page, + size, + total, + items: chunks.get(page).cloned().unwrap_or_default().into(), + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +pub(crate) struct Pagination { + size: Option, + page: Option, +} + +impl Pagination { + // unwrap stored values or use predefined defaults + pub(crate) fn intoto_inner_values(self) -> (usize, usize) { + const SIZE_DEFAULT: usize = 10; + const SIZE_MAX: usize = 200; + + const PAGE_DEFAULT: usize = 0; + + ( + self.size.unwrap_or(SIZE_DEFAULT).min(SIZE_MAX), + self.page.unwrap_or(PAGE_DEFAULT), + ) + } +} diff --git a/nym-node-status-api/src/http/server.rs b/nym-node-status-api/src/http/server.rs new file mode 100644 index 0000000000..17d5d64ab0 --- /dev/null +++ b/nym-node-status-api/src/http/server.rs @@ -0,0 +1,93 @@ +use axum::Router; +use core::net::SocketAddr; +use tokio::{net::TcpListener, task::JoinHandle}; +use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; + +use crate::{ + db::DbPool, + http::{api::RouterBuilder, state::AppState}, +}; + +/// Return handles that allow for graceful shutdown of server + awaiting its +/// background tokio task +pub(crate) async fn start_http_api( + db_pool: DbPool, + http_port: u16, + nym_http_cache_ttl: u64, +) -> anyhow::Result { + let router_builder = RouterBuilder::with_default_routes(); + + let state = AppState::new(db_pool, nym_http_cache_ttl); + let router = router_builder.with_state(state); + + // TODO dz do we need this to be configurable? + let bind_addr = format!("0.0.0.0:{}", http_port); + tracing::info!("Binding server to {bind_addr}"); + let server = router.build_server(bind_addr).await?; + + Ok(start_server(server)) +} + +fn start_server(server: HttpServer) -> ShutdownHandles { + // one copy is stored to trigger a graceful shutdown later + let shutdown_button = CancellationToken::new(); + // other copy is given to server to listen for a shutdown + let shutdown_receiver = shutdown_button.clone(); + let shutdown_receiver = shutdown_receiver.cancelled_owned(); + + let server_handle = tokio::spawn(async move { server.run(shutdown_receiver).await }); + + ShutdownHandles { + server_handle, + shutdown_button, + } +} + +pub(crate) struct ShutdownHandles { + server_handle: JoinHandle>, + shutdown_button: CancellationToken, +} + +impl ShutdownHandles { + /// Send graceful shutdown signal to server and wait for server task to complete + pub(crate) async fn shutdown(self) -> anyhow::Result<()> { + self.shutdown_button.cancel(); + + match self.server_handle.await { + Ok(Ok(_)) => { + tracing::info!("HTTP server shut down without errors"); + } + Ok(Err(err)) => { + tracing::error!("HTTP server terminated with: {err}"); + anyhow::bail!(err) + } + Err(err) => { + tracing::error!("Server task panicked: {err}"); + } + }; + + Ok(()) + } +} + +pub(crate) struct HttpServer { + router: Router, + listener: TcpListener, +} + +impl HttpServer { + pub(crate) fn new(router: Router, listener: TcpListener) -> Self { + Self { router, listener } + } + + pub(crate) async fn run(self, receiver: WaitForCancellationFutureOwned) -> std::io::Result<()> { + // into_make_service_with_connect_info allows us to see client ip address + axum::serve( + self.listener, + self.router + .into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(receiver) + .await + } +} diff --git a/nym-node-status-api/src/http/state.rs b/nym-node-status-api/src/http/state.rs new file mode 100644 index 0000000000..6bccea39a1 --- /dev/null +++ b/nym-node-status-api/src/http/state.rs @@ -0,0 +1,223 @@ +use std::{sync::Arc, time::Duration}; + +use moka::{future::Cache, Entry}; +use tokio::sync::RwLock; + +use crate::{ + db::DbPool, + http::models::{DailyStats, Gateway, Mixnode, SummaryHistory}, +}; + +#[derive(Debug, Clone)] +pub(crate) struct AppState { + db_pool: DbPool, + cache: HttpCache, +} + +impl AppState { + pub(crate) fn new(db_pool: DbPool, cache_ttl: u64) -> Self { + Self { + db_pool, + cache: HttpCache::new(cache_ttl), + } + } + + pub(crate) fn db_pool(&self) -> &DbPool { + &self.db_pool + } + + pub(crate) fn cache(&self) -> &HttpCache { + &self.cache + } +} + +static GATEWAYS_LIST_KEY: &str = "gateways"; +static MIXNODES_LIST_KEY: &str = "mixnodes"; +static MIXSTATS_LIST_KEY: &str = "mixstats"; +static SUMMARY_HISTORY_LIST_KEY: &str = "summary-history"; + +#[derive(Debug, Clone)] +pub(crate) struct HttpCache { + gateways: Cache>>>, + mixnodes: Cache>>>, + mixstats: Cache>>>, + history: Cache>>>, +} + +impl HttpCache { + pub fn new(ttl_seconds: u64) -> Self { + HttpCache { + gateways: Cache::builder() + .max_capacity(2) + .time_to_live(Duration::from_secs(ttl_seconds)) + .build(), + mixnodes: Cache::builder() + .max_capacity(2) + .time_to_live(Duration::from_secs(ttl_seconds)) + .build(), + mixstats: Cache::builder() + .max_capacity(2) + .time_to_live(Duration::from_secs(ttl_seconds)) + .build(), + history: Cache::builder() + .max_capacity(2) + .time_to_live(Duration::from_secs(ttl_seconds)) + .build(), + } + } + + pub async fn upsert_gateway_list( + &self, + new_gateway_list: Vec, + ) -> Entry>>> { + self.gateways + .entry_by_ref(GATEWAYS_LIST_KEY) + .and_upsert_with(|maybe_entry| async { + if let Some(entry) = maybe_entry { + let v = entry.into_value(); + let mut guard = v.write().await; + *guard = new_gateway_list; + v.clone() + } else { + Arc::new(RwLock::new(new_gateway_list)) + } + }) + .await + } + + pub async fn get_gateway_list(&self, db: &DbPool) -> Vec { + match self.gateways.get(GATEWAYS_LIST_KEY).await { + Some(guard) => { + let read_lock = guard.read().await; + read_lock.clone() + } + None => { + // the key is missing so populate it + tracing::warn!("No gateways in cache, refreshing cache from DB..."); + + let gateways = crate::db::queries::get_all_gateways(db) + .await + .unwrap_or_default(); + self.upsert_gateway_list(gateways.clone()).await; + + if gateways.is_empty() { + tracing::warn!("Database contains 0 gateways"); + } + + gateways + } + } + } + + pub async fn upsert_mixnode_list( + &self, + new_mixnode_list: Vec, + ) -> Entry>>> { + self.mixnodes + .entry_by_ref(MIXNODES_LIST_KEY) + .and_upsert_with(|maybe_entry| async { + if let Some(entry) = maybe_entry { + let v = entry.into_value(); + let mut guard = v.write().await; + *guard = new_mixnode_list; + v.clone() + } else { + Arc::new(RwLock::new(new_mixnode_list)) + } + }) + .await + } + + pub async fn get_mixnodes_list(&self, db: &DbPool) -> Vec { + match self.mixnodes.get(MIXNODES_LIST_KEY).await { + Some(guard) => { + let read_lock = guard.read().await; + read_lock.clone() + } + None => { + tracing::warn!("No mixnodes in cache, refreshing cache from DB..."); + + let mixnodes = crate::db::queries::get_all_mixnodes(db) + .await + .unwrap_or_default(); + self.upsert_mixnode_list(mixnodes.clone()).await; + + if mixnodes.is_empty() { + tracing::warn!("Database contains 0 mixnodes"); + } + + mixnodes + } + } + } + + pub async fn upsert_mixnode_stats( + &self, + mixnode_stats: Vec, + ) -> Entry>>> { + self.mixstats + .entry_by_ref(MIXSTATS_LIST_KEY) + .and_upsert_with(|maybe_entry| async { + if let Some(entry) = maybe_entry { + let v = entry.into_value(); + let mut guard = v.write().await; + *guard = mixnode_stats; + v.clone() + } else { + Arc::new(RwLock::new(mixnode_stats)) + } + }) + .await + } + + pub async fn get_mixnode_stats(&self, db: &DbPool) -> Vec { + match self.mixstats.get(MIXSTATS_LIST_KEY).await { + Some(guard) => { + let read_lock = guard.read().await; + read_lock.to_vec() + } + None => { + let mixnode_stats = crate::db::queries::get_daily_stats(db) + .await + .unwrap_or_default(); + self.upsert_mixnode_stats(mixnode_stats.clone()).await; + mixnode_stats + } + } + } + + pub async fn get_summary_history(&self, db: &DbPool) -> Vec { + match self.history.get(SUMMARY_HISTORY_LIST_KEY).await { + Some(guard) => { + let read_lock = guard.read().await; + read_lock.to_vec() + } + None => { + let summary_history = crate::db::queries::get_summary_history(db) + .await + .unwrap_or(vec![]); + self.upsert_summary_history(summary_history.clone()).await; + summary_history + } + } + } + + pub async fn upsert_summary_history( + &self, + summary_history: Vec, + ) -> Entry>>> { + self.history + .entry_by_ref(SUMMARY_HISTORY_LIST_KEY) + .and_upsert_with(|maybe_entry| async { + if let Some(entry) = maybe_entry { + let v = entry.into_value(); + let mut guard = v.write().await; + *guard = summary_history; + v.clone() + } else { + Arc::new(RwLock::new(summary_history)) + } + }) + .await + } +} diff --git a/nym-node-status-api/src/logging.rs b/nym-node-status-api/src/logging.rs new file mode 100644 index 0000000000..6bdad3bae5 --- /dev/null +++ b/nym-node-status-api/src/logging.rs @@ -0,0 +1,69 @@ +use tracing::level_filters::LevelFilter; +use tracing_subscriber::{filter::Directive, EnvFilter}; + +// TODO dz you can get the tracing-subscriber via basic-tracing feature on nym-bin-common +pub(crate) fn setup_tracing_logger() -> anyhow::Result<()> { + fn directive_checked(directive: impl Into) -> anyhow::Result { + directive.into().parse().map_err(From::from) + } + + let log_builder = tracing_subscriber::fmt() + // Use a more compact, abbreviated log format + .compact() + // Display source code file paths + .with_file(true) + // Display source code line numbers + .with_line_number(true) + .with_thread_ids(true) + // Don't display the event's target (module path) + .with_target(false); + + let mut filter = EnvFilter::builder() + // if RUST_LOG isn't set, set default level + .with_default_directive(LevelFilter::DEBUG.into()) + .from_env_lossy(); + + // these crates are more granularly filtered + let warn_crates = [ + "reqwest", + "rustls", + "hyper", + "sqlx", + "h2", + "tendermint_rpc", + "tower_http", + "axum", + ]; + for crate_name in warn_crates { + filter = filter.add_directive(directive_checked(format!("{}=warn", crate_name))?); + } + + let log_level_hint = filter.max_level_hint(); + + // debug or higher granularity (e.g. trace) + let debug_or_higher = std::cmp::max( + log_level_hint.unwrap_or(LevelFilter::DEBUG), + LevelFilter::DEBUG, + ); + filter = filter.add_directive(directive_checked(format!( + "nym_bin_common={}", + debug_or_higher + ))?); + filter = filter.add_directive(directive_checked(format!( + "nym_explorer_client={}", + debug_or_higher + ))?); + filter = filter.add_directive(directive_checked(format!( + "nym_network_defaults={}", + debug_or_higher + ))?); + filter = filter.add_directive(directive_checked(format!( + "nym_validator_client={}", + debug_or_higher + ))?); + + log_builder.with_env_filter(filter).init(); + tracing::info!("Log level: {:?}", log_level_hint); + + Ok(()) +} diff --git a/nym-node-status-api/src/main.rs b/nym-node-status-api/src/main.rs new file mode 100644 index 0000000000..b2c68b391b --- /dev/null +++ b/nym-node-status-api/src/main.rs @@ -0,0 +1,53 @@ +use clap::Parser; +use nym_task::signal::wait_for_signal; + +mod cli; +mod db; +mod http; +mod logging; +mod monitor; +mod testruns; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + logging::setup_tracing_logger()?; + + let args = cli::Cli::parse(); + + let connection_url = args.database_url.clone(); + tracing::debug!("Using config:\n{:#?}", args); + + let storage = db::Storage::init(connection_url).await?; + let db_pool = storage.pool_owned(); + let args_clone = args.clone(); + tokio::spawn(async move { + monitor::spawn_in_background( + db_pool, + args_clone.explorer_client_timeout, + args_clone.nym_api_client_timeout, + &args_clone.nyxd_addr, + args_clone.monitor_refresh_interval, + ) + .await; + tracing::info!("Started monitor task"); + }); + testruns::spawn(storage.pool_owned(), args.testruns_refresh_interval).await; + + let shutdown_handles = http::server::start_http_api( + storage.pool_owned(), + args.http_port, + args.nym_http_cache_ttl, + ) + .await + .expect("Failed to start server"); + + tracing::info!("Started HTTP server on port {}", args.http_port); + + wait_for_signal().await; + + if let Err(err) = shutdown_handles.shutdown().await { + tracing::error!("{err}"); + }; + + Ok(()) +} diff --git a/nym-node-status-api/src/monitor/mod.rs b/nym-node-status-api/src/monitor/mod.rs index a9f3761f35..d0d1c5f638 100644 --- a/nym-node-status-api/src/monitor/mod.rs +++ b/nym-node-status-api/src/monitor/mod.rs @@ -1,3 +1,5 @@ +#![allow(deprecated)] + use crate::db::models::{ gateway, mixnode, GatewayRecord, MixnodeRecord, NetworkSummary, GATEWAYS_BLACKLISTED_COUNT, GATEWAYS_BONDED_COUNT, GATEWAYS_EXPLORER_COUNT, GATEWAYS_HISTORICAL_COUNT, diff --git a/nym-node-status-api/src/testruns/models.rs b/nym-node-status-api/src/testruns/models.rs new file mode 100644 index 0000000000..fe4b33384c --- /dev/null +++ b/nym-node-status-api/src/testruns/models.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[allow(dead_code)] // it's not dead code but clippy doesn't detect usage in sqlx macros +#[derive(Debug, Clone)] +pub struct GatewayIdentityDto { + pub gateway_identity_key: String, + pub bonded: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] +pub struct TestRun { + pub id: u32, + pub identity_key: String, + pub status: String, + pub log: String, +}