iobroker.js-controller
Version:
Updated by reinstall.js on 2018-06-11T15:19:56.688Z
373 lines • 12.4 kB
JavaScript
import { exec as execAsync } from 'promisify-child-process';
import { tools, logger } from '@iobroker/js-controller-common';
import { valid } from 'semver';
import { dbConnectAsync } from '@iobroker/js-controller-cli';
import http from 'node:http';
import https from 'node:https';
import { setTimeout as wait } from 'node:timers/promises';
import fs from 'fs-extra';
import url from 'node:url';
import process from 'node:process';
class UpgradeManager {
/** Wait ms until controller is stopped */
STOP_TIMEOUT_MS = 5_000;
/** Wait ms for delivery of final response */
SHUTDOWN_TIMEOUT = 10_000;
/** Instance of admin to get information from */
adminInstance;
/** Desired controller version */
version;
/** Group id the process should run as */
gid;
/** User id the process should run as */
uid;
/** Response send by webserver */
response = {
running: true,
stderr: [],
stdout: [],
};
/** Used to stop the stop shutdown timeout */
shutdownAbortController;
/** Logger to log to file and other transports */
logger;
/** The server used for communicating upgrade status */
server;
/** All socket connections of the webserver */
sockets = new Set();
/** Name of the host for logging purposes */
hostname = tools.getHostName();
constructor(args) {
this.adminInstance = args.adminInstance;
this.version = args.version;
this.logger = this.setupLogger();
this.gid = args.gid;
this.uid = args.uid;
}
/**
* To prevent commands (including npm) running as root, we apply the passed in gid and uid
*/
applyUser() {
if (!process.setuid || !process.setgid) {
const errMessage = 'Cannot ensure user and group ids on this system, because no POSIX platform';
this.log(errMessage, true);
throw new Error(errMessage);
}
try {
process.setgid(this.gid);
process.setuid(this.uid);
}
catch (e) {
const errMessage = `Could not ensure user and group ids on this system: ${e.message}`;
this.log(errMessage, true);
throw new Error(errMessage);
}
}
/**
* Set up the logger, to stream to file and other configured transports
*/
setupLogger() {
const config = fs.readJSONSync(tools.getConfigFileName());
return logger({ ...config.log, noStdout: false });
}
/**
* Parse the commands from the cli
*/
static parseCliCommands() {
const additionalArgs = process.argv.slice(2);
const version = additionalArgs[0];
const adminInstance = parseInt(additionalArgs[1]);
const uid = parseInt(additionalArgs[2]);
const gid = parseInt(additionalArgs[3]);
const isValid = !!valid(version);
if (!isValid) {
UpgradeManager.printUsage();
throw new Error('The provided version is not valid');
}
if (isNaN(adminInstance)) {
UpgradeManager.printUsage();
throw new Error('Please provide a valid admin instance');
}
if (isNaN(uid)) {
UpgradeManager.printUsage();
throw new Error('Please provide a valid uid');
}
if (isNaN(gid)) {
UpgradeManager.printUsage();
throw new Error('Please provide a valid gid');
}
return { version, adminInstance, uid, gid };
}
/**
* Log via console and provide the logs for the server too
*
* @param message the message which will be logged
* @param error if it is an error
*/
log(message, error = false) {
if (error) {
this.logger.error(`host.${this.hostname} [CONTROLLER_AUTO_UPGRADE] ${message}`);
this.response.stderr.push(message);
return;
}
this.logger.info(`host.${this.hostname} [CONTROLLER_AUTO_UPGRADE] ${message}`);
this.response.stdout.push(message);
}
/**
* Stops the js-controller via cli call
*/
async stopController() {
if (tools.isDocker()) {
await execAsync('/opt/scripts/maintenance.sh on -kbn');
}
else {
await execAsync(`${tools.appNameLowerCase} stop`);
}
await wait(this.STOP_TIMEOUT_MS);
}
/**
* Starts the js-controller via cli
*/
startController() {
if (tools.isDocker()) {
return execAsync('/opt/scripts/maintenance.sh off -y');
}
return execAsync(`${tools.appNameLowerCase} start`);
}
/**
* Print how the module should be used
*/
static printUsage() {
console.info('Example usage: "node upgradeManager.js <version> <adminInstance> <uid> <gid>"');
}
/**
* Install given version of js-controller
*/
async npmInstall() {
const res = await tools.installNodeModule(`iobroker.js-controller@${this.version}`, {
cwd: '/opt/iobroker',
debug: true,
});
this.response.stderr.push(...res.stderr.split('\n'));
this.response.stdout.push(...res.stdout.split('\n'));
this.response.success = res.success;
if (!res.success) {
throw new Error(`Could not install js-controller@${this.version}`);
}
}
/**
* Starts the web server for admin communication either secure or insecure
*
* @param params Web server configuration
*/
async startWebServer(params) {
const { useHttps } = params;
if (useHttps) {
await this.startSecureWebServer(params);
}
else {
await this.startInsecureWebServer(params);
}
}
/**
* Shuts down the server, restarts the controller and exits the program
*/
shutdownApp() {
if (this.shutdownAbortController) {
this.shutdownAbortController.abort();
}
if (!this.server) {
process.exit();
}
this.destroySockets();
this.server.close(async () => {
await this.startController();
this.log('Successfully started js-controller');
process.exit();
});
}
/**
* Destroy all sockets, to prevent requests from keeping server alive
*/
destroySockets() {
for (const socket of this.sockets) {
socket.destroy();
this.sockets.delete(socket);
}
}
/**
* This function is called when the webserver receives a message
*
* @param res server response
*/
webServerCallback(res) {
res.writeHead(200, {
'Access-Control-Allow-Origin': '*',
});
res.end(JSON.stringify(this.response));
if (!this.response.running) {
this.log('Final information delivered');
this.shutdownApp();
}
}
/**
* Start an insecure web server for admin communication
*
* @param params Web server configuration
*/
async startInsecureWebServer(params) {
const { port } = params;
this.server = http.createServer((_req, res) => {
this.webServerCallback(res);
});
this.monitorSockets(this.server);
await new Promise(resolve => {
this.server.listen(port, () => {
resolve();
});
});
this.log(`Server is running on http://localhost:${port}`);
}
/**
* Start a secure web server for admin communication
*
* @param params Web server configuration
*/
async startSecureWebServer(params) {
const { port, certPublic, certPrivate } = params;
this.server = https.createServer({ key: certPrivate, cert: certPublic }, (_req, res) => {
this.webServerCallback(res);
});
this.monitorSockets(this.server);
await new Promise(resolve => {
this.server.listen(port, () => {
resolve();
});
});
this.log(`Server is running on https://localhost:${port}`);
}
/**
* Keep track of all existing sockets
*
* @param server the webserver
*/
monitorSockets(server) {
server.on('connection', socket => {
this.sockets.add(socket);
server.once('close', () => {
this.sockets.delete(socket);
});
});
}
/**
* Get certificates from the DB
*
* @param params certificate information
*/
async getCertificates(params) {
const { objects, certPublicName, certPrivateName } = params;
const obj = await objects.getObjectAsync('system.certificates');
if (!obj) {
throw new Error('No certificates found');
}
const certs = obj.native.certificates;
return { certPrivate: certs[certPrivateName], certPublic: certs[certPublicName] };
}
/**
* Collect parameters for webserver from admin instance
*/
async collectWebServerParameters() {
const { objects } = await dbConnectAsync(false);
const obj = await objects.getObjectAsync(`system.adapter.admin.${this.adminInstance}`);
if (!obj) {
UpgradeManager.printUsage();
throw new Error('Please provide a valid admin instance');
}
if (obj.native.secure) {
const { certPublic: certPublicName, certPrivate: certPrivateName } = obj.native;
const { certPublic, certPrivate } = await this.getCertificates({
objects,
certPublicName,
certPrivateName,
});
return {
useHttps: obj.native.secure,
port: obj.native.port,
certPublic,
certPrivate,
};
}
return {
useHttps: false,
port: obj.native.port,
};
}
/**
* Tells the upgrade manager, that server can be shut down on next response or on timeout
*/
async setFinished() {
this.response.running = false;
await this.startShutdownTimeout();
}
/**
* Start a timeout which starts controller and shuts down the app if expired
*/
async startShutdownTimeout() {
this.shutdownAbortController = new AbortController();
try {
await wait(this.SHUTDOWN_TIMEOUT, null, { signal: this.shutdownAbortController.signal });
this.log('Timeout expired, initializing shutdown');
this.shutdownApp();
}
catch (e) {
if (e.code !== 'ABORT_ERR') {
this.log(e.message, true);
}
}
}
}
/**
* Main logic
*/
async function main() {
const upgradeArguments = UpgradeManager.parseCliCommands();
const upgradeManager = new UpgradeManager(upgradeArguments);
registerErrorHandlers(upgradeManager);
const webServerParameters = await upgradeManager.collectWebServerParameters();
upgradeManager.log('Stopping controller');
await upgradeManager.stopController();
upgradeManager.log('Successfully stopped js-controller');
await upgradeManager.startWebServer(webServerParameters);
// do this after web server is started, else we cannot bind on privileged ports after using setgid
upgradeManager.applyUser();
try {
await upgradeManager.npmInstall();
}
catch (e) {
upgradeManager.log(e.message, true);
}
await upgradeManager.setFinished();
}
/**
* Stream unhandled errors to the log files
*
* @param upgradeManager the instance of Upgrade Manager
*/
function registerErrorHandlers(upgradeManager) {
process.on('uncaughtException', e => {
upgradeManager.log(`Uncaught Exception: ${e.stack}`, true);
});
process.on('unhandledRejection', rej => {
upgradeManager.log(`Unhandled rejection: ${rej instanceof Error ? rej.stack : JSON.stringify(rej)}`, true);
});
}
/**
* This file always needs to be executed in a process different from js-controller
* else it will be canceled when the file itself stops the controller
*/
// eslint-disable-next-line unicorn/prefer-module
const modulePath = url.fileURLToPath(import.meta.url || `file://${__filename}`);
if (process.argv[1] === modulePath) {
main();
}
//# sourceMappingURL=upgradeManager.js.map