diff --git a/docs/usage/self-hosted-experimental.md b/docs/usage/self-hosted-experimental.md index 5945938bef3cbf47a19714e338e515914dd618bc..47fd66b1cfd0be17e7d4eca83ab48c47a25b0dda 100644 --- a/docs/usage/self-hosted-experimental.md +++ b/docs/usage/self-hosted-experimental.md @@ -71,6 +71,13 @@ Source: [AWS S3 documentation - Interface BucketEndpointInputConfig](https://doc If set, Renovate will terminate the whole process group of a terminated child process spawned by Renovate. +## `RENOVATE_X_DELETE_CONFIG_FILE` + +If `true` Renovate tries to delete the self-hosted config file after reading it. +You can set the config file Renovate should read with the `RENOVATE_CONFIG_FILE` environment variable. + +The process that runs Renovate must have the correct permissions to delete the config file. + ## `RENOVATE_X_MATCH_PACKAGE_NAMES_MORE` If set, you'll get the following behavior. diff --git a/lib/workers/global/config/parse/file.spec.ts b/lib/workers/global/config/parse/file.spec.ts index 6dd841bd6eec7fcb8e43b8925b90b7d4b137135b..e0aaf92406a91ec872a535a0a96497d032f53b8a 100644 --- a/lib/workers/global/config/parse/file.spec.ts +++ b/lib/workers/global/config/parse/file.spec.ts @@ -7,6 +7,10 @@ import customConfig from './__fixtures__/config'; import * as file from './file'; describe('workers/global/config/parse/file', () => { + const processExitSpy = jest.spyOn(process, 'exit'); + const fsPathExistsSpy = jest.spyOn(fsExtra, 'pathExists'); + const fsRemoveSpy = jest.spyOn(fsExtra, 'remove'); + let tmp: DirectoryResult; beforeAll(async () => { @@ -71,32 +75,28 @@ describe('workers/global/config/parse/file', () => { ])( 'fatal error and exit if error in parsing %s', async (fileName, fileContent) => { - const mockProcessExit = jest - .spyOn(process, 'exit') - .mockImplementationOnce(() => undefined as never); + processExitSpy.mockImplementationOnce(() => undefined as never); const configFile = upath.resolve(tmp.path, fileName); fs.writeFileSync(configFile, fileContent, { encoding: 'utf8' }); await file.getConfig({ RENOVATE_CONFIG_FILE: configFile }); - expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(processExitSpy).toHaveBeenCalledWith(1); fs.unlinkSync(configFile); } ); it('fatal error and exit if custom config file does not exist', async () => { - const mockProcessExit = jest - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); - + processExitSpy + .mockImplementationOnce(() => undefined as never) + .mockImplementationOnce(() => undefined as never); const configFile = upath.resolve(tmp.path, './file4.js'); + await file.getConfig({ RENOVATE_CONFIG_FILE: configFile }); - expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(processExitSpy).toHaveBeenCalledWith(1); }); it('fatal error and exit if config.js contains unresolved env var', async () => { - const mockProcessExit = jest - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); + processExitSpy.mockImplementationOnce(() => undefined as never); const configFile = upath.resolve( __dirname, @@ -113,22 +113,115 @@ describe('workers/global/config/parse/file', () => { expect(logger.fatal).toHaveBeenCalledWith( `Error parsing config file due to unresolved variable(s): CI_API_V4_URL is not defined` ); - expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(processExitSpy).toHaveBeenCalledWith(1); }); it.each([ ['invalid config file type', './file.txt'], ['missing config file type', './file'], ])('fatal error and exit if %s', async (fileType, filePath) => { - const mockProcessExit = jest - .spyOn(process, 'exit') - .mockImplementationOnce(() => undefined as never); + processExitSpy.mockImplementationOnce(() => undefined as never); const configFile = upath.resolve(tmp.path, filePath); fs.writeFileSync(configFile, `{"token": "abc"}`, { encoding: 'utf8' }); await file.getConfig({ RENOVATE_CONFIG_FILE: configFile }); - expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(processExitSpy).toHaveBeenCalledWith(1); expect(logger.fatal).toHaveBeenCalledWith('Unsupported file type'); fs.unlinkSync(configFile); }); + + it('removes the config file if RENOVATE_CONFIG_FILE & RENOVATE_X_DELETE_CONFIG_FILE are set', async () => { + fsRemoveSpy.mockImplementationOnce(() => { + // no-op + }); + fsPathExistsSpy.mockResolvedValueOnce(true as never); + const configFile = upath.resolve(tmp.path, './config.json'); + fs.writeFileSync(configFile, `{"token": "abc"}`, { encoding: 'utf8' }); + + await file.getConfig({ + RENOVATE_CONFIG_FILE: configFile, + RENOVATE_X_DELETE_CONFIG_FILE: 'true', + }); + + expect(processExitSpy).not.toHaveBeenCalled(); + expect(fsRemoveSpy).toHaveBeenCalledTimes(1); + expect(fsRemoveSpy).toHaveBeenCalledWith(configFile); + fs.unlinkSync(configFile); + }); + }); + + describe('deleteConfigFile()', () => { + it.each([[undefined], [' ']])( + 'skip when RENOVATE_CONFIG_FILE is not set ("%s")', + async (configFile) => { + await file.deleteNonDefaultConfig({ RENOVATE_CONFIG_FILE: configFile }); + + expect(fsRemoveSpy).toHaveBeenCalledTimes(0); + } + ); + + it('skip when config file does not exist', async () => { + fsPathExistsSpy.mockResolvedValueOnce(false as never); + + await file.deleteNonDefaultConfig({ + RENOVATE_CONFIG_FILE: 'path', + RENOVATE_X_DELETE_CONFIG_FILE: 'true', + }); + + expect(fsRemoveSpy).toHaveBeenCalledTimes(0); + }); + + it.each([['false'], [' ']])( + 'skip if RENOVATE_X_DELETE_CONFIG_FILE is not set ("%s")', + async (deleteConfig) => { + fsPathExistsSpy.mockResolvedValueOnce(true as never); + + await file.deleteNonDefaultConfig({ + RENOVATE_X_DELETE_CONFIG_FILE: deleteConfig, + RENOVATE_CONFIG_FILE: '/path/to/config.js', + }); + + expect(fsRemoveSpy).toHaveBeenCalledTimes(0); + } + ); + + it('removes the specified config file', async () => { + fsRemoveSpy.mockImplementationOnce(() => { + // no-op + }); + fsPathExistsSpy.mockResolvedValueOnce(true as never); + const configFile = '/path/to/config.js'; + + await file.deleteNonDefaultConfig({ + RENOVATE_CONFIG_FILE: configFile, + RENOVATE_X_DELETE_CONFIG_FILE: 'true', + }); + + expect(fsRemoveSpy).toHaveBeenCalledTimes(1); + expect(fsRemoveSpy).toHaveBeenCalledWith(configFile); + expect(logger.trace).toHaveBeenCalledWith( + expect.anything(), + 'config file successfully deleted' + ); + }); + + it('fails silently when attempting to delete the config file', async () => { + fsRemoveSpy.mockImplementationOnce(() => { + throw new Error(); + }); + fsPathExistsSpy.mockResolvedValueOnce(true as never); + const configFile = '/path/to/config.js'; + + await file.deleteNonDefaultConfig({ + RENOVATE_CONFIG_FILE: configFile, + RENOVATE_X_DELETE_CONFIG_FILE: 'true', + }); + + expect(fsRemoveSpy).toHaveBeenCalledTimes(1); + expect(fsRemoveSpy).toHaveBeenCalledWith(configFile); + expect(logger.warn).toHaveBeenCalledWith( + expect.anything(), + 'error deleting config file' + ); + }); }); }); diff --git a/lib/workers/global/config/parse/file.ts b/lib/workers/global/config/parse/file.ts index 871fe10f457cea0aa8a5f6d3a0ef5fb77ab5ca9e..ba255ecd10d79492ce40eced41c1746bffea3005 100644 --- a/lib/workers/global/config/parse/file.ts +++ b/lib/workers/global/config/parse/file.ts @@ -71,6 +71,9 @@ export async function getConfig(env: NodeJS.ProcessEnv): Promise<AllConfig> { logger.debug('No config file found on disk - skipping'); } } + + await deleteNonDefaultConfig(env); // Attempt deletion only if RENOVATE_CONFIG_FILE is specified + const { isMigrated, migratedConfig } = migrateConfig(config); if (isMigrated) { logger.warn( @@ -81,3 +84,28 @@ export async function getConfig(env: NodeJS.ProcessEnv): Promise<AllConfig> { } return config; } + +export async function deleteNonDefaultConfig( + env: NodeJS.ProcessEnv +): Promise<void> { + const configFile = env.RENOVATE_CONFIG_FILE; + + if (is.undefined(configFile) || is.emptyStringOrWhitespace(configFile)) { + return; + } + + if (env.RENOVATE_X_DELETE_CONFIG_FILE !== 'true') { + return; + } + + if (!(await fs.pathExists(configFile))) { + return; + } + + try { + await fs.remove(configFile); + logger.trace({ path: configFile }, 'config file successfully deleted'); + } catch (err) { + logger.warn({ err }, 'error deleting config file'); + } +}