/** * This is the main dynamic content file that provides the 'fetchAndProcessDynamicContent' function * * This is the main entry point for reading and processing dynamic content */ import day from 'dayjs'; import * as jsyaml from 'js-yaml'; import semver from 'semver'; import { isAdminUser } from '@shell/store/type-map'; import { getVersionData } from '@shell/config/version'; import { processReleaseVersion } from './new-release'; import { processSupportNotices } from './support-notice'; import { Context, DynamicContent, VersionInfo } from './types'; import { createLogger, LOCAL_STORAGE_CONTENT_DEBUG_LOG } from './util'; import { getConfig } from './config'; import { SystemInfoProvider } from './info'; const FETCH_DELAY = 3 * 1000; // Short delay to let UI settle before we fetch the updates document const FETCH_REQUEST_TIMEOUT = 15000; // Time out the request after 15 seconds const FETCH_CONCURRENT_SECONDS = 30; // Time to wait to ignore another in-progress fetch (seconds) export const UPDATE_DATE_FORMAT = 'YYYY-MM-DD'; // Format of the fetch date const LOCAL_STORAGE_UPDATE_FETCH_DATE = 'rancher-updates-fetch-next'; // Local storage setting that holds the date when we should next try and fetch content const LOCAL_STORAGE_UPDATE_CONTENT = 'rancher-updates-last-content'; // Local storage setting that holds the last fetched content const LOCAL_STORAGE_UPDATE_ERRORS = 'rancher-updates-fetch-errors'; // Local storage setting that holds the count of contiguous errors const LOCAL_STORAGE_UPDATE_FETCHING = 'rancher-updates-fetching'; // Local storage setting that holds the date and time of the last fetch that was started const BACKOFFS = [1, 1, 1, 2, 2, 3, 5]; // Backoff in days for the contiguous number of errors (i.e. after 1 errors, we wait 1 day, after 3 errors, we wait 2 days, etc.) const DEFAULT_RELEASE_NOTES_URL = 'https://github.com/rancher/rancher/releases/tag/v$version'; // Default release notes URL /** * Fetch dynamic content if needed and process it if it has changed since we last checked */ export async function fetchAndProcessDynamicContent(dispatch: Function, getters: any, axios: any) { // Check that the product is Rancher // => Check that we are NOT in single product mode (e.g. Harvester) const isSingleProduct = getters['isSingleProduct']; if (!!isSingleProduct) { return; } const config = getConfig(getters); // If not enabled via the configuration, then just return if (!config.enabled) { console.log('Dynamic content disabled through configuration'); // eslint-disable-line no-console return; } const logger = createLogger(config); // Common context to pass through to functions for store access, logging, etc const context: Context = { dispatch, getters, axios, logger, config, isAdmin: isAdminUser(getters), settings: { releaseNotesUrl: DEFAULT_RELEASE_NOTES_URL, suseExtensions: [], } }; logger.debug('Read configuration', context.config); try { // Fetch the dynamic content if required, otherwise return the cached content or empty object if no content available const content = await fetchDynamicContent(context); // Version metadata const versionData = getVersionData(); const version = semver.coerce(versionData.Version); if (!version || !content) { return; } const versionInfo: VersionInfo = { version, isPrime: config.prime, }; // If not logging, then clear out any log data from local storage if (!config.log) { window.localStorage.removeItem(LOCAL_STORAGE_CONTENT_DEBUG_LOG); } if (content?.settings) { // Update the settings data from the content, so that it is has the settings with their defaults or values from the dynamic content payload context.settings = { ...context.settings, ...content.settings }; } // If the cached content has a debug version then use that as an override for the current version number // This is only for debug and testing purposes if (content.settings?.debugVersion) { versionInfo.version = semver.coerce(content.settings.debugVersion); logger.debug(`Overriding version number to ${ content.settings.debugVersion }`); } // We always process the content in case the Rancher version has changed or the date means that an announcement/notification should now be shown // New release notifications and support notifications are shown to ALL community users, but only to admin users when Prime if (!config.prime || context.isAdmin) { // New release notifications processReleaseVersion(context, content.releases, versionInfo); // EOM, EOL notifications processSupportNotices(context, content.support, versionInfo); } } catch (e) { logger.error('Error reading or processing dynamic content', e); } } /** * We use a signal to timeout the connection * For air-gapped environments, this ensures the request will timeout after FETCH_REQUEST_TIMEOUT * This timeout is set relatively low (15s). The default, otherwise, is 2 minutes. * * @param timeoutMs Time in milliseconds after which the abort signal should signal */ function newRequestAbortSignal(timeoutMs: number) { const abortController = new AbortController(); setTimeout(() => abortController.abort(), timeoutMs || 0); return abortController.signal; } /** * Update the local storage data that tracks when to next fetch content and how many consecutive errors we have had * * @param didError Indicates if we should update to record content retrieved without error or with error */ function updateFetchInfo(didError: boolean) { if (!didError) { // No error, so check again tomorrow and remove the backoff setting, so it will get its default next time const nextFetch = day().add(1, 'day'); const nextFetchString = nextFetch.format(UPDATE_DATE_FORMAT); window.localStorage.setItem(LOCAL_STORAGE_UPDATE_FETCH_DATE, nextFetchString); window.localStorage.removeItem(LOCAL_STORAGE_UPDATE_ERRORS); } else { // Did error, read the backoff, increase and add to the date const contiguousErrorsString = window.localStorage.getItem(LOCAL_STORAGE_UPDATE_ERRORS) || '0'; let contiguousErrors = parseInt(contiguousErrorsString, 10); // Increase the number of errors that have happened in a row contiguousErrors++; // Once we reach the max backoff, just stick with it if (contiguousErrors >= BACKOFFS.length ) { contiguousErrors = BACKOFFS.length - 1; } // Now find the backoff (days) given the error count and calculate the date of the next fetch const daysToAdd = BACKOFFS[contiguousErrors]; const nextFetch = day().add(daysToAdd, 'day'); const nextFetchString = nextFetch.format(UPDATE_DATE_FORMAT); window.localStorage.setItem(LOCAL_STORAGE_UPDATE_FETCH_DATE, nextFetchString); window.localStorage.setItem(LOCAL_STORAGE_UPDATE_ERRORS, contiguousErrors.toString()); } } /** * Fetch dynamic content (if needed) */ export async function fetchDynamicContent(context: Context): Promise | undefined> { const { getters, logger, config } = context; // Check if we already have done an update check today let content: Partial = {}; try { const today = day(); const todayString = today.format(UPDATE_DATE_FORMAT); const nextFetch = window.localStorage.getItem(LOCAL_STORAGE_UPDATE_FETCH_DATE) || todayString; // Read the cached content from local storage if possible content = JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_UPDATE_CONTENT) || '{}'); const nextFetchDay = day(nextFetch); // Just in case next day gets reset to the past or corrupt, otherwise next fetch needs to not be in the future if (!nextFetchDay.isValid() || !nextFetchDay.isAfter(today)) { logger.info(`Performing update check on ${ todayString }`); logger.debug(`Performing update check on ${ todayString }`); const activeFetch = window.localStorage.getItem(LOCAL_STORAGE_UPDATE_FETCHING); if (activeFetch) { const activeFetchDate = day(activeFetch); if (activeFetchDate.isValid() && today.diff(activeFetchDate, 'second') < FETCH_CONCURRENT_SECONDS) { logger.debug('Already fetching dynamic content in another tab (or previous tab closed while fetching) - skipping'); return content; } } // Set the local storage key that indicates a tab is fetching the content - prevents other tabs doing so at the same time window.localStorage.setItem(LOCAL_STORAGE_UPDATE_FETCHING, today.toString()); // Wait a short while before fetching dynamic content await new Promise((resolve) => setTimeout(resolve, FETCH_DELAY)); const systemData = new SystemInfoProvider(getters, content?.settings || {}); const qs = systemData.buildQueryString(); const distribution = config.prime ? 'prime' : 'community'; const url = `${ config.endpoint.replace('$dist', distribution) }?${ qs }`; logger.debug(`Fetching dynamic content from: ${ url.split('?')[0] }`, url); // We use axios directly so that we can pass in the abort signal to implement the connection timeout const res = await context.axios({ url, method: 'get', timeout: FETCH_REQUEST_TIMEOUT, noApiCsrf: true, withCredentials: false, signal: newRequestAbortSignal(FETCH_REQUEST_TIMEOUT), responseType: 'text' // We always want the raw text back - otherwise YAML gives text and JSON gives object }); // The data should be YAML (or JSON) in the 'data' attribute if (res?.data) { try { content = jsyaml.load(res.data) as any; window.localStorage.setItem(LOCAL_STORAGE_UPDATE_CONTENT, JSON.stringify(content)); // Update the last date now updateFetchInfo(false); } catch (e) { logger.error('Failed to parse YAML/JSON from dynamic content package', e); } } else { logger.error('Error fetching dynamic content package (unexpected data)'); } } else { logger.info(`Skipping update check for dynamic content - next check due on ${ nextFetch } (today is ${ todayString })`); // If debug mode, then wait a bit to simulate the delay we would have had if we were fetching if (config.debug) { await new Promise((resolve) => setTimeout(resolve, FETCH_DELAY)); } } } catch (e) { logger.error('Error occurred reading dynamic content', e); // We had an error, so update data in local storage so that we try again appropriately next time updateFetchInfo(true); } logger.debug('End fetchDynamicContent'); // Remove the local storage key that indicates a tab is fetching the content window.localStorage.removeItem(LOCAL_STORAGE_UPDATE_FETCHING); return content; }