From ee9c45aa5147ed4ea3480708404cc83c8c745989 Mon Sep 17 00:00:00 2001
From: Gabriel-Ladzaretti
 <97394622+Gabriel-Ladzaretti@users.noreply.github.com>
Date: Wed, 21 Jun 2023 00:01:25 +0300
Subject: [PATCH] feat(config): optionally remove self-hosted config file once
 read (#22857)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
---
 docs/usage/self-hosted-experimental.md       |   7 +
 lib/workers/global/config/parse/file.spec.ts | 127 ++++++++++++++++---
 lib/workers/global/config/parse/file.ts      |  28 ++++
 3 files changed, 145 insertions(+), 17 deletions(-)

diff --git a/docs/usage/self-hosted-experimental.md b/docs/usage/self-hosted-experimental.md
index 5945938bef..47fd66b1cf 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 6dd841bd6e..e0aaf92406 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 871fe10f45..ba255ecd10 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');
+  }
+}
-- 
GitLab