diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md index af593128fbcc0d756660b9c0d5c86166bcc414e0..88c3aee431e6c9258e758fd4f1f7d9e2846289d8 100644 --- a/docs/usage/self-hosted-configuration.md +++ b/docs/usage/self-hosted-configuration.md @@ -666,6 +666,16 @@ Use the `extends` field instead of this if, for example, you need the ability fo When Renovate resolves `globalExtends` it does not fully process the configuration. This means that Renovate does not have the authentication it needs to fetch private things. +## httpCacheTtlDays + +This option sets the number of days that Renovate will cache HTTP responses. +The default value is 90 days. +Value of `0` means no caching. + +<!-- prettier-ignore --> +!!! warning + When you set `httpCacheTtlDays` to `0`, Renovate will remove the cached HTTP data. + ## includeMirrors By default, Renovate does not autodiscover repositories that are mirrors. diff --git a/lib/config/global.ts b/lib/config/global.ts index e59c0ae0ca116a0236da866edf9bb385b568c6ef..21b2b56cc25f8d614eb61ea6239d2616cb36671a 100644 --- a/lib/config/global.ts +++ b/lib/config/global.ts @@ -32,6 +32,7 @@ export class GlobalConfig { 'gitTimeout', 'platform', 'endpoint', + 'httpCacheTtlDays', ]; private static config: RepoGlobalConfig = {}; diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index d2994100d0bf8416bd178ce9c9407d51a829f02d..1b18948a1e69e9f794bfe87ef7ad01e8cf02507f 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -2981,6 +2981,14 @@ const options: RenovateOptions[] = [ default: null, supportedPlatforms: ['github'], }, + { + name: 'httpCacheTtlDays', + description: 'Maximum duration in days to keep HTTP cache entries.', + type: 'integer', + stage: 'repository', + default: 90, + globalOnly: true, + }, ]; export function getOptions(): RenovateOptions[] { diff --git a/lib/config/types.ts b/lib/config/types.ts index b34d914addbdf211d4e0f463a9957bfc9f73ffed..6c68f35093a84a87b80186a52fe0c85a1d535d2a 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -158,6 +158,7 @@ export interface RepoGlobalConfig { presetCachePersistence?: boolean; privateKey?: string; privateKeyOld?: string; + httpCacheTtlDays?: number; } export interface LegacyAdminConfig { diff --git a/lib/util/cache/repository/http-cache.spec.ts b/lib/util/cache/repository/http-cache.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d68f5e89d5bcf91245d7008a628b3aa3163535d --- /dev/null +++ b/lib/util/cache/repository/http-cache.spec.ts @@ -0,0 +1,75 @@ +import { DateTime, Settings } from 'luxon'; +import { GlobalConfig } from '../../../config/global'; +import { cleanupHttpCache } from './http-cache'; + +describe('util/cache/repository/http-cache', () => { + beforeEach(() => { + const now = DateTime.fromISO('2024-04-12T12:00:00.000Z').valueOf(); + Settings.now = () => now; + GlobalConfig.reset(); + }); + + it('should not throw if cache is not a valid HttpCache', () => { + expect(() => cleanupHttpCache('not a valid cache')).not.toThrow(); + }); + + it('should remove expired items from the cache', () => { + const now = DateTime.now(); + const expiredItemTimestamp = now.minus({ days: 91 }).toISO(); + const cache = { + httpCache: { + 'http://example.com/foo': { + timestamp: expiredItemTimestamp, + etag: 'abc', + lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT', + httpResponse: {}, + }, + 'http://example.com/bar': { + timestamp: now.toISO(), + etag: 'abc', + lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT', + httpResponse: {}, + }, + }, + }; + + cleanupHttpCache(cache); + + expect(cache).toEqual({ + httpCache: { + 'http://example.com/bar': { + timestamp: now.toISO(), + etag: 'abc', + httpResponse: {}, + lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT', + }, + }, + }); + }); + + it('should remove all items if ttlDays is not configured', () => { + GlobalConfig.set({ httpCacheTtlDays: 0 }); + + const now = DateTime.now(); + const cache = { + httpCache: { + 'http://example.com/foo': { + timestamp: now.toISO(), + etag: 'abc', + lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT', + httpResponse: {}, + }, + 'http://example.com/bar': { + timestamp: now.toISO(), + etag: 'abc', + lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT', + httpResponse: {}, + }, + }, + }; + + cleanupHttpCache(cache); + + expect(cache).toEqual({}); + }); +}); diff --git a/lib/util/cache/repository/http-cache.ts b/lib/util/cache/repository/http-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..36121766a0f816ad1c55fdcda7016893bd2f0573 --- /dev/null +++ b/lib/util/cache/repository/http-cache.ts @@ -0,0 +1,33 @@ +import is from '@sindresorhus/is'; +import { DateTime } from 'luxon'; +import { GlobalConfig } from '../../../config/global'; +import { logger } from '../../../logger'; +import { HttpCacheSchema } from '../../http/cache/schema'; + +export function cleanupHttpCache(cacheData: unknown): void { + if (!is.plainObject(cacheData) || !is.plainObject(cacheData['httpCache'])) { + logger.warn('cleanupHttpCache: invalid cache data'); + return; + } + const httpCache = cacheData['httpCache']; + + const ttlDays = GlobalConfig.get('httpCacheTtlDays', 90); + if (ttlDays === 0) { + logger.trace('cleanupHttpCache: zero value received, removing the cache'); + delete cacheData['httpCache']; + return; + } + + const now = DateTime.now(); + for (const [url, item] of Object.entries(httpCache)) { + const parsed = HttpCacheSchema.safeParse(item); + if (parsed.success && parsed.data) { + const item = parsed.data; + const expiry = DateTime.fromISO(item.timestamp).plus({ days: ttlDays }); + if (expiry < now) { + logger.debug(`http cache: removing expired cache for ${url}`); + delete httpCache[url]; + } + } + } +} diff --git a/lib/util/cache/repository/impl/base.ts b/lib/util/cache/repository/impl/base.ts index 59a78649c5959900b56d685cc5a51f45c45ea839..2fbb6ef8964a7ff129c46e2c5b097dd43b039970 100644 --- a/lib/util/cache/repository/impl/base.ts +++ b/lib/util/cache/repository/impl/base.ts @@ -5,6 +5,7 @@ import { compressToBase64, decompressFromBase64 } from '../../../compress'; import { hash } from '../../../hash'; import { safeStringify } from '../../../stringify'; import { CACHE_REVISION } from '../common'; +import { cleanupHttpCache } from '../http-cache'; import { RepoCacheRecord, RepoCacheV13 } from '../schema'; import type { RepoCache, RepoCacheData } from '../types'; @@ -70,6 +71,8 @@ export abstract class RepoCacheBase implements RepoCache { } async save(): Promise<void> { + cleanupHttpCache(this.data); + const jsonStr = safeStringify(this.data); const hashedJsonStr = hash(jsonStr); if (hashedJsonStr === this.oldHash) {