UNPKG

domotz-remote-pawn

Version:

Domotz Agent

740 lines (658 loc) 29.3 kB
#!/usr/bin/env node /** This file is part of Domotz Agent. * Copyright (C) 2017 Domotz Ltd * * Domotz Agent is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Domotz Agent is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Domotz Agent. If not, see <http://www.gnu.org/licenses/>. * */ var modPath = require('path'); var modUtil = require('util'); var modFs = require('fs'); var modHttp = require('http'); var modHttps = require('https'); var modChildProcess = require('child_process'); var modRequest = require('request'); const MODULE_NAME = 'AGENT_UPDATER'; var enableDebug = process.env.DLEVEL || 'info'; console.debug = function() { console.log.apply(console, arguments); } if (process.version.charAt(1) === '0') { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; } var logger = { log: function(level, message) { if (message === undefined) { return; } var timestamp = new Date().toISOString(); var formattedLevel = level.charAt(0).toUpperCase(); // Manually capitalize var logPrefix = MODULE_NAME + ' [' + formattedLevel + ' ' + timestamp + '] '; // Use util.format to handle %s replacement for arguments var params = Array.prototype.slice.call(arguments, 2)[0]; // Build the formatted message using util.format if parameters are present var formattedMessage = logPrefix; if (params.length > 0) formattedMessage += modUtil.format.apply(modUtil, [message].concat(params)); else formattedMessage += message; // Use apply to pass the formatted message and the remaining arguments console[level].apply(console, [formattedMessage]); }, info: function(message) { this.log('info', message, Array.prototype.slice.call(arguments, 1)); }, error: function(message) { this.log('error', message, Array.prototype.slice.call(arguments, 1)); }, debug: function(message) { if (enableDebug === 'debug') { this.log('debug', message, Array.prototype.slice.call(arguments, 1)); } }, warn: function(message) { this.log('warn', message, Array.prototype.slice.call(arguments, 1)); } }; var utils = { // Derived from : https://stackoverflow.com/questions/18052762/remove-directory-which-is-not-empty recursiveRemoveFolder: function(pathname) { logger.debug('recursiveRemoveFolder - called with path %s', pathname); if (modFs.existsSync(pathname) && pathname !== '/') { modFs.readdirSync(pathname).forEach(function(file) { var currentPath = modPath.join(pathname, file); if (modFs.lstatSync(currentPath).isDirectory()) { utils.recursiveRemoveFolder(currentPath); } else { modFs.unlinkSync(currentPath); } }); modFs.rmdirSync(pathname); } }, getCloudHostname: function() { try { return process.env.PORTAL_API_ENDPOINT .replace("/portal-api/v1/", "") .replace("https://", ""); } catch (e) { return 'portal.domotz.com'; } }, getAgentPid: function() { if (process.env.LISTENER_PID_FILE === undefined) { logger.warn("Missing LISTENER_PID_FILE variable"); return null; } var pidStr; try { pidStr = modFs.readFileSync(process.env.LISTENER_PID_FILE, 'utf-8'); } catch (e) { logger.warn('Listener PID file is missing in %s', process.env.LISTENER_PID_FILE); return null; } if (pidStr === '' || pidStr === undefined) { logger.warn('Listener PID is missing in %s', process.env.LISTENER_PID_FILE); return null; } return parseInt(pidStr); }, getAgentPort: function() { try { return modFs.readFileSync(process.env.DOMOTZ_LISTENER_PORT_FILE, 'utf-8'); } catch (e) { logger.error('Missing %s file, unable to get agent port, using default port (3000)', process.env.DOMOTZ_LISTENER_PORT_FILE); return 3000; } }, tryobj: function(objVar, keysArr) { if (objVar === null || objVar === undefined) { return null; } var lastObj = objVar; keysArr.forEach(function(key) { if (lastObj[key] === null || lastObj[key] === undefined) { return; } lastObj = lastObj[key]; }); return lastObj; }, skip_fn: function() { }, }; // Timers const BASE_WARMUP_PERIOD = 5 * 1000; // 5 seconds const ONE_HOUR_IN_MSEC = 60 * 60 * 1000; // 1 hour const UPDATE_CHECK_PERIOD = 11 * 60 * 60 * 1000; // 11 hours const MAX_PROCESS_AGE = 48 * 60 * 60 * 1000; // 2 days const AGENT_RESTART_DELAY = 30 * 1000; const AGENT_STATUS_DELAY = 10 * 1000; const MAX_AGENT_STATUS_RETRY = 6; // Hostnames and endpoints const UPDATE_INFO_HOSTNAME = utils.getCloudHostname(); const UPDATE_INFO_PATH = '/assets/domotz-npm-agent.json'; const UPDATE_AGENT_START_PATH = '/domotz-agent-update/start'; const UPDATE_AGENT_END_PATH = '/domotz-agent-update/end'; // Local running agent const AGENT_HOSTNAME = 'http://127.0.0.1'; const AGENT_PORT = utils.getAgentPort(); const AGENT_STATUS_PATH = '/api/v1/status'; const AGENT_STATUS_URL = AGENT_HOSTNAME + ':' + AGENT_PORT + AGENT_STATUS_PATH; // Node + npm const NODE_COMMAND = modPath.join(process.env.DOMOTZ_BIN_DIR, 'domotz_node'); const NPM_CACHE_DIR = process.env.DOMOTZ_CACHE_DIR || process.env.DOMOTZ_ROOT_DIR; const NPM_BASE_COMMAND = [ modPath.join(process.env.DOMOTZ_BIN_DIR, 'domotz_npm'), '--cache', modPath.join(NPM_CACHE_DIR, '.npm'), '--userconfig', modPath.join(NPM_CACHE_DIR, '.npmrc'), '--unsafe-perm', ]; ('use strict'); var pkgJson = require(modPath.join(process.env.DOMOTZ_LIB_DIR, 'node_modules', 'domotz-remote-pawn', 'package.json')); var envCopy = Object.create(process.env); envCopy.HOME = '/tmp'; var spawnOptions = { stdio: 'ignore', shell: false, env: envCopy }; var updaterStatus = { error: false, update_in_progress: false, modifyCurrentStatus: function(inProgress, error) { this.update_in_progress = inProgress; this.error = error; }, /** * Starts the update process if it is not already in progress. * * @function startProgress * @returns {boolean} * Returns `true` if the update process was started, `false` if it was already in progress. * * @example * if (updaterStatus.startProgress()) { * logger.debug('Update process started.'); * } else { * logger.debug('Update process already in progress.'); * } */ startProgress: function() { if (this.update_in_progress === true) { return false; } this.modifyCurrentStatus(true, false); return true; }, }; /** * Useful data structure to carry data around * @typedef {Object} UpgradeParams * @property {string} registry - The registry URL for the package. * @property {string} name - The name of the package to upgrade. * @property {string} from_version - The current version of the package. * @property {string} to_version - The target version to upgrade to. */ /** * Payload structure to send data to the cloud (Revamper) to notify the start and end of the agent update * @typedef {Object} UpdateAgentPayload * @property {string} [mac_address] - The MAC address of the device. * @property {string} from_version - The current version of the package. * @property {string} to_version - The target version to upgrade to. * @property {string} update_type - The type of update, e.g., 'NPM'. * @property {string} platform - The platform identifier (e.g., 'linux', 'windows'). * @property {string} architecture - The architecture type (e.g., 'x64', 'arm'). * @property {boolean} skip_cloud_notification - If true, do not notify cloud, used in case the agent is not responding to the /status. */ var updater = { updaterStartTimestamp: null, sendStartUpdateEvent: function(payload, onStartUpdateEvent) { updater.sendUpdateEvent(UPDATE_AGENT_START_PATH, payload, onStartUpdateEvent); }, sendEndUpdateEvent: function(payload, onEndUpdateEvent) { updater.sendUpdateEvent(UPDATE_AGENT_END_PATH, payload, onEndUpdateEvent); }, sendUpdateEvent: function(endpoint, updateAgentPayload, callback) { logger.info('sendUpdateEvent - Start calling %s with payload %s', endpoint, JSON.stringify(updateAgentPayload) ); const options = { url: 'https://' + UPDATE_INFO_HOSTNAME + endpoint, body: updateAgentPayload, json: true, }; modRequest.post(options, function(error, response, body) { if (error) { logger.error('Error:', error.message); } else { logger.info('sendUpdateEvent - Response: %s Body %s', JSON.stringify(response), JSON.stringify(body) ); } callback(); }); }, /** * After calling the /status build the UpdateAgentPayload to call the /start * @param {UpgradeParams} upgradeParams * @param error * @param response * @param body */ callbackAgentStatus: function(upgradeParams, error, response, body) { var updateAgentPayload = { mac_address: null, from_version: upgradeParams.from_version, to_version: upgradeParams.to_version, update_type: 'NPM', platform: process.env.DPLATFORM, architecture: process.env.DARCHITECTURE, skip_cloud_notification: true }; if (error) { logger.error('callbackAgentStatus - Error fetching %s, continue without notify cloud', error.message); } else if (response.statusCode !== 200) { logger.warn('callbackAgentStatus - Unexpected status code: %s, continue without notify cloud', response.statusCode); } else { logger.info('callbackAgentStatus - Success! Building StartUpdateEvent payload'); try { // Parse the /status response to extract the MAC address const defaultInterface = body.default_interface; // Construct the payload updateAgentPayload.mac_address = body.interfaces_list[defaultInterface].mac; updateAgentPayload.skip_cloud_notification = false; } catch (parseError) { logger.error('Error parsing /status data ending update process:', parseError.message); } } if (updateAgentPayload.skip_cloud_notification) { updater.upgradePawn(upgradeParams, updateAgentPayload); } else { return updater.sendStartUpdateEvent( updateAgentPayload, updater.upgradePawn.bind(this, upgradeParams, updateAgentPayload) ); } }, /** * * @param {function} callback what to do after the /status is called */ callAgentStatus: function(callback) { const options = { url: AGENT_STATUS_URL, json: true }; logger.info('callAgentStatus - Start calling %s', options.url); modRequest.get(options, callback); }, /** * Call the agent status until get a 200 with status ok, there are a max number of retries (MAX_AGENT_STATUS_RETRY). * @param {UpgradeParams} upgradeParams * @param {UpdateAgentPayload} updateAgentPayload */ waitUntilAgentIsUp: function(upgradeParams, updateAgentPayload) { logger.info('waitUntilAgentIsUp - Start polling agent on /status'); var retries = 0; const checkAgentStartup = function() { logger.info('waitUntilAgentIsUp - Call %s of max %s', retries, MAX_AGENT_STATUS_RETRY); updater.callAgentStatus(function(error, response, json) { retries++; if (error) { logger.debug('Retrying due to error:', error.message); } else if (response.statusCode !== 200) { logger.error('waitUntilAgentIsUp - Unexpected status code: %s', response.statusCode); } else if (json) { logger.debug('waitUntilAgentIsUp - JSON: %s', JSON.stringify(json, null, 2)); if (json.status && json.package && json.package.agent_version) { if (json.status === 'ok' && json.package.agent_version === upgradeParams.to_version) { logger.info('waitUntilAgentIsUp - Success! Response: json.status %s json.package.agent_version %s', json.status, json.package.agent_version ); // Inform the cloud that update is ended well, then the update is ended. updater.sendEndUpdateEvent(updateAgentPayload, utils.skip_fn); return; } else { logger.error('waitUntilAgentIsUp - /status returned wrong values status: %s, version: %s. Version should be %s', json.status, json.package.agent_version, upgradeParams.to_version ); return; } } else { logger.warn('waitUntilAgentIsUp - Missing fields in JSON response'); } } if (retries < MAX_AGENT_STATUS_RETRY) { logger.debug('waitUntilAgentIsUp - Will recall /status in %s', AGENT_STATUS_DELAY); setTimeout(checkAgentStartup, AGENT_STATUS_DELAY); } else { logger.error('waitUntilAgentIsUp - Exceeded maximus retries on /status'); } }); }; checkAgentStartup(); }, /** * @param {string} tmpInstallationFolder * @param {string} oldPawnFolder */ prepareFileSystemForInstallation: function(tmpInstallationFolder, oldPawnFolder) { logger.info('Cleaning up old temporary folders...'); utils.recursiveRemoveFolder(tmpInstallationFolder); utils.recursiveRemoveFolder(oldPawnFolder); logger.info('Creating new installation folders (%s)', tmpInstallationFolder); modFs.mkdirSync(tmpInstallationFolder); }, /** * @param {string} stablePawnFolder * @param {string} oldPawnFolder * @param {string} tmpPawnFolder * @param {string} tmpInstallationFolder * @param {Function} [afterCleanUpCallback] Function to call after cleanup */ postAgentInstallProcedure: function( stablePawnFolder, oldPawnFolder, tmpPawnFolder, tmpInstallationFolder, afterCleanUpCallback ) { try { if (process.env.DPLATFORM === 'docker') { modChildProcess.spawnSync('cp', ['-R', stablePawnFolder, oldPawnFolder], spawnOptions); modChildProcess.spawnSync('rm', ['-rf', stablePawnFolder], spawnOptions); modChildProcess.spawnSync('cp', ['-R', tmpPawnFolder, stablePawnFolder], spawnOptions); modChildProcess.spawnSync('rm', ['-rf', tmpPawnFolder], spawnOptions); } else { modFs.renameSync(stablePawnFolder, oldPawnFolder); modFs.renameSync(tmpPawnFolder, stablePawnFolder); } logger.info('Remote pawn updated'); updaterStatus.modifyCurrentStatus(false, false); utils.recursiveRemoveFolder(tmpInstallationFolder); if (afterCleanUpCallback) { logger.info('postAgentInstallProcedure - In %s will start the polling', AGENT_RESTART_DELAY); setTimeout(afterCleanUpCallback, AGENT_RESTART_DELAY); } else { logger.info('postAgentInstallProcedure - Agent update ended successfully'); } } catch (e) { logger.error('postAgentInstallProcedure - Error on post-upgrade procedure (%s)', JSON.stringify(e)); updaterStatus.modifyCurrentStatus(false, true); } finally { // Clean the npm cache and Force the agent restart utils.recursiveRemoveFolder(modPath.join(NPM_CACHE_DIR, '.npm')); var agentPid = utils.getAgentPid(); if (agentPid !== null) { logger.info('killing listener (pid=%d)', agentPid); try { process.kill(agentPid, 'SIGTERM'); } catch (e) { logger.error('error killing listener (pid=%d)', agentPid); } } else { logger.warn('Skipping kill domotz_listener process, pid not found'); } } }, onNpmInstallExit: function(updateAgentPayload, stablePawnFolder, oldPawnFolder, tmpPawnFolder, tmpInstallationFolder, upgradeParams) { return function(code) { var resultOk = code === 0; logger.info('Post install procedure (result=%s)', resultOk); if (resultOk !== true) { logger.error('ERROR: remote pawn update failed'); updaterStatus.modifyCurrentStatus(false, true); process.exit(1); // If the updater process is simulated, we can only exit at this point } if (updateAgentPayload.skip_cloud_notification) { updater.postAgentInstallProcedure( stablePawnFolder, oldPawnFolder, tmpPawnFolder, tmpInstallationFolder ); } else { updater.postAgentInstallProcedure( stablePawnFolder, oldPawnFolder, tmpPawnFolder, tmpInstallationFolder, updater.waitUntilAgentIsUp.bind(this, upgradeParams, updateAgentPayload) ); } }; }, /** * The actual upgrade process for upgrading the agent. * * @function upgradePawn * @param {UpgradeParams} upgradeParams - Parameters related to the upgrade process. * @param {UpdateAgentPayload} updateAgentPayload - The payload to send to the end update to the cloud. * @returns {void} */ upgradePawn: function(upgradeParams, updateAgentPayload) { var name = upgradeParams.name; var version = upgradeParams.to_version; var tmpInstallationFolder = modPath.join(process.env.DOMOTZ_LIB_DIR, 'tmp_inst'); var tmpPawnFolder = modPath.join(tmpInstallationFolder, 'node_modules', 'domotz-remote-pawn'); var stablePawnFolder = modPath.join(process.env.DOMOTZ_LIB_DIR, 'node_modules', 'domotz-remote-pawn'); var oldPawnFolder = stablePawnFolder + '_old'; updater.prepareFileSystemForInstallation(tmpInstallationFolder, oldPawnFolder); var npmCommand = NPM_BASE_COMMAND.concat(['--legacy-bundle', 'install', name + '@' + version, '--prefix', tmpInstallationFolder]); logger.info('upgradePawn - Invoking npm installer: %s', npmCommand.join(' ')); var npmInstaller = modChildProcess.spawn(NODE_COMMAND, npmCommand, spawnOptions); npmInstaller.on('error', function(err) { logger.error('upgradePawn - Error on npm install (%s). Cleaning up the npm cache.', JSON.stringify(err)); utils.recursiveRemoveFolder(modPath.join(NPM_CACHE_DIR, '.npm')); updaterStatus.modifyCurrentStatus(false, true); }); npmInstaller.on('exit', this.onNpmInstallExit( updateAgentPayload, stablePawnFolder, oldPawnFolder, tmpPawnFolder, tmpInstallationFolder, upgradeParams ) ); }, /** * @param {Buffer} updateInfo should represent a JSON string with 'registry' 'name' and 'version' keys * @param {Boolean} forceVersion If skip any version control and do the update * (used when the agent wants to update) */ handleJsonUpdateInfo: function(updateInfo, forceVersion) { var parsedUpdateInfo = JSON.parse(updateInfo.toString('utf8')); logger.debug('handleJsonUpdateInfo - Input JSON: %s', JSON.stringify(parsedUpdateInfo)); if (Object.keys(parsedUpdateInfo).length < 3) { logger.error('handleJsonUpdateInfo - Not enough keys, should have "registry", "name" and "version"'); updaterStatus.modifyCurrentStatus(false, false); return; } var dPlatform = process.env.DPLATFORM; var minReqRegistry = utils.tryobj(parsedUpdateInfo, [dPlatform, 'registry']) || utils.tryobj(parsedUpdateInfo, ['registry']); var minReqName = utils.tryobj(parsedUpdateInfo, [dPlatform, 'name']) || utils.tryobj(parsedUpdateInfo, ['name']); var minReqVersion = utils.tryobj(parsedUpdateInfo, [dPlatform, 'version']) || utils.tryobj(parsedUpdateInfo, ['version']); logger.info('handleJsonUpdateInfo - Current version %s future version %s', pkgJson.version, minReqVersion); var upgradeParams = { registry: minReqRegistry, name: minReqName, from_version: pkgJson.version, to_version: minReqVersion, }; if (pkgJson.version !== minReqVersion || forceVersion === true) { logger.info('handleJsonUpdateInfo - Start Agent Update process. from %s. Name: %s, Version: %s -> %s', upgradeParams.registry, upgradeParams.name, upgradeParams.from_version, upgradeParams.to_version ); updater.callAgentStatus(updater.callbackAgentStatus.bind(this, upgradeParams)); return; } else { logger.info('handleJsonUpdateInfo - Skip update'); } updaterStatus.modifyCurrentStatus(false, false); }, getUpdateInfoViaHttps: function() { var requestOptions = { hostname: UPDATE_INFO_HOSTNAME, path: UPDATE_INFO_PATH + '?agent=' + encodeURIComponent(process.env.DPLATFORM + '.' + process.env.DARCHITECTURE + '.' + process.env.DVERSION + '.js.' + pkgJson.version), headers: { 'Content-Type': 'application/json' }, }; logger.info('getUpdateInfoViaHttps - hostname %s - path %s', requestOptions.hostname, requestOptions.path ); var handleRawHttpBody = function(res) { var body = new Buffer(0); res.on('data', function(chunk) { body = Buffer.concat([body, chunk]); }); res.on('end', function() { if (!this.complete || res.statusCode !== 200) { logger.error('handleRawHttpBody - HTTP Error (complete=%s, statusCode=%d)', this.complete, res.statusCode); updaterStatus.modifyCurrentStatus(false, true); return; } try { logger.info('handleRawHttpBody - Handling response from server (len=%d, timestamp=%s)', body.length, new Date().toISOString() ); updater.handleJsonUpdateInfo(body, false); } catch (e) { logger.error('handleRawHttpBody - Error on callback : %s', e.message); updaterStatus.modifyCurrentStatus(false, true); } }); }; modHttps.get(requestOptions, handleRawHttpBody).on('error', function(err) { logger.error('Error: ' + err.message); updaterStatus.modifyCurrentStatus(false, true); }); }, taskScheduler: function() { logger.debug('taskScheduler - Scheduler: checking for tasks to execute ...'); if (updaterStatus.startProgress() === true) { // If the updater is up and running for more than MAX_PROCESS_AGE (2 days) if (new Date().getTime() - this.updaterStartTimestamp.getTime() > MAX_PROCESS_AGE) { logger.info('Graceful shutdown'); process.exit(0); } logger.debug('taskScheduler - Scheduler: running getUpdateInfoViaHttps task'); setImmediate(updater.getUpdateInfoViaHttps); } var nextRun = UPDATE_CHECK_PERIOD + Math.round(ONE_HOUR_IN_MSEC * Math.random()); logger.info('taskScheduler - Scheduler: next run in %s seconds (%s)', nextRun / 1000, new Date(Date.now() + nextRun) ); setTimeout(updater.taskScheduler.bind(this), nextRun); }, createHttpService: function (unixSocket) { var requestHandler = function(httpRequest, response) { var body = new Buffer(0); response.on('error', function(err) { logger.info('Error sending response :%s', err); }); if (httpRequest.method.toUpperCase() !== 'POST') { response.statusCode = 405; // METHOD NOT ALLOWED response.end(); return; } httpRequest.on('error', function(err) { logger.info('Error receiving request :%s', err); }); httpRequest.on('data', function(chunk) { body = Buffer.concat([body, chunk]); }); httpRequest.on('end', function() { logger.info('Received request (len=%d, busy=%s)', body.length, updaterStatus.update_in_progress ); if (updaterStatus.startProgress() === true) { var helperFn = function() { try { updater.handleJsonUpdateInfo(body, true); } catch (e) { logger.info('Error serving request : %s', JSON.stringify(e)); updaterStatus.modifyCurrentStatus(false, true); } }; setTimeout(helperFn, 1000); } response.statusCode = 202; // ACCEPTED response.end(); }); }; try { modFs.unlinkSync(unixSocket); } catch (e) { logger.info('Old Unix socket not found %s', unixSocket); } modHttp .createServer(requestHandler) .listen(unixSocket, function() { logger.info('Listening on unix socket %s', unixSocket); }) .on('error', function(err) { logger.error('Unable to listen on unix socket %s', err); }); }, start: function () { logger.info('Initializing the updater process ...'); logger.debug('Current Node.js version: %s', process.version); logger.debug('Agent pid %s', utils.getAgentPid()); this.updaterStartTimestamp = new Date(); if (!process.env.DOMOTZ_UPDATER_UNIX_SOCKET) { setTimeout(function() { logger.error('Exit: wrong environment variables'); }, 30000); return; } var warmup = BASE_WARMUP_PERIOD + Math.round(4 * BASE_WARMUP_PERIOD * Math.random()); logger.info('Starting the scheduler in %d seconds', warmup / 1000); // this bind is required to use updaterStartTimestamp setTimeout(updater.taskScheduler.bind(this), warmup); logger.info('Starting the http service'); updater.createHttpService(process.env.DOMOTZ_UPDATER_UNIX_SOCKET); }, }; if (require.main === module) { updater.start(); } else { if (global.jasmine) { module.exports.mock_mod_fs = modFs; module.exports.mock_mod_http = modHttp; module.exports.mock_mod_https = modHttps; module.exports.mock_mod_child_process = modChildProcess; module.exports.mock_mod_request = modRequest; module.exports.utils = utils; module.exports.updater = updater; module.exports.updaterStatus = updaterStatus; } module.exports.start = updater.start; module.exports.createHttpService = updater.createHttpService; }