Finished basic beta implementation of entire thing.
Some checks failed
continuous-integration/appveyor/branch AppVeyor build failed

This commit is contained in:
Jonatan Nilsson 2022-02-15 11:28:30 +00:00
parent e540a54844
commit 5f3e688b8c
26 changed files with 555 additions and 754 deletions

View file

@ -26,3 +26,4 @@ test_script:
chmod -R 777 /appveyor/projects
npm install
npm test
npm test:integration

176
cli.mjs Normal file
View file

@ -0,0 +1,176 @@
#!/usr/bin/env node
// Get arguments
const [,, ...args] = process.argv
import os from 'os'
import fs from 'fs/promises'
import Util from './core/util.mjs'
import Core from './core/core.mjs'
import path from 'path'
if (!args.length) PrintHelp()
if (args[0] !== 'checkconfig' && args[0] !== 'install' && args[0] !== 'uninstall') PrintHelp
let configFile = 'config.json'
if (args[1]) {
configFile = args[1]
}
const basicRunnerTemplate = `
import fs from 'fs'
import { runner } from 'service-core'
runner(import.meta.url, '${configFile}', 'db.json')
.then(
function(core) {
core.log.info('core is running')
},
function(err) {
runner.log.error(err, 'Error starting runner')
process.exit(1)
}
)
`
const util = new Util(import.meta.url)
function PrintHelp() {
console.log('')
console.log('Usage: sccli <command> [config.json]')
console.log('')
console.log('<Commands>')
console.log(' checkconfig : Test local config.json for errors')
console.log(' install : Install local config.json as service')
console.log(' uninstall : Uninstall local config.json as a service')
console.log('')
process.exit(1)
}
if (args[0] === 'checkconfig') {
fs.readFile(configFile)
.then(async function(content) {
let config = JSON.parse(content)
util.verifyConfig(config)
let names = util.getAppNames(config)
if (!names.length) {
return Promise.reject(new Error('No application names were found'))
}
for (let name of names) {
let provConstructor = Core.providers.get(config[name].provider)
let provider = new provConstructor(config[name])
await provider.checkConfig(config[name])
}
console.log(`${configFile} is a valid config with ${names.length} application${names.length > 1 ? 's' : ''}:`)
for (let name of names) {
console.log(` * ${name} (${config[name].provider}${config[name].url ? ': ' + config[name].url : ' provider'})`)
}
})
.catch(function(err) {
console.log('Error checking config:', err)
process.exit(2)
})
.then(function() {
process.exit(0)
})
} else if (args[0] === 'install') {
if(os.platform() === 'win32') {
const runner = path.join(process.cwd(), './runner.mjs')
fs.stat(runner).catch(function() {
return fs.writeFile(runner, basicRunnerTemplate)
}).then(function() {
return Promise.all([
fs.readFile(configFile),
import('node-windows'),
])
}).then(function([content, nodewindows]) {
let config = JSON.parse(content)
const Service = nodewindows.Service
let serviceConfig = {
name: config.title || config.name,
description: config.description,
script: runner,
env: {
name: 'NODE_ENV',
value: 'production',
},
wait: 0,
grow: .5,
maxRestarts: 10
//, workingDirectory: '...'
//, allowServiceLogon: true
}
console.log('Service', serviceConfig)
// Create a new service object
let svc = new Service(serviceConfig);
svc.on('install',function(){
svc.start();
process.exit(0)
});
svc.on('alreadyinstalled',function(){
svc.start();
process.exit(0)
});
svc.install();
})
} else {
console.log('non windows install targets are currently unsupported')
process.exit(2)
}
} else if (args[0] === 'uninstall') {
if(os.platform() === 'win32') {
const runner = path.join(process.cwd(), './runner.mjs')
fs.stat(runner).catch(function() {
return fs.writeFile(runner, basicRunnerTemplate)
}).then(function() {
return Promise.all([
fs.readFile(configFile),
import('node-windows'),
])
}).then(function([content, nodewindows]) {
let config = JSON.parse(content)
const Service = nodewindows.Service
let serviceConfig = {
name: config.title || config.name,
description: config.description,
script: runner,
env: {
name: 'NODE_ENV',
value: 'production',
},
wait: 0,
grow: .5,
maxRestarts: 10
//, workingDirectory: '...'
//, allowServiceLogon: true
}
console.log('Service', serviceConfig)
// Create a new service object
let svc = new Service(serviceConfig);
svc.on('uninstall',function(){
console.log('Uninstall complete.');
console.log('The service exists: ',svc.exists);
process.exit(0)
});
svc.uninstall();
})
} else {
console.log('non windows install targets are currently unsupported')
process.exit(2)
}
}

View file

@ -1,11 +0,0 @@
{
"name": "service-core",
"githubAuthToken": null,
"serviceName": "Service-Core Node",
"description": "NodeJS Test Service",
"port": 4270,
"managePort": 4271,
"devPort": 4269,
"appRepository": "thething/sc-helloworld",
"manageRepository": "TheThing/sc-manager"
}

View file

@ -32,7 +32,7 @@ export default class Application extends EventEmitter {
this.fresh = true
// Apply defaults to config
this.config.updateEvery = this.config.updateEvery || 180
this.config.updateEvery = this.config.updateEvery != null ? this.config.updateEvery : 180
this.config.startWaitUntilFail = this.config.startWaitUntilFail || (60 * 1000)
this.config.heartbeatTimeout = this.config.heartbeatTimeout || (3 * 1000)
this.config.heartbeatAttempts = this.config.heartbeatAttempts || 5
@ -49,6 +49,7 @@ export default class Application extends EventEmitter {
startAutoupdater() {
if (this.provider.static) return
if (this.config.updateEvery === 0) return
let timer = this.setInterval(() => {
this.update().then(

0
core/cli.mjs Normal file
View file

View file

@ -22,9 +22,8 @@ export default class Core {
Core.providers.set(name, provider)
}
constructor(db, util, log, restart) {
constructor(db, util, log) {
// some sanity checks
if (typeof(restart) !== 'function') throw new Error('restart parameter was not a function')
if (!log || typeof(log) !== 'object') throw new Error('log parameter was invalid')
if (typeof(log.event) !== 'object') throw new Error('log parameter was invalid')
if (typeof(log.info) !== 'function'
@ -40,9 +39,9 @@ export default class Core {
this.db = db
this.util = util
this.log = log
this.restart = restart
this.applications = []
this.applicationMap = new Map()
this._applicationFatalCrash = null
}
getApplication(name) {
@ -114,6 +113,12 @@ export default class Core {
}
}
criticalError(application, version, errorCode) {
application.ctx.log.fatal(`Critical error ${errorCode} running ${version.version}`)
version.stable = -2
this.db.writeSync()
}
async runApplication(application) {
let name = application.name
let found = false
@ -128,6 +133,9 @@ export default class Core {
if (version.stable < 0 && !application.fresh) continue
await application.closeServer()
this._applicationFatalCrash = this.criticalError.bind(this, application, version)
process.once('exit', this._applicationFatalCrash)
try {
application.ctx.log.info(`Attempting to run version ${version.version}`)
await application.runVersion(version.version)
@ -136,9 +144,15 @@ export default class Core {
await this.db.write()
break
} catch(err) {
version.stable = Math.min(version.stable, 0) - 1
if (application.fresh) {
version.stable = -2
} else {
version.stable = Math.min(version.stable, 0) - 1
}
await this.db.write()
application.ctx.log.error(err, `Error starting ${version.version}: ${err.message}`)
} finally {
process.off('exit', this._applicationFatalCrash)
}
}

View file

@ -1,499 +0,0 @@
import fs from 'fs'
import { EventEmitter } from 'events'
import { request } from './client.mjs'
import HttpServer from './http.mjs'
const fsp = fs.promises
export default class Core extends EventEmitter{
constructor(util, config, db, log, closeCb) {
super()
process.stdin.resume()
this.http = new HttpServer()
this.util = util
this.config = config
this.db = db
this.log = log
this._close = closeCb
this._activeCrashHandler = null
this.appRunning = false
this.manageRunning = false
this.monitoring = false
this._appUpdating = {
fresh: true,
updating: false,
starting: false,
logs: '',
}
this._manageUpdating = {
fresh: true,
updating: false,
starting: false,
logs: '',
}
this.db.set('core.manageActive', null)
.set('core.appActive', null)
.write().then()
}
startMonitor() {
if (this.monitoring) return
this.log.info('[Scheduler] Automatic updater has been turned on. Will check for updates every 3 hours')
let updating = false
this.monitoring = setInterval(async () => {
if (updating) return
updating = true
this.log.info('[Scheduler] Starting automatic check for latest version of app and manage')
try {
await this.installLatestVersion('app')
await this.installLatestVersion('manage')
} catch(err) {
this.log.error(err, 'Error checking for latest versions')
this.log.event.error('Error checking for latest versions: ' + err.message)
updating = false
return
}
try {
if (this.hasNewVersionAvailable('app') || !this.appRunning) {
await this.tryStartProgram('app')
}
} catch(err) {
this.log.error(err, 'Unknown error occured attempting to app')
this.log.event.error('Unknown error starting app: ' + err.message)
}
try {
if (this.hasNewVersionAvailable('manage') || !this.manageRunning) {
await this.tryStartProgram('manage')
}
} catch(err) {
this.log.error(err, 'Unknown error occured attempting to start manage')
this.log.event.error('Unknown error starting manage: ' + err.message)
}
updating = false
}, 1000 * 60 * 60 * 3) // every 3 hours
}
restart() {
this._close()
}
status() {
return {
app: this.appRunning,
manage: this.manageRunning,
appUpdating: this._appUpdating.updating,
manageUpdating: this._manageUpdating.updating,
appStarting: this._appUpdating.starting,
manageStarting: this._manageUpdating.starting,
}
}
async getLatestVersion(active, name) {
// Example: 'https://api.github.com/repos/thething/sc-helloworld/releases'
this.logActive(name, active, `Updater: Fetching release info from: https://api.github.com/repos/${this.config[name + 'Repository']}/releases\n`)
let result = await request(this.config, `https://api.github.com/repos/${this.config[name + 'Repository']}/releases`)
let items = result.body.filter(function(item) {
if (!item.assets.length) return false
for (let i = 0; i < item.assets.length; i++) {
if (item.assets[i].name.endsWith('-sc.zip')) return true
}
})
if (items && items.length) {
for (let x = 0; x < items.length; x++) {
let item = items[x]
for (let i = 0; i < item.assets.length; i++) {
if (item.assets[i].name.endsWith('-sc.zip')) {
if (this.db.get('core.' + name + 'LatestInstalled').value() === item.name) {
this.logActive(name, active, `Updater: Latest version already installed, exiting early\n`)
return null
}
this.logActive(name, active, `Updater: Found version ${item.name} with file ${item.assets[i].name}\n`)
await this.db.set(`core.${name}LatestVersion`, item.name)
.write()
this.emit('dbupdated', {})
return {
name: item.name,
filename: item.assets[i].name,
url: item.assets[i].browser_download_url,
description: item.body,
}
}
}
}
} else {
return null
}
}
logActive(name, active, logline, doNotPrint = false) {
if (!doNotPrint) {
this.log.info(`[${name}] ` + logline.replace(/\n/g, ''))
}
active.logs += logline
this.emit(name + 'log', active)
}
getProgramLogs(name) {
if (name === 'app' && this._appUpdating.logs) {
return this._appUpdating.logs
} else if (name === 'manage' && this._manageUpdating.logs) {
return this._manageUpdating.logs
}
let latestInstalled = this.db.get('core.' + name + 'LatestInstalled').value()
let latestVersion = this.db.get('core.' + name + 'LatestVersion').value()
if (latestVersion) {
let value = this.db.get(`core_${name}History`).getById(latestVersion).value()
if (value) return value.logs
}
if (latestInstalled) {
let value = this.db.get(`core_${name}History`).getById(latestInstalled).value()
if (value) return value.logs
}
return '< no logs found >'
}
async installVersion(name, active, version) {
if (fs.existsSync(this.util.getPathFromRoot(`./${name}/` + version.name))) {
await this.util.runCommand('rmdir', ['/S', '/Q', `"${this.util.getPathFromRoot(`./${name}/` + version.name)}"`])
}
if (!fs.existsSync(this.util.getPathFromRoot(`./${name}/`))) {
await fsp.mkdir(this.util.getPathFromRoot(`./${name}/`))
}
try {
await fsp.mkdir(this.util.getPathFromRoot(`./${name}/` + version.name))
} catch(err) {
if (err.code !== 'EEXIST') {
throw err
}
}
// await fsp.mkdir(this.util.getPathFromRoot(`./${name}/` + version.name + '/node_modules'))
this.logActive(name, active, `Installer: Downloading ${version.name} (${version.url}) to ${version.name + '/' + version.name + '.zip'}\n`)
let filePath = this.util.getPathFromRoot(`./${name}/` + version.name + '/' + version.name + '.zip')
await request(this.config, version.url, filePath)
this.logActive(name, active, `Installer: Downloading finished, starting extraction\n`)
await this.util.runCommand(
'"C:\\Program Files\\7-Zip\\7z.exe"',
['x', `"${filePath}"`],
this.util.getPathFromRoot(`./${name}/` + version.name + '/'),
this.logActive.bind(this, name, active)
)
if (!fs.existsSync(this.util.getPathFromRoot(`./${name}/` + version.name + '/index.mjs'))) {
this.logActive(name, active, `\nInstaller: ERROR: Missing index.mjs in the folder, exiting\n`)
throw new Error(`Missing index.mjs in ${this.util.getPathFromRoot(`./${name}/` + version.name + '/index.mjs')}`)
}
this.logActive(name, active, `\nInstaller: Starting npm install\n`)
await this.util.runCommand(
'npm.cmd',
['install', '--production', '--no-optional', '--no-package-lock', '--no-audit'],
this.util.getPathFromRoot(`./${name}/` + version.name + '/'),
this.logActive.bind(this, name, active)
)
await this.db.set(`core.${name}LatestInstalled`, version.name)
.write()
this.emit('dbupdated', {})
this.logActive(name, active, `\nInstaller: Successfully installed ${version.name}\n`)
}
getActive(name) {
if (name === 'app') {
return this._appUpdating
} else if (name === 'manage') {
return this._manageUpdating
} else {
throw new Error('Invalid name: ' + name)
}
}
async startModule(module, port) {
let out = await module.start(this.config, this.db, this.log, this, this.http, port)
if (out && out.then) {
await out
}
if (!this.http.getCurrentServer()) {
this.log.warn('Module did not call http.createServer')
}
}
hasNewVersionAvailable(name) {
let newestVersion = this.db.get(`core.${name}LatestInstalled`).value()
if (!newestVersion) return false
let history = this.db.get(`core_${name}History`).getById(newestVersion).value()
if (history.installed && history.stable === 0) {
return true
}
return false
}
async tryStartProgram(name) {
let active = this.getActive(name)
if (this[name + 'Running'] && !this.hasNewVersionAvailable(name)) {
this.log.event.warn('Attempting to start ' + name + ' which is already running')
this.log.warn('Attempting to start ' + name + ' which is already running')
this.logActive(name, active, `Runner: Attempting to start it but it is already running\n`, true)
return
}
active.starting = true
if (this[name + 'Running']) {
let success = await this.http.closeServer(name)
if (!success) {
if (process.env.NODE_ENV === 'production') {
this.logActive(name, active, `Runner: Found new version but server could not be shut down, restarting service core\n`)
await new Promise(() => {
this.log.event.warn('Found new version of ' + name + ' but server could not be shut down gracefully, restarting...', null, () => {
process.exit(100)
})
})
} else {
this.logActive(name, active, `Runner: Found new version but server could not be shut down\n`)
return
}
}
this[name + 'Running'] = false
this.emit('statusupdated', {})
}
let history = this.db.get(`core_${name}History`)
.filter('installed')
.orderBy('installed', 'desc')
.value()
this.logActive(name, active, `Runner: Finding available version\n`)
for (let i = 0; i < history.length; i++) {
if ((history[i].stable === -1 && !active.fresh)
|| (history[i].stable < -1)) {
this.logActive(name, active, `Runner: Skipping version ${history[i].name} due to marked as unstable\n`)
continue
}
await this.db.set(`core.${name}Active`, history[i].name)
.write()
this.emit('dbupdated', {})
let running = await this.tryStartProgramVersion(name, active, history[i].name)
if (running) {
history[i].stable = 1
} else {
if (active.fresh || history[i].stable === -1) {
history[i].stable = -2
} else {
history[i].stable = -1
}
await this.db.set(`core.${name}Active`, null)
.write()
this.emit('dbupdated', {})
}
active.fresh = false
await this.db.get(`core_${name}History`).updateById(history[i].id, history[i].stable).write()
if (history[i].stable > 0) break
}
if (!this.db.get(`core.${name}Active`).value()) {
this.logActive(name, active, `Runner: Could not find any available stable version of ${name}\n`)
this.log.error('Unable to start ' + name)
this.log.event.error('Unable to start ' + name)
}
active.starting = false
}
programCrashed(name, version, active, oldStable) {
let newStable = -2
console.log('EXITING:', oldStable, active)
if (oldStable === 0 && !active.fresh) {
newStable = -1
}
let temp = this.db.get(`core_${name}History`).getById(version).set('stable', newStable )
temp.value() // Trigger update on __wrapped__
fs.writeFileSync(this.db.adapterFilePath, JSON.stringify(temp.__wrapped__, null, 2))
}
async tryStartProgramVersion(name, active, version) {
if (!version) return false
this.logActive(name, active, `Runner: Attempting to start ${version}\n`)
let indexPath = this.util.getUrlFromRoot(`./${name}/` + version + '/index.mjs')
let module
try {
this.logActive(name, active, `Runner: Loading ${indexPath}\n`)
module = await import(indexPath)
} catch (err) {
this.logActive(name, active, `Runner: Error importing module\n`, true)
this.logActive(name, active, `${err.stack}\n`, true)
this.log.error(err, `Failed to load ${indexPath}`)
return false
}
let checkTimeout = null
let oldStable = this.db.get(`core_${name}History`).getById(version).value().stable
this._activeCrashHandler = this.programCrashed.bind(this, name, version, active, oldStable)
process.once('exit', this._activeCrashHandler)
try {
let port = name === 'app' ? this.config.port : this.config.managePort
await new Promise((res, rej) => {
checkTimeout = setTimeout(function() {
rej(new Error('Program took longer than 60 seconds to resolve promise'))
}, 60 * 1000)
this.logActive(name, active, `Runner: Starting module\n`)
try {
this.http.setContext(name)
this.startModule(module, port)
.then(res, rej)
} catch (err) {
rej(err)
}
})
clearTimeout(checkTimeout)
await this.checkProgramRunning(name, active, port)
process.off('exit', this._activeCrashHandler)
} catch (err) {
clearTimeout(checkTimeout)
process.off('exit', this._activeCrashHandler)
await this.http.closeServer(name)
this.logActive(name, active, `Runner: Error starting\n`, true)
this.logActive(name, active, `${err.stack}\n`, true)
this.log.error(err, `Failed to start ${name}`)
return false
}
this._activeCrashHandler = null
this.logActive(name, active, `Runner: Successfully started version ${version}\n`)
await this.db.set(`core.${name}Active`, version)
.write()
if (name === 'app') {
this.appRunning = true
} else {
this.manageRunning = true
}
this.emit('statusupdated', {})
this.logActive(name, active, `Runner: Module is running successfully\n`)
return true
}
async checkProgramRunning(name, active, port) {
this.logActive(name, active, `Checker: Testing out module port ${port}\n`)
let start = new Date()
let error = null
let success = false
while (new Date() - start < 10 * 1000) {
try {
let check = await request(this.config, `http://localhost:${port}`, null, 0, true)
success = true
break
} catch(err) {
this.logActive(name, active, `Checker: ${err.message}, retrying in 3 seconds\n`)
error = err
await new Promise(function(res) { setTimeout(res, 3000)})
}
}
if (success) return true
throw error || new Error('Checking server failed')
}
async installLatestVersion(name) {
if (!this.config[name + 'Repository']) {
if (name === 'app') {
this.log.error(name + ' Repository was missing from config')
this.log.event.error(name + ' Repository was missing from config')
} else {
this.log.warn(name + ' Repository was missing from config')
this.log.event.warn(name + ' Repository was missing from config')
}
return
}
let active = this.getActive(name)
let oldLogs = active.logs || ''
if (oldLogs) {
oldLogs += '\n'
}
active.logs = ''
active.updating = true
this.emit('statusupdated', {})
this.logActive(name, active, `Installer: Checking for updates at time: ${new Date().toISOString().replace('T', ' ').split('.')[0]}\n`)
let version = null
let installed = false
let found = false
try {
version = await this.getLatestVersion(active, name)
if (version) {
let core = this.db.get('core').value()
let fromDb = this.db.get(`core_${name}History`).getById(version.name).value()
if (!fromDb || !fromDb.installed) {
let oldVersion = core[name + 'Current'] || '<none>'
this.logActive(name, active, `Installer: Updating from ${oldVersion} to ${version.name}\n`)
await this.installVersion(name, active, version)
this.logActive(name, active, `Installer: Finished: ${new Date().toISOString().replace('T', ' ').split('.')[0]}\n`)
installed = new Date()
} else {
found = true
this.logActive(name, active, `Installer: Version ${version.name} already installed\n`)
}
}
} catch(err) {
this.logActive(name, active, '\n', true)
this.logActive(name, active, `Installer: Exception occured while updating ${name}\n`, true)
this.logActive(name, active, err.stack, true)
this.log.error('Error while updating ' + name, err)
}
active.updating = false
if (version && !found) {
await this.db.get(`core_${name}History`).upsert({
id: version.name,
name: version.name,
filename: version.filename,
url: version.url,
description: version.description,
logs: active.logs,
stable: 0,
installed: installed && installed.toISOString(),
}).write()
}
active.logs = oldLogs + active.logs
this.emit(name + 'log', active)
this.emit('statusupdated', {})
}
async start(name) {
var version = this.db.get('core.' + name + 'LatestInstalled').value()
if (version) {
await this.tryStartProgram(name)
}
await this.installLatestVersion(name)
if (version !== this.db.get('core.' + name + 'LatestInstalled').value()) {
if (!this[name + 'Running'] || this.hasNewVersionAvailable(name)) {
await this.tryStartProgram(name)
}
}
}
}

View file

@ -1,5 +1,6 @@
import { setTimeout } from 'timers/promises'
import { Low, JSONFile, Memory } from 'lowdb'
import fs from 'fs'
import { defaults, isObject } from './defaults.mjs'
export default function GetDB(config, log, orgFilename = 'db.json') {
@ -12,7 +13,7 @@ export default function GetDB(config, log, orgFilename = 'db.json') {
const db = new Low(adapter)
db.id = 'id'
db.filename = fullpath
db.config = config
db.createId = function(collection) {
@ -110,6 +111,14 @@ export default function GetDB(config, log, orgFilename = 'db.json') {
})
}
db.writeSync = function() {
try {
fs.writeFileSync(db.filename, JSON.stringify(db.data))
} catch(err) {
db.log.error(err, `Error during writeSync to ${db.filename}`)
}
}
return db.read()
.then(function() {
if (!isObject(db.data)) {

40
core/lib.mjs Normal file
View file

@ -0,0 +1,40 @@
import Util from './util.mjs'
import getLog from './log.mjs'
import GetDB from './db.mjs'
import StaticProvider from './providers/static.mjs'
import Core from './core.mjs'
export default class ServiceCore {
constructor(name, root_import_meta_url, dbfilename = 'db.json') {
if (!root_import_meta_url) {
throw new Error('ServiceCore must be called with the full string from "import.meta.url" from a file residing in the root directory')
}
this._root_import_meta_url = root_import_meta_url
this.util = new Util(this._root_import_meta_url)
this.dbfilename = dbname
this.log = getLog(name)
this.name = name
this.config = {}
this.db = null
this.core = null
this.app = null
}
async init(module = null) {
this.db = await GetDB(this.config, this.log, this.dbfilename)
this.core = new Core(this.db, this.util, this.log)
let provider = new StaticProvider()
this.app = new Application({
db: this.db,
util: this.util,
log: this.log,
core: this.core,
}, provider, this.name)
this.app.registerModule(module)
}
run() {
return this.app.runVersion('static')
}
}

View file

@ -26,7 +26,7 @@ export async function runner(root_import_meta_url, configname = 'config.json', d
runner.log = log
const db = await GetDB(config, log, util.getPathFromRoot('./' + dbname))
const core = new Core(db, util, log, function() {})
const core = new Core(db, util, log)
await core.init()
await core.run()

View file

@ -188,7 +188,9 @@ export default class Util {
processor._kill = processor.kill
processor.kill = function() {
if(os.platform() === 'win32'){
execSync('taskkill /pid ' + processor.pid + ' /T /F')
try {
execSync('taskkill /pid ' + processor.pid + ' /T /F')
} catch {}
}else{
processor.kill();
}

24
exampleconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "service-core",
"title": "ExampleService",
"description": "Example config service running helloworld",
"helloworld": {
"provider": "git",
"url": "https://git.nfp.is/api/v1/repos/thething/sc-helloworld/releases",
"token": null,
"port": 8888,
"https": false,
"updateEvery": 180,
"startWaitUntilFail": 60000,
"heartbeatTimeout": 3000,
"heartbeatAttempts": 5,
"heartbeatAttemptsWait": 2000,
"heartbeatPath": "/",
"log": [
{
"level": "info",
"stream": "process.stdout"
}
]
}
}

21
index.mjs Normal file
View file

@ -0,0 +1,21 @@
import bunyan from 'bunyan-lite'
import { runner } from './core/runner.mjs'
import { ServiceCore } from './core/lib.mjs'
import Core from './core/core.mjs'
import Application from './core/application.mjs'
import Util from './core/util.mjs'
import HttpServer from './core/http.mjs'
import { request } from './core/client.mjs'
import getLog from './core/log.mjs'
export {
bunyan,
runner,
ServiceCore,
Core,
Application,
Util,
HttpServer,
request,
getLog,
}

View file

@ -1,2 +0,0 @@
node service\install.mjs
PAUSE

52
lib.mjs
View file

@ -1,52 +0,0 @@
import Util from './core/util.mjs'
import { readFileSync } from 'fs'
import getLog from './core/log.mjs'
import lowdb from './core/db.mjs'
import Core from './core/core.mjs'
export default class ServiceCore {
constructor(name, root_import_meta_url) {
if (!root_import_meta_url) {
throw new Error('ServiceCore must be called with the full string from "import.meta.url" from a file residing in the root directory')
}
this._root_import_meta_url = root_import_meta_url
this.util = new Util(this._root_import_meta_url)
this.log = getLog(name)
this.db = null
this.config = null
this.core = null
}
close(err) {
if (err) {
this.log.fatal(err, 'App recorded a fatal error')
process.exit(4)
}
this.log.warn('App asked to be restarted')
process.exit(0)
}
async init(module = null) {
try {
this.config = JSON.parse(readFileSync(this.util.getPathFromRoot('./config.json')))
} catch (err) {
throw new Error('Unable to read config.json from root directory: ' + err)
}
try {
this.db = await lowdb(this.util, this.log)
} catch (err) {
throw new Error('Unable to read initialise lowdb: ' + err)
}
this.core = new Core(this.util, this.config, this.db, this.log, (err) => this.close(err))
if (module) {
return this.startModule(module)
}
}
startModule(module) {
return this.core.startModule(module, this.config.devPort)
}
}

View file

@ -1,2 +0,0 @@
npm install
PAUSE

View file

@ -36,10 +36,12 @@
},
"homepage": "https://github.com/TheThing/service-core#readme",
"files": [
"lib.mjs",
"index.mjs",
"cli.mjs",
"package.json",
"README.md",
"core"
"core",
"bin"
],
"dependencies": {
"bunyan-lite": "^1.0.1",

View file

@ -1,69 +0,0 @@
import { readFileSync } from 'fs'
import getLog from './core/log.mjs'
import lowdb from './core/db.mjs'
import Core from './core/core.mjs'
import Util from './core/util.mjs'
let config
try {
config = JSON.parse(readFileSync('./config.json'))
} catch (err) {
let logger = getLog('critical-error')
logger.fatal('Error opening config file')
logger.fatal('Make sure it is valid JSON')
logger.fatal(err)
logger.event.error('Unable to start, error in config.json: ' + err.message)
process.exit(10)
}
const log = getLog(config.name)
const close = function(err) {
if (err) {
log.fatal(err, 'App recorded a fatal error')
log.event.error('App recorded a fatal error: ' + err.message, null, function() {
process.exit(4)
})
return
}
log.warn('App asked to be restarted')
log.event.warn('App requested to be restarted', null, function() {
process.exit(0)
})
}
const util = new Util(import.meta.url)
lowdb(util, log).then(async function(db) {
let core = new Core(util, config, db, log, close)
let errors = 0
try {
await core.start('app')
} catch (err) {
log.event.error('Unable to start app: ' + err.message)
log.error(err, 'Unable to start app')
errors++
}
try {
await core.start('manage')
} catch (err) {
log.event.error('Unable to start manage: ' + err.message)
log.error(err, 'Unable to start manage')
errors++
}
core.startMonitor()
if (errors === 2 || (!core.appRunning && !core.manageRunning)) {
throw new Error('Neither manage or app were started, exiting.')
}
}, function(err) {
log.fatal(err, 'Critical error opening database')
log.event.error('Critical error opening database: ' + err.message, null, function() {
process.exit(2)
})
}).catch(function(err) {
log.fatal(err, 'Unknown error occured opening app')
log.event.error('Unknown error occured opening app: ' + err.message, null, function() {
process.exit(3)
})
})

View file

@ -1,11 +0,0 @@
import svc from './service.mjs'
svc.on('install',function(){
svc.start();
});
svc.on('alreadyinstalled',function(){
svc.start();
});
svc.install();

View file

@ -1,35 +0,0 @@
import path from 'path'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import nodewindows from 'node-windows'
function getPathFromRoot(add) {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
return path.join(__dirname,'../', add)
}
let config = JSON.parse(readFileSync(getPathFromRoot('./config.json')))
const Service = nodewindows.Service
let serviceConfig = {
name: config.serviceName,
description: config.description,
script: getPathFromRoot('./runner.mjs'),
env: {
name: 'NODE_ENV',
value: 'production',
},
wait: 0,
grow: .5,
maxRestarts: 10
//, workingDirectory: '...'
//, allowServiceLogon: true
}
console.log('Service', serviceConfig)
// Create a new service object
let svc = new Service(serviceConfig);
export default svc

View file

@ -1,10 +0,0 @@
import svc from './service.mjs'
// Listen for the "install" event, which indicates the
// process is available as a service.
svc.on('uninstall',function(){
console.log('Uninstall complete.');
console.log('The service exists: ',svc.exists);
});
svc.uninstall();

View file

@ -70,6 +70,24 @@ t.describe('constructor()', function() {
assert.strictEqual(typeof(app.http.closeServer), 'function')
})
t.test('should not default updateEvery if its zero', function() {
const assertTest = { a: 1, updateEvery: 0 }
const assertName = 'test'
ctx.db.config = {
test: assertTest,
app: { b: 2},
manage: { c: 3 },
}
let app = new Application(ctx, {}, assertName)
assert.notStrictEqual(app.config, assertTest)
assert.strictEqual(app.config.a, assertTest.a)
assert.strictEqual(app.config.updateEvery, assertTest.updateEvery)
assert.strictEqual(app.config.updateEvery, 0)
})
t.test('should create http instance correctly', function() {
ctx.db.config = {
testapp: { a: 1, https: true },
@ -105,6 +123,15 @@ t.timeout(250).describe('#startAutoupdater()', function() {
app.startAutoupdater()
})
t.test('should do nothing if updateEvery is zero', async function() {
const stubInterval = stub()
stubInterval.throws(new Error('should not be seen'))
ctx.db.config.test = { updateEvery: 0 }
let app = new Application(ctx, { }, 'test', { setInterval: stubInterval })
app.startAutoupdater()
})
t.test('should call setInterval correctly', function() {
const assertTimeMinutes = 1440

View file

@ -6,31 +6,30 @@ import { request } from '../core/client.mjs'
import { setTimeout } from 'timers/promises'
import { prettyPrintMessage } from './helpers.mjs'
import { pipeline } from 'stream'
import getLog from '../core/log.mjs'
const util = new Util(import.meta.url)
const port = 61412
t.timeout(5000).describe('', function() {
t.timeout(10000).describe('', function() {
let server = null
let prefix = `http://localhost:${port}/`
let files = []
let logs = []
let versions = []
let requests = []
let processor
let integrationLog = getLog('test.integration', [])
t.before(function(cb) {
server = http.createServer(function(req, res) {
req.on('error', function(err) {
console.log('error', err)
integrationLog.error(err, 'error')
})
res.on('error', function(err) {
console.log('error', err)
integrationLog.error(err, 'error')
})
requests.push(req.url)
console.log(req.url)
integrationLog.info('[SERVER] got request ' + req.url)
if (req.url === '/releases') {
res.statusCode = 200
@ -78,7 +77,7 @@ t.timeout(5000).describe('', function() {
return fs.rm(file, { force: true, recursive: true })
}))
.then(function() {
if (processor) {
if (processor && !processor.exitCode) {
processor.kill()
}
})
@ -105,6 +104,21 @@ t.timeout(5000).describe('', function() {
const version_3_crashing = `
export function start(http, port, ctx) {
process.exit(1)
}
`
const version_4_stable = `
export function start(http, port, ctx) {
const server = http.createServer(function (req, res) {
res.writeHead(200);
res.end(JSON.stringify({ version: 'v4' }))
})
return server.listenAsync(port, '0.0.0.0')
.then(() => {
ctx.log.info({ port: port, listening: true }, \`Server is listening on \${port} serving v4\`)
})
}
`
@ -141,6 +155,10 @@ t.timeout(5000).describe('', function() {
}
}
integrationLog.on('newlog', function(record) {
prettyPrintMessage(JSON.stringify(record))
})
let logWaitIndex = 0
function hasLogLine(regMatch) {
if (logs.length > logWaitIndex) {
@ -241,9 +259,12 @@ t.timeout(5000).describe('', function() {
assert.strictEqual(checkListening.body.version, 'v1')
while (!hasLogLine(/core is running/)) {
catchupLog()
await setTimeout(10)
}
catchupLog()
let db = JSON.parse(await fs.readFile(util.getPathFromRoot('./db.json')))
assert.strictEqual(db.core.testapp.active, assertNameVersion1)
assert.strictEqual(db.core.testapp.versions.length, 1)
@ -257,18 +278,11 @@ t.timeout(5000).describe('', function() {
const assertNameVersion2 = 'v2_nolisten'
file(`./testapp/${assertNameVersion2}`)
versions.splice(0, 0, [assertNameVersion2, 'no listen version', 'v2-sc.7z'])
requests.splice(0, requests.length)
assert.strictEqual(requests.length, 0)
// wait a second for it to trigger an update
await setTimeout(500)
while (requests.length < 2) {
catchupLog()
await setTimeout(25)
}
while (!hasLogLine(/Error starting v2_nolisten/)) {
catchupLog()
await setTimeout(10)
@ -326,8 +340,8 @@ t.timeout(5000).describe('', function() {
assert.ok(findInLogs(/Error starting v2_nolisten/))
processor.kill()
await waitUntilClosed()
await waitUntilClosed()
processor = startRunner()
listening = await waitUntilListening()
@ -350,10 +364,85 @@ t.timeout(5000).describe('', function() {
assert.notOk(findInLogs(/Attempting to run version v2_nolisten/))
assert.notOk(findInLogs(/Error starting v2_nolisten/))
// Create our third version
await fs.writeFile(index, version_3_crashing)
await util.runCommand(util.get7zipExecutable(), ['a', file('./v3-sc.7z'), index], util.getPathFromRoot('./testapp'))
const assertNameVersion3 = 'v3_crash'
file(`./testapp/${assertNameVersion3}`)
versions.splice(0, 0, [assertNameVersion3, 'crash version', 'v3-sc.7z'])
// wait a second for it to trigger an update
await setTimeout(500)
while (!hasLogLine(/Attempting to run version v3_crash/)) {
catchupLog()
await setTimeout(10)
}
while (processor.exitCode == null) {
catchupLog()
await setTimeout(10)
}
db = JSON.parse(await fs.readFile(util.getPathFromRoot('./db.json')))
assert.strictEqual(db.core.testapp.active, assertNameVersion3)
assert.strictEqual(db.core.testapp.versions.length, 3)
assert.strictEqual(db.core.testapp.versions[0].stable, -2)
assert.strictEqual(db.core.testapp.versions[1].stable, -2)
assert.strictEqual(db.core.testapp.versions[2].stable, 1)
catchupLog()
// Should recover afterwards
await waitUntilClosed()
processor = startRunner()
listening = await waitUntilListening()
assert.ok(listening)
checkListening = await request({}, `http://localhost:${listening.port}/`)
assert.strictEqual(checkListening.body.version, 'v1')
while (!hasLogLine(/core is running/)) {
await setTimeout(10)
}
// Create our fourth version
await fs.writeFile(index, version_4_stable)
await util.runCommand(util.get7zipExecutable(), ['a', file('./v4-sc.7z'), index], util.getPathFromRoot('./testapp'))
const assertNameVersion4 = 'v4_stable'
file(`./testapp/${assertNameVersion4}`)
versions.splice(0, 0, [assertNameVersion4, 'no listen version', 'v4-sc.7z'])
// wait a second for it to trigger an update
await setTimeout(500)
while (!hasLogLine(/Attempting to run version v4_stable/)) {
catchupLog()
await setTimeout(10)
}
while (!hasLogLine(/Server is listening on 31313 serving v4/)) {
catchupLog()
await setTimeout(10)
}
catchupLog()
checkListening = await request({}, `http://localhost:${listening.port}/`)
assert.strictEqual(checkListening.body.version, 'v4')
await setTimeout(10)
db = JSON.parse(await fs.readFile(util.getPathFromRoot('./db.json')))
assert.strictEqual(db.core.testapp.active, assertNameVersion4)
assert.strictEqual(db.core.testapp.versions.length, 4)
assert.strictEqual(db.core.testapp.versions[0].stable, 1)
assert.strictEqual(db.core.testapp.versions[1].stable, -2)
})
})

View file

@ -126,27 +126,6 @@ t.describe('Core.addProvider()', function() {
})
t.describe('#constructor()', function() {
t.test('should throw if close is not a function', function() {
let tests = [
[1, 'number'],
[0, 'false number'],
['asdf', 'string'],
['', 'false string'],
[[], 'array'],
[{}, 'object'],
]
tests.forEach(function(check) {
assert.throws(function() {
new Core(db, util, log, check[0])
}, function(err) {
assert.match(err.message, /restart/i)
assert.match(err.message, /function/i)
return true
}, `throw if restart is ${check[1]}`)
})
})
t.test('should throw if util is not util', function() {
let tests = [
[1, 'number'],
@ -160,7 +139,7 @@ t.describe('#constructor()', function() {
tests.forEach(function(check) {
assert.throws(function() {
new Core(db, check[0], log, function() {})
new Core(db, check[0], log)
}, function(err) {
assert.match(err.message, /util/i)
assert.match(err.message, /instance/i)
@ -182,7 +161,7 @@ t.describe('#constructor()', function() {
tests.forEach(function(check) {
assert.throws(function() {
new Core(check[0], util, log, function() {})
new Core(check[0], util, log)
}, function(err) {
assert.match(err.message, /db/i)
assert.match(err.message, /instance/i)
@ -230,13 +209,11 @@ t.describe('#constructor()', function() {
t.test('should accept log, util and close function', function() {
const assertLog = log
const assertClose = function() {}
let core = new Core(db, util, assertLog, assertClose)
let core = new Core(db, util, assertLog)
assert.strictEqual(core.db, db)
assert.strictEqual(core.util, util)
assert.strictEqual(core.log, assertLog)
assert.strictEqual(core.restart, assertClose)
assert.deepStrictEqual(core.applications, [])
assert.ok(core.applicationMap)
})
@ -247,7 +224,7 @@ t.describe('#getApplication()', function() {
t.test('should return application based on the name', function() {
const assertName = 'Yami no Naka'
const assertApplication = { a: 1 }
let core = new Core(db, util, log, function() {})
let core = new Core(db, util, log)
core.applicationMap.set(assertName, assertApplication)
assert.strictEqual(core.getApplication(assertName), assertApplication)
})
@ -268,7 +245,7 @@ t.describe('#init()', function() {
t.beforeEach(function() {
log.error.reset()
core = new Core(db, util, log, function() {})
core = new Core(db, util, log)
core.util = fakeUtil = {
verifyConfig: stub(),
getAppNames: stub().returns([]),
@ -453,7 +430,7 @@ t.describe('#run()', function() {
versions: []
},
}
core = new Core(db, util, log, function() {})
core = new Core(db, util, log)
core.runApplication = stubRunApplication = stub().resolves()
db.write = stubWrite = stub().resolves()
log.info.reset()
@ -642,7 +619,7 @@ t.describe('#runApplication()', function() {
versions: []
}
}
core = new Core(db, util, log, function() {})
core = new Core(db, util, log)
db.write = stubWrite = stub().resolves()
log.info.reset()
log.warn.reset()
@ -770,7 +747,6 @@ t.describe('#runApplication()', function() {
assert.strict(testApp.runVersion.firstCall[0], '32')
})
t.test('should skip version with stable of -1 if fresh is false', async function() {
const assertError = new Error('Daikichi to Rin')
testApp.runVersion.rejects(assertError)
@ -841,7 +817,7 @@ t.describe('#runApplication()', function() {
assert.match(testApp.ctx.log.error.secondCall[1], new RegExp('31'))
assert.match(testApp.ctx.log.error.secondCall[1], new RegExp(assertError.message))
assert.strictEqual(db.data.core[testAppName].versions[0].stable, -1)
assert.strictEqual(db.data.core[testAppName].versions[0].stable, -2)
assert.strictEqual(db.data.core[testAppName].versions[1].stable, -2)
assert.ok(stubWrite.callCount, 2)
@ -1003,3 +979,88 @@ t.describe('#runApplication()', function() {
assert.ok(stubWrite.called)
})
})
t.describe('#criticalError()', function() {
let core
let testApp
let testAppName
let stubWrite
t.beforeEach(function() {
testAppName = 'nano.RIPE'
core = new Core(db, util, log)
db.writeSync = stubWrite = stub()
log.info.reset()
log.warn.reset()
log.error.reset()
testApp = {
name: testAppName,
fresh: false,
ctx: {
log: {
info: stub(),
warn: stub(),
error: stub(),
fatal: stub(),
},
},
closeServer: stub(),
runVersion: stub(),
}
})
t.test('should log to fatal', function() {
const assertVersion = {
version: 'Dai kirai! Aishiteru',
stable: 0,
}
assert.notOk(testApp.ctx.log.fatal.called)
core.criticalError(testApp, assertVersion)
assert.ok(testApp.ctx.log.fatal.called)
assert.match(testApp.ctx.log.fatal.firstCall[0], /critical/i)
assert.match(testApp.ctx.log.fatal.firstCall[0], new RegExp(assertVersion.version))
})
t.test('should always change to stable -2 regardless of fresh', function() {
const assertVersion = {
version: 'Dai kirai! Aishiteru',
stable: 0,
}
testApp.fresh = false
assertVersion.stable = 5
core.criticalError(testApp, assertVersion)
assert.strictEqual(assertVersion.stable, -2)
assertVersion.stable = -1
core.criticalError(testApp, assertVersion)
assert.strictEqual(assertVersion.stable, -2)
testApp.fresh = true
assertVersion.stable = 5
core.criticalError(testApp, assertVersion)
assert.strictEqual(assertVersion.stable, -2)
assertVersion.stable = -1
core.criticalError(testApp, assertVersion)
assert.strictEqual(assertVersion.stable, -2)
})
t.test('should call db.writeSync afterwards', function() {
let checkStable = 0
const assertVersion = {
version: 'Dai kirai! Aishiteru',
stable: 0,
}
stubWrite.returnWith(function() {
checkStable = assertVersion.stable
})
assert.notOk(stubWrite.called)
core.criticalError(testApp, assertVersion)
assert.ok(stubWrite.called)
assert.strictEqual(checkStable, -2)
})
})

View file

@ -1,5 +1,6 @@
import { Eltro as t, assert, stub } from 'eltro'
import fs from 'fs/promises'
import fsSync from 'fs'
import lowdb from '../core/db.mjs'
import Util from '../core/util.mjs'
@ -36,6 +37,35 @@ t.test('Should auto create file with some defaults', async function() {
assert.ok(db.data.core.version)
assert.notOk(db.data.core.app)
assert.notOk(db.data.core.manager)
assert.strictEqual(db.filename, util.getPathFromRoot('./db_test.json'))
})
t.test('#writeSync() Should support syncronous write', function() {
const filename = util.getPathFromRoot('./db_test.json')
const assertValue = 'Yume no Naka no Watashi no Yume'
return lowdb({}, logger, filename)
.then(function(db) {
db.data.songtest = assertValue
db.writeSync()
let content = JSON.parse(fsSync.readFileSync(filename))
assert.strictEqual(content.songtest, assertValue)
})
})
t.test('#writeSync() Should not throw', async function() {
let db = await lowdb({}, logger, util.getPathFromRoot('./db_test.json'))
db.filename = util.getPathFromRoot('../test')
assert.notOk(db.log.error.called)
db.writeSync()
assert.ok(db.log.error.called)
assert.match(db.log.error.firstCall[0].message, /directory/i)
assert.match(db.log.error.firstCall[1], /writ/)
assert.match(db.log.error.firstCall[1], new RegExp(db.filename.replace(/\\/g, '\\\\')))
})
t.test('Should support in-memory db', async function() {

View file

@ -1,5 +0,0 @@
@setlocal enableextensions
@cd /d "%~dp0"
node service\uninstall.mjs
PAUSE