UNPKG

@flowfuse/device-agent

Version:

An Edge Agent for running Node-RED instances deployed from the FlowFuse Platform

358 lines (332 loc) 15.1 kB
#!/usr/bin/env node const semver = require('semver') if (semver.lt(process.version, '14.0.0')) { // eslint-disable-next-line no-console console.log('FlowFuse Device Agent requires at least NodeJS v14.x') process.exit(1) } const TESTING = process.env.NODE_ENV === 'test' const commandLineArgs = require('command-line-args') const { info, warn } = require('./lib/log') const { hasProperty } = require('./lib/utils') const path = require('path') const fs = require('fs') const { AgentManager } = require('./lib/AgentManager') const { WebServer } = require('./frontend/server') const ConfigLoader = require('./lib/config') const webServer = new WebServer() const figures = require('@inquirer/figures').default const confirm = require('@inquirer/confirm').default const print = (message, /** @type {figures} */ figure = chalk.gray(figures.lineBold)) => console.info(figure ?? chalk.gray(figures.lineBold), message) const flowImport = require('./lib/cli/flowsImporter').flowImport const { OLD_PROJECT_FILE, PROJECT_FILE } = require('./lib/agent') const chalk = require('yoctocolors-cjs') // switch to the lighter yoctocolors-cjs to match @inquirer function main (testOptions) { const pkg = require('./package.json') if (pkg.name === '@flowforge/flowforge-device-agent') { // eslint-disable-next-line no-console console.log(` ************************************************************************** * The FlowFuse Device Agent is moving to '@flowfuse/device-agent' on npm * * and 'flowfuse/device-agent' on DockerHub. Please upgrade to the new * * packages to ensure you continue to receive updates. * * See https://flowfuse.com/docs/device-agent/install/ for details * ************************************************************************** `) } let options try { options = commandLineArgs(require('./lib/cli/args'), { camelCase: true }) options = options._all } catch (err) { console.error(err) console.error('Run with -h for help') quit() } if (options.version) { console.error(pkg.version) quit() } if (options.help) { console.error(require('./lib/cli/usage').usage()) quit() } // Configure silent mode let installerMode = false if (options.installerMode) { installerMode = true } if (options.dir === '') { // No dir has been explicitly set, so we need to set the default. // 1. Use `/opt/flowforge-device` if it exists // 2. Otherwise use `/opt/flowfuse-device` if (fs.existsSync('/opt/flowforge-device')) { options.dir = '/opt/flowforge-device' } else { options.dir = '/opt/flowfuse-device' } } if (!path.isAbsolute(options.dir)) { options.dir = path.join(process.cwd(), options.dir) } // Require dir to be created if (!fs.existsSync(options.dir)) { try { fs.mkdirSync(options.dir, { recursive: true }) if (!fs.existsSync(options.dir)) { throw new Error('Failed to create dir') } } catch (err) { const quitMsg = `Cannot create dir '${options.dir}'. Please ensure the parent directory is writable, or set a different path with -d` quit(quitMsg, 20) // Exit Code 20 - Invalid dir // REF: https://slg.ddnss.de/list-of-common-exit-codes-for-gnu-linux/ return } } // Locate the config file. Either the path exactly as specified, // or relative to dir let configFound = false const deviceFile1 = options.config || 'device.yaml' const deviceFile2 = path.join(options.dir, deviceFile1) if (fs.existsSync(deviceFile1)) { configFound = true options.deviceFile = deviceFile1 } else if (fs.existsSync(deviceFile2)) { configFound = true options.deviceFile = deviceFile2 } // If the config file is not found, set the `deviceFile` to the default value // ready for when the config file is created. if (!configFound) { options.deviceFile = deviceFile2 // deviceFile2 is the default value } delete options.config AgentManager.init(options) if (hasProperty(options, 'otc') || hasProperty(options, 'ffUrl')) { // Quick Connect mode if (!options.otc || options.otc.length < 8) { // 8 is the minimum length of an OTC // e.g. ab-cd-ef warn('Device setup requires parameter --otc to be 8 or more characters') quit(null, 2) } console.info() print('Setting up your device...', chalk.gray(figures.bullet)) if (!options.ffUrl) { warn('Device setup requires parameter --ff-url to be set') quit(null, 2) } let deviceSettings = null AgentManager.quickConnectDevice().then((provisioningData) => { deviceSettings = provisioningData if (!deviceSettings) { print('Device setup was unsuccessful', chalk.redBright(figures.cross)) quit(null, 2) } const runCommandInfo = ['flowfuse-device-agent'] if (options.dir !== '/opt/flowfuse-device') { runCommandInfo.push(`-d ${options.dir}`) } if (installerMode) { print('Success!', chalk.green(figures.tick)) } else { print('Success!', chalk.green(figures.tick)) print('This Device can be launched at any time using the following command:') print(runCommandInfo.join(' '), ' ') } if (!options.otcNoImport) { // Support for importing flows during initial state check-in was added after 2.16.0. // Use semver.coerce to validate the ffVersion. This will, by default, strip off suffixes to ensure // a clean x.y.z comparison. const ffVersion = semver.coerce(deviceSettings.meta?.ffVersion || '0.0.0') // Strip suffixes like -beta.1 const ffSupportsImport = (ffVersion && semver.gt(ffVersion, '2.16.0')) if (ffSupportsImport) { const home = process.env.HOME || process.env.USERPROFILE || process.env.HOMEPATH || '/' const parentOfHome = path.dirname(home) const root = path.parse(parentOfHome).root const homeNodeRed = path.join(home, '.node-red') const rootNodeRed1 = path.join(root, 'node-red') const rootNodeRed2 = path.join(root, '.node-red') const rootNodeRed3 = path.join(root, 'nodered') const rootNodeRed4 = path.join(root, 'data') // common location for Node-RED data const suggestedDirs = [process.cwd(), homeNodeRed, rootNodeRed1, rootNodeRed2, rootNodeRed3, rootNodeRed4] try { // get an array of .node-red dirs in the home directories const parentDirNodeRedDirs = fs.readdirSync(parentOfHome, { withFileTypes: true }) .filter(dir => dir.isDirectory()) .map(dir => path.join(parentOfHome, dir.name, '.node-red')) .filter(dir => fs.existsSync(dir) && fs.statSync(dir).isDirectory()) suggestedDirs.push(...parentDirNodeRedDirs) } catch (_err) { // If we can't read the parent directory, just ignore it } // add common locations for FlowFuse Device Agent projects flows suggestedDirs.push(path.join('/opt/flowfuse-device/project')) suggestedDirs.push(path.join('/opt/flowforge-device/project')) // if provided, add the dir option as a suggested directory if (options.dir) { suggestedDirs.push(options.dir) suggestedDirs.push(path.join(options.dir, 'project')) } const absoluteSuggestedDirs = suggestedDirs.map(dir => path.resolve(dir)) // absolute paths const uniqueSuggestedDirs = [...new Set(absoluteSuggestedDirs)] return flowImport(uniqueSuggestedDirs) } } return Promise.resolve() }).then((importOptions) => { if (importOptions) { const deviceConfig = { flows: importOptions.flows || [], credentials: importOptions.credentials || {}, package: importOptions.package || {} } print('Uploading snapshot as the target for this Device...') return AgentManager.postState( { token: deviceSettings.credentials.token, deviceId: deviceSettings.id, forgeURL: options.ffUrl }, { provisioning: { deviceConfig, credentialSecret: importOptions.credentialSecret, description: `Flows imported from '${importOptions.flowsFile}' at ${new Date().toISOString()}`, name: 'Existing Flows Imported' }, agentVersion: pkg.version, state: 'provisioning' } ) } return Promise.resolve() }).then((importResponse) => { if (importResponse) { if (importResponse.statusCode === 200) { // at this point, flowImport has successfully created a snapshot on the platform - we can safely clean up the local files // check to see if project dir exists & if so, clean it up const projectDir = path.join(options.dir, 'project') if (fs.existsSync(projectDir)) { print('Cleaning up existing project directory...') fs.rmSync(projectDir, { force: true, recursive: true }) } let projectJson = path.join(options.dir, OLD_PROJECT_FILE) if (fs.existsSync(projectJson)) { print('Cleaning up existing project file...') fs.rmSync(projectJson, { force: true }) } projectJson = path.join(options.dir, PROJECT_FILE) if (fs.existsSync(projectJson)) { print('Cleaning up existing project file...') fs.rmSync(projectJson, { force: true }) } print('Success!', chalk.green(figures.tick)) } else { print(`Snapshot import was unsuccessful (${importResponse.statusCode})`, chalk.redBright(figures.cross)) } } // If the user has set otcNoStart, then we don't want to start the agent console.info() if (!options.otcNoStart) { return confirm({ message: 'Do you want to start the Device Agent now?' }) } else { quit() } }).then((startNow) => { if (startNow) { console.info() info('Starting Device Agent with new configuration') delete options.otc delete options.ffUrl options.deviceFile = path.join(options.dir, 'device.yml') start(options, true) } else { console.info() quit() } }).catch((err) => { console.info() quit(err.message, 2) }) return } start(options, configFound) function start (options, configFound) { info('FlowFuse Device Agent') info('----------------------') if (options.ui) { info('Starting Web UI') if (!options.uiUser || !options.uiPass) { quit('Web UI cannot run without a username and password. These are set via with --ui-user and --ui-pass', 2) } const uiRuntime = Number(options.uiRuntime) if (isNaN(uiRuntime) || uiRuntime === Infinity || uiRuntime < 0) { quit('Web UI runtime must be 0 or greater', 2) } const opts = { port: options.uiPort || 1879, host: options.uiHost || '0.0.0.0', credentials: { username: options.uiUser, password: options.uiPass }, runtime: uiRuntime, dir: options.dir, config: options.config, deviceFile: options.deviceFile } webServer.initialize(AgentManager, opts) webServer.start().then().catch((err) => { info(`Web UI failed to start: ${err.message}`) }) } process.on('SIGINT', closeAgentAndQuit) process.on('SIGTERM', closeAgentAndQuit) process.on('SIGQUIT', closeAgentAndQuit) const parsedConfig = configFound && (ConfigLoader.parseDeviceConfigFile(options.deviceFile) || { valid: false }) const isValidDeviceConfig = !!parsedConfig.valid if (isValidDeviceConfig) { AgentManager.startAgent() } else if (configFound && options.ui === true) { info(`Invalid config file '${options.deviceFile}'.`) } else if (!configFound && options.ui === true) { info(`No config file found at '${deviceFile1}' or '${deviceFile2}'`) } else { if (configFound) { quit(`Invalid config file '${options.deviceFile}': ${parsedConfig?.message || 'Unknown error'}'.`, 9) // Exit Code 9 - Invalid config file } else { quit(`No config file found at '${deviceFile1}' or '${deviceFile2}'`, 2) // No such file or directory } } } function quit (msg, errCode = 0) { if (msg) { console.error(msg) } if (TESTING) { // don't exit if we are testing. Instead, call the onExit callback stub if (testOptions?.onExit) { testOptions.onExit(msg, errCode) } } else { process.exit(errCode) } } async function closeAgentAndQuit (msg, errCode = 0) { if (AgentManager) { await AgentManager.close() } quit(msg, errCode) } if (TESTING) { return { AgentManager, webServer, options: { ...ConfigLoader.defaults, ...options } } } return null } // if we are testing, export the main function so we can call it directly, otherwise call it now if (TESTING) { module.exports = { main } } else { main() }