diff --git a/.docker/local-network.Dockerfile b/.docker/local-network.Dockerfile new file mode 100644 index 0000000..bfe4360 --- /dev/null +++ b/.docker/local-network.Dockerfile @@ -0,0 +1,56 @@ +FROM ubuntu:22.04 + +ARG NODE_VERSION=18.19.1 +ARG TARGETARCH +# The server's native dependency tree currently pulls time 0.3.31, which fails +# with rustc >= 1.80. Keep this pin until the server dependencies move forward. +ARG SERVER_RUST_VERSION=1.79.0 +ARG PROXY_RUST_VERSION=1.86.0 + +ENV DEBIAN_FRONTEND=noninteractive +ENV PATH="/opt/node/bin:/root/.cargo/bin:${PATH}" +ENV LIBERDUS_SERVER_RUST_TOOLCHAIN="${SERVER_RUST_VERSION}" +ENV LIBERDUS_PROXY_RUST_TOOLCHAIN="${PROXY_RUST_VERSION}" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + jq \ + libssl-dev \ + lsof \ + build-essential \ + pkg-config \ + procps \ + python3 \ + rsync \ + xz-utils \ + && rm -rf /var/lib/apt/lists/* + +RUN case "${TARGETARCH:-amd64}" in \ + amd64) node_arch="x64" ;; \ + arm64) node_arch="arm64" ;; \ + *) echo "Unsupported Docker target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" -o /tmp/node.tar.xz \ + && mkdir -p /opt/node \ + && tar -xJf /tmp/node.tar.xz -C /opt/node --strip-components=1 \ + && rm /tmp/node.tar.xz \ + && npm install -g http-server \ + && npm cache clean --force + +RUN git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" \ + && git config --global url."https://github.com/".insteadOf "git@github.com:" + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain "${SERVER_RUST_VERSION}" --profile minimal \ + && rustup toolchain install "${PROXY_RUST_VERSION}" --profile minimal + +WORKDIR /workspace/client-testing + +COPY scripts/local-network /usr/local/bin/liberdus-local-network +RUN chmod +x /usr/local/bin/liberdus-local-network/healthcheck.sh + +CMD ["node", "/usr/local/bin/liberdus-local-network/start.js"] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0ece63f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.devcontainer +playwright-tests/node_modules +playwright-tests/playwright-report +playwright-tests/test-results +playwright-tests/.cache +local-network.env +.deps +.artifacts +server +liberdus-proxy +tools-cli-shardus-network +web-client-v2 +*.log +*.pid diff --git a/.github/workflows/local-network-smoke.yml b/.github/workflows/local-network-smoke.yml new file mode 100644 index 0000000..bf157d8 --- /dev/null +++ b/.github/workflows/local-network-smoke.yml @@ -0,0 +1,187 @@ +name: Local Network Smoke + +on: + workflow_dispatch: + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + CI: "true" + LIBERDUS_PROXY_REF: new-nodelist-response + PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080/ + + steps: + - name: Checkout client-testing + uses: actions/checkout@v4 + + - name: Checkout server + uses: actions/checkout@v4 + with: + repository: Liberdus/server + path: .deps/server + + - name: Checkout liberdus-proxy + uses: actions/checkout@v4 + with: + repository: Liberdus/liberdus-proxy + ref: ${{ env.LIBERDUS_PROXY_REF }} + path: .deps/liberdus-proxy + + - name: Checkout web-client-v2 + uses: actions/checkout@v4 + with: + repository: Liberdus/web-client-v2 + path: .deps/web-client-v2 + + - name: Normalize bind paths for act on Windows + if: ${{ env.ACT == 'true' }} + run: | + set -euo pipefail + + case "$GITHUB_WORKSPACE" in + /mnt/[a-zA-Z]/*) + drive="${GITHUB_WORKSPACE#/mnt/}" + drive="${drive%%/*}" + prefix="/mnt/${drive}/" + rest="${GITHUB_WORKSPACE#"$prefix"}" + win_workspace="${drive^^}:\\${rest//\//\\}" + + { + echo "LIBERDUS_SERVER_DIR=${win_workspace}\\.deps\\server" + echo "LIBERDUS_PROXY_DIR=${win_workspace}\\.deps\\liberdus-proxy" + echo "LIBERDUS_WEB_CLIENT_DIR=${win_workspace}\\.deps\\web-client-v2" + } >> "$GITHUB_ENV" + ;; + esac + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: playwright-tests/package-lock.json + + - name: Show Docker versions + run: | + docker --version + docker compose version + + - name: Validate local network compose config + run: docker compose -f docker-compose.local.yml config --quiet + + - name: Clean previous local network state + run: docker compose -f docker-compose.local.yml down -v --remove-orphans + + - name: Install Playwright dependencies + working-directory: playwright-tests + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Build local network image + run: docker compose -f docker-compose.local.yml build --progress=plain local-network + + - name: Start local network + run: docker compose -f docker-compose.local.yml up -d --no-build local-network + + - name: Wait for local network readiness + run: | + set -euo pipefail + + previous_health="" + previous_phase="" + previous_healthcheck="" + for attempt in {1..240}; do + cid="$(docker compose -f docker-compose.local.yml ps -q local-network || true)" + if [ -z "$cid" ]; then + echo "local-network container not found" + docker compose -f docker-compose.local.yml ps || true + docker compose -f docker-compose.local.yml logs --no-color --tail=400 local-network || true + exit 1 + fi + + status="$(docker inspect --format='{{.State.Status}}' "$cid")" + health="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid")" + phase="$( + docker compose -f docker-compose.local.yml logs --no-color --tail=200 local-network 2>/dev/null \ + | awk '/\[liberdus-local\]/ { line=$0 } END { print line }' \ + | sed -E 's/^.*\[liberdus-local\] //' + )" + healthcheck="$( + docker inspect "$cid" \ + | jq -r '.[0].State.Health.Log[-1].Output // ""' \ + | tr '\n' ' ' \ + | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//' + )" + + if [ "$health" != "$previous_health" ] || [ $((attempt % 4)) -eq 1 ]; then + echo "container status=${status} health=${health}" + previous_health="$health" + fi + if [ -n "$phase" ] && [ "$phase" != "$previous_phase" ]; then + echo "startup phase: ${phase}" + previous_phase="$phase" + fi + if [ -n "$healthcheck" ] && [ "$healthcheck" != "$previous_healthcheck" ]; then + echo "${healthcheck}" + previous_healthcheck="$healthcheck" + fi + + if [ "$health" = "healthy" ]; then + exit 0 + fi + + if [ "$status" != "running" ]; then + docker compose -f docker-compose.local.yml logs --no-color --tail=400 local-network + exit 1 + fi + + sleep 15 + done + + docker compose -f docker-compose.local.yml logs --no-color --tail=400 local-network + exit 1 + + - name: Show local network status + run: | + docker compose -f docker-compose.local.yml ps + curl -fsS http://127.0.0.1:4000/cycleinfo/1 | jq '.cycleInfo[0] | {counter, mode, active, standby, syncing, desired, target, networkId}' + curl -fsS http://127.0.0.1:3030/account/0000000000000000000000000000000000000000000000000000000000000000 >/dev/null + curl -fsS http://127.0.0.1:8080/ >/dev/null + + - name: Run Playwright smoke + working-directory: playwright-tests + run: npx playwright test --project=chromium --grep '@smoke' --workers=1 --retries=0 --reporter=github,line + + - name: Upload Playwright HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: local-network-playwright-report-${{ github.run_id }} + path: playwright-tests/playwright-report/ + if-no-files-found: warn + retention-days: 14 + + - name: Collect local network logs + if: always() + run: | + mkdir -p .artifacts + docker compose -f docker-compose.local.yml logs --no-color local-network > .artifacts/local-network.log || true + docker compose -f docker-compose.local.yml ps > .artifacts/local-network-ps.txt || true + + - name: Upload local network logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: local-network-logs-${{ github.run_id }} + path: | + .artifacts/ + playwright-tests/test-results/ + if-no-files-found: warn + retention-days: 14 + + - name: Stop local network + if: always() + run: docker compose -f docker-compose.local.yml down -v --remove-orphans diff --git a/.gitignore b/.gitignore index 6dda183..9fefcd0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,8 @@ xvfb.pid fluxbox.log fluxbox.pid playwright-tests/.cache/network-params.json +playwright-tests/node_modules/ +playwright-tests/.env +local-network.env +.deps/ +.artifacts/ diff --git a/README.md b/README.md index 8e15028..386d32a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ https://drive.google.com/file/d/19oWRpK_AJUo3G3hHYYGN_ZryAT-CSjyd/view?usp=sharing -## Usage +## Legacy Dev Container Usage + +This older flow uses the root `liberdus-*.sh` scripts inside the dev container. It is separate from the Docker Compose local-network stack used for CI smoke testing. 1. Make sure you have the dev containers extension for vscode. @@ -30,6 +32,19 @@ https://drive.google.com/file/d/19oWRpK_AJUo3G3hHYYGN_ZryAT-CSjyd/view?usp=shari 12. Run `./vnc-stop.sh` in the workspace root directory to stop the VNC server in the container. +## Local Network with Docker Compose + +The Playwright tests default to `https://liberdus.com/dev/`, but the Docker Compose stack can run the smoke test against a local network. This is the CI-focused local-network path. + +To run the tagged smoke test against the default dev network, leave `PLAYWRIGHT_BASE_URL` unset and run: + +```bash +cd playwright-tests +npm run test:smoke +``` + +See [docs/local-network.md](docs/local-network.md) for the short version of what runs and how to run it locally. + ## Manually Setting up a Local Liberdus Network with web-client-v2 1. Setup an environment with the following software: @@ -42,9 +57,9 @@ https://drive.google.com/file/d/19oWRpK_AJUo3G3hHYYGN_ZryAT-CSjyd/view?usp=shari * pkg-config 1.8.0 - * Node.js 18.16.1 + * Node.js 18.19.1 - * Rust 1.74 + * Rust 1.79.0 for the server, plus Rust 1.86.0 for `liberdus-proxy` * Python 3.x @@ -54,12 +69,6 @@ https://drive.google.com/file/d/19oWRpK_AJUo3G3hHYYGN_ZryAT-CSjyd/view?usp=shari * Navigate to the repo's root directory and run `npm install`. - * https://github.com/shardus/tools-cli-shardus-network.git - - - Navigate to the repo's root directory and run `npm install`. - - - Run `npm link` in the root directory of this repo after installing dependencies to put the `shardus-network` command into the path. - * https://github.com/Liberdus/web-client-v2.git * No dependencies to install. @@ -70,13 +79,13 @@ https://drive.google.com/file/d/19oWRpK_AJUo3G3hHYYGN_ZryAT-CSjyd/view?usp=shari 3. Start a local network of Liberdus nodes. - * Navigate to the `Liberdus/server` repo and run `shardus-network start 10` to start a local network of 10 nodes. + * Navigate to the `Liberdus/server` repo and run `shardus start 5` to start a local network of 5 nodes. * The minimum number of nodes needed to process transactions is defined by the `minNodes` property in `server/src/config/index.ts`: ```js ... - minNodes: process.env.minNodes ? parseInt(process.env.minNodes) : 10, + minNodes: process.env.minNodes ? parseInt(process.env.minNodes) : 5, ... ``` diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..70978ca --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,49 @@ +services: + local-network: + build: + context: . + dockerfile: .docker/local-network.Dockerfile + init: true + environment: + LIBERDUS_SERVER_SOURCE: /sources/server + LIBERDUS_PROXY_SOURCE: /sources/liberdus-proxy + LIBERDUS_WEB_CLIENT_SOURCE: /sources/web-client-v2 + LIBERDUS_RUNTIME_ROOT: /workspace/runtime + LIBERDUS_NODE_COUNT: ${LIBERDUS_NODE_COUNT:-10} + LIBERDUS_EXTERNAL_PORT_START: ${LIBERDUS_VALIDATOR_CONTAINER_PORT_START:-9001} + LIBERDUS_INTERNAL_PORT_START: ${LIBERDUS_INTERNAL_PORT_START:-10001} + LIBERDUS_PUBLIC_HOST: ${LIBERDUS_PUBLIC_HOST:-127.0.0.1} + LIBERDUS_MONITOR_PUBLIC_PORT: ${LIBERDUS_MONITOR_HOST_PORT:-3000} + LIBERDUS_PROXY_PUBLIC_PORT: ${LIBERDUS_PROXY_HOST_PORT:-3030} + LIBERDUS_PROXY_WS_PUBLIC_PORT: ${LIBERDUS_PROXY_WS_HOST_PORT:-3031} + LIBERDUS_WEB_PUBLIC_PORT: ${LIBERDUS_WEB_CLIENT_HOST_PORT:-8080} + volumes: + - type: bind + source: ${LIBERDUS_SERVER_DIR:-./.deps/server} + target: /sources/server + read_only: true + - type: bind + source: ${LIBERDUS_PROXY_DIR:-./.deps/liberdus-proxy} + target: /sources/liberdus-proxy + read_only: true + - type: bind + source: ${LIBERDUS_WEB_CLIENT_DIR:-./.deps/web-client-v2} + target: /sources/web-client-v2 + read_only: true + - local-network-runtime:/workspace/runtime + ports: + - "${LIBERDUS_MONITOR_HOST_PORT:-3000}:3000" + - "${LIBERDUS_ARCHIVER_HOST_PORT:-4000}:4000" + - "${LIBERDUS_PROXY_HOST_PORT:-3030}:3030" + - "${LIBERDUS_PROXY_WS_HOST_PORT:-3031}:3031" + - "${LIBERDUS_WEB_CLIENT_HOST_PORT:-8080}:8080" + - "${LIBERDUS_VALIDATOR_HOST_PORT_START:-9101}-${LIBERDUS_VALIDATOR_HOST_PORT_END:-9110}:${LIBERDUS_VALIDATOR_CONTAINER_PORT_START:-9001}-${LIBERDUS_VALIDATOR_CONTAINER_PORT_END:-9010}" + healthcheck: + test: ["CMD", "/usr/local/bin/liberdus-local-network/healthcheck.sh"] + interval: 15s + timeout: 10s + retries: 3 + start_period: 30m + +volumes: + local-network-runtime: diff --git a/docs/local-network.md b/docs/local-network.md new file mode 100644 index 0000000..68d7d97 --- /dev/null +++ b/docs/local-network.md @@ -0,0 +1,61 @@ +# Local Network Test Stack + +This starts a local Liberdus network, serves `web-client-v2`, and points Playwright at it instead of `https://liberdus.com/dev/`. + +## What Runs + +### GitHub Workflow + +`.github/workflows/local-network-smoke.yml` is manual-only in this repo. It checks out the needed repos into `.deps/`, installs Playwright, starts Docker Compose, waits for Docker health, runs one smoke test, uploads logs, and stops the stack. + +### Docker Compose + +`docker-compose.local.yml` builds the local-network image, mounts the dependency repos, exposes local ports, and uses `healthcheck.sh` to decide when the stack is test-ready. + +### Startup Script + +`scripts/local-network/start.js` copies the mounted repos into a writable Docker volume, writes local proxy/web config, reuses cached builds when possible, starts a 10-node Shardus network, waits for `processing`, starts `liberdus-proxy`, writes `web-client-v2/network.js`, and serves the web client. + +### Healthcheck + +`scripts/local-network/healthcheck.sh` checks that `network.js` is served, the proxy can read the zero account, and the archiver reports `processing`. + +## Run Locally + +Create the dependency checkouts: + +```powershell +New-Item -ItemType Directory -Force .deps +git clone https://github.com/Liberdus/server.git .deps/server +git clone --branch new-nodelist-response https://github.com/Liberdus/liberdus-proxy.git .deps/liberdus-proxy +git clone https://github.com/Liberdus/web-client-v2.git .deps/web-client-v2 +``` + +Start the stack: + +```powershell +docker compose -f docker-compose.local.yml up --build +``` + +Run the smoke test in another shell: + +```powershell +cd playwright-tests +npm ci +$env:PLAYWRIGHT_BASE_URL='http://127.0.0.1:8080/' +npm run test:smoke -- --workers=1 --retries=0 +``` + +Stop the stack: + +```powershell +docker compose -f docker-compose.local.yml down +``` + +Useful local URLs: + +- Web client: `http://127.0.0.1:8080/` +- Proxy: `http://127.0.0.1:3030/` +- Monitor: `http://127.0.0.1:3000/` + +If your repos are somewhere else, set `LIBERDUS_SERVER_DIR`, `LIBERDUS_PROXY_DIR`, and `LIBERDUS_WEB_CLIENT_DIR` before running Compose. diff --git a/playwright-tests/.env.example b/playwright-tests/.env.example new file mode 100644 index 0000000..da3c15e --- /dev/null +++ b/playwright-tests/.env.example @@ -0,0 +1,3 @@ +# Defaults remain devnet when this file is absent. +# Copy to .env to run tests against the Docker Compose local network. +PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080/ diff --git a/playwright-tests/helpers/global-setup.js b/playwright-tests/helpers/global-setup.js index a87ec0f..8aa7f9a 100644 --- a/playwright-tests/helpers/global-setup.js +++ b/playwright-tests/helpers/global-setup.js @@ -2,7 +2,7 @@ * Playwright global setup * * Purpose: - * - Read `baseURL` from Playwright config + * - Read `baseURL` from Playwright's resolved config * - Fetch `${baseURL}/network.js` and extract the first gateway web URL * - Fetch the zero-account from the gateway and read network parameters * - Convert USD values to LIB using stabilityFactor @@ -10,7 +10,7 @@ * * Strictness: * - If any step fails, throw to abort the test run - * - No env or hardcoded fallbacks for baseURL or network params + * - No hardcoded fallbacks for network params */ const fs = require('fs'); @@ -18,33 +18,19 @@ const path = require('path'); const http = require('http'); const https = require('https'); -/** - * Extract the active baseURL from `playwright.config.ts`. - * - Ignores commented lines - * - Uses the last valid occurrence if multiple - * - Throws if not found (no fallback) - */ -function readBaseURLFromPlaywrightConfig() { - try { - const cfgPath = path.resolve(__dirname, '..', 'playwright.config.ts'); - const text = fs.readFileSync(cfgPath, 'utf8'); - const lines = text.split(/\r?\n/); - let candidate; - for (const line of lines) { - const idx = line.indexOf('baseURL:'); - if (idx === -1) continue; - const trimmed = line.trim(); - if (/^\/\//.test(trimmed)) continue; - const cmt = line.indexOf('//'); - if (cmt !== -1 && cmt < idx) continue; - const m = line.match(/baseURL:\s*['\"]([^'\"]+)['\"]/); - if (m && m[1]) candidate = m[1]; - } - if (candidate) return candidate; - throw new Error('baseURL not found in playwright.config.ts'); - } catch (e) { - throw new Error(`Failed to read baseURL from config: ${e && e.message ? e.message : e}`); - } +function resolveBaseURL(config) { + const envBaseURL = process.env.PLAYWRIGHT_BASE_URL || process.env.BASE_URL; + if (envBaseURL) return envBaseURL; + + const projectBaseURL = config && config.projects && config.projects[0] && config.projects[0].use + ? config.projects[0].use.baseURL + : undefined; + if (projectBaseURL) return projectBaseURL; + + const sharedBaseURL = config && config.use ? config.use.baseURL : undefined; + if (sharedBaseURL) return sharedBaseURL; + + throw new Error('baseURL not found in Playwright config'); } /** @@ -83,7 +69,20 @@ function fetchText(url, timeoutMs = 15000, maxRedirects = 5) { }); } -async function globalSetup() { +function parseNumber(value) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : NaN; +} + +function requirePositiveNetworkNumber(current, fieldName) { + const value = parseNumber(current[fieldName]); + if (!(value > 0)) { + throw new Error(`Missing or invalid positive network parameter: ${fieldName}`); + } + return value; +} + +async function globalSetup(config) { const outDir = path.resolve(__dirname, '..', '.cache'); // Clear any previous cache to ensure fresh fetch each run try { @@ -91,8 +90,8 @@ async function globalSetup() { } catch {} fs.mkdirSync(outDir, { recursive: true }); - // 1) Discover base URL from Playwright config - const baseURL = readBaseURLFromPlaywrightConfig(); + // 1) Discover base URL from Playwright's resolved config + const baseURL = resolveBaseURL(config); const networkJsUrl = baseURL.replace(/\/+$/, '') + '/network.js'; // 2) Fetch and evaluate network.js to extract gateway web URL @@ -121,9 +120,9 @@ async function globalSetup() { const account = JSON.parse(acctText); const current = account && account.account && account.account.current ? account.account.current : {}; - const stabilityFactor = parseFloat(current.stabilityFactorStr || '0'); - const feeUsd = parseFloat(current.transactionFeeUsdStr || '0'); - const minTollUsd = parseFloat(current.minTollUsdStr || '0'); + const stabilityFactor = requirePositiveNetworkNumber(current, 'stabilityFactorStr'); + const feeUsd = requirePositiveNetworkNumber(current, 'transactionFeeUsdStr'); + const minTollUsd = requirePositiveNetworkNumber(current, 'minTollUsdStr'); const networkTollTaxPercent = Number(current.tollNetworkTaxPercent || 0); // USD → LIB conversions using: LIB = USD / stabilityFactor diff --git a/playwright-tests/package.json b/playwright-tests/package.json index e139725..2969d79 100644 --- a/playwright-tests/package.json +++ b/playwright-tests/package.json @@ -4,7 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "test": "playwright test --project=chromium" + "test": "playwright test --project=chromium", + "test:smoke": "playwright test --project=chromium --grep @smoke" }, "keywords": [], "author": "", diff --git a/playwright-tests/playwright.config.ts b/playwright-tests/playwright.config.ts index 3264602..1bc963b 100644 --- a/playwright-tests/playwright.config.ts +++ b/playwright-tests/playwright.config.ts @@ -4,9 +4,11 @@ import { defineConfig, devices } from '@playwright/test'; * Read environment variables from file. * https://github.com/motdotla/dotenv */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +const baseURL = process.env.PLAYWRIGHT_BASE_URL || process.env.BASE_URL || 'https://liberdus.com/dev/'; /** * See https://playwright.dev/docs/test-configuration. @@ -27,7 +29,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: 'https://liberdus.com/dev/', + baseURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', diff --git a/playwright-tests/tests/lock.e2e.test.js b/playwright-tests/tests/lock.e2e.test.js index 8242176..d9ccf7e 100644 --- a/playwright-tests/tests/lock.e2e.test.js +++ b/playwright-tests/tests/lock.e2e.test.js @@ -42,7 +42,7 @@ const test = base.extend({ } }); -test('Lock and Unlock Account', async ({ browser, browserName}) => { +test('Lock and Unlock Account @smoke', async ({ browser, browserName}) => { // 1 create a user const username = generateUsername(browserName); const ctx = await newContext(browser); diff --git a/playwright-tests/tests/smoke.e2e.test.js b/playwright-tests/tests/smoke.e2e.test.js index d846a15..f078c29 100644 --- a/playwright-tests/tests/smoke.e2e.test.js +++ b/playwright-tests/tests/smoke.e2e.test.js @@ -5,7 +5,7 @@ const { createAndSignInUser, generateUsername } = require('../helpers/userHelper let RECIPIENT; -test.describe('Tests requiring recipient user', () => { +test.describe('Tests requiring recipient user @smoke', () => { // Create the recipient user once before all tests test.beforeAll(async ({ browser, browserName }) => { const page = await browser.newPage(); @@ -100,7 +100,7 @@ test.describe('Tests requiring recipient user', () => { }); }); -test('should navigate to Contacts and Wallet views', async ({ page }) => { +test('should navigate to Contacts and Wallet views @smoke', async ({ page }) => { await page.click('#switchToContacts'); await expect(page.locator('#contactsScreen.active')).toBeVisible(); await page.click('#switchToWallet'); @@ -109,7 +109,7 @@ test('should navigate to Contacts and Wallet views', async ({ page }) => { await expect(page.locator('#chatsScreen.active')).toBeVisible(); }); -test('should sign out successfully', async ({ page }) => { +test('should sign out successfully @smoke', async ({ page }) => { // wait for UI animation await page.waitForTimeout(1000); await page.click('#toggleMenu'); @@ -118,7 +118,7 @@ test('should sign out successfully', async ({ page }) => { await expect(page.locator('#welcomeScreen')).toBeVisible({ timeout: 30_000 }); }); -test('Should set toll', async ({ page }) => { +test('Should set toll @smoke', async ({ page }) => { const toll = 5; await page.click('#toggleSettings'); await page.waitForSelector('#settingsModal', { timeout: 5_000 }); @@ -131,7 +131,7 @@ test('Should set toll', async ({ page }) => { expect(tollText.trim().startsWith(toll.toString())).toBeTruthy(); }); -test('Should update profile', async ({ page, username }) => { +test('Should update profile @smoke', async ({ page, username }) => { const name = "Testername"; const linkedin = "testerlinkedin"; const x = "testerx"; diff --git a/scripts/local-network/healthcheck.sh b/scripts/local-network/healthcheck.sh new file mode 100644 index 0000000..82471c9 --- /dev/null +++ b/scripts/local-network/healthcheck.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +web_port="${LIBERDUS_WEB_BIND_PORT:-${LIBERDUS_WEB_CLIENT_PORT:-8080}}" +proxy_port="${LIBERDUS_PROXY_BIND_PORT:-${LIBERDUS_PROXY_HTTP_PORT:-3030}}" +zero_account="0000000000000000000000000000000000000000000000000000000000000000" +web_url="http://127.0.0.1:${web_port}/network.js" +proxy_url="http://127.0.0.1:${proxy_port}/account/${zero_account}" +cycle_url="http://127.0.0.1:4000/cycleinfo/1" + +say() { + echo "healthcheck: $*" +} + +if ! curl -fsS "${web_url}" >/dev/null 2>&1; then + say "waiting for web network.js (${web_url})" + exit 1 +fi + +if ! account_json="$(curl -fsS "${proxy_url}" 2>/dev/null)"; then + say "waiting for proxy zero account (${proxy_url})" + exit 1 +fi + +if ! jq -e '.account.type == "NetworkAccount"' >/dev/null <<<"${account_json}"; then + account_type="$(jq -r '.account.type // "missing"' <<<"${account_json}" 2>/dev/null || echo "unknown")" + say "waiting for proxy NetworkAccount response (account.type=${account_type})" + exit 1 +fi + +if ! cycle_json="$(curl -fsS "${cycle_url}" 2>/dev/null)"; then + say "waiting for archiver cycle (${cycle_url})" + exit 1 +fi + +cycle_summary="$( + jq -r ' + (.cycleInfo // [])[0] as $cycle + | if $cycle then + "mode=\($cycle.mode // "missing") active=\($cycle.active // "missing") desired=\($cycle.desired // "missing") target=\($cycle.target // "missing") networkId=\(if ($cycle.networkId | type) == "string" then "present" else "missing" end)" + else + "cycleInfo=missing" + end + ' <<<"${cycle_json}" 2>/dev/null || echo "cycleInfo=unreadable" +)" + +if ! jq -e '((.cycleInfo // []) | length) > 0 and .cycleInfo[0].mode == "processing" and (.cycleInfo[0].networkId | type == "string")' >/dev/null <<<"${cycle_json}"; then + say "waiting for archiver processing cycle (${cycle_summary})" + exit 1 +fi + +say "healthy: web network.js reachable; proxy zero account is NetworkAccount; archiver ${cycle_summary}" diff --git a/scripts/local-network/start.js b/scripts/local-network/start.js new file mode 100644 index 0000000..3eada2f --- /dev/null +++ b/scripts/local-network/start.js @@ -0,0 +1,729 @@ +#!/usr/bin/env node +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const net = require('net'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); + +const ZERO_ACCOUNT = '0000000000000000000000000000000000000000000000000000000000000000'; +const ARCHIVER_SEED = [{ publicKey: '758b1c119412298802cd28dbfa394cdfeecc4074492d60844cc192d632d84de3', port: 4000, ip: '127.0.0.1' }]; +const SOURCE_EXCLUDES = { + server: ['.git', 'node_modules', 'instances', 'dist'], + proxy: ['.git', 'target'], + web: ['.git', 'node_modules'], +}; + +const state = { + serverDir: undefined, + proxyProcess: undefined, + webProcess: undefined, + shuttingDown: false, +}; + +// ----------------------------- basic helpers ----------------------------- + +// Prefixes every line we own so Docker/CI logs are easy to scan. +function log(message) { + process.stderr.write(`[liberdus-local] ${message}\n`); +} + +// Reads an environment variable with a fallback. +function env(name, fallback) { + return process.env[name] || fallback; +} + +// Reads an integer environment variable with validation. +function intEnv(name, fallback) { + const value = Number.parseInt(env(name, String(fallback)), 10); + if (!Number.isFinite(value)) throw new Error(`Expected ${name} to be an integer`); + return value; +} + +// Pauses async polling loops. +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Builds the runtime configuration once so the startup flow can pass one object around. +function buildConfig() { + const runtimeRoot = env('LIBERDUS_RUNTIME_ROOT', '/workspace/runtime'); + const nodeCount = intEnv('LIBERDUS_NODE_COUNT', 10); + const proxyBindPort = intEnv('LIBERDUS_PROXY_BIND_PORT', 3030); + + return { + runtimeRoot, + nodeCount, + sources: { + server: env('LIBERDUS_SERVER_SOURCE', '/sources/server'), + proxy: env('LIBERDUS_PROXY_SOURCE', '/sources/liberdus-proxy'), + web: env('LIBERDUS_WEB_CLIENT_SOURCE', '/sources/web-client-v2'), + }, + paths: { + server: path.join(runtimeRoot, 'server'), + proxy: path.join(runtimeRoot, 'liberdus-proxy'), + web: path.join(runtimeRoot, 'web-client-v2'), + logs: path.join(runtimeRoot, 'logs'), + }, + ports: { + externalStart: intEnv('LIBERDUS_EXTERNAL_PORT_START', 9001), + internalStart: intEnv('LIBERDUS_INTERNAL_PORT_START', 10001), + proxyBind: proxyBindPort, + proxyWsBind: proxyBindPort + 1, + proxyPublic: intEnv('LIBERDUS_PROXY_PUBLIC_PORT', 3030), + proxyWsPublic: intEnv('LIBERDUS_PROXY_WS_PUBLIC_PORT', 3031), + webBind: intEnv('LIBERDUS_WEB_BIND_PORT', 8080), + webPublic: intEnv('LIBERDUS_WEB_PUBLIC_PORT', 8080), + monitorPublic: intEnv('LIBERDUS_MONITOR_PUBLIC_PORT', 3000), + }, + publicHost: env('LIBERDUS_PUBLIC_HOST', '127.0.0.1'), + toolchains: { + serverRust: env('LIBERDUS_SERVER_RUST_TOOLCHAIN', '1.79.0'), + proxyRust: env('LIBERDUS_PROXY_RUST_TOOLCHAIN', '1.86.0'), + }, + }; +} + +// Runs a command synchronously, streaming output by default. +function run(command, args = [], options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: { ...process.env, ...(options.env || {}) }, + encoding: options.encoding === false ? undefined : 'utf8', + stdio: options.stdio || 'inherit', + }); + + if (result.error && !options.allowFailure) throw result.error; + if (result.status !== 0 && !options.allowFailure) { + throw new Error(`Command failed with exit ${result.status}: ${command} ${args.join(' ')}`); + } + + return result; +} + +// Runs a command and returns trimmed stdout. +function capture(command, args, options = {}) { + const result = run(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); + return String(result.stdout || '').trim(); +} + +// Checks whether a command is available in the container. +function commandExists(command) { + return spawnSync('bash', ['-lc', `command -v ${command}`], { stdio: 'ignore' }).status === 0; +} + +// Ensures a required source checkout exists before the expensive work begins. +function requireDir(label, dir) { + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { + throw new Error(`Missing ${label}: ${dir}`); + } +} + +// Writes a file, creating parent directories when needed. +function writeFile(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +// Normalizes a directory path for rsync source and destination arguments. +function trailingSlash(dir) { + return dir.endsWith(path.sep) || dir.endsWith('/') ? dir : `${dir}/`; +} + +// ----------------------------- hashing/cache ------------------------------ + +// Hashes one file, returning a stable marker for optional missing files. +function hashFile(filePath) { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile() + ? crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex') + : 'missing'; +} + +// Recursively lists the requested files/directories that actually exist. +function listFiles(root, requestedPaths) { + const files = []; + + function walk(absolutePath, relativePath) { + const stat = fs.statSync(absolutePath); + if (stat.isFile()) return files.push(relativePath); + if (!stat.isDirectory()) return; + for (const entry of fs.readdirSync(absolutePath).sort()) { + walk(path.join(absolutePath, entry), path.posix.join(relativePath, entry)); + } + } + + for (const requestedPath of requestedPaths) { + const absolutePath = path.join(root, requestedPath); + if (fs.existsSync(absolutePath)) walk(absolutePath, requestedPath); + } + + return files.sort(); +} + +// Hashes a selected source surface to decide whether a cached build is reusable. +function hashPaths(root, requestedPaths) { + if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return 'missing'; + + const files = listFiles(root, requestedPaths); + if (files.length === 0) return 'missing'; + + const digest = crypto.createHash('sha256'); + for (const relativePath of files) { + digest.update(`${relativePath}\0${hashFile(path.join(root, relativePath))}\0`); + } + return digest.digest('hex'); +} + +// Checks a cache marker and the concrete files required by that cache. +function cacheValid(markerFile, expectedMarker, requiredPaths) { + return fs.existsSync(markerFile) + && fs.readFileSync(markerFile, 'utf8') === expectedMarker + && requiredPaths.every((requiredPath) => fs.existsSync(requiredPath)); +} + +// Computes all cache keys from source, lockfiles, and toolchain versions. +function buildCacheMarkers(cfg) { + const serverDepMarker = [ + `node=${capture('node', ['--version'])}`, + `rust=${capture('rustc', [`+${cfg.toolchains.serverRust}`, '--version'])}`, + `package=${hashFile(path.join(cfg.paths.server, 'package.json'))}`, + `lock=${hashFile(path.join(cfg.paths.server, 'package-lock.json'))}`, + ].join(';'); + + return { + serverDeps: serverDepMarker, + serverBuild: `${serverDepMarker};source=${hashPaths(cfg.paths.server, ['package.json', 'package-lock.json', 'tsconfig.json', 'src', 'client.js'])}`, + proxyBuild: [ + `rust=${capture('rustc', [`+${cfg.toolchains.proxyRust}`, '--version'])}`, + `source=${hashPaths(cfg.paths.proxy, ['Cargo.toml', 'Cargo.lock', 'src'])}`, + ].join(';'), + }; +} + +// ----------------------------- HTTP/waiting ------------------------------- + +// Fetches text over HTTP or HTTPS with a short timeout. +function requestText(url, options = {}) { + const timeoutMs = options.timeoutMs || 15000; + const failOnStatus = options.failOnStatus !== false; + const client = url.startsWith('https:') ? https : http; + + return new Promise((resolve, reject) => { + const request = client.get(url, (response) => { + const chunks = []; + if (failOnStatus && (response.statusCode < 200 || response.statusCode >= 300)) { + response.resume(); + return reject(new Error(`HTTP ${response.statusCode} from ${url}`)); + } + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }); + + request.setTimeout(timeoutMs, () => request.destroy(new Error(`Timed out requesting ${url}`))); + request.on('error', reject); + }); +} + +// Polls until a check returns a truthy value, then returns that value. +async function waitUntil(label, timeoutSeconds, check, intervalMs = 5000) { + const deadline = Date.now() + timeoutSeconds * 1000; + + while (Date.now() < deadline) { + const result = await check(); + if (result) return result; + await sleep(intervalMs); + } + + throw new Error(`Timed out waiting for ${label}`); +} + +// Polls a JSON endpoint until it satisfies a predicate. +function waitForJson(url, predicate, timeoutSeconds, label) { + return waitUntil(`${label} from ${url}`, timeoutSeconds, async () => { + try { + return predicate(JSON.parse(await requestText(url, { timeoutMs: 5000 }))); + } catch { + return false; + } + }); +} + +// Returns true when an HTTP endpoint responds at all. +async function httpResponds(url) { + try { + await requestText(url, { timeoutMs: 3000, failOnStatus: false }); + return true; + } catch { + return false; + } +} + +// Waits for an HTTP endpoint, optionally suppressing the timeout log. +async function waitForHttp(url, timeoutSeconds, label, quiet = false) { + try { + await waitUntil(`${label} from ${url}`, timeoutSeconds, () => httpResponds(url), 2000); + return true; + } catch (error) { + if (!quiet) log(error.message); + return false; + } +} + +// Waits until the proxy and archiver expose the local network id. +function waitForNetworkId(cfg) { + const accountUrl = `http://127.0.0.1:${cfg.ports.proxyBind}/account/${ZERO_ACCOUNT}`; + const cycleUrl = 'http://127.0.0.1:4000/cycleinfo/1'; + + return waitUntil(`network parameters from ${accountUrl}`, 600, async () => { + if (childExited(state.proxyProcess)) { + throw new Error(`Proxy exited while waiting for network parameters; see ${path.join(cfg.paths.logs, 'proxy.log')}`); + } + + try { + const account = JSON.parse(await requestText(accountUrl, { timeoutMs: 5000 })); + const cycle = JSON.parse(await requestText(cycleUrl, { timeoutMs: 5000 })); + return account.account && account.account.type === 'NetworkAccount' + ? cycle.cycleInfo && cycle.cycleInfo[0] && cycle.cycleInfo[0].networkId + : ''; + } catch { + return ''; + } + }); +} + +// ----------------------------- PM2/readiness ------------------------------ + +// Returns true when a spawned child has already exited. +function childExited(child) { + return child && (child.exitCode !== null || child.signalCode !== null); +} + +// Returns the Shardus PM2 home for this runtime volume. +function pm2Home(cfg) { + return path.join(cfg.paths.server, 'instances', '.pm2'); +} + +// Extracts the first complete JSON array from noisy PM2 stdout. +function extractJsonArray(text) { + const start = String(text).indexOf('['); + if (start === -1) return '[]'; + + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = start; index < text.length; index += 1) { + const char = text[index]; + if (escaped) { escaped = false; continue; } + if (char === '\\') { escaped = true; continue; } + if (char === '"') { inString = !inString; continue; } + if (inString) continue; + if (char === '[') depth += 1; + if (char === ']' && --depth === 0) return text.slice(start, index + 1); + } + + return '[]'; +} + +// Reads PM2 process metadata, tolerating empty or noisy output. +function pm2Processes(cfg) { + const result = run('pm2', ['jlist'], { + allowFailure: true, + env: { PM2_HOME: pm2Home(cfg) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + try { + return JSON.parse(extractJsonArray(result.stdout || '')); + } catch { + return []; + } +} + +// Finds a PM2 process id by Shardus process name. +function pm2ProcessIdByName(cfg, name) { + const processEntry = pm2Processes(cfg).find((entry) => String(entry.name || '').replace(/"/g, '') === name); + return processEntry ? String(processEntry.pm_id) : ''; +} + +// Restarts one PM2 process by name. +function restartPm2ProcessByName(cfg, name) { + const pm2Id = pm2ProcessIdByName(cfg, name); + if (!pm2Id) throw new Error(`Could not find PM2 process named ${name}`); + run('pm2', ['restart', pm2Id, '--no-color'], { env: { PM2_HOME: pm2Home(cfg) }, stdio: 'ignore' }); +} + +// Restarts the monitor once if PM2 says it is online but the port never binds. +async function ensureMonitorReady(cfg) { + const initialTimeout = intEnv('LIBERDUS_MONITOR_READY_TIMEOUT', 60); + const restartTimeout = intEnv('LIBERDUS_MONITOR_RESTART_READY_TIMEOUT', 120); + const monitorUrl = 'http://127.0.0.1:3000/'; + + if (await waitForHttp(monitorUrl, initialTimeout, 'monitor', true)) return; + + log(`Monitor did not bind on port 3000 after ${initialTimeout}s; restarting monitor-server once`); + restartPm2ProcessByName(cfg, 'monitor-server'); + if (!await waitForHttp(monitorUrl, restartTimeout, 'monitor')) { + throw new Error('Monitor did not become ready after restart'); + } +} + +// Checks a local TCP listener, preferring lsof to handle IPv6 wildcard sockets. +async function portListening(port) { + if (commandExists('lsof')) { + return run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], { allowFailure: true, stdio: 'ignore' }).status === 0; + } + + return new Promise((resolve) => { + const socket = net.createConnection({ host: '127.0.0.1', port, timeout: 1000 }, () => { + socket.destroy(); + resolve(true); + }); + socket.on('timeout', () => { socket.destroy(); resolve(false); }); + socket.on('error', () => resolve(false)); + }); +} + +// Lists validator ports that are not listening yet. +async function missingValidatorPorts(cfg) { + const missing = []; + const { externalStart } = cfg.ports; + + for (let port = externalStart; port < externalStart + cfg.nodeCount; port += 1) { + if (!await portListening(port)) missing.push(port); + } + + return missing; +} + +// Waits for every validator port to bind. +async function waitForValidatorPorts(cfg, timeoutSeconds) { + let missing = []; + + try { + await waitUntil('validator ports to bind', timeoutSeconds, async () => { + missing = await missingValidatorPorts(cfg); + return missing.length === 0; + }); + return true; + } catch { + log(`Timed out waiting for validator ports to bind: ${missing.join(' ')}`); + return false; + } +} + +// Restarts validators once if their PM2 process is online but the port is not bound. +async function ensureValidatorPortsReady(cfg) { + const initialTimeout = intEnv('LIBERDUS_VALIDATOR_PORT_READY_TIMEOUT', 60); + const restartTimeout = intEnv('LIBERDUS_VALIDATOR_RESTART_READY_TIMEOUT', 180); + + if (await waitForValidatorPorts(cfg, initialTimeout)) return; + + const missing = await missingValidatorPorts(cfg); + log(`Restarting validators that did not bind after ${initialTimeout}s: ${missing.join(' ')}`); + for (const port of missing) restartPm2ProcessByName(cfg, `shardus-instance-${port}`); + + if (!await waitForValidatorPorts(cfg, restartTimeout)) { + throw new Error('Validator ports did not become ready after restart'); + } +} + +// ----------------------------- config writers ----------------------------- + +// Ensures a nested object exists before assigning generated local config. +function ensureObject(parent, key) { + if (!parent[key] || typeof parent[key] !== 'object' || Array.isArray(parent[key])) parent[key] = {}; + return parent[key]; +} + +// Writes proxy config for a standalone local network. +function writeProxyConfig(cfg) { + const configPath = path.join(cfg.paths.proxy, 'src', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + config.http_port = cfg.ports.proxyBind; + config.archiver_seed_path = './src/archiver_seed.json'; + Object.assign(ensureObject(config, 'standalone_network'), { enabled: true, replacement_ip: '127.0.0.1' }); + Object.assign(ensureObject(config, 'node_filtering'), { + enabled: false, + remove_top_nodes: 0, + remove_bottom_nodes: 0, + min_nodes_for_filtering: 0, + }); + Object.assign(ensureObject(config, 'shardus_monitor'), { upstream_ip: '127.0.0.1', upstream_port: 3000, https: false }); + Object.assign(ensureObject(config, 'local_source'), { + collector_api_ip: '127.0.0.1', + collector_api_port: 6101, + collector_event_server_ip: '127.0.0.1', + collector_event_server_port: 4444, + }); + Object.assign(ensureObject(config, 'notifier'), { ip: '127.0.0.1', port: 4444 }); + + writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); + writeFile(path.join(cfg.paths.proxy, 'src', 'archiver_seed.json'), `${JSON.stringify(ARCHIVER_SEED)}\n`); +} + +// Writes the web client's local network.js from the detected network id. +function writeWebNetwork(cfg, networkId) { + const network = { + name: 'Localnet', + netid: networkId, + netids: [networkId], + gateways: [{ web: `http://${cfg.publicHost}:${cfg.ports.proxyPublic}`, ws: `ws://${cfg.publicHost}:${cfg.ports.proxyWsPublic}` }], + bridges: [ + { name: 'Polygon', username: 'bridgepolygon' }, + { name: 'Ethereum', username: 'bridgeeth' }, + { name: 'BSC', username: 'bridgebsc' }, + ], + farmUrl: 'https://liberdus.com/farm', + validatorUrl: 'https://liberdus.com/validator', + bridgeUrl: './bridge', + }; + + writeFile(path.join(cfg.paths.web, 'network.js'), `const network = ${JSON.stringify(network, null, 2)}\n`); +} + +// ----------------------------- startup phases ----------------------------- + +// Validates source checkouts and the proxy branch requirement. +function validateSources(cfg) { + requireDir('server source', cfg.sources.server); + requireDir('liberdus-proxy source', cfg.sources.proxy); + requireDir('web-client-v2 source', cfg.sources.web); + + const liberdusRs = path.join(cfg.sources.proxy, 'src', 'liberdus.rs'); + const proxySource = fs.existsSync(liberdusRs) ? fs.readFileSync(liberdusRs, 'utf8') : ''; + if (!proxySource.includes('foundationNode')) { + throw new Error([ + 'The configured liberdus-proxy checkout does not support the current archiver nodelist response.', + 'Use a liberdus-proxy branch that includes the foundationNode nodelist fix, such as origin/new-nodelist-response.', + ].join('\n')); + } +} + +// Copies read-only mounted repos into the writable runtime volume. +function syncSources(cfg) { + fs.mkdirSync(cfg.paths.logs, { recursive: true }); + log('Syncing source repos into Docker volume'); + + for (const [name, destination] of Object.entries(cfg.paths)) { + if (name === 'logs') continue; + const excludes = SOURCE_EXCLUDES[name].flatMap((entry) => ['--exclude', entry]); + run('rsync', ['-a', '--delete', ...excludes, trailingSlash(cfg.sources[name]), trailingSlash(destination)]); + } +} + +// Installs server dependencies and compiles dist when the cache marker is stale. +function prepareServer(cfg, markers) { + const nodeModules = path.join(cfg.paths.server, 'node_modules'); + const dist = path.join(cfg.paths.server, 'dist'); + const depsMarker = path.join(nodeModules, '.local-network-build'); + const buildMarker = path.join(dist, '.local-network-build'); + let depsWereCached = false; + + if (cacheValid(depsMarker, markers.serverDeps, [path.join(nodeModules, '.bin', 'shardus')])) { + depsWereCached = true; + log('Reusing cached server dependencies'); + } else { + log('Installing server dependencies'); + fs.rmSync(nodeModules, { recursive: true, force: true }); + run('npm', ['install'], { cwd: cfg.paths.server, env: { RUSTUP_TOOLCHAIN: cfg.toolchains.serverRust } }); + writeFile(depsMarker, markers.serverDeps); + } + + if (cacheValid(buildMarker, markers.serverBuild, [path.join(dist, 'index.js')])) { + log('Reusing cached server build'); + } else if (!depsWereCached && fs.existsSync(path.join(dist, 'index.js'))) { + log('Using server build produced during dependency install'); + writeFile(buildMarker, markers.serverBuild); + } else { + log('Compiling server'); + run('npm', ['run', 'compile'], { cwd: cfg.paths.server, env: { RUSTUP_TOOLCHAIN: cfg.toolchains.serverRust } }); + writeFile(buildMarker, markers.serverBuild); + } +} + +// Clears old Shardus/PM2 state before starting a fresh network. +function resetShardusState(cfg) { + process.env.PATH = `${path.join(cfg.paths.server, 'node_modules', '.bin')}:${process.env.PATH}`; + log('Resetting any previous Shardus instances'); + + run('shardus', ['stop'], { allowFailure: true, cwd: cfg.paths.server, stdio: 'ignore' }); + if (fs.existsSync(pm2Home(cfg))) { + run('pm2', ['kill'], { allowFailure: true, env: { PM2_HOME: pm2Home(cfg) }, stdio: 'ignore' }); + } + fs.rmSync(path.join(cfg.paths.server, 'instances'), { recursive: true, force: true }); +} + +// Starts Shardus using the normal shardus start command. +function startShardus(cfg) { + process.env.minNodes = String(cfg.nodeCount); + process.env.baselineNodes = String(cfg.nodeCount); + process.env.maxNodes = String(cfg.nodeCount * 2); + + log(`Starting ${cfg.nodeCount}-node Shardus network on validator ports ${cfg.ports.externalStart}-${cfg.ports.externalStart + cfg.nodeCount - 1}`); + if (cfg.ports.externalStart !== 9001 || cfg.ports.internalStart !== 10001) { + run('shardus', [ + 'create', + '--no-start', + '--starting-external-port', String(cfg.ports.externalStart), + '--starting-internal-port', String(cfg.ports.internalStart), + String(cfg.nodeCount), + 'pm2--no-autorestart', + ], { cwd: cfg.paths.server }); + } + + run('shardus', ['start', String(cfg.nodeCount), 'pm2--no-autorestart'], { cwd: cfg.paths.server }); +} + +// Waits for the network state needed before app transactions can succeed. +async function waitForShardusReady(cfg) { + log('Waiting for archiver, monitor, and active nodelist'); + await waitForJson('http://127.0.0.1:4000/archivers', (data) => ((data.activeArchivers || data.archivers || []).length) > 0, 600, 'active archivers'); + await ensureMonitorReady(cfg); + await ensureValidatorPortsReady(cfg); + await waitForJson('http://127.0.0.1:4000/full-nodelist?activeOnly=true', (data) => ((data.nodeList || data.nodes || data.nodelist || []).length) > 0, 600, 'active nodelist'); + await waitForJson('http://127.0.0.1:4000/cycleinfo/1', (data) => ( + Array.isArray(data.cycleInfo) + && data.cycleInfo.length > 0 + && data.cycleInfo[0].mode === 'processing' + && Number(data.cycleInfo[0].active || 0) >= cfg.nodeCount + ), 2400, 'processing cycle'); +} + +// Builds liberdus-proxy when its source or Rust toolchain marker changes. +function prepareProxy(cfg, markers) { + const target = path.join(cfg.paths.proxy, 'target'); + const proxyBin = path.join(target, 'debug', 'liberdus-proxy'); + const marker = path.join(target, '.local-network-build'); + + if (cacheValid(marker, markers.proxyBuild, [proxyBin])) { + log('Reusing cached Liberdus proxy build'); + return; + } + + log('Building Liberdus proxy'); + fs.rmSync(target, { recursive: true, force: true }); + run('cargo', [`+${cfg.toolchains.proxyRust}`, 'build'], { cwd: cfg.paths.proxy }); + writeFile(marker, markers.proxyBuild); +} + +// Starts a child process with stdout/stderr redirected to a runtime log file. +function startLoggedProcess(label, command, args, cwd, logFile) { + const fd = fs.openSync(logFile, 'a'); + const child = spawn(command, args, { cwd, env: process.env, stdio: ['ignore', fd, fd] }); + + child.on('exit', (code, signal) => { + if (!state.shuttingDown) log(`${label} exited with ${signal || code}; see ${logFile}`); + }); + child.on('error', (error) => { + if (!state.shuttingDown) log(`${label} failed to start: ${error.message}`); + }); + + return child; +} + +// Starts the proxy, waits for network id, writes network.js, then serves the web client. +async function startProxyAndWeb(cfg) { + log('Starting Liberdus proxy'); + state.proxyProcess = startLoggedProcess('Liberdus proxy', './target/debug/liberdus-proxy', [], cfg.paths.proxy, path.join(cfg.paths.logs, 'proxy.log')); + + const networkId = await waitForNetworkId(cfg); + log(`Detected local network id: ${networkId}`); + writeWebNetwork(cfg, networkId); + + log(`Starting web client on http://${cfg.publicHost}:${cfg.ports.webPublic}/`); + state.webProcess = startLoggedProcess( + 'Web client server', + 'python3', + ['-m', 'http.server', String(cfg.ports.webBind), '--bind', '0.0.0.0'], + cfg.paths.web, + path.join(cfg.paths.logs, 'web-client.log'), + ); +} + +// Keeps the container alive and fails if a foreground child exits. +async function watchChildren(cfg) { + log('Local network is ready'); + log(`Monitor: http://${cfg.publicHost}:${cfg.ports.monitorPublic}/`); + log(`Proxy: http://${cfg.publicHost}:${cfg.ports.proxyPublic}/ (binds ${cfg.ports.proxyBind}/${cfg.ports.proxyWsBind})`); + log(`Web client: http://${cfg.publicHost}:${cfg.ports.webPublic}/`); + + while (true) { + if (childExited(state.proxyProcess)) throw new Error(`Proxy exited; see ${path.join(cfg.paths.logs, 'proxy.log')} in the local-network-runtime volume`); + if (childExited(state.webProcess)) throw new Error(`Web client server exited; see ${path.join(cfg.paths.logs, 'web-client.log')} in the local-network-runtime volume`); + await sleep(5000); + } +} + +// Stops child services when the container exits or receives a signal. +function stopStack() { + if (state.shuttingDown) return; + state.shuttingDown = true; + + if (state.serverDir && fs.existsSync(state.serverDir)) { + log('Stopping Shardus network'); + run('shardus', ['stop'], { + allowFailure: true, + cwd: state.serverDir, + env: { PATH: `${path.join(state.serverDir, 'node_modules', '.bin')}:${process.env.PATH}` }, + stdio: 'ignore', + }); + } + + if (state.proxyProcess && !childExited(state.proxyProcess)) state.proxyProcess.kill('SIGTERM'); + if (state.webProcess && !childExited(state.webProcess)) state.webProcess.kill('SIGTERM'); +} + +// Installs shutdown handlers once at process startup. +function installSignalHandlers() { + process.once('exit', stopStack); + for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { + stopStack(); + process.exit(signal === 'SIGINT' ? 130 : 143); + }); + } +} + +// ----------------------------- main runbook ------------------------------- + +// Runs the local-network startup sequence from sources to ready web client. +async function main() { + installSignalHandlers(); + + const cfg = buildConfig(); + state.serverDir = cfg.paths.server; + + // Validate source checkouts before doing expensive install/build work. + validateSources(cfg); + + // Copy mounted repos into the writable runtime volume and write generated config. + syncSources(cfg); + writeProxyConfig(cfg); + + // Reuse server/proxy builds when source, lockfiles, and toolchains match. + const markers = buildCacheMarkers(cfg); + prepareServer(cfg, markers); + + // Start Shardus and wait until the network is in processing mode. + resetShardusState(cfg); + startShardus(cfg); + await waitForShardusReady(cfg); + + // Start proxy/web only after Shardus can accept app transactions. + prepareProxy(cfg, markers); + await startProxyAndWeb(cfg); + + // Keep PID 1 alive while the child services stay healthy. + await watchChildren(cfg); +} + +main().catch((error) => { + log(error && error.stack ? error.stack : String(error)); + stopStack(); + process.exit(1); +});