From 9df095f6181e392ef8b0805676fc0ab8d1b76481 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Mon, 26 Oct 2026 10:45:15 -0700 Subject: [PATCH] Pin supported Node 26 runtime for Sysnode backend --- .github/workflows/ci.yml | 10 ++++----- .nvmrc | 1 + README.md | 15 +++++++------ deploy/automation/README.md | 36 ++++++++++++++++++++++++++++++- deploy/automation/host-deploy.cjs | 17 +++++++++------ package-lock.json | 2 +- package.json | 2 +- tests/automaticDeployment.test.js | 15 +++++++++++-- 8 files changed, 75 insertions(+), 23 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7081baa..722057b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - node: ['20', '22'] + node: ['26.12.1'] env: CI: 'true' @@ -61,7 +61,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' cache: npm - name: Install pinned Anvil uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1 @@ -80,10 +80,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up Node 22 + - name: Set up Node 26.12.1 uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' cache: npm - name: Install @@ -126,7 +126,7 @@ jobs: persist-credentials: false - uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' - name: Request exact-commit deployment run: node scripts/request-deploy.cjs backend env: diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..63f87c2 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +26.12.1 diff --git a/README.md b/README.md index 509a593..2509161 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Authenticated routes (cookie + CSRF, same-site): ## Requirements -- Node.js 20 LTS (engines in `package.json` gate Node ≥ 20, < 24) +- Node.js 26.12.1 (pinned in `.nvmrc` and CI; engines allow patched Node 26 releases from 26.12.1, below 27) - A reachable `syscoind` on mainnet or testnet, with RPC enabled - SMTP server for verification emails and vote reminders (or `MAIL_TRANSPORT=log` for dry-run) - SQLite 3 (via `better-sqlite3`, no separate install; native module compiles at `npm install`) @@ -49,6 +49,7 @@ Optional, used only for the Pali PSBT collateral path: ```bash git clone https://github.com/syscoin/sysnode-backend.git cd sysnode-backend +nvm install && nvm use # exact version from .nvmrc npm ci cp .env.example .env # then edit — see .env.example for inline docs npm run dev # nodemon on :3001 @@ -150,17 +151,17 @@ For remote RPC nodes, either configure `rpcauth=` in `syscoin.conf` and use the These steps stand up `sysnode-backend` + [`sysnode-info`](https://github.com/syscoin/sysnode-info) on one Ubuntu box that already runs `syscoind`. HTTP-only; intended for staging and testing, not production. Everything installs into the user's home directory — **no `sudo` required** on most steps (a couple of optional hardening steps do need it; they are clearly marked). -Walked end-to-end against Ubuntu 24.04 LTS + Node 22. Port layout: backend :3001, frontend :3000. +The commands below target Ubuntu 24.04 LTS with Node 26.12.1. Port layout: backend :3001, frontend :3000. For an existing production installation, follow the runtime migration requirements in [the deployment guide](deploy/automation/README.md#node-runtime-upgrades) before restarting either service. -### 1. Node.js 20 or 22 + basic tooling +### 1. Node.js 26.12.1 + basic tooling -Node.js ≥ 20, < 24 (see `engines` in `package.json`). If the box already has a Node in that range, skip the `nvm` block. Ubuntu 24.04 ships a compatible Node in its default repos; many one-click images come with Node 20 or 22 pre-installed. +Use the exact Node version in `.nvmrc`; the operating system's default Node package may be older. The package engine requires Node ≥ 26.12.1, < 27. Node 20, 22 and 24 are not qualified for this revision. ```bash -# Only if you don't already have Node 20–23.x +# Install the pinned runtime if it is not already available curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash source ~/.bashrc -nvm install 22 && nvm use 22 +nvm install 26.12.1 && nvm use 26.12.1 # Use a user-local npm prefix so global installs don't need sudo mkdir -p ~/.npm-global ~/.local/bin @@ -288,7 +289,7 @@ EOF mkdir -p data ``` -> **The backend does not load `.env` automatically.** It has no `dotenv` dependency. We load the file with Node's native `--env-file=` flag (Node 20.6+), which is why every invocation of `node` for this repo below passes `--env-file=.env`. +> **The backend does not load `.env` automatically.** It has no `dotenv` dependency. We load the file with Node's native `--env-file=` flag (available in the pinned Node runtime), which is why every invocation of `node` for this repo below passes `--env-file=.env`. > **Cookie file permissions.** If the backend user can't read `~/.syscoin/.cookie` (different uid than `syscoind`), add `rpccookieperms=group` to `~/.syscoin/syscoin.conf` and restart `syscoind`, then add the backend's user to the syscoin group. The backend's boot log prints the exact errno (`ENOENT` / `EACCES`) if it can't read the file. diff --git a/deploy/automation/README.md b/deploy/automation/README.md index 9cbce72..00566e8 100644 --- a/deploy/automation/README.md +++ b/deploy/automation/README.md @@ -13,7 +13,7 @@ for application code. Install a reviewed copy of `host-deploy.cjs` outside both live checkouts, at `/home/ubuntu/.local/libexec/sysnode-deploy/host-deploy.cjs`, with private `config.json` beside it. Create the configured state directory as mode 0700. The -example configuration uses the existing Node 22, PM2 applications, mainnet genesis, +example configuration uses Node 26.12.1, the existing PM2 applications, mainnet genesis, public HTTPS origin and loopback backend port; verify each value before enabling. The host runner must be updated explicitly from reviewed source, not replaced by an arbitrary candidate's deployment script. @@ -40,6 +40,40 @@ Review default-branch changes and dependencies accordingly. Production credentia are never sourced into staged tests/builds, but same-user filesystem isolation is not claimed. +## Node runtime upgrades + +Development and every CI job use Node 26.12.1, pinned in `.nvmrc`. The host runner +refuses Node versions below 26.12.1 or outside major 26. Staged npm commands and +child processes put the runner's own Node directory first on `PATH` so a different +system default cannot silently install native modules under another ABI. + +Changing `.nvmrc` or merging this file does not replace the host's Node runtime, +the independently installed host runner, an existing PM2 daemon, or running apps. +Before the first deployment on the new major, an operator must separately: + +1. Install and verify Node 26.12.1 and its npm on the host. The forced commands + above currently use `/usr/bin/node`; that executable must resolve to the + supported runtime. Confirm `node --version`, `node -p process.execPath`, + `/usr/bin/node --version`, and the runtime used by the PM2 daemon and both apps. +2. Prepare and test fresh dependencies under Node 26.12.1. `better-sqlite3` is a + native addon: do not reuse `node_modules` built for Node 20, 22 or 24. The host + runner loads the installed backend addon for read-only database checks, so its + current backend dependencies must also be compatible before the runner changes. + Preserve old code, dependencies and the previous Node executable for rollback. +3. Coordinate a controlled restart of the existing PM2 applications under the new + runtime, preserving `.env`, launch arguments, the SQLite database and its live + writes. Rebuilding native dependencies is not a database migration; never remove + `data/` or restore a stale database backup as part of this upgrade. +4. Install the reviewed `host-deploy.cjs` outside the application checkouts. It now + requires `Jest (Node 26.12.1)` for the backend and `Jest + build (Node 26.12.1)` + for the frontend; both workflows must match before automatic deployments resume. +5. Check the actual restarted process runtime, read-only SQLite integrity and + schema, both public and loopback health, and exact deployed Git revisions. Then + exercise the restricted `status` and qualified no-op deployment checks above. + +This repository does not automatically upgrade the host or restart its processes. +The normal exact-commit deployment and rollback fences remain unchanged. + ## GitHub environment setup For each repo's branch-restricted `production` environment: diff --git a/deploy/automation/host-deploy.cjs b/deploy/automation/host-deploy.cjs index bc9cd94..735c565 100644 --- a/deploy/automation/host-deploy.cjs +++ b/deploy/automation/host-deploy.cjs @@ -11,9 +11,14 @@ const { createHash } = require('node:crypto'); const { parseEnv, isDeepStrictEqual } = require('node:util'); const APPS = Object.freeze({ - frontend: { repo: 'syscoin/sysnode-info', branch: 'master', directory: 'sysnode-info', jobs: ['Jest + build (Node 20)', 'Jest + build (Node 22)'] }, - backend: { repo: 'syscoin/sysnode-backend', branch: 'main', directory: 'sysnode-backend', jobs: ['Jest (Node 20)', 'Jest (Node 22)', 'Production dependency audit', 'Schema sanity'] }, + frontend: { repo: 'syscoin/sysnode-info', branch: 'master', directory: 'sysnode-info', jobs: ['Jest + build (Node 26.12.1)'] }, + backend: { repo: 'syscoin/sysnode-backend', branch: 'main', directory: 'sysnode-backend', jobs: ['Jest (Node 26.12.1)', 'Production dependency audit', 'Schema sanity'] }, }); +const runtimePath = `${path.dirname(process.execPath)}:/home/ubuntu/.npm-global/bin:/usr/local/bin:/usr/bin:/bin`; +function validateNodeRuntime(version = process.versions.node) { + const match = typeof version === 'string' && /^26\.(\d+)\.(\d+)$/.exec(version); + if (!match || match[0] !== version || Number(match[1]) < 12 || (Number(match[1]) === 12 && Number(match[2]) < 1)) fail('node_26_12_1_or_later_required'); +} const sha256 = value => createHash('sha256').update(value).digest('hex'); function canonical(value) { if (Array.isArray(value)) return value.map(canonical); @@ -131,7 +136,7 @@ function retainSuccessful(config, app, currentJob) { } } function stageEnvironment(job) { - return { PATH: '/home/ubuntu/.npm-global/bin:/usr/local/bin:/usr/bin:/bin', HOME: os.homedir(), LANG: 'C.UTF-8', CI: 'true', NPM_CONFIG_USERCONFIG: '/dev/null', NPM_CONFIG_CACHE: path.join(job, 'npm-cache') }; + return { PATH: runtimePath, HOME: os.homedir(), LANG: 'C.UTF-8', CI: 'true', NPM_CONFIG_USERCONFIG: '/dev/null', NPM_CONFIG_CACHE: path.join(job, 'npm-cache') }; } function databasePath(config, apps) { const root = path.join(config.appsRoot, APPS.backend.directory); @@ -372,8 +377,8 @@ async function requestDeploy(configFile, app, request, io = {}) { fail('deployment_status_timeout_worker_may_continue'); } async function main() { - process.env.PATH = '/home/ubuntu/.npm-global/bin:/usr/local/bin:/usr/bin:/bin'; - if (Number(process.versions.node.split('.')[0]) !== 22) fail('node_22_required'); + validateNodeRuntime(); + process.env.PATH = runtimePath; const [mode, app, configFile, sha, runId, attempt, job] = process.argv.slice(2); if (['request', 'admitted'].includes(mode) && process.argv.length === 5) { if (!APPS[app]) fail('unknown_application'); @@ -400,4 +405,4 @@ async function main() { catch (error) { writeJson(path.join(job, 'status.json'), { state: 'failed', phase: 'deployment stopped', code: /^[a-z_]+$/.test(error.code || '') ? error.code : 'deployment_failed' }); throw error; } } if (require.main === module) main().catch(error => { console.error(/^[a-z_]+$/.test(error.code || '') ? error.code : 'deployment_failed'); process.exitCode = 1; }); -module.exports = { APPS, canonical, parseCommand, validateRun, validateJobsPage, validateCapabilities, sourceSchemaFence, sourceRuntimeFence, schema, compareState, stageEnvironment, deploy, requestDeploy, supervise, failedIfUnfinished, retainSuccessful }; +module.exports = { APPS, validateNodeRuntime, canonical, parseCommand, validateRun, validateJobsPage, validateCapabilities, sourceSchemaFence, sourceRuntimeFence, schema, compareState, stageEnvironment, deploy, requestDeploy, supervise, failedIfUnfinished, retainSuccessful }; diff --git a/package-lock.json b/package-lock.json index fdc49b8..a31e1ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "supertest": "^7.2.2" }, "engines": { - "node": ">=20.19.0" + "node": ">=26.12.1 <27" } }, "node_modules/@adraffy/ens-normalize": { diff --git a/package.json b/package.json index 15f3ff8..84b8704 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "sysnode", "version": "1.0.0", "engines": { - "node": ">=20.19.0" + "node": ">=26.12.1 <27" }, "description": "Syscoin backend for masternode and governance stats", "main": "server.js", diff --git a/tests/automaticDeployment.test.js b/tests/automaticDeployment.test.js index acb6e67..a028b81 100644 --- a/tests/automaticDeployment.test.js +++ b/tests/automaticDeployment.test.js @@ -4,13 +4,13 @@ const os = require('node:os'); const path = require('node:path'); const cp = require('node:child_process'); const { EventEmitter } = require('node:events'); -const { parseCommand, validateRun, validateJobsPage, validateCapabilities, sourceRuntimeFence, canonical, compareState, schema, stageEnvironment, supervise, requestDeploy, retainSuccessful } = require('../deploy/automation/host-deploy.cjs'); +const { parseCommand, validateNodeRuntime, validateRun, validateJobsPage, validateCapabilities, sourceRuntimeFence, canonical, compareState, schema, stageEnvironment, supervise, requestDeploy, retainSuccessful } = require('../deploy/automation/host-deploy.cjs'); const { inputs, privateKeyText } = require('../scripts/request-deploy.cjs'); const sha = 'a'.repeat(40); const request = { sha, runId: '123', attempt: '2' }; const run = { id: 123, run_attempt: 2, event: 'push', head_branch: 'main', head_sha: sha, repository: { full_name: 'syscoin/sysnode-backend' }, head_repository: { full_name: 'syscoin/sysnode-backend' }, path: '.github/workflows/ci.yml', status: 'in_progress' }; -const jobs = ['Jest (Node 20)', 'Jest (Node 22)', 'Production dependency audit', 'Schema sanity'].map(name => ({ name, status: 'completed', conclusion: 'success' })); +const jobs = ['Jest (Node 26.12.1)', 'Production dependency audit', 'Schema sanity'].map(name => ({ name, status: 'completed', conclusion: 'success' })); const env = { GITHUB_EVENT_NAME: 'push', GITHUB_REPOSITORY: 'syscoin/sysnode-backend', GITHUB_REF: 'refs/heads/main', GITHUB_SERVER_URL: 'https://github.com', GITHUB_SHA: sha, GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2', SYSNODE_DEPLOY_HOST: '192.0.2.10', SYSNODE_DEPLOY_USER: 'ubuntu', SYSNODE_DEPLOY_KEY: '-----BEGIN OPENSSH PRIVATE KEY-----\nsynthetic-only\n-----END OPENSSH PRIVATE KEY-----', SYSNODE_KNOWN_HOSTS: 'host ssh-ed25519 synthetic' }; test('real OpenSSH keys remain parseable when secrets omit final LF or use CRLF', () => { @@ -30,6 +30,11 @@ test('real OpenSSH keys remain parseable when secrets omit final LF or use CRLF' } finally { fs.rmSync(root, { recursive: true }); } }); +test('host deployment requires the supported patched Node runtime', () => { + for (const version of ['26.12.1', '26.12.2', '26.13.0']) expect(() => validateNodeRuntime(version)).not.toThrow(); + for (const version of ['20.20.0', '22.22.0', '24.22.1', '26.11.9', '26.12.0', '27.0.0', '26.12.1-rc.1', '26.12.1\n', 'invalid']) expect(() => validateNodeRuntime(version)).toThrow('node_26_12_1_or_later_required'); +}); + test('forced command accepts only exact status or SHA/run/attempt deployment', () => { expect(parseCommand('status')).toEqual({ status: true }); expect(parseCommand(`deploy ${sha} 123 2`)).toEqual(request); @@ -114,6 +119,12 @@ test('staging never inherits the live application environment', () => { process.env.SMTP_PASS = 'synthetic-only'; try { expect(stageEnvironment('/tmp/job')).not.toHaveProperty('SMTP_PASS'); expect(stageEnvironment('/tmp/job')).not.toHaveProperty('SYSNODE_DB_PATH'); expect(stageEnvironment('/tmp/job').NPM_CONFIG_USERCONFIG).toBe('/dev/null'); } finally { delete process.env.SMTP_PASS; } }); +test('staging resolves Node from the verified host runner before system defaults', () => { + const directory = path.dirname(process.execPath); + const env = stageEnvironment('/tmp/job'); + expect(env.PATH.split(path.delimiter)[0]).toBe(directory); + expect(cp.execFileSync('node', ['-p', 'process.execPath'], { env, encoding: 'utf8' }).trim()).toBe(process.execPath); +}); test('health capabilities preserve the exact configured chain and PQ gate', () => { const config = { network: 'mainnet', era: 'legacy-v3', genesisHash: 'ab'.repeat(32) };