From 0490d97f12e7b3791b08a30c78e81222e48eb3ac Mon Sep 17 00:00:00 2001 From: Max Hauser Date: Fri, 20 Sep 2024 15:56:16 +0200 Subject: [PATCH] [feat]: generate notification if new image is available on docker hub (#2882) * generate notification if new image is available on docker hub - this is only done for official docker systems - closes #2881 * jsdoc desc * as discussed with Andre, we need to check for the update timestamp instead of the version, as there might be several builds for the same version * unwrap the cb hell of generating uuid * fix merge problem --------- Co-authored-by: Bluefox --- CHANGELOG.md | 1 + packages/common-db/src/lib/common/tools.ts | 384 +++++++++++---------- packages/common-db/src/lib/types.d.ts | 12 + packages/common-db/tsconfig.build.json | 3 +- packages/common-db/tsconfig.json | 3 +- packages/controller/io-package.json | 32 ++ packages/controller/src/lib/objects.ts | 22 ++ packages/controller/src/main.ts | 38 ++ 8 files changed, 317 insertions(+), 178 deletions(-) create mode 100644 packages/common-db/src/lib/types.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2622b0193c..08fc86a67a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * (bluefox) Added support for dynamic notification layout (in Admin) * (@foxriver76) updated plugin base and sentry plugin to version 2 * (@foxriver76) enhanced translations for the `diskSpaceIssues` notification category +* (@foxriver76) added notification if new image is available on Docker Hub (for official docker systems) * (@foxriver76) extend the time to wait until controller is stopped on controller UI upgrade * (@foxriver76) improved backup/restore process to work for arbitrary large installations * (@GermanBluefox/@foxriver76) implemented automatic upload on adapter start if version mismatch is detected diff --git a/packages/common-db/src/lib/common/tools.ts b/packages/common-db/src/lib/common/tools.ts index 42d50aa3ed..ad2e96ece3 100644 --- a/packages/common-db/src/lib/common/tools.ts +++ b/packages/common-db/src/lib/common/tools.ts @@ -102,6 +102,32 @@ interface FormatAliasValueOptions { targetId?: string; } +/** + * Response from DOCKER_INFO_URL + */ +interface DockerHubResponse { + /** Results, filtered to one entry */ + results: [ + { + /** Contains the version like v1.5.3 */ + name: string; + /** Timestamp of last update of this image, like 2024-08-29T01:26:32.378554Z */ + last_updated: string; + [other: string]: unknown; + } + ]; + [other: string]: unknown; +} + +export interface DockerImageInformation { + /** The official version like v10.0.0 */ + version: string; + /** Time of last image update */ + lastUpdated: string; + /** If the version is newer than the one currently running */ + isNew: boolean; +} + export enum ERRORS { ERROR_NOT_FOUND = 'Not exists', ERROR_EMPTY_OBJECT = 'null object', @@ -117,6 +143,10 @@ const randomID = Math.round(Math.random() * 10_000_000_000_000); // Used for cre const VENDOR_FILE = '/etc/iob-vendor.json'; /** This file contains the version string in an official docker image */ const OFFICIAL_DOCKER_FILE = '/opt/scripts/.docker_config/.thisisdocker'; +/** URL to fetch information of the latest docker image */ +const DOCKER_INFO_URL = 'https://hub.docker.com/v2/namespaces/iobroker/repositories/iobroker/tags?page_size=1'; +/** Time the image approx. needs to be built and published to DockerHub */ +const DOCKER_HUB_BUILD_TIME_MS = 6 * 60 * 60 * 1_000; /** Version of official Docker image which started to support UI upgrade */ const DOCKER_VERSION_UI_UPGRADE = '8.1.0'; @@ -393,37 +423,58 @@ function findPath(path: string, url: string): string { } } -function getMac(callback: (e?: Error | null, mac?: string) => void): void { +/** + * Get MAC address of this host + */ +async function getMac(): Promise { const macRegex = /(?:[a-z0-9]{2}[:-]){5}[a-z0-9]{2}/gi; const zeroRegex = /(?:[0]{2}[:-]){5}[0]{2}/; const command = process.platform.indexOf('win') === 0 ? 'getmac' : 'ifconfig || ip link'; - exec(command, { windowsHide: true }, (err, stdout, _stderr) => { - if (err) { - callback(err); - } else { - let macAddress; - let match; - let result = null; - - while (true) { - match = macRegex.exec(stdout); - if (!match) { - break; - } - macAddress = match[0]; - if (!zeroRegex.test(macAddress) && !result) { - result = macAddress; - } - } + const { stdout, stderr } = await execAsync(command); - if (result === null) { - callback(new Error(`could not determine the mac address from:\n${stdout}`)); - } else { - callback(null, result.replace(/-/g, ':').toLowerCase()); - } + if (typeof stderr === 'string') { + throw new Error(stderr); + } + + if (typeof stdout !== 'string') { + throw new Error(`Unexpected stdout: ${stdout?.toString()}`); + } + + let macAddress; + let match; + let result = null; + + while (true) { + match = macRegex.exec(stdout); + if (!match) { + break; } - }); + macAddress = match[0]; + if (!zeroRegex.test(macAddress) && !result) { + result = macAddress; + } + } + + if (result === null) { + throw new Error(`Could not determine the mac address from:\n${stdout}`); + } + + return result.replace(/-/g, ':').toLowerCase(); +} + +/** + * Fetch the image information of the newest available (official) ioBroker Docker image from DockerHub + */ +export async function getNewestDockerImageVersion(): Promise { + const res = await axios.get(DOCKER_INFO_URL); + + const dockerResult = res.data.results[0]; + const isNew = + new Date(dockerResult.last_updated).getTime() > + new Date(process.env.BUILD).getTime() + DOCKER_HUB_BUILD_TIME_MS; + + return { version: dockerResult.name, lastUpdated: dockerResult.last_updated, isNew }; } /** @@ -492,19 +543,18 @@ export function isDocker(): boolean { } } -// Build unique uuid based on MAC address if possible -function uuid(givenMac: string | null, callback: (uuid: string) => void): void { - if (typeof givenMac === 'function') { - callback = givenMac; - givenMac = ''; - } - +/** + * Build unique uuid based on MAC address if possible + * + * @param givenMac the given MAC address + */ +async function uuid(givenMac?: string): Promise { + givenMac = givenMac ?? ''; const _isDocker = isDocker(); // return constant UUID for all CI environments to keep the statistics clean - if (require('ci-info').isCI) { - return callback('55travis-pipe-line-cior-githubaction'); + return '55travis-pipe-line-cior-githubaction'; } let mac = givenMac !== null ? givenMac || '' : null; @@ -531,23 +581,15 @@ function uuid(givenMac: string | null, callback: (uuid: string) => void): void { } if (!_isDocker && mac === '') { - return getMac((_err, mac) => uuid(mac || null, callback)); + const mac = await getMac(); + return uuid(mac); } if (!_isDocker && mac) { const md5sum = crypto.createHash('md5'); md5sum.update(mac); mac = md5sum.digest('hex'); - u = - mac.substring(0, 8) + - '-' + - mac.substring(8, 12) + - '-' + - mac.substring(12, 16) + - '-' + - mac.substring(16, 20) + - '-' + - mac.substring(20); + u = `${mac.substring(0, 8)}-${mac.substring(8, 12)}-${mac.substring(12, 16)}-${mac.substring(16, 20)}-${mac.substring(20)}`; } else { // Returns a RFC4122 compliant v4 UUID https://gist.github.com/LeverOne/1308368 (DO WTF YOU WANT TO PUBLIC LICENSE) let a: any; @@ -559,55 +601,56 @@ function uuid(givenMac: string | null, callback: (uuid: string) => void): void { u = b; } - callback(u); + return u; } -function updateUuid(newUuid: string, _objects: any, callback: (uuid?: string) => void): void { - uuid('', async _uuid => { - _uuid = newUuid || _uuid; - // Add vendor prefix to UUID - if (fs.existsSync(VENDOR_FILE)) { - try { - const vendor = await fs.readJSON(VENDOR_FILE); - if (vendor.vendor?.uuidPrefix?.length === 2 && !_uuid.startsWith(vendor.vendor.uuidPrefix)) { - _uuid = vendor.vendor.uuidPrefix + _uuid; - } - } catch { - console.error(`Cannot parse ${VENDOR_FILE}`); +/** + * Update the installation UUID + * + * @param newUuid the new UUID to set + * @param _objects the objects DB instance + */ +async function updateUuid(newUuid: string, _objects: any): Promise { + let _uuid = await uuid(''); + _uuid = newUuid || _uuid; + // Add vendor prefix to UUID + if (fs.existsSync(VENDOR_FILE)) { + try { + const vendor = await fs.readJSON(VENDOR_FILE); + if (vendor.vendor?.uuidPrefix?.length === 2 && !_uuid.startsWith(vendor.vendor.uuidPrefix)) { + _uuid = vendor.vendor.uuidPrefix + _uuid; } + } catch { + console.error(`Cannot parse ${VENDOR_FILE}`); } + } - _objects.setObject( - 'system.meta.uuid', - { - type: 'meta', - common: { - name: 'uuid', - type: 'uuid' - }, - ts: new Date().getTime(), - from: `system.host.${getHostName()}.tools`, - native: { - uuid: _uuid - } + try { + await _objects.setObject('system.meta.uuid', { + type: 'meta', + common: { + name: 'uuid', + type: 'uuid' }, - (err: Error | null) => { - if (err) { - console.error(`object system.meta.uuid cannot be updated: ${err.message}`); - callback(); - } else { - _objects.getObject('system.meta.uuid', (err: Error | null, obj: ioBroker.Object) => { - if (obj.native.uuid !== _uuid) { - console.error('object system.meta.uuid cannot be updated: write protected'); - } else { - console.log(`object system.meta.uuid created: ${_uuid}`); - } - callback(_uuid); - }); - } + ts: new Date().getTime(), + from: `system.host.${getHostName()}.tools`, + native: { + uuid: _uuid } - ); - }); + }); + } catch (e) { + throw new Error(`Object system.meta.uuid cannot be updated: ${e.message}`); + } + + const obj: ioBroker.Object = await _objects.getObject('system.meta.uuid'); + + if (obj.native.uuid !== _uuid) { + console.error('object system.meta.uuid cannot be updated: write protected'); + } else { + console.log(`object system.meta.uuid created: ${_uuid}`); + } + + return _uuid; } /** @@ -617,101 +660,90 @@ function updateUuid(newUuid: string, _objects: any, callback: (uuid?: string) => * @returns uuid if successfully created/updated */ export async function createUuid(objects: any): Promise { - const promiseCheckPassword = new Promise(resolve => - objects.getObject('system.user.admin', (err: Error | null, obj: ioBroker.UserObject) => { - if (err || !obj) { - // Default Password for user 'admin' is application name in lower case - password(appName).hash(null, null, (err, res) => { - err && console.error(err); - - // Create user here and not in io-package.js because of hash password - objects.setObject( - 'system.user.admin', - { - type: 'user', - common: { - name: 'admin', - password: res, - dontDelete: true, - enabled: true - }, - ts: new Date().getTime(), - from: `system.host.${getHostName()}.tools`, - native: {} - }, - () => { - console.log('object system.user.admin created'); - resolve(); - } - ); + const userObj: ioBroker.UserObject = await objects.getObject('system.user.admin'); + if (!userObj) { + await new Promise(resolve => { + // Default Password for user 'admin' is application name in lower case + password(appName).hash(null, null, async (err, res) => { + err && console.error(err); + + // Create user here and not in io-package.js because of hash password + await objects.setObject('system.user.admin', { + type: 'user', + common: { + name: 'admin', + password: res, + dontDelete: true, + enabled: true + }, + ts: new Date().getTime(), + from: `system.host.${getHostName()}.tools`, + native: {} }); - } else { + + console.log('object system.user.admin created'); resolve(); - } - }) - ); - const promiseCheckUuid = new Promise(resolve => - objects.getObject('system.meta.uuid', (err: Error | null, obj: ioBroker.Object) => { - if (!err && obj?.native?.uuid) { - const PROBLEM_UUIDS = [ - 'ab265f4a-67f9-a46a-c0b2-61e4b95cefe5', - '7abd3182-d399-f7bd-da19-9550d8babede', - 'deb6f2a8-fe69-5491-0a50-a9f9b8f3419c', - 'ec66c85e-fc36-f6f9-f1c9-f5a2882d23c7', - 'e6203b03-f5f4-253a-e4f6-b295fc543ab7', - 'd659fa3d-7ef9-202a-ea23-acd0aff67b24' - ]; - - // if COMMON invalid docker uuid - if (PROBLEM_UUIDS.includes(obj.native.uuid)) { - // Read vis license - objects.getObject('system.adapter.vis.0', (err: Error | null, licObj: ioBroker.Object) => { - if (!licObj || !licObj.native || !licObj.native.license) { - // generate new UUID - updateUuid('', objects, _uuid => resolve(_uuid)); - } else { - // decode obj.native.license - let data; - try { - data = jwt.decode(licObj.native.license); - } catch { - data = null; - } + }); + }); + } - if (!data || typeof data === 'string' || !data.uuid) { - // generate new UUID - updateUuid('', objects, __uuid => resolve(__uuid)); - } else { - if (data.uuid !== obj.native.uuid) { - updateUuid(data.correct ? data.uuid : '', objects, _uuid => resolve(_uuid)); - } else { - // Show error - console.warn( - `Your iobroker.vis license must be updated. Please contact info@iobroker.net to get a new license!` - ); - console.warn( - `Provide following information in email: ${data.email}, invoice: ${data.invoice}` - ); - resolve(); - } - } - } - }); - } else { - resolve(); - } + const obj: ioBroker.Object = await objects.getObject('system.meta.uuid'); + if (!obj?.native?.uuid) { + // generate new UUID + return updateUuid('', objects); + } + + const PROBLEM_UUIDS = [ + 'ab265f4a-67f9-a46a-c0b2-61e4b95cefe5', + '7abd3182-d399-f7bd-da19-9550d8babede', + 'deb6f2a8-fe69-5491-0a50-a9f9b8f3419c', + 'ec66c85e-fc36-f6f9-f1c9-f5a2882d23c7', + 'e6203b03-f5f4-253a-e4f6-b295fc543ab7', + 'd659fa3d-7ef9-202a-ea23-acd0aff67b24' + ]; + + // check if COMMON invalid docker uuid + if (!PROBLEM_UUIDS.includes(obj.native.uuid)) { + return; + } + + // Read vis license + const licObj: ioBroker.Object = objects.getObject('system.adapter.vis.0'); + if (!licObj || !licObj.native || !licObj.native.license) { + return updateUuid('', objects); + } else { + // decode obj.native.license + let data; + try { + data = jwt.decode(licObj.native.license); + } catch { + data = null; + } + + if (!data || typeof data === 'string' || !data.uuid) { + // generate new UUID + return updateUuid('', objects); + } else { + if (data.uuid !== obj.native.uuid) { + return updateUuid(data.correct ? data.uuid : '', objects); } else { - // generate new UUID - updateUuid('', objects, _uuid => resolve(_uuid)); + // Show error + console.warn( + `Your iobroker.vis license must be updated. Please contact info@iobroker.net to get a new license!` + ); + console.warn(`Provide following information in email: ${data.email}, invoice: ${data.invoice}`); } - }) - ); - - const result = await Promise.all([promiseCheckPassword, promiseCheckUuid]); - return result[1]; + } + } } -// Download file to tmp or return file name directly +/** + * Download file to tmp or return file name directly + * + * @param urlOrPath + * @param fileName + * @param callback + */ export async function getFile(urlOrPath: string, fileName: string, callback: (file?: string) => void): Promise { // If object was read if ( @@ -1584,10 +1616,10 @@ export function getAdapterDir(adapter: string): string | null { * Returns the hostname of this host */ export function getHostName(): string { - // for tests purposes if (process.env.IOB_HOSTNAME) { return process.env.IOB_HOSTNAME; } + try { const configName = getConfigFileName(); const config = fs.readJSONSync(configName); diff --git a/packages/common-db/src/lib/types.d.ts b/packages/common-db/src/lib/types.d.ts new file mode 100644 index 0000000000..69a03525e7 --- /dev/null +++ b/packages/common-db/src/lib/types.d.ts @@ -0,0 +1,12 @@ +export {}; + +declare global { + namespace NodeJS { + interface ProcessEnv { + /** The build time of the Docker image, only available in the official Docker image */ + BUILD: string; + /** Allows overriding the host name via env variable for test purposes */ + IOB_HOSTNAME?: string; + } + } +} diff --git a/packages/common-db/tsconfig.build.json b/packages/common-db/tsconfig.build.json index 60507397a2..1d4963192c 100644 --- a/packages/common-db/tsconfig.build.json +++ b/packages/common-db/tsconfig.build.json @@ -8,7 +8,8 @@ "outDir": "build/esm" }, "include": [ - "src/**/*.ts" + "src/**/*.ts", + "src/**/*.d.ts" ], "exclude": [ "src/**/*.test.ts", diff --git a/packages/common-db/tsconfig.json b/packages/common-db/tsconfig.json index 178b3f6600..d3a4877689 100644 --- a/packages/common-db/tsconfig.json +++ b/packages/common-db/tsconfig.json @@ -11,7 +11,8 @@ } }, "include": [ - "src/**/*.ts" + "src/**/*.ts", + "src/**/*.d.ts" ], "exclude": [ "build/**", diff --git a/packages/controller/io-package.json b/packages/controller/io-package.json index 2572a1e55d..b09253de53 100644 --- a/packages/controller/io-package.json +++ b/packages/controller/io-package.json @@ -1234,6 +1234,38 @@ "regex": [], "limit": 1 }, + { + "category": "dockerUpdate", + "name": { + "en": "New Docker image available", + "de": "Neues Docker-Image verfügbar", + "ru": "Доступен новый образ Docker", + "pt": "Nova imagem do Docker disponível", + "nl": "Nieuw Docker-image beschikbaar", + "fr": "Nouvelle image Docker disponible", + "it": "Nuova immagine Docker disponibile", + "es": "Nueva imagen Docker disponible", + "pl": "Dostępny jest nowy obraz Docker", + "uk": "Доступний новий образ Docker", + "zh-cn": "提供新的 Docker 映像" + }, + "severity": "notify", + "description": { + "en": "A new ioBroker Docker image is available on Docker Hub. Consider an upgrade.", + "de": "Ein neues ioBroker-Docker-Image ist auf Docker Hub verfügbar. Ziehen Sie ein Upgrade in Betracht.", + "ru": "Новый докер-образ ioBroker доступен на Docker Hub. Рассмотрите возможность обновления.", + "pt": "Uma nova imagem do ioBroker Docker está disponível no Docker Hub. Considere uma atualização.", + "nl": "Een nieuw ioBroker Docker image is beschikbaar op Docker Hub. Overweeg een upgrade.", + "fr": "Une nouvelle image Docker de ioBroker est disponible sur Docker Hub. Envisagez une mise à jour.", + "it": "Una nuova immagine Docker di ioBroker è disponibile su Docker Hub. Considerate un aggiornamento.", + "es": "Una nueva imagen Docker de ioBroker está disponible en Docker Hub. Considere una actualización.", + "pl": "Nowy obraz ioBroker Docker jest dostępny na Docker Hub. Warto rozważyć aktualizację.", + "uk": "Новий образ ioBroker Docker доступний на Docker Hub. Подумайте про оновлення.", + "zh-cn": "Docker Hub 上有新的 ioBroker Docker 映像。请考虑升级。" + }, + "regex": [], + "limit": 1 + }, { "category": "systemRebootRequired", "name": { diff --git a/packages/controller/src/lib/objects.ts b/packages/controller/src/lib/objects.ts index 41215122ee..cbb951b493 100644 --- a/packages/controller/src/lib/objects.ts +++ b/packages/controller/src/lib/objects.ts @@ -1,5 +1,6 @@ import fs from 'fs-extra'; import { DEFAULT_DISK_WARNING_LEVEL } from '@/lib/utils.js'; +import { tools } from '@iobroker/js-controller-common-db'; interface GetHostOptions { /** The host base id */ @@ -14,6 +15,11 @@ interface GetHostOptions { export type TaskObject = ioBroker.SettableObject & { state?: ioBroker.SettableState }; +/** + * Get all ioBroker objects which should be created in the `system.host.` scope + * + * @param options information about hostname, compact controller, the base ID and the config + */ export function getHostObjects(options: GetHostOptions): TaskObject[] { const { id, hostname, isCompactGroupController, config } = options; @@ -396,5 +402,21 @@ export function getHostObjects(options: GetHostOptions): TaskObject[] { }); } + if (tools.getDockerInformation().isOfficial) { + objs.push({ + _id: `${id}.availableDockerBuild`, + type: 'state', + common: { + name: 'Last update of the Docker Image', + desc: 'The timestamp of the last update of the Docker Image', + type: 'string', + read: true, + write: false, + role: 'date' + }, + native: {} + }); + } + return objs; } diff --git a/packages/controller/src/main.ts b/packages/controller/src/main.ts index ccc8020a74..e6b92b925a 100644 --- a/packages/controller/src/main.ts +++ b/packages/controller/src/main.ts @@ -2190,6 +2190,12 @@ async function processMessage(msg: ioBroker.SendableMessage): Promise { + const dockerInfo = tools.getDockerInformation(); + + if (!dockerInfo.isOfficial || !states) { + return; + } + + const { isNew, lastUpdated, version } = await tools.getNewestDockerImageVersion(); + + if (!isNew) { + return; + } + + const dockerVersionStateId = `${hostObjectPrefix}.availableDockerBuild`; + const knownLastUpdated = (await states.getState(dockerVersionStateId))?.val; + await states.setState(dockerVersionStateId, { val: lastUpdated, ack: true }); + + if (knownLastUpdated === lastUpdated) { + return; + } + + await notificationHandler.addMessage({ + scope: 'system', + category: 'dockerUpdate', + message: `${version} (${lastUpdated})`, + instance: `system.host.${hostname}` + }); +} + /** * Check for updatable OS packages and register them as notification */