From a9a33dd94d7a747693f897ca0cf4ca9b266d462f Mon Sep 17 00:00:00 2001 From: Aleksandr Mezin Date: Sat, 24 Feb 2024 09:16:34 +0200 Subject: [PATCH] feat(presets): fetch presets from HTTP URLs (#27359) --- docs/usage/config-presets.md | 24 +++++++++++++ lib/config/presets/http/index.spec.ts | 50 +++++++++++++++++++++++++++ lib/config/presets/http/index.ts | 35 +++++++++++++++++++ lib/config/presets/index.spec.ts | 42 ++++++++++++++++++++++ lib/config/presets/index.ts | 7 ++++ 5 files changed, 158 insertions(+) create mode 100644 lib/config/presets/http/index.spec.ts create mode 100644 lib/config/presets/http/index.ts diff --git a/docs/usage/config-presets.md b/docs/usage/config-presets.md index 54dad726f10e56..b6b168c09e3ceb 100644 --- a/docs/usage/config-presets.md +++ b/docs/usage/config-presets.md @@ -33,6 +33,8 @@ Presets can be nested. Presets should be hosted in repositories, which usually means the same platform host as Renovate is running against. +Alternatively, Renovate can fetch preset files from an HTTP server. + !!! warning We deprecated npm-based presets. @@ -208,6 +210,28 @@ This is especially helpful in self-hosted scenarios where public presets cannot Local presets are specified either by leaving out any prefix, e.g. `owner/name`, or explicitly by adding a `local>` prefix, e.g. `local>owner/name`. Renovate will determine the current platform and look up the preset from there. +## Fetching presets from an HTTP server + +If your desired platform is not yet supported, or if you want presets to work when you run Renovate with `--platform=local`, you can specify presets using HTTP URLs: + +```json +{ + "extends": [ + "http://my.server/users/me/repos/renovate-presets/raw/default.json?at=refs%2Fheads%2Fmain" + ] +} +``` + +Parameters are supported similar to other methods: + +```json +{ + "extends": [ + "http://my.server/users/me/repos/renovate-presets/raw/default.json?at=refs%2Fheads%2Fmain(param)" + ] +} +``` + ## Contributing to presets Have you configured a rule that could help others? diff --git a/lib/config/presets/http/index.spec.ts b/lib/config/presets/http/index.spec.ts new file mode 100644 index 00000000000000..315619b8742be5 --- /dev/null +++ b/lib/config/presets/http/index.spec.ts @@ -0,0 +1,50 @@ +import * as httpMock from '../../../../test/http-mock'; +import { PRESET_DEP_NOT_FOUND, PRESET_INVALID_JSON } from '../util'; +import * as http from '.'; + +const host = 'https://my.server/'; +const filePath = '/test-preset.json'; +const repo = 'https://my.server/test-preset.json'; + +describe('config/presets/http/index', () => { + describe('getPreset()', () => { + it('should return parsed JSON', async () => { + httpMock.scope(host).get(filePath).reply(200, { foo: 'bar' }); + + expect(await http.getPreset({ repo })).toEqual({ foo: 'bar' }); + }); + + it('should return parsed JSON5', async () => { + httpMock + .scope('https://my.server/') + .get('/test-preset.json5') + .reply(200, '{ foo: "bar" } // comment'); + + const repo = 'https://my.server/test-preset.json5'; + + expect(await http.getPreset({ repo })).toEqual({ foo: 'bar' }); + }); + + it('throws if fails to parse', async () => { + httpMock.scope(host).get(filePath).reply(200, 'not json'); + + await expect(http.getPreset({ repo })).rejects.toThrow( + PRESET_INVALID_JSON, + ); + }); + + it('throws if file not found', async () => { + httpMock.scope(host).get(filePath).reply(404); + + await expect(http.getPreset({ repo })).rejects.toThrow( + PRESET_DEP_NOT_FOUND, + ); + }); + + it('throws on malformed URL', async () => { + await expect(http.getPreset({ repo: 'malformed!' })).rejects.toThrow( + PRESET_DEP_NOT_FOUND, + ); + }); + }); +}); diff --git a/lib/config/presets/http/index.ts b/lib/config/presets/http/index.ts new file mode 100644 index 00000000000000..646b39a462e9de --- /dev/null +++ b/lib/config/presets/http/index.ts @@ -0,0 +1,35 @@ +import { logger } from '../../../logger'; +import { ExternalHostError } from '../../../types/errors/external-host-error'; +import { Http } from '../../../util/http'; +import type { HttpResponse } from '../../../util/http/types'; +import { parseUrl } from '../../../util/url'; +import type { Preset, PresetConfig } from '../types'; +import { PRESET_DEP_NOT_FOUND, parsePreset } from '../util'; + +const http = new Http('preset'); + +export async function getPreset({ + repo: url, +}: PresetConfig): Promise { + const parsedUrl = parseUrl(url); + let response: HttpResponse; + + if (!parsedUrl) { + logger.debug(`Preset URL ${url} is malformed`); + throw new Error(PRESET_DEP_NOT_FOUND); + } + + try { + response = await http.get(url); + } catch (err) { + // istanbul ignore if: not testable with nock + if (err instanceof ExternalHostError) { + throw err; + } + + logger.debug(`Preset file ${url} not found`); + throw new Error(PRESET_DEP_NOT_FOUND); + } + + return parsePreset(response.body, parsedUrl.pathname); +} diff --git a/lib/config/presets/index.spec.ts b/lib/config/presets/index.spec.ts index 1f2b2b8b0866d3..2a29243300c9c7 100644 --- a/lib/config/presets/index.spec.ts +++ b/lib/config/presets/index.spec.ts @@ -936,6 +936,48 @@ describe('config/presets/index', () => { presetSource: 'npm', }); }); + + it('parses HTTPS URLs', () => { + expect( + presets.parsePreset( + 'https://my.server/gitea/renovate-config/raw/branch/main/default.json', + ), + ).toEqual({ + repo: 'https://my.server/gitea/renovate-config/raw/branch/main/default.json', + params: undefined, + presetName: '', + presetPath: undefined, + presetSource: 'http', + }); + }); + + it('parses HTTP URLs', () => { + expect( + presets.parsePreset( + 'http://my.server/users/me/repos/renovate-presets/raw/default.json?at=refs%2Fheads%2Fmain', + ), + ).toEqual({ + repo: 'http://my.server/users/me/repos/renovate-presets/raw/default.json?at=refs%2Fheads%2Fmain', + params: undefined, + presetName: '', + presetPath: undefined, + presetSource: 'http', + }); + }); + + it('parses HTTPS URLs with parameters', () => { + expect( + presets.parsePreset( + 'https://my.server/gitea/renovate-config/raw/branch/main/default.json(param1)', + ), + ).toEqual({ + repo: 'https://my.server/gitea/renovate-config/raw/branch/main/default.json', + params: ['param1'], + presetName: '', + presetPath: undefined, + presetSource: 'http', + }); + }); }); describe('getPreset', () => { diff --git a/lib/config/presets/index.ts b/lib/config/presets/index.ts index 8daa57f9e3fdf6..827b1eef0768b2 100644 --- a/lib/config/presets/index.ts +++ b/lib/config/presets/index.ts @@ -19,6 +19,7 @@ import { removedPresets } from './common'; import * as gitea from './gitea'; import * as github from './github'; import * as gitlab from './gitlab'; +import * as http from './http'; import * as internal from './internal'; import * as local from './local'; import * as npm from './npm'; @@ -39,6 +40,7 @@ const presetSources: Record = { gitea, local, internal, + http, }; const presetCacheNamespace = 'preset'; @@ -122,6 +124,8 @@ export function parsePreset(input: string): ParsedPreset { } else if (str.startsWith('local>')) { presetSource = 'local'; str = str.substring('local>'.length); + } else if (str.startsWith('http://') || str.startsWith('https://')) { + presetSource = 'http'; } else if ( !str.startsWith('@') && !str.startsWith(':') && @@ -138,6 +142,9 @@ export function parsePreset(input: string): ParsedPreset { .map((elem) => elem.trim()); str = str.slice(0, str.indexOf('(')); } + if (presetSource === 'http') { + return { presetSource, repo: str, presetName: '', params }; + } const presetsPackages = [ 'compatibility', 'config',