@flowfuse/device-agent
Version:
An Edge Agent for running Node-RED instances deployed from the FlowFuse Platform
681 lines (630 loc) • 28.9 kB
JavaScript
const childProcess = require('child_process')
const { existsSync } = require('fs')
const fs = require('fs/promises')
const { readFileSync } = require('fs')
const path = require('path')
const { log, info, debug, warn, NRlog } = require('./logging/log')
const { hasProperty } = require('./utils')
const { States } = require('./states')
const { default: got } = require('got')
const MIN_RESTART_TIME = 10000 // 10 seconds
const MAX_RESTART_COUNT = 5
/** How long wait for Node-RED to cleanly stop before killing */
const NODE_RED_STOP_TIMEOUT = 10000
const packageJSONTemplate = {
name: 'flowfuse-project',
description: 'A FlowFuse Project',
private: true,
version: '0.0.1',
dependencies: {
}
}
class Launcher {
constructor (agent, application, project, snapshot, settings, mode) {
this.config = agent?.config
this.application = application
this.project = project
this.snapshot = snapshot
this.settings = settings
this.mode = mode
this.restartCount = 0
this.startTime = []
this.state = States.STOPPED
this.stopReason = ''
this.installProcess = null
this.deferredStop = null
/** @type {import('./agent.js').Agent */
this.agent = agent
this.auditLogURL = `${this.config.forgeURL}/logging/device/${this.config.deviceId}/audit`
// A callback function that will be set if the launcher is waiting
// for Node-RED to exit
this.exitCallback = null
this.projectDir = path.join(this.config.dir, 'project')
this.files = {
packageJSON: path.join(this.projectDir, 'package.json'),
flows: path.join(this.projectDir, 'flows.json'),
credentials: path.join(this.projectDir, 'flows_cred.json'),
settings: path.join(this.projectDir, 'settings.js'),
userSettings: path.join(this.projectDir, 'settings.json'),
npmrc: path.join(this.projectDir, '.npmrc')
}
}
async writePackage () {
debug(`Updating package.json: ${this.files.packageJSON}`)
const packageData = JSON.parse(JSON.stringify(packageJSONTemplate))
packageData.dependencies = JSON.parse(JSON.stringify(this.snapshot.modules))
// if we are working in a development env and the project nodes src is available in the right place,
// then use the development version of the project nodes
if (packageData.dependencies?.['@flowfuse/nr-project-nodes'] && process.env.NODE_ENV === 'development') {
const devPath = path.join(__dirname, '..', '..', 'nr-project-nodes')
if (existsSync(devPath)) {
packageData.dependencies['@flowfuse/nr-project-nodes'] = `file:${devPath}`
}
}
// if we are working in a development env and the assistant plugin src is available in the right place,
// then use the development version of the assistant plugin
if (packageData.dependencies?.['@flowfuse/nr-assistant'] && process.env.NODE_ENV === 'development') {
const devPath = path.join(__dirname, '..', '..', 'nr-assistant')
if (existsSync(devPath)) {
packageData.dependencies['@flowfuse/nr-assistant'] = `file:${devPath}`
}
}
// if (!packageData.dependencies['@flowfuse/nr-theme']) {
// // Ensure the theme package is in package.json so that its resources
// // don't get removed by npm.
// packageData.dependencies['@flowfuse/nr-theme'] = '*'
// }
packageData.version = `0.0.0-${this.snapshot.id}`
packageData.name = this.snapshot.env.FF_PROJECT_NAME
if (this.snapshot.name && this.snapshot.description) {
packageData.description = `${this.snapshot.name} - ${this.snapshot.description}`
}
await fs.writeFile(this.files.packageJSON, JSON.stringify(packageData, ' ', 2))
await fs.rm(path.join(this.projectDir, '.config.nodes.json'), { force: true })
await fs.rm(path.join(this.projectDir, '.config.nodes.json.backup'), { force: true })
}
readPackage () {
debug(`Reading package.json: ${this.files.packageJSON}`)
const data = {
modules: {},
version: '',
name: '',
description: ''
}
try {
const packageJSON = readFileSync(this.files.packageJSON)
const packageData = JSON.parse(packageJSON)
data.modules = packageData.dependencies
data.version = packageData.version
data.name = packageData.name
data.description = packageData.descriptions
} catch (e) {
console.error(e)
}
return data
}
async installDependencies () {
info('Installing dependencies')
this.state = States.UPDATING
if (this.config.moduleCache) {
info('Using module_cache')
const sourceDir = path.join(this.config.dir, 'module_cache/node_modules')
const targetDir = path.join(this.projectDir, 'node_modules')
try {
await fs.access(sourceDir)
} catch (ee) {
return Promise.reject(ee)
}
if (existsSync(targetDir)) {
await fs.rm(targetDir, { force: true, recursive: true })
}
return fs.symlink(sourceDir, targetDir, 'dir')
} else {
this.installProcess = new Promise((resolve, reject) => {
childProcess.exec('npm install --production', {
cwd: this.projectDir
}, (error, stdout, stderr) => {
if (!error) {
resolve()
this.installProcess = null
} else {
warn('Install failed')
warn(stderr)
reject(error)
this.installProcess = null
}
})
})
return this.installProcess
}
}
async writeFlow () {
debug(`Updating flows file: ${this.files.flows}`)
const flows = JSON.stringify(this.snapshot.flows)
return fs.writeFile(this.files.flows, flows)
}
async readFlow () {
debug(`Reading flows file: ${this.files.flows}`)
const flows = await fs.readFile(this.files.flows, 'utf8')
return JSON.parse(flows)
}
async writeCredentials () {
debug(`Updating credentials file: ${this.files.credentials}`)
const credentials = JSON.stringify(this.snapshot.credentials || {})
return fs.writeFile(this.files.credentials, credentials)
}
async readCredentials () {
debug(`Reading credentials file: ${this.files.flows}`)
const creds = await fs.readFile(this.files.credentials, 'utf8')
return JSON.parse(creds)
}
async writeSettings () {
debug(`Updating settings file: ${this.files.userSettings}`)
const templatePath = path.join(__dirname, './template/template-settings.js')
await fs.copyFile(templatePath, this.files.settings)
let teamID
let projectLink
if (this.config.brokerUsername) {
// Parse the teamID out of the brokerUsername
teamID = this.config.brokerUsername.split(':')[1]
// Determine if projectLink is enabled (default to true if not set in settings for backwards compatibility)
const enabled = !!(this.settings?.features ? this.settings.features.projectComms : true)
projectLink = {
featureEnabled: enabled,
// always include the token to permit project enumeration in the project nodes.
// This permits the node to be usable/discoverable but no comms will
// be possible due to the feature being disabled and the broker url/user/pass being empty.
// The project nodes will inform the user of the need to enable or upgrade.
token: this.config.token,
broker: {
url: enabled ? this.config.brokerURL : '',
username: enabled ? this.config.brokerUsername : '',
password: enabled ? this.config.brokerPassword : ''
},
teamBrokerEnabled: enabled && !!this.settings?.features?.teamBroker
}
}
const themeName = this.config.theme || 'forge-light'
const assistant = {
enabled: this.settings?.assistant?.enabled || false, // overall enable/disable
url: `${this.config.forgeURL}/api/v1/assistant/`, // URL for the assistant service
token: this.config.token,
requestTimeout: this.settings?.assistant?.requestTimeout || 60000 // timeout for assistant requests
}
const settings = {
credentialSecret: this.config.credentialSecret,
port: this.config.port,
codeEditor: this.settings?.codeEditor || 'monaco',
[themeName]: { launcherVersion: this.config.version, forgeURL: this.config.forgeURL, projectURL: `${this.config.forgeURL}/device/${this.config.deviceId}/overview` },
editorTheme: {
theme: themeName,
codeEditor: {
lib: this.settings?.codeEditor || this.config.codeEditor || 'monaco'
},
// library: TODO
tours: false,
palette: {}
},
flowforge: {
forgeURL: this.config.forgeURL,
projectID: this.project || undefined,
applicationID: this.application || undefined,
teamID,
deviceId: this.config.deviceId,
auditLogger: {
url: this.auditLogURL,
token: this.config.token,
bin: path.join(__dirname, 'auditLogger', 'index.js')
},
projectLink,
assistant
},
nodesDir: [
// This path exists when running the agent as a git clone
path.resolve(path.join(__dirname, '..', 'node_modules', '@flowfuse', 'nr-theme')),
// This path exists when running the agent as an npm installed package
path.resolve(path.join(__dirname, '..', '..', 'nr-theme'))
]
}
// if licensed, add palette catalogues
if (this.config.licensed) {
if (this.project) {
if (this.snapshot?.settings?.palette?.catalogue !== undefined) {
settings.editorTheme.palette.catalogues = this.snapshot.settings.palette.catalogue
}
} else if (this.application) {
if (this.settings.palette?.catalogues) {
settings.editorTheme.palette.catalogues = this.settings.palette.catalogues
}
}
}
// if licensed, add shared library config
const libraryEnabled = this.settings?.features ? this.settings.features['shared-library'] : false
if (libraryEnabled && this.config.licensed) {
settings.nodesDir = settings.nodesDir || []
settings.nodesDir.push(path.join(__dirname, 'plugins', 'node_modules', '@flowforge', 'flowforge-library-plugin'))
const sharedLibraryConfig = {
id: 'flowfuse-team-library',
type: 'flowfuse-team-library',
label: 'Team Library',
icon: 'font-awesome/fa-users',
baseURL: this.config.forgeURL + '/storage', // Ideally, this would come from the model via API however it is currently just a virtual column of forgeURL + '/storage'
projectID: settings.flowforge.projectID,
applicationID: settings.flowforge.applicationID,
libraryID: settings.flowforge.teamID,
token: this.config.token
}
settings.editorTheme.library = {
sources: [sharedLibraryConfig]
}
}
if (this.config.https) {
// The `https` config can contain any valid setting from the Node-RED
// https object. For convenience, the `key`, `ca` and `cert` settings
// have `*Path` equivalents that can be used to provide a path to load
// the corresponding values from. The loading of the file contents
// is done in settings.js - but we validate the files exist here to
// ensure the config looks valid.
const httpsErrors = []
;['keyPath', 'caPath', 'certPath'].forEach(key => {
if (this.config.https[key]) {
if (!existsSync(this.config.https[key])) {
httpsErrors.push(`https.${key} file not found: ${this.config.https[key]}`)
}
}
})
if (httpsErrors.length > 0) {
warn('Invalid HTTPS configuration:')
httpsErrors.forEach(err => warn(` - ${err}`))
delete this.config.https
} else {
settings.https = this.config.https
}
}
if (this.config.httpStatic) {
// The `httpStatic` config is passed straight through to Node-RED
settings.httpStatic = this.config.httpStatic
}
if (this.config.httpNodeAuth) {
// The `httpNodeAuth` config is passed straight through to Node-RED
// It is however sanitised in config.js to ensure it is an object
// containing `user` and `pass` properties.
settings.flowforge.httpNodeAuth = {
type: 'basic',
...this.config.httpNodeAuth
}
} else if (this.settings?.security?.httpNodeAuth) {
settings.flowforge.httpNodeAuth = {
...this.settings.security.httpNodeAuth,
bin: path.join(__dirname, 'plugins/node_modules/@flowfuse/flowfuse-auth/httpAuthMiddleware.js')
}
if (settings.flowforge.httpNodeAuth.type === 'ff-user') {
// Add the ff-auth plugin
settings.nodesDir = settings.nodesDir || []
settings.nodesDir.push(path.join(__dirname, 'plugins', 'node_modules', '@flowfuse', 'flowfuse-auth'))
}
}
if (this.config.localAuth?.enabled) {
settings.localAuth = {
enabled: true,
user: this.config.localAuth.user,
pass: this.config.localAuth.pass
}
} else if (this.settings?.security?.localAuth?.enabled === true) {
settings.localAuth = {
enabled: true,
user: this.settings.security.localAuth.user,
pass: this.settings.security.localAuth.pass
}
} else {
delete settings.flowforge.localAuth
}
await fs.writeFile(this.files.userSettings, JSON.stringify(settings))
}
/**
* Write .npmrc file
*/
async writeNPMRCFile () {
if (this.project) {
if (this.snapshot.settings?.palette?.npmrc) {
await fs.writeFile(this.files.npmrc, this.snapshot.settings.palette.npmrc)
} else {
if (existsSync(this.files.npmrc)) {
await fs.rm(this.files.npmrc)
}
}
} else if (this.application) {
if (this.settings.palette?.npmrc) {
await fs.writeFile(this.files.npmrc, this.settings.palette.npmrc)
} else {
if (existsSync(this.files.npmrc)) {
await fs.rm(this.files.npmrc)
}
}
}
}
/**
* Write the configuration files to disk
* @param {Object} options Save options
* @param {boolean} options.updateSnapshot Update the snapshot (flows, credentials, package.json)
* @param {boolean} options.updateSettings Update the settings (settings.js, settings.json)
*/
async writeConfiguration (options = { updateSnapshot: true, updateSettings: true }) {
let fullWrite = !options // default to full write if no options are provided
// If this is an application owned device, the NR version might be user defined.
// When the updateSettings flag is set, the user defined version is specified
// and the versions differ, set the fullWrite flag to cause the package.json
// to be updated and the installDependencies function to be run.
const userDefinedNRVersion = this.settings?.editor?.nodeRedVersion
if (userDefinedNRVersion && options?.updateSettings && this.agent?.currentOwnerType === 'application') {
const pkg = this.readPackage()
const pkgNRVersion = pkg.modules?.['node-red'] || 'latest'
const snapshotNRVersion = this.snapshot?.modules?.['node-red']
if ((pkgNRVersion !== userDefinedNRVersion || snapshotNRVersion !== userDefinedNRVersion)) {
// package.json dependencies will be updated with snapshot.modules when writePackage is called
// so here, we need to update the snapshot modules node-red version with the user defined version
this.snapshot.modules['node-red'] = userDefinedNRVersion
fullWrite = true
}
}
info('Updating configuration files')
await fs.mkdir(this.projectDir, { recursive: true })
if (fullWrite || options.updateSnapshot) {
this.state = States.INSTALLING
await this.writeNPMRCFile()
await this.writePackage()
await this.installDependencies()
await this.writeFlow()
await this.writeCredentials()
}
if (fullWrite || options.updateSettings === true) {
await this.writeSettings()
await this.writeNPMRCFile()
}
}
async logAuditEvent (event, body) {
const data = {
timestamp: Date.now(),
event
}
if (body && typeof body === 'object') {
if (body.error) {
data.error = {
code: body.error.code || 'unexpected_error',
error: body.error.error || body.error.message || 'Unexpected error'
}
} else {
Object.assign(data, body)
}
}
return got.post(this.auditLogURL, {
json: data,
headers: {
authorization: 'Bearer ' + this.config.token
}
}).catch(_err => {
console.error('Failed to log audit event', _err, event)
})
}
async start () {
if (this.deferredStop) {
await this.deferredStop
}
this.state = States.STARTING
if (!existsSync(this.projectDir) ||
!existsSync(this.files.flows) ||
!existsSync(this.files.credentials) ||
!existsSync(this.files.settings) ||
!existsSync(this.files.userSettings)
) {
// If anything is missing - rewrite the whole project snapshot
await this.writeConfiguration()
} else {
// All files exist - but it is possible that 'port' has changed
// via CLI/config flag.
// Rewrite the config file just to be sure
await this.writeSettings()
}
const filterEnv = (env) =>
Object.entries(env).reduce((acc, [key, value]) =>
key.startsWith('FORGE') ? acc : { ...acc, [key]: value }, {})
// According to https://github.com/flowforge/flowforge-nr-launcher/pull/145,
// and in order to keep this feature coherent between launchers,
// setting FORGE_EXPOSE_HOST_ENV on the container unlocks the host env propagation.
const env = Object.assign({}, this.snapshot.env,
process.env.FORGE_EXPOSE_HOST_ENV ? filterEnv(process.env) : {})
if (this.settings?.env) {
Object.assign(env, this.settings?.env)
}
// must always include the PATH so npm works
env.PATH = process.env.PATH
// pass through extra certs
if (process.env.NODE_EXTRA_CA_CERTS) {
env.NODE_EXTRA_CA_CERTS = process.env.NODE_EXTRA_CA_CERTS
}
// should set HOME env var
if (process.platform === 'win32') {
env.UserProfile = process.env.UserProfile
} else {
env.HOME = process.env.HOME
}
// Use local timezone if set, else use one from snapshot settings
// this will be ignored on Windows as it does not use the TZ env var
env.TZ = process.env.TZ ? process.env.TZ : this.settings?.settings?.timeZone
// Add any proxy vars found in process.env. Note, this will override
// any proxy settings provided by the devices settings.env
const proxyVars = ['http_proxy', 'https_proxy', 'no_proxy', 'all_proxy']
proxyVars.forEach(ev => {
if (hasProperty(process.env, ev)) {
env[ev] = process.env[ev]
}
})
info('Starting Node-RED')
this.state = States.STARTING // state may have been changed by stop() or deferredStop or Installing
this.stopReason = ''
const appEnv = env
const processArgs = [
'-u',
this.projectDir
]
// Additional include paths for node modules.
// library, auth and other things loaded by node-red may require additional modules explicitly installed
// by the device agent but not necessarily in the projects node_modules directory (e.g. the proxy agents)
const nodePaths = []
if (appEnv.NODE_PATH) {
nodePaths.push(appEnv.NODE_PATH)
}
nodePaths.push(path.join(path.resolve(this.projectDir), 'node_modules'))
nodePaths.push(path.join(__dirname, '..', 'node_modules'))
appEnv.NODE_PATH = nodePaths.join(path.delimiter)
const processOptions = {
windowHide: true,
env: appEnv,
stdio: ['ignore', 'pipe', 'pipe'],
cwd: this.projectDir
}
const execPathJS = path.join(this.projectDir, 'node_modules', 'node-red', 'red.js')
const execPath = process.execPath
processArgs.unshift(
'--max_old_space_size=512',
execPathJS
)
debug(`CMD: ${execPath} ${processArgs.join(' ')}`)
/** @type {childProcess.ChildProcess} */
this.proc = childProcess.spawn(
execPath,
processArgs,
processOptions
)
this.proc.on('spawn', () => {
this.startTime.push(Date.now())
if (this.startTime.length > MAX_RESTART_COUNT) {
this.startTime.shift()
}
this.state = States.RUNNING
})
this.proc.on('exit', async (code, signal) => {
// determine if Node-RED exited for an expected reason
// if yes, don't restart it since it was specifically stopped (e.g. not crashed)
const expected = ['shutdown', States.RESTARTING, States.UPDATING, States.SUSPENDED].includes(this.stopReason)
if (expected) {
this.state = States.STOPPED // assume stopped
} else {
this.state = States.CRASHED // assume crashed
}
if (this.exitCallback) {
this.exitCallback()
}
if (!expected) {
let restart = true
if (this.startTime.length === MAX_RESTART_COUNT) {
let avg = 0
for (let i = this.startTime.length - 1; i > 0; i--) {
avg += (this.startTime[i] - this.startTime[i - 1])
}
avg /= MAX_RESTART_COUNT
if (avg < MIN_RESTART_TIME) {
// restarting too fast
info('Node-RED restart loop detected - stopping')
this.state = States.CRASHED
restart = false
await this.logAuditEvent('crashed', { info: { code: 'loop_detected', info: 'Node-RED restart loop detected' } })
await this.agent?.checkIn()
}
}
if (restart) {
info('Node-RED stopped unexpectedly - restarting')
this.start()
}
}
})
let stdoutBuffer = ''
const handleLog = (data) => {
stdoutBuffer += data
let linebreak = stdoutBuffer.indexOf('\n')
while (linebreak > -1) {
const line = stdoutBuffer.substring(0, linebreak)
if (line.length > 0) {
// console.log('[NR]', line)
NRlog(line, '[NR]')
}
stdoutBuffer = stdoutBuffer.substring(linebreak + 1)
linebreak = stdoutBuffer.indexOf('\n')
}
}
this.proc.stdout.on('data', handleLog)
this.proc.stderr.on('data', handleLog)
}
async stop (clean, reason) {
if (this.installProcess && this.state === States.INSTALLING) {
// If the launcher is currently installing, we should try not to interrupt this
// to avoid corruption (NPM can leave temporary directories preventing future installs)
// We should wait for the install to finish before stopping
// give it a few seconds to finish
const timeout = new Promise(resolve => setTimeout(resolve, 10000))
await Promise.race([this.installProcess, timeout])
// now proceed with stopping, regardless of whether the install finished
}
let finalState = States.STOPPED
this.stopReason = reason || 'shutdown'
info('Stopping Node-RED. Reason: ' + this.stopReason)
if (this.stopReason === States.SUSPENDED) {
finalState = States.SUSPENDED
}
if (this.deferredStop) {
// A stop request is already inflight - return the existing deferred object
return this.deferredStop
}
/** Operations that should be performed after the process has exited */
const postShutdownOps = async () => {
if (clean) {
info('Cleaning instance directory')
try {
await fs.rm(this.projectDir, { force: true, recursive: true })
} catch (err) {
warn('Error cleaning instance directory', err)
}
}
info('Node-RED Stopped')
await this.agent?.checkIn() // let FF know we've stopped
}
if (this.proc && this.proc.exitCode === null) {
// Setup a promise that will resolve once the process has really exited
this.deferredStop = new Promise((resolve, reject) => {
// Setup a timeout so we can more forcefully kill Node-RED
this.exitTimeout = setTimeout(async () => {
log('Node-RED stop timed-out. Sending SIGKILL', 'system')
if (this.proc) {
this.proc.kill('SIGKILL')
}
}, NODE_RED_STOP_TIMEOUT)
// Setup a callback for when the process has actually exited
this.exitCallback = async () => {
clearTimeout(this.exitTimeout)
this.exitCallback = null
this.deferredStop = null
this.exitTimeout = null
this.proc && this.proc.unref()
this.proc = undefined
await postShutdownOps()
resolve()
}
// Send a kill signal. On Linux this will be a SIGTERM and
// allow Node-RED to shutdown cleanly. Windows looks like it does
// it more forcefully by default.
this.proc.kill()
this.state = finalState
})
return this.deferredStop
} else {
this.proc && this.proc.unref()
this.proc = undefined
this.state = finalState
await postShutdownOps()
}
}
}
module.exports = {
newLauncher: (agent, application, project, snapshot, settings, mode) => new Launcher(agent, application, project, snapshot, settings, mode),
Launcher
}