Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node: ['20', '22']
node: ['26.12.1']

env:
CI: 'true'
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
26.12.1
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
36 changes: 35 additions & 1 deletion deploy/automation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down
17 changes: 11 additions & 6 deletions deploy/automation/host-deploy.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -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 };
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 13 additions & 2 deletions tests/automaticDeployment.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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) };
Expand Down
Loading