diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1839879a..81e5c12d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,3 +93,6 @@ jobs: - name: Check | Saturn started run: docker exec station bin/station.js activity | grep "Saturn module started" + + - name: Check | Zinnia started + run: docker exec station bin/station.js activity | grep "Zinnia started" diff --git a/.mocharc.yaml b/.mocharc.yaml new file mode 100644 index 00000000..9cdc99ee --- /dev/null +++ b/.mocharc.yaml @@ -0,0 +1,2 @@ +timeout: 15000 +exit: true diff --git a/Dockerfile b/Dockerfile index 972075e2..148e685a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:18 LABEL org.opencontainers.image.source https://github.com/filecoin-station/core USER node WORKDIR /usr/src/app diff --git a/bin/station.js b/bin/station.js index 6829aec2..d1a37660 100755 --- a/bin/station.js +++ b/bin/station.js @@ -21,6 +21,7 @@ Sentry.init({ const core = new Core(getDefaultRootDirs()) const modules = [ + 'zinnia', 'saturn-L2-node', 'bacalhau' ] diff --git a/commands/station.js b/commands/station.js index e9241240..1efb476c 100644 --- a/commands/station.js +++ b/commands/station.js @@ -1,5 +1,6 @@ import { join } from 'node:path' import * as saturnNode from '../lib/saturn-node.js' +import * as zinniaRuntime from '../lib/zinnia.js' import { formatActivityObject } from '../lib/activity.js' import lockfile from 'proper-lockfile' import { maybeCreateFile } from '../lib/util.js' @@ -36,6 +37,16 @@ export const station = async ({ core, json, experimental }) => { logStream: core.logs.createWriteStream( join(core.paths.moduleLogs, 'saturn-L2-node.log') ) + }), + zinniaRuntime.start({ + FIL_WALLET_ADDRESS, + STATE_ROOT: join(core.paths.moduleState, 'zinnia'), + CACHE_ROOT: join(core.paths.moduleCache, 'zinnia'), + metricsStream: await core.metrics.createWriteStream('zinnia'), + activityStream: core.activity.createWriteStream('Zinnia'), + logStream: core.logs.createWriteStream( + join(core.paths.moduleLogs, 'zinnia.log') + ) }) ] diff --git a/lib/modules.js b/lib/modules.js index 79dbf3b8..7b3ef05f 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -21,10 +21,14 @@ export const getBinaryModuleExecutable = ({ return join( moduleBinaries, module, - `${executable}${os.platform() === 'win32' ? '.exe' : ''}` + getExecutableFileName(executable) ) } +const getExecutableFileName = executable => { + return `${executable}${os.platform() === 'win32' ? '.exe' : ''}` +} + export const installBinaryModule = async ({ module, repo, @@ -79,8 +83,9 @@ export const installBinaryModule = async ({ const [entry] = /** @type {[UnzipStreamEntry]} */ (await once(parser, 'entry')) - if (entry.path === executable) { - const outPath = join(moduleBinaries, module, executable) + const executableFileName = getExecutableFileName(executable) + if (entry.path === executableFileName) { + const outPath = join(moduleBinaries, module, executableFileName) await pipeline(entry, createWriteStream(outPath)) await chmod(outPath, 0o755) return @@ -92,3 +97,35 @@ export const installBinaryModule = async ({ } console.log(`[${module}] ✓ ${outFile}`) } + +export async function downloadSourceFiles ({ module, repo, distTag }) { + await mkdir(moduleBinaries, { recursive: true }) + const outDir = join(moduleBinaries, module) + + console.log(`[${module}] ⇣ downloading source files`) + + const url = `https://github.com/${repo}/archive/refs/tags/${distTag}.tar.gz` + const res = await fetch(url, { + headers: { + ...(authorization ? { authorization } : {}) + }, + redirect: 'follow' + }) + + if (res.status >= 300) { + throw new Error( + `[${module}] Cannot fetch ${module} archive for tag ${distTag}: ${res.status}\n` + + await res.text() + ) + } + + if (!res.body) { + throw new Error( + `[${module}] Cannot fetch ${module} archive for tag ${distTag}: no response body` + ) + } + + // `{ strip: 1}` tells tar to remove the top-level directory (e.g. `mod-peer-checker-v1.0.0`) + await pipeline(res.body, gunzip(), tar.extract(outDir, { strip: 1 })) + console.log(`[${module}] ✓ ${outDir}`) +} diff --git a/lib/zinnia.js b/lib/zinnia.js new file mode 100644 index 00000000..1960d3b8 --- /dev/null +++ b/lib/zinnia.js @@ -0,0 +1,144 @@ +import timers from 'node:timers/promises' +import { execa } from 'execa' +import * as Sentry from '@sentry/node' +import { installBinaryModule, downloadSourceFiles, getBinaryModuleExecutable } from './modules.js' +import { moduleBinaries } from './paths.js' + +const ZINNIA_DIST_TAG = 'v0.8.0' +const ZINNIA_MODULES = [ + { + module: 'peer-checker', + repo: 'filecoin-station/mod-peer-checker', + distTag: 'v1.0.0' + } +] + +export async function install () { + await Promise.all([ + installBinaryModule({ + module: 'zinnia', + repo: 'filecoin-station/zinnia', + distTag: ZINNIA_DIST_TAG, + executable: 'zinniad', + targets: [ + { platform: 'darwin', arch: 'arm64', asset: 'zinniad-macos-arm64.zip' }, + { platform: 'darwin', arch: 'x64', asset: 'zinniad-macos-x64.zip' }, + { platform: 'linux', arch: 'arm64', asset: 'zinniad-linux-arm64.tar.gz' }, + { platform: 'linux', arch: 'x64', asset: 'zinniad-linux-x64.tar.gz' }, + { platform: 'win32', arch: 'x64', asset: 'zinniad-windows-x64.zip' } + ] + }), + + ...Object.values(ZINNIA_MODULES).map(downloadSourceFiles) + ]) +} + +async function start ({ + FIL_WALLET_ADDRESS, + STATE_ROOT, + CACHE_ROOT, + metricsStream, + activityStream, + logStream +}) { + logStream.write('Starting Zinnia') + + const zinniadExe = getBinaryModuleExecutable({ module: 'zinnia', executable: 'zinniad' }) + const modules = [ + // all paths are relative to `moduleBinaries` + 'peer-checker/peer-checker.js' + ] + const childProcess = execa(zinniadExe, modules, { + cwd: moduleBinaries, + env: { + FIL_WALLET_ADDRESS, + STATE_ROOT, + CACHE_ROOT + } + }) + + const readyPromise = new Promise((resolve, reject) => { + childProcess.stdout.setEncoding('utf-8') + childProcess.stdout.on('data', data => { + logStream.write(data) + handleEvents({ activityStream, metricsStream }, data) + }) + + childProcess.stderr.setEncoding('utf-8') + childProcess.stderr.on('data', data => { + logStream.write(data) + }) + + childProcess.stdout.once('data', _data => { + // This is based on an implicit assumption that zinniad reports an info activity + // after it starts + resolve() + }) + childProcess.catch(reject) + }) + + childProcess.on('close', code => { + logStream.write(`Zinnia closed all stdio with code ${code ?? ''}`) + childProcess.stderr.removeAllListeners() + childProcess.stdout.removeAllListeners() + Sentry.captureException('Zinnia exited') + }) + + childProcess.on('exit', (code, signal) => { + const reason = signal ? `via signal ${signal}` : `with code: ${code}` + const msg = `Zinnia exited ${reason}` + logStream.write(msg) + activityStream.write({ type: 'info', message: msg }) + }) + + try { + await Promise.race([ + readyPromise, + timers.setTimeout(500) + ]) + } catch (err) { + const errorMsg = err instanceof Error ? err.message : '' + err + const message = `Cannot start Zinnia: ${errorMsg}` + logStream.write(message) + activityStream.write({ type: 'error', message }) + } +} + +function handleEvents ({ activityStream, metricsStream }, text) { + text + .trimEnd() + .split(/\n/g) + .forEach(line => { + try { + const event = JSON.parse(line) + switch (event.type) { + case 'activity:info': + activityStream.write({ + type: 'info', + message: event.message.replace(/Module Runtime/, 'Zinnia'), + source: event.module ?? 'Zinnia' + }) + break + + case 'activity:error': + activityStream.write({ + type: 'error', + message: event.message.replace(/Module Runtime/, 'Zinnia'), + source: event.module ?? 'Zinnia' + }) + break + + case 'jobs-completed': + metricsStream.write({ totalJobsCompleted: event.total, totalEarnings: '0' }) + break + + default: + console.error('Ignoring Zinnia event of unknown type:', event) + } + } catch (err) { + console.error('Ignoring malformed Zinnia event:', line) + } + }) +} + +export { start } diff --git a/package-lock.json b/package-lock.json index 7554aaaa..c10b0cca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "station": "bin/station.js" }, "devDependencies": { + "get-stream": "^6.0.1", "mocha": "^10.2.0", "np": "^7.6.3", "prettier": "^2.8.4", diff --git a/package.json b/package.json index 1e6bb9e1..b7d25409 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,10 @@ "scripts": { "build": "tsc", "format": "prettier --write .", + "start": "node ./bin/station.js", "test": "npm run build && npm run test:lint && npm run test:unit", "test:lint": "prettier --check . && standard", - "test:unit": "mocha --timeout 15000", + "test:unit": "mocha", "version": "npm run build && node ./scripts/version.js", "postinstall": "node ./scripts/post-install.js", "postpublish": "node ./scripts/post-publish.js", @@ -29,6 +30,7 @@ ] }, "devDependencies": { + "get-stream": "^6.0.1", "mocha": "^10.2.0", "np": "^7.6.3", "prettier": "^2.8.4", diff --git a/scripts/post-install.js b/scripts/post-install.js index 29d53af0..9c27f2eb 100755 --- a/scripts/post-install.js +++ b/scripts/post-install.js @@ -2,8 +2,10 @@ import { install as installSaturn } from '../lib/saturn-node.js' import { install as installBacalhau } from '../lib/bacalhau.js' +import { install as installZinnia } from '../lib/zinnia.js' await Promise.all([ installSaturn(), - installBacalhau() + installBacalhau(), + installZinnia() ]) diff --git a/test/station.js b/test/station.js index 6bd43e86..f43678f3 100644 --- a/test/station.js +++ b/test/station.js @@ -1,63 +1,79 @@ +import assert from 'node:assert' import { execa } from 'execa' import { station, FIL_WALLET_ADDRESS } from './util.js' import { tmpdir } from 'node:os' import { randomUUID } from 'node:crypto' import { join } from 'node:path' import streamMatch from 'stream-match' +import getStream from 'get-stream' describe('Station', () => { - it('runs Saturn', async () => { - const CACHE_ROOT = join(tmpdir(), randomUUID()) - const STATE_ROOT = join(tmpdir(), randomUUID()) - const ps = execa( - station, - { env: { CACHE_ROOT, STATE_ROOT, FIL_WALLET_ADDRESS } } - ) + it('runs Saturn and Zinnia', async () => { + const ps = startStation() await Promise.all([ streamMatch(ps.stdout, 'totalJobsCompleted'), - streamMatch(ps.stdout, 'Saturn Node will try to connect') + streamMatch(ps.stdout, 'Zinnia started'), + streamMatch(ps.stdout, 'Saturn module started') ]) - ps.kill() + stopStation() }) it('runs experimental modules', () => { it('runs Bacalhau', async () => { - const CACHE_ROOT = join(tmpdir(), randomUUID()) - const STATE_ROOT = join(tmpdir(), randomUUID()) - const ps = execa( - station, - ['--experimental'], - { env: { CACHE_ROOT, STATE_ROOT, FIL_WALLET_ADDRESS } } - ) + const ps = startStation(['--experimental']) await streamMatch(ps.stdout, 'Bacalhau module started.') - ps.kill() + stopStation() }) }) it('outputs events', async () => { - const CACHE_ROOT = join(tmpdir(), randomUUID()) - const STATE_ROOT = join(tmpdir(), randomUUID()) - const ps = execa( - station, - [], - { env: { CACHE_ROOT, STATE_ROOT, FIL_WALLET_ADDRESS } } - ) + const ps = startStation() await Promise.all([ streamMatch(ps.stdout, 'totalJobsCompleted'), - streamMatch(ps.stdout, 'Saturn Node will try to connect') + streamMatch(ps.stdout, 'Zinnia started'), + streamMatch(ps.stdout, 'Saturn module started') ]) - ps.kill() + stopStation() }) it('outputs events json', async () => { + const ps = startStation(['--json']) + + await Promise.all([ + streamMatch(ps.stdout, 'jobs-completed'), + streamMatch(ps.stdout, /activity:info.*(Zinnia started)/), + streamMatch(ps.stdout, /activity:info.*(Saturn module started)/) + ]) + + stopStation() + }) + + let ps, stdout, stderr + function startStation (cliArgs = []) { + assert(!ps, 'Station is already running') + const CACHE_ROOT = join(tmpdir(), randomUUID()) const STATE_ROOT = join(tmpdir(), randomUUID()) - const ps = execa( + ps = execa( station, - ['--json'], + cliArgs, { env: { CACHE_ROOT, STATE_ROOT, FIL_WALLET_ADDRESS } } ) - await Promise.all([ - streamMatch(ps.stdout, 'jobs-completed'), - streamMatch(ps.stdout, 'activity:info') - ]) + stdout = getStream(ps.stdout) + stderr = getStream(ps.stderr) + return ps + } + + function stopStation () { ps.kill() + ps = undefined + } + + afterEach(async () => { + if (!ps) return + // The test failed and did not stop the Station process + // Let's stop the process and print stdout & stderr for troubleshooting + stopStation() + + console.log('== STATION STDOUT ==\n%s', await stdout) + console.log('== STATION STDERR ==\n%s', await stderr) + console.log('== END ==') }) }) diff --git a/test/storage.js b/test/storage.js index f268ef8f..867b209b 100644 --- a/test/storage.js +++ b/test/storage.js @@ -22,6 +22,11 @@ describe('Storage', async () => { STATE_ROOT, 'logs', 'modules', 'saturn-L2-node.log' ) ) + await fs.stat( + join( + STATE_ROOT, 'logs', 'modules', 'zinnia.log' + ) + ) break } catch {} }