/* * * @copyright Copyright (c) OX Software GmbH, Germany <info@open-xchange.com> * @license AGPL-3.0 * * This code is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with OX App Suite. If not, see <https://www.gnu.org/licenses/agpl-3.0.txt>. * * Any use of the work other than as authorized under this license or copyright law is prohibited. * */ import { configMap } from './config_map.js' import { getRedisKey, hash } from './util.js' import logger from './logger.js' import * as cache from './cache.js' import * as redis from './redis.js' import { Gauge } from 'prom-client' import { warmCache } from './files.js' import { getViteManifests } from './manifests.js' export const versionInfo = { version: null, details: {} } export const versionUpdateGauge = new Gauge({ name: 'version_update_event', help: 'Timestamp of a version update event', labelNames: ['version'] }) /** * Fetches latest version information from all the ui-containers * @returns {Promise<{version, details}>} Return a promise containing this information */ export async function fetchVersionInfo () { const versions = await Promise.all(configMap.urls.map(async baseUrl => { try { const response = await fetch(new URL('meta.json', baseUrl), { cache: 'no-store' }) if (!response.ok) throw new Error() if (response.headers.get('version')) return response.headers.get('version') const meta = await response.json() const version = meta.commitSha || meta.buildDate || meta.version if (!version) throw new Error() return version } catch (err) { logger.warn(`[Version] UI container at ${baseUrl} does not have meta.json. Fall back to version hash based on manifest.`) } const response = await fetch(new URL('manifest.json', baseUrl), { cache: 'no-store' }) if (!response.ok) throw new Error(`Cannot fetch manifest.json from ${baseUrl}`) const manifest = await response.json() return hash(manifest) })) const details = Object.fromEntries(configMap.urls.map((url, i) => [url, versions[i]])) const version = `${hash(Object.values(details))}${configMap.salt ? `-${configMap.salt}` : ''}` return { details, version } } /** * Gets version information from redis or the ui-containers, if not cached. * @returns {Promise<{version, details}>} Return a promise containing this information */ export async function getVersionInfo () { if (versionInfo.version) return versionInfo const redisVersionInfo = await redis.client.get(getRedisKey({ name: 'versionInfo' })) if (redisVersionInfo) { try { Object.assign(versionInfo, JSON.parse(redisVersionInfo)) logger.info(`[Version] Got initial version from redis: '${versionInfo.version}'`) versionUpdateGauge.setToCurrentTime({ version: versionInfo.version }) } catch (err) { logger.error('[Version] Error in getVersionInfo', err) } } return versionInfo } /** * Fetches latest version from all the ui-containers * @returns {Promise<string>} Return a promise containing this information */ export async function fetchLatestVersion () { const versionInfo = await fetchVersionInfo() return versionInfo.version } /** * Gets latest version from redis or the ui-containers, if not cached. * @returns {Promise<string>} Return a promise containing this information */ export async function getLatestVersion () { const versionInfo = await getVersionInfo() return versionInfo.version } export function registerLatestVersionListener (client) { const key = getRedisKey({ name: 'updateVersionInfo' }) client.subscribe(key, (errs, count) => logger.info(`[Redis] Subscribed to ${key}.`)) client.on('message', async (channel, stringifiedVersionInfo) => { if (channel !== key) return const updatedVersionInfo = JSON.parse(stringifiedVersionInfo) if (versionInfo.version === updatedVersionInfo.version) return logger.info(`[Version] Received 'updateVersionInfo' event but already contains that version '${updatedVersionInfo.version}'`) logger.info(`[Version] Received 'updateVersionInfo' event. Clearing cache. New version: '${updatedVersionInfo.version}'`) await configMap.load() versionUpdateGauge.setToCurrentTime({ version: updatedVersionInfo.version }) cache.clear() warmCache({ version: updatedVersionInfo.version }).catch(err => logger.error(err)) Object.assign(versionInfo, updatedVersionInfo) }) } // only observe the version update event, no need to store the reference // eslint-disable-next-line no-new new Gauge({ name: 'manifest_file_entries', help: 'Number of entries in merged vite manifest (number of all known files)', async collect () { const version = versionInfo.version this.set({ version }, Object.keys(await getViteManifests({ version })).length) }, labelNames: ['version'] }) export async function fetchMergedMetadata () { const metadata = await Promise.all(configMap.urls.map(async url => { const { origin } = new URL(url) try { const response = await fetch(new URL('meta.json', origin), { cache: 'no-store' }) if (!response.ok) return return response.json() } catch (e) { // unhandled } })) metadata.push({ id: 'ui-middleware', name: 'UI Middleware', buildDate: process.env.BUILD_TIMESTAMP, commitSha: process.env.CI_COMMIT_SHA, version: process.env.APP_VERSION }) // only return when contains data return metadata.filter(Boolean) } let prevProcessedVersion = null export async function updateVersionProcessor (pubClient) { try { logger.debug('[Version] Check for new version') await configMap.load() const [storedVersion, fetchedVersionInfo] = await Promise.all([ getLatestVersion(), fetchVersionInfo() ]) if (prevProcessedVersion && storedVersion === fetchedVersionInfo.version) { // make sure to limit memory consumption and always check redis cache.clear() logger.debug(`[Version] No new version has been found. No update needed. Current version: ${storedVersion}`) return storedVersion } logger.info(`[Version] Found new source version. Current version: '${storedVersion}', new version: '${fetchedVersionInfo.version}'`) // that means, that between the previous update processing and this one, there was no version change if (!storedVersion || storedVersion === 'unknown' || prevProcessedVersion === fetchedVersionInfo.version) { // update local version info Object.assign(versionInfo, fetchedVersionInfo) const stringifiedVersionInfo = JSON.stringify(versionInfo) cache.clear() await warmCache({ version: fetchedVersionInfo.version, fetchFiles: true }) await redis.client.set(getRedisKey({ name: 'versionInfo' }), stringifiedVersionInfo) await cache.get(getRedisKey({ version: fetchedVersionInfo.version, name: 'mergedMetadata' }), async () => [await fetchMergedMetadata()]) versionUpdateGauge.setToCurrentTime({ version: fetchedVersionInfo.version }) logger.info('[Version] publish update to other nodes.') pubClient.publish(getRedisKey({ name: 'updateVersionInfo' }), stringifiedVersionInfo) } else { logger.info(`[Version] do not execute update yet. Store version ${fetchedVersionInfo.version} as previous version.`) prevProcessedVersion = fetchedVersionInfo.version } } catch (err) { versionInfo.version = null logger.error(`[Version] comparing version is not possible. Error: ${err.message}`) } }