diff --git a/CHANGELOG.md b/CHANGELOG.md index f941408fce..b86151b1d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,9 @@ Placeholder for the next version (at the beginning of the line): ## __WORK IN PROGRESS__ --> -## 5.0.6 (2023-07-02) - Jana +## ## __WORK IN PROGRESS__ - Jana **BREAKING CHANGES** -* Support for Node.js 12 is dropped! Supported are Node.js 14.18.0+, 16.4.0+ and 18.x +* Support for Node.js 12 and 14 is dropped! Supported are Node.js 16.4.0+ and 18.x * Backups created with the new js-controller version cannot be restored on hosts with lower js-controller version! * Update recommended npm version to 8 * Deprecate binary states, Adapters will change to use Files instead! diff --git a/README.md b/README.md index c5073d7386..7ac44be972 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,49 @@ If port 8081 is occupied, you can install a second Admin UI on an alternate port The command line interface is described at https://www.iobroker.net/#de/documentation/config/cli.md +### Adapter Upgrade with Webserver +**Feature status:** New in 5.0.0 + +**Feature Flag for detection:** `ADAPTER_WEBSERVER_UPGRADE` + +An adapter can be upgraded via `sendToHost`. The adapter sends parameters to the `js-controller` which contain the +information of the adapter to upgrade as well as information to start a webserver. The webserver can be polled by a UI, +even if the adapter itself is stopped during upgrade or does not know of the upgrade. + +Example: + +```typescript +sendToHostAsync('system.host.test', 'upgradeAdapterWithWebserver', { + version: '1.0.5', + adapterName: 'hm-rpc', + useHttps: true, + port: 8081, + certPrivateName: 'defaultPrivate', + certPublicName: 'defaultPublic' +}); +``` + +In this example the controller will upgrade the adapter `hm-rpc` to version `1.0.5`. During the upgrade, +the log and status information is provided by a webserver running on port `8081`, and using `https` with +the given certificates. + +During the update, you can perform a `GET` request against the webserver to get the current status of the upgrade. + +The webserver response is defined as following: + +```typescript +interface ServerResponse { + /** If the update is still running */ + running: boolean; + /** Stderr log during the upgrade */ + stderr: string[]; + /** Stdout log during the upgrade */ + stdout: string[]; + /** If installation process succeeded */ + success?: boolean; +} +``` + ### Controller UI Upgrade **Feature status:** New in 5.0.0 @@ -97,6 +140,21 @@ In this example, the controller will be upgraded to version `5.0.5` and the web take the configuration (http/s, port, certificates) of `system.adapter.admin.0`. During the update, you can perform a `GET` request against the webserver to get the current status of the upgrade. +The webserver response is defined as following: + +```typescript +interface ServerResponse { + /** If the update is still running */ + running: boolean; + /** Stderr log during the upgrade */ + stderr: string[]; + /** Stdout log during the upgrade */ + stdout: string[]; + /** If installation process succeeded */ + success?: boolean; +} +``` + ### Hostname **Feature status:** stable diff --git a/packages/adapter/src/lib/adapter/adapter.ts b/packages/adapter/src/lib/adapter/adapter.ts index fdc7a45da8..3fd260e917 100644 --- a/packages/adapter/src/lib/adapter/adapter.ts +++ b/packages/adapter/src/lib/adapter/adapter.ts @@ -9417,13 +9417,6 @@ export class AdapterClass extends EventEmitter { } if (sourceObj?.common) { - // TODO: this is just a test, remove it after short test period (version 5.0) - if (sourceObj._id !== sourceId) { - this._logger.error( - `${this.namespaceLog} Alias ids do not match sourceId=${sourceId}, sourceObjId=${sourceObj._id}, report this at https://github.com/ioBroker/ioBroker.js-controller/issues` - ); - } - if (!this.aliases.has(sourceObj._id)) { // TODO what means this, we ensured alias existed, did some async stuff now it's gone -> alias has been deleted? this._logger.error( diff --git a/packages/adapter/src/lib/adapter/constants.ts b/packages/adapter/src/lib/adapter/constants.ts index c52d394a3f..c2132cd905 100644 --- a/packages/adapter/src/lib/adapter/constants.ts +++ b/packages/adapter/src/lib/adapter/constants.ts @@ -65,7 +65,8 @@ const SUPPORTED_FEATURES_INTERNAL = [ 'BINARY_STATE_EVENT', // stateChange event could be received for binary states too. Deprecated in js-controller 5.0 'CUSTOM_FULL_VIEW', // `getObjectView('system', 'custom-full', ...)` will return full objects and not only `common.custom` part. Since `js-controller` 5.0 'ADAPTER_GET_OBJECTS_BY_ARRAY', // getForeignObjects supports array of ids too. Since js-controller 5.0 - 'CONTROLLER_UI_UPGRADE' // Controller can be updated via sendToHost('upgradeController', ...) + 'CONTROLLER_UI_UPGRADE', // Controller can be updated via sendToHost('upgradeController', ...) + 'ADAPTER_WEBSERVER_UPGRADE' // Controller supports upgrading adapter and providing a webserver (triggered via sendToHost). Since `js-controller` 5.0 ] as const; export const SUPPORTED_FEATURES = [...SUPPORTED_FEATURES_INTERNAL]; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b55473262b..fe38e73ea4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,3 +5,5 @@ export { Vendor } from './lib/setup/setupVendor'; export { Upload } from './lib/setup/setupUpload'; // required by testConsole export { BackupRestore } from './lib/setup/setupBackup'; +// used by adapter upgrade manager +export { Upgrade } from './lib/setup/setupUpgrade'; diff --git a/packages/cli/src/lib/_Types.ts b/packages/cli/src/lib/_Types.ts index fa0e5da4f1..4157a87744 100644 --- a/packages/cli/src/lib/_Types.ts +++ b/packages/cli/src/lib/_Types.ts @@ -2,10 +2,6 @@ import type { Client as StatesRedisClient } from '@iobroker/db-states-redis'; import type { Client as ObjectsRedisClient } from '@iobroker/db-objects-redis'; export type ProcessExitCallback = (exitCode: number) => void; -export type GetRepositoryHandler = ( - repoName: string | undefined, - params: Record -) => Promise>; export type CleanDatabaseHandler = (isDeleteDb: boolean) => any; export type DbConnectCallback = (params: DbConnectAsyncReturn) => void; diff --git a/packages/cli/src/lib/setup.ts b/packages/cli/src/lib/setup.ts index 8ed995a87b..ad24b11790 100644 --- a/packages/cli/src/lib/setup.ts +++ b/packages/cli/src/lib/setup.ts @@ -22,6 +22,7 @@ import type { DbConnectCallback, DbConnectAsyncReturn } from './_Types'; import type { Client as ObjectsInRedisClient } from '@iobroker/db-objects-redis'; import type { Client as StateRedisClient } from '@iobroker/db-states-redis'; import type { PluginHandlerSettings } from '@iobroker/plugin-base/types'; +import { getRepository } from './setup/utils'; tools.ensureDNSOrder(); @@ -623,7 +624,6 @@ async function processCommand( const install = new Install({ objects: objects!, states: states!, - getRepository, processExit: callback, params }); @@ -817,7 +817,6 @@ async function processCommand( const install = new Install({ objects, states, - getRepository, processExit: callback, params }); @@ -921,7 +920,6 @@ async function processCommand( const install = new Install({ objects, states, - getRepository, processExit: callback, params }); @@ -1126,7 +1124,6 @@ async function processCommand( const install = new Install({ objects, states, - getRepository, processExit: callback, params }); @@ -1141,7 +1138,6 @@ async function processCommand( const install = new Install({ objects, states, - getRepository, processExit: callback, params }); @@ -1216,7 +1212,6 @@ async function processCommand( const upgrade = new Upgrade({ objects, states, - getRepository, params, processExit: callback, restartController @@ -1244,7 +1239,7 @@ async function processCommand( } else { // upgrade all try { - const links = await getRepository(); + const links = await getRepository(objects); if (!links) { return void callback(EXIT_CODES.INVALID_REPO); } @@ -2128,11 +2123,12 @@ async function processCommand( node: '>=12' }, optionalDependencies: {} as Record, - dependencies: {} as Record, + dependencies: { + [`${tools.appName.toLowerCase()}.js-controller`]: '*', + [`${tools.appName.toLowerCase()}.admin`]: '*' + }, author: 'bluefox ' }; - json.dependencies[`${tools.appName.toLowerCase()}.js-controller`] = '*'; - json.dependencies[`${tools.appName.toLowerCase()}.admin`] = '*'; // @ts-expect-error todo fix it tools.getRepositoryFile(null, null, (_err, sources, _sourcesHash) => { @@ -2159,7 +2155,7 @@ async function processCommand( } case 'set': { - const instance = args[0]; + const instance = args[0] as `${string}.${number}`; if (!instance) { console.warn('please specify instance.'); return void callback(EXIT_CODES.INVALID_ADAPTER_ID); @@ -2168,65 +2164,71 @@ async function processCommand( console.warn(`please specify instance, like "${instance}.0"`); return void callback(EXIT_CODES.INVALID_ADAPTER_ID); } - dbConnect(params, () => { - objects!.getObject('system.adapter.' + instance, (err, obj) => { - if (!err && obj) { - let changed = false; - for (let a = 0; a < process.argv.length; a++) { - if ( - process.argv[a].startsWith('--') && - process.argv[a + 1] && - !process.argv[a + 1].startsWith('--') - ) { - const attr = process.argv[a].substring(2); - let val: number | string | boolean = process.argv[a + 1]; - if (val === '__EMPTY__') { - val = ''; - } else if (val === 'true') { - val = true; - } else if (val === 'false') { - val = false; - } else if (parseFloat(val).toString() === val) { - val = parseFloat(val); - } - if (attr.indexOf('.') !== -1) { - const parts = attr.split('.'); - if (!obj.native[parts[0]] || obj.native[parts[0]][parts[1]] === undefined) { - console.warn(`Adapter "${instance}" has no setting "${attr}".`); - } else { - changed = true; - obj.native[parts[0]][parts[1]] = val; - console.log(`New ${attr} for "${instance}" is: ${val}`); - } - } else { - if (obj.native[attr] === undefined) { - console.warn(`Adapter "${instance}" has no setting "${attr}".`); - } else { - changed = true; - obj.native[attr] = val; - console.log(`New ${attr} for "${instance}" is: ${val}`); - } - } - a++; - } + const { objects } = await dbConnectAsync(false, params); + + let obj: ioBroker.InstanceObject | undefined | null; + + try { + obj = await objects.getObjectAsync(`system.adapter.${instance}`); + } catch { + // ignore + } + + if (obj) { + let changed = false; + for (let a = 0; a < process.argv.length; a++) { + if ( + process.argv[a].startsWith('--') && + process.argv[a + 1] && + !process.argv[a + 1].startsWith('--') + ) { + const attr = process.argv[a].substring(2); + let val: number | string | boolean = process.argv[a + 1]; + if (val === '__EMPTY__') { + val = ''; + } else if (val === 'true') { + val = true; + } else if (val === 'false') { + val = false; + } else if (parseFloat(val).toString() === val) { + val = parseFloat(val); } - if (changed) { - obj.from = 'system.host.' + tools.getHostName() + '.cli'; - obj.ts = new Date().getTime(); - objects!.setObject('system.adapter.' + instance, obj, () => { - console.log(`Instance settings for "${instance}" are changed.`); - return void callback(); - }); + if (attr.indexOf('.') !== -1) { + const parts = attr.split('.'); + if (!obj.native[parts[0]] || obj.native[parts[0]][parts[1]] === undefined) { + console.warn(`Adapter "${instance}" has no setting "${attr}".`); + } else { + changed = true; + obj.native[parts[0]][parts[1]] = val; + console.log(`New ${attr} for "${instance}" is: ${val}`); + } } else { - console.log('No parameters set.'); - return void callback(); + if (obj.native[attr] === undefined) { + console.warn(`Adapter "${instance}" has no setting "${attr}".`); + } else { + changed = true; + obj.native[attr] = val; + console.log(`New ${attr} for "${instance}" is: ${val}`); + } } - } else { - CLIError.invalidInstance(instance); - return void callback(EXIT_CODES.INVALID_ADAPTER_ID); + a++; } - }); - }); + } + if (changed) { + obj.from = `system.host.${tools.getHostName()}.cli`; + obj.ts = new Date().getTime(); + objects.setObject(`system.adapter.${instance}`, obj, () => { + console.log(`Instance settings for "${instance}" are changed.`); + return void callback(); + }); + } else { + console.log('No parameters set.'); + return void callback(); + } + } else { + CLIError.invalidInstance(instance); + return void callback(EXIT_CODES.INVALID_ADAPTER_ID); + } break; } @@ -2971,70 +2973,6 @@ async function restartController(): Promise { child.unref(); } -async function getRepository(repoName?: string, params?: Record): Promise> { - params = params || {}; - - if (!objects) { - await dbConnectAsync(params as any); - } - - if (!repoName || repoName === 'auto') { - const systemConfig = await objects!.getObjectAsync('system.config'); - repoName = systemConfig!.common.activeRepo; - } - - const repoArr = !Array.isArray(repoName) ? [repoName!] : repoName!; - - const systemRepos = (await objects!.getObjectAsync('system.repositories'))!; - const allSources = {}; - let changed = false; - let anyFound = false; - for (let r = 0; r < repoArr.length; r++) { - const repo = repoArr[r]; - if (systemRepos.native.repositories[repo]) { - if (typeof systemRepos.native.repositories[repo] === 'string') { - systemRepos.native.repositories[repo] = { - link: systemRepos.native.repositories[repo], - json: null - }; - changed = true; - } - - // If repo is not yet loaded - if (!systemRepos.native.repositories[repo].json) { - console.log(`Update repository "${repo}" under "${systemRepos.native.repositories[repo].link}"`); - const data = await tools.getRepositoryFileAsync(systemRepos.native.repositories[repo].link); - systemRepos.native.repositories[repo].json = data.json; - systemRepos.native.repositories[repo].hash = data.hash; - systemRepos.from = `system.host.${tools.getHostName()}.cli`; - systemRepos.ts = new Date().getTime(); - changed = true; - } - - if (systemRepos.native.repositories[repo].json) { - Object.assign(allSources, systemRepos.native.repositories[repo].json); - anyFound = true; - } - } - - if (changed) { - await objects!.setObjectAsync('system.repositories', systemRepos); - } - } - - if (!anyFound) { - console.error( - `ERROR: No repositories defined. Please define one repository as active: "iob repo set <${Object.keys( - systemRepos.native.repositories - ).join(' | ')}>` - ); - // @ts-expect-error todo throw code or description? - throw new Error(EXIT_CODES.INVALID_REPO); - } else { - return allSources; - } -} - async function resetDbConnect(): Promise { if (objects) { await objects.destroy(); diff --git a/packages/cli/src/lib/setup/setupInstall.ts b/packages/cli/src/lib/setup/setupInstall.ts index 00775d9ee7..bfab4fc0b8 100644 --- a/packages/cli/src/lib/setup/setupInstall.ts +++ b/packages/cli/src/lib/setup/setupInstall.ts @@ -19,7 +19,8 @@ import { Upload } from './setupUpload'; import { PacketManager } from './setupPacketManager'; import type { Client as StatesRedisClient } from '@iobroker/db-states-redis'; import type { Client as ObjectsRedisClient } from '@iobroker/db-objects-redis'; -import type { GetRepositoryHandler, ProcessExitCallback } from '../_Types'; +import type { ProcessExitCallback } from '../_Types'; +import { getRepository } from './utils'; const hostname = tools.getHostName(); const osPlatform = process.platform; @@ -34,7 +35,6 @@ interface NpmInstallResult { export interface CLIInstallOptions { params: Record; - getRepository: GetRepositoryHandler; states: StatesRedisClient; objects: ObjectsRedisClient; processExit: ProcessExitCallback; @@ -70,7 +70,6 @@ export class Install { private readonly objects: ObjectsRedisClient; private readonly states: StatesRedisClient; private readonly processExit: ProcessExitCallback; - private readonly getRepository: GetRepositoryHandler; private readonly params: Record; private readonly tarballRegex: RegExp; private upload: Upload; @@ -90,14 +89,10 @@ export class Install { if (!options.processExit) { throw new Error('Invalid arguments: processExit is missing'); } - if (!options.getRepository) { - throw new Error('Invalid arguments: getRepository is missing'); - } this.objects = options.objects; this.states = options.states; this.processExit = options.processExit; - this.getRepository = options.getRepository; this.params = options.params || {}; this.tarballRegex = /\/tarball\/[^/]+$/; @@ -151,7 +146,7 @@ export class Install { if (!repoUrl || !tools.isObject(repoUrl)) { try { - sources = await this.getRepository(repoUrl, this.params); + sources = await getRepository(this.objects, repoUrl); } catch (err) { return this.processExit(err); } diff --git a/packages/cli/src/lib/setup/setupSetup.ts b/packages/cli/src/lib/setup/setupSetup.ts index dc5da2e3d1..f34c2502d2 100644 --- a/packages/cli/src/lib/setup/setupSetup.ts +++ b/packages/cli/src/lib/setup/setupSetup.ts @@ -556,7 +556,7 @@ Please DO NOT copy files manually into ioBroker storage directories!` const { objects: objectsNew, states: statesNew } = await this.dbConnectAsync(true, { ...this.params, - timeout: 20000 + timeout: 20_000 }); this.objects = objectsNew; diff --git a/packages/cli/src/lib/setup/setupUpgrade.ts b/packages/cli/src/lib/setup/setupUpgrade.ts index 9e4063d74a..889d3bb39d 100644 --- a/packages/cli/src/lib/setup/setupUpgrade.ts +++ b/packages/cli/src/lib/setup/setupUpgrade.ts @@ -17,7 +17,8 @@ import tty from 'tty'; import path from 'path'; import type { Client as ObjectsInRedisClient } from '@iobroker/db-objects-redis'; import type { Client as StatesInRedisClient } from '@iobroker/db-states-redis'; -import type { GetRepositoryHandler, ProcessExitCallback } from '../_Types'; +import type { ProcessExitCallback } from '../_Types'; +import { getRepository } from './utils'; const debug = Debug('iobroker:cli'); @@ -26,7 +27,6 @@ type IoPackDependencies = string[] | Record[] | Record interface CLIUpgradeOptions { processExit: ProcessExitCallback; restartController: () => void; - getRepository: GetRepositoryHandler; objects: ObjectsInRedisClient; states: StatesInRedisClient; params: Record; @@ -36,10 +36,9 @@ export class Upgrade { private readonly hostname = tools.getHostName(); private readonly upload: Upload; private readonly install: Install; - private objects: ObjectsInRedisClient; + private readonly objects: ObjectsInRedisClient; private readonly processExit: ProcessExitCallback; private readonly params: Record; - private readonly getRepository: GetRepositoryHandler; constructor(options: CLIUpgradeOptions) { options = options || {}; @@ -50,12 +49,8 @@ export class Upgrade { if (!options.restartController) { throw new Error('Invalid arguments: restartController is missing'); } - if (!options.getRepository) { - throw new Error('Invalid arguments: getRepository is missing'); - } this.processExit = options.processExit; - this.getRepository = options.getRepository; this.params = options.params; this.objects = options.objects; @@ -189,7 +184,7 @@ export class Upgrade { return Promise.reject(err); } - if (objs && objs.rows && objs.rows.length) { + if (objs?.rows?.length) { for (const dName in allDeps) { if (dName === 'js-controller') { const version = allDeps[dName]; @@ -226,9 +221,7 @@ export class Upgrade { // local dep get all instances on same host locInstances = objs.rows.filter( obj => - obj && - obj.value && - obj.value.common && + obj?.value?.common && obj.value.common.name === dName && obj.value.common.host === this.hostname ); @@ -300,14 +293,14 @@ export class Upgrade { /** * Try to async upgrade adapter from given source with some checks * - * @param repoUrlOrObject url of the selected repository or parsed repo + * @param repoUrlOrObject url of the selected repository or parsed repo, if undefined use current active repository * @param adapter name of the adapter (can also include version like web@3.0.0) * @param forceDowngrade flag to force downgrade * @param autoConfirm automatically confirm the tty questions (bypass) * @param upgradeAll if true, this is an upgrade all call, we don't do major upgrades if no tty */ async upgradeAdapter( - repoUrlOrObject: string | Record, + repoUrlOrObject: string | Record | undefined, adapter: string, forceDowngrade: boolean, autoConfirm: boolean, @@ -316,7 +309,7 @@ export class Upgrade { let sources: Record; if (!repoUrlOrObject || !tools.isObject(repoUrlOrObject)) { try { - sources = await this.getRepository(repoUrlOrObject, this.params); + sources = await getRepository(this.objects, repoUrlOrObject); } catch (e) { return this.processExit(e); } @@ -428,7 +421,7 @@ export class Upgrade { const isDowngrade = semver.lt(targetVersion, installedVersion); // if information in repo files -> show news - if (repoAdapter && repoAdapter.news) { + if (repoAdapter?.news) { const news = repoAdapter.news; let first = true; @@ -560,8 +553,8 @@ export class Upgrade { console.log(`Can not check version information to display upgrade infos: ${err.message}`); } console.log(`Update ${adapter} from @${installedVersion} to @${targetVersion}`); - // Get the adapter from web site - // @ts-expect-error it could also call processExit internally but we want change it in future anyway + // Get the adapter from website + // @ts-expect-error it could also call processExit internally, but we want change it in future anyway const { packetName, stoppedList } = await this.install.downloadPacket( sources, `${adapter}@${targetVersion}` @@ -606,7 +599,7 @@ export class Upgrade { console.log(`Can not check version information to display upgrade infos: ${err.message}`); } console.log(`Update ${adapter} from @${installedVersion} to @${targetVersion}`); - // @ts-expect-error it could also call processExit internally but we want change it in future anyway + // @ts-expect-error it could also call processExit internally, but we want change it in future anyway const { packetName, stoppedList } = await this.install.downloadPacket( sources, `${adapter}@${targetVersion}` @@ -625,7 +618,7 @@ export class Upgrade { } console.warn(`Unable to get version for "${adapter}". Update anyway.`); console.log(`Update ${adapter} from @${installedVersion} to @${version}`); - // Get the adapter from web site + // Get the adapter from website // @ts-expect-error it could also call processExit internally but we want change it in future anyway const { packetName, stoppedList } = await this.install.downloadPacket(sources, `${adapter}@${version}`); await finishUpgrade(packetName); @@ -651,7 +644,7 @@ export class Upgrade { let sources: Record; if (!repoUrlOrObject || !tools.isObject(repoUrlOrObject)) { try { - const result = await this.getRepository(repoUrlOrObject, this.params); + const result = await getRepository(this.objects, repoUrlOrObject); if (!result) { return console.warn(`Cannot get repository under "${repoUrlOrObject}"`); } @@ -696,7 +689,7 @@ export class Upgrade { console.warn(`Controller is running. Please stop ioBroker first.`); } else { console.log(`Update ${controllerName} from @${installed.common.version} to @${repoController.version}`); - // Get the controller from web site + // Get the controller from website await this.install.downloadPacket(sources, `${controllerName}@${repoController.version}`, { stopDb: true }); diff --git a/packages/cli/src/lib/setup/utils.ts b/packages/cli/src/lib/setup/utils.ts new file mode 100644 index 0000000000..c5a8dc0de0 --- /dev/null +++ b/packages/cli/src/lib/setup/utils.ts @@ -0,0 +1,65 @@ +import { EXIT_CODES, tools } from '@iobroker/js-controller-common'; +import type { Client as ObjectsClient } from '@iobroker/db-objects-redis'; + +/** + * Get content of the given repository + * + * @param objects the objects DB client + * @param repoName name of the repository, if not given uses current active repository + */ +export async function getRepository(objects: ObjectsClient, repoName?: string): Promise> { + if (!repoName || repoName === 'auto') { + const systemConfig = await objects.getObjectAsync('system.config'); + repoName = systemConfig!.common.activeRepo; + } + + const repoArr = !Array.isArray(repoName) ? [repoName] : repoName; + + const systemRepos = (await objects.getObjectAsync('system.repositories'))!; + const allSources = {}; + let changed = false; + let anyFound = false; + for (const repo of repoArr) { + if (systemRepos.native.repositories[repo]) { + if (typeof systemRepos.native.repositories[repo] === 'string') { + systemRepos.native.repositories[repo] = { + link: systemRepos.native.repositories[repo], + json: null + }; + changed = true; + } + + // If repo is not yet loaded + if (!systemRepos.native.repositories[repo].json) { + console.log(`Update repository "${repo}" under "${systemRepos.native.repositories[repo].link}"`); + const data = await tools.getRepositoryFileAsync(systemRepos.native.repositories[repo].link); + systemRepos.native.repositories[repo].json = data.json; + systemRepos.native.repositories[repo].hash = data.hash; + systemRepos.from = `system.host.${tools.getHostName()}.cli`; + systemRepos.ts = new Date().getTime(); + changed = true; + } + + if (systemRepos.native.repositories[repo].json) { + Object.assign(allSources, systemRepos.native.repositories[repo].json); + anyFound = true; + } + } + + if (changed) { + await objects.setObjectAsync('system.repositories', systemRepos); + } + } + + if (!anyFound) { + console.error( + `ERROR: No repositories defined. Please define one repository as active: "iob repo set <${Object.keys( + systemRepos.native.repositories + ).join(' | ')}>` + ); + // @ts-expect-error todo throw code or description? + throw new Error(EXIT_CODES.INVALID_REPO); + } else { + return allSources; + } +} diff --git a/packages/controller/src/lib/adapterUpgradeManager.ts b/packages/controller/src/lib/adapterUpgradeManager.ts new file mode 100644 index 0000000000..acf4e241d1 --- /dev/null +++ b/packages/controller/src/lib/adapterUpgradeManager.ts @@ -0,0 +1,347 @@ +import { tools } from '@iobroker/js-controller-common'; +import http from 'http'; +import https from 'https'; +import type { Client as ObjectsClient } from '@iobroker/db-objects-redis'; +import type { Client as StatesClient } from '@iobroker/db-states-redis'; +import { setTimeout as wait } from 'timers/promises'; +import type { Logger } from 'winston'; +import { Upgrade } from '@iobroker/js-controller-cli'; +import type { ProcessExitCallback } from '@iobroker/js-controller-cli/build/lib/_Types'; + +interface Certificates { + /** Public certificate */ + certPublic: string; + /** Private certificate */ + certPrivate: string; +} + +interface InsecureWebServerParameters { + /** if https should be used for the webserver */ + useHttps: false; + /** port of the web server */ + port: number; +} + +type SecureWebServerParameters = Omit & { + useHttps: true; + certPrivateName: string; + certPublicName: string; +}; +type WebServerParameters = InsecureWebServerParameters | SecureWebServerParameters; + +export type AdapterUpgradeManagerOptions = { + /** Version of adapter to upgrade too */ + version: string; + /** Name of the adapter to upgrade */ + adapterName: string; + /** The objects DB client */ + objects: ObjectsClient; + /** The states DB client */ + states: StatesClient; + /** A logger instance */ + logger: Logger; +} & WebServerParameters; + +interface GetCertificatesParams { + /** Name of the public certificate */ + certPublicName: string; + /** Name of the private certificate */ + certPrivateName: string; +} + +interface ServerResponse { + /** If the update is still running */ + running: boolean; + stderr: string[]; + stdout: string[]; + /** if installation process succeeded */ + success?: boolean; +} + +export class AdapterUpgradeManager { + /** Wait ms until controller is stopped */ + private readonly STOP_TIMEOUT_MS = 3_000; + /** Wait ms for delivery of final response */ + private readonly SHUTDOWN_TIMEOUT = 10_000; + /** Name of the adapter to upgrade */ + private readonly adapterName: string; + /** Desired controller version */ + private readonly version: string; + /** Response send by webserver */ + private readonly response: ServerResponse = { + running: true, + stderr: [], + stdout: [] + }; + /** Used to stop the stop shutdown timeout */ + private shutdownAbortController?: AbortController; + /** Logger to log to file and other transports */ + private readonly logger: Logger; + + /** The server used for communicating upgrade status */ + private server?: https.Server | http.Server; + /** Name of the host for logging purposes */ + private readonly hostname = tools.getHostName(); + /** The objects DB client */ + private readonly objects: ObjectsClient; + /** The states DB client */ + private readonly states: StatesClient; + /** List of instances which have been stopped */ + private stoppedInstances: string[] = []; + /** If webserver should be started with https */ + private readonly useHttps: boolean; + /** Public certificate name if https is desired */ + private readonly certPublicName?: string; + /** Private certificate name if https is desired */ + private readonly certPrivateName?: string; + /** Port where the webserver should be running */ + private readonly port: number; + + constructor(options: AdapterUpgradeManagerOptions) { + this.adapterName = options.adapterName; + this.version = options.version; + this.logger = options.logger; + this.objects = options.objects; + this.states = options.states; + this.useHttps = options.useHttps; + this.port = options.port; + + if (options.useHttps) { + this.certPublicName = options.certPublicName; + this.certPrivateName = options.certPrivateName; + } + } + + /** + * Stops the adapter and returns ids of stopped instances + */ + async stopAdapter(): Promise { + this.stoppedInstances = await this.getAllEnabledInstances(); + await this.enableInstances(this.stoppedInstances, false); + await wait(this.STOP_TIMEOUT_MS); + } + + /** + * Start all instances which were enabled before the upgrade + */ + async startAdapter(): Promise { + await this.enableInstances(this.stoppedInstances, true); + } + + /** + * Start or stop given instances + * + * @param instances id of instances which will be stopped + * @param enabled if enable or disable instances + */ + async enableInstances(instances: string[], enabled: boolean): Promise { + const ts = Date.now(); + for (const instance of instances) { + const updatedObj = { + common: { + enabled + }, + from: `system.host.${this.hostname}`, + ts + } as Partial; + + await this.objects.extendObjectAsync(instance, updatedObj); + } + } + + /** + * Install given version of adapter + */ + async performUpgrade(): Promise { + const processExitHandler: ProcessExitCallback = exitCode => { + this.log(`Upgrade process exited with code: ${exitCode}`, true); + }; + + const upgrade = new Upgrade({ + objects: this.objects, + processExit: processExitHandler, + states: this.states, + restartController: () => undefined, + params: {} + }); + + try { + await upgrade.upgradeAdapter(undefined, `${this.adapterName}@${this.version}`, true, true, false); + this.response.success = true; + this.log(`Successfully upgraded ${this.adapterName} to version ${this.version}`); + } catch (e) { + this.log(e.message, true); + this.response.success = false; + } + + await this.setFinished(); + } + + /** + * Starts the web server for admin communication either secure or insecure + */ + async startWebServer(): Promise { + if (this.useHttps && this.certPublicName && this.certPrivateName) { + await this.startSecureWebServer({ + certPublicName: this.certPublicName, + certPrivateName: this.certPrivateName, + port: this.port, + useHttps: true + }); + } else { + this.startInsecureWebServer({ port: this.port, useHttps: false }); + } + } + + /** + * Shuts down the server, restarts the adapter + */ + shutdownServer(): void { + if (this.shutdownAbortController) { + this.shutdownAbortController.abort(); + } + + if (!this.server) { + process.exit(); + } + + this.server.close(async () => { + await this.startAdapter(); + this.log('Successfully started adapter'); + }); + } + + /** + * This function is called when the webserver receives a message + * + * @param req received message + * @param res server response + */ + webServerCallback(req: http.IncomingMessage, res: http.ServerResponse): void { + res.writeHead(200); + res.end(JSON.stringify(this.response)); + + if (!this.response.running) { + this.log('Final information delivered'); + this.shutdownServer(); + } + } + + /** + * Get all instances of the adapter + */ + async getAllEnabledInstances(): Promise { + const res = await this.objects.getObjectListAsync({ + startkey: `system.adapter.${this.adapterName}.`, + endkey: `system.adapter.${this.adapterName}.\u9999` + }); + + let enabledInstances: string[] = []; + + if (res) { + enabledInstances = res.rows + .filter(row => row.value.common.enabled && this.hostname === row.value.common.host) + .map(row => row.value._id); + } + + return enabledInstances; + } + + /** + * Log via logger 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: string, error = false): void { + if (error) { + this.logger.error(`host.${this.hostname} ${message}`); + this.response.stderr.push(message); + return; + } + + this.logger.info(`host.${this.hostname} [CONTROLLER_AUTO_UPGRADE] ${message}`); + this.response.stdout.push(message); + } + + /** + * Start an insecure web server for admin communication + * + * @param params Web server configuration + */ + startInsecureWebServer(params: InsecureWebServerParameters): void { + const { port } = params; + + this.server = http.createServer((req, res) => { + this.webServerCallback(req, res); + }); + + this.server.listen(port, () => { + 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: SecureWebServerParameters): Promise { + const { port, certPublicName, certPrivateName } = params; + + const { certPublic, certPrivate } = await this.getCertificates({ certPublicName, certPrivateName }); + + this.server = https.createServer({ key: certPrivate, cert: certPublic }, (req, res) => { + this.webServerCallback(req, res); + }); + + this.server.listen(port, () => { + this.log(`Server is running on http://localhost:${port}`); + }); + } + + /** + * Get certificates from the DB + * + * @param params certificate information + */ + async getCertificates(params: GetCertificatesParams): Promise { + const { certPublicName, certPrivateName } = params; + + const obj = await this.objects.getObjectAsync('system.certificates'); + + if (!obj) { + throw new Error('No certificates found'); + } + + const certs = obj.native.certificates; + + return { certPrivate: certs[certPrivateName], certPublic: certs[certPublicName] }; + } + + /** + * Tells the upgrade manager, that server can be shut down on next response or on timeout + */ + private async setFinished(): Promise { + this.response.running = false; + + await this.startShutdownTimeout(); + } + + /** + * Start a timeout which starts adapter and shuts down the server if expired + */ + async startShutdownTimeout(): Promise { + this.shutdownAbortController = new AbortController(); + try { + await wait(this.SHUTDOWN_TIMEOUT, null, { signal: this.shutdownAbortController.signal }); + + this.log('Timeout expired, initializing shutdown'); + this.shutdownServer(); + } catch (e) { + if (e.code !== 'ABORT_ERR') { + this.log(e.message, true); + } + } + } +} diff --git a/packages/controller/src/lib/upgradeManager.ts b/packages/controller/src/lib/upgradeManager.ts index 270beb750c..1875823d18 100644 --- a/packages/controller/src/lib/upgradeManager.ts +++ b/packages/controller/src/lib/upgradeManager.ts @@ -201,7 +201,7 @@ class UpgradeManager { } /** - * Shuts down the server, restarts the controller and exists the program + * Shuts down the server, restarts the controller and exits the program */ shutdownApp(): void { if (this.shutdownAbortController) { diff --git a/packages/controller/src/main.ts b/packages/controller/src/main.ts index ac89b67833..d628be672d 100644 --- a/packages/controller/src/main.ts +++ b/packages/controller/src/main.ts @@ -31,6 +31,7 @@ import decache from 'decache'; import type { PluginHandlerSettings } from '@iobroker/plugin-base/types'; import { getDefaultNodeArgs } from './lib/tools'; import type { UpgradeArguments } from './lib/upgradeManager'; +import { AdapterUpgradeManager } from './lib/adapterUpgradeManager'; type TaskObject = ioBroker.SettableObject & { state?: ioBroker.SettableState }; type DiagInfoType = 'extended' | 'normal' | 'no-city' | 'none'; @@ -3173,6 +3174,28 @@ async function processMessage(msg: ioBroker.SendableMessage): Promise