From 2dd26e1b0a4ce5409fa37f254dad70544cf2d324 Mon Sep 17 00:00:00 2001
From: Jonathan Ballet <jon@multani.info>
Date: Mon, 10 Oct 2022 09:04:55 +0200
Subject: [PATCH] feat: TFLint plugin manager (#17954)

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 lib/modules/manager/api.ts                    |   2 +
 .../manager/tflint-plugin/extract.spec.ts     | 160 ++++++++++++++++++
 lib/modules/manager/tflint-plugin/extract.ts  |  54 ++++++
 lib/modules/manager/tflint-plugin/index.ts    |  12 ++
 lib/modules/manager/tflint-plugin/plugins.ts  |  87 ++++++++++
 lib/modules/manager/tflint-plugin/readme.md   |   4 +
 lib/modules/manager/tflint-plugin/types.ts    |   6 +
 lib/modules/manager/tflint-plugin/util.ts     |  10 ++
 8 files changed, 335 insertions(+)
 create mode 100644 lib/modules/manager/tflint-plugin/extract.spec.ts
 create mode 100644 lib/modules/manager/tflint-plugin/extract.ts
 create mode 100644 lib/modules/manager/tflint-plugin/index.ts
 create mode 100644 lib/modules/manager/tflint-plugin/plugins.ts
 create mode 100644 lib/modules/manager/tflint-plugin/readme.md
 create mode 100644 lib/modules/manager/tflint-plugin/types.ts
 create mode 100644 lib/modules/manager/tflint-plugin/util.ts

diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts
index 89d9cc2508..3f1282ed17 100644
--- a/lib/modules/manager/api.ts
+++ b/lib/modules/manager/api.ts
@@ -73,6 +73,7 @@ import * as terraform from './terraform';
 import * as terraformVersion from './terraform-version';
 import * as terragrunt from './terragrunt';
 import * as terragruntVersion from './terragrunt-version';
+import * as tflintPlugin from './tflint-plugin';
 import * as travis from './travis';
 import type { ManagerApi } from './types';
 import * as velaci from './velaci';
@@ -156,6 +157,7 @@ api.set('terraform', terraform);
 api.set('terraform-version', terraformVersion);
 api.set('terragrunt', terragrunt);
 api.set('terragrunt-version', terragruntVersion);
+api.set('tflint-plugin', tflintPlugin);
 api.set('travis', travis);
 api.set('velaci', velaci);
 api.set('woodpecker', woodpecker);
diff --git a/lib/modules/manager/tflint-plugin/extract.spec.ts b/lib/modules/manager/tflint-plugin/extract.spec.ts
new file mode 100644
index 0000000000..04e2dccda3
--- /dev/null
+++ b/lib/modules/manager/tflint-plugin/extract.spec.ts
@@ -0,0 +1,160 @@
+import { join } from 'upath';
+import { GlobalConfig } from '../../../config/global';
+import type { RepoGlobalConfig } from '../../../config/types';
+import { extractPackageFile } from '.';
+
+const configNormal = `
+plugin "foo" {
+  enabled = true
+  version = "0.1.0"
+  source  = "github.com/org/tflint-ruleset-foo"
+}
+
+plugin "bar" {
+  enabled = true
+  version = "1.42.0"
+  source  = "github.com/org2/tflint-ruleset-bar"
+}
+`;
+
+const configNoVersion = `
+plugin "bundled" {}
+`;
+
+const configFull = `
+config {
+  format = "compact"
+  plugin_dir = "~/.tflint.d/plugins"
+
+  module = true
+  force = false
+  disabled_by_default = false
+
+  ignore_module = {
+    "terraform-aws-modules/vpc/aws"            = true
+    "terraform-aws-modules/security-group/aws" = true
+  }
+
+  varfile = ["example1.tfvars", "example2.tfvars"]
+}
+
+plugin "aws" {
+  enabled = true
+  version = "0.4.0"
+  source  = "github.com/terraform-linters/tflint-ruleset-aws"
+}
+
+rule "aws_instance_invalid_type" {
+  enabled = false
+}
+`;
+
+const noSource = `
+plugin "aws" {
+  enabled = true
+  version = "0.4.0"
+}
+
+plugin "bundled" {
+  # A bundled plugin, probably.
+  enabled = true
+}
+`;
+
+const notGithub = `
+plugin "aws" {
+  enabled = true
+  version = "0.4.0"
+  source  = "gitlab.com/terraform-linters/tflint-ruleset-aws"
+}
+`;
+
+const adminConfig: RepoGlobalConfig = {
+  localDir: join('/tmp/github/some/repo'),
+  cacheDir: join('/tmp/cache'),
+  containerbaseDir: join('/tmp/cache/containerbase'),
+};
+
+// auto-mock fs
+jest.mock('../../../util/fs');
+
+describe('modules/manager/tflint-plugin/extract', () => {
+  beforeEach(() => {
+    GlobalConfig.set(adminConfig);
+  });
+
+  describe('extractPackageFile()', () => {
+    it('returns null for empty', () => {
+      expect(
+        extractPackageFile('nothing here', 'doesnt-exist.hcl', {})
+      ).toBeNull();
+    });
+
+    it('returns null when there are no version', () => {
+      expect(
+        extractPackageFile(configNoVersion, 'doesnt-exist.hcl', {})
+      ).toBeNull();
+    });
+
+    it('extracts plugins', () => {
+      const res = extractPackageFile(configNormal, 'tflint-1.hcl', {});
+      expect(res).toEqual({
+        deps: [
+          {
+            currentValue: '0.1.0',
+            datasource: 'github-releases',
+            depType: 'plugin',
+            depName: 'org/tflint-ruleset-foo',
+          },
+          {
+            currentValue: '1.42.0',
+            datasource: 'github-releases',
+            depType: 'plugin',
+            depName: 'org2/tflint-ruleset-bar',
+          },
+        ],
+      });
+    });
+
+    it('extracts from full configuration', () => {
+      const res = extractPackageFile(configFull, 'tflint-full.hcl', {});
+      expect(res).toEqual({
+        deps: [
+          {
+            currentValue: '0.4.0',
+            datasource: 'github-releases',
+            depType: 'plugin',
+            depName: 'terraform-linters/tflint-ruleset-aws',
+          },
+        ],
+      });
+    });
+
+    it('extracts no source', () => {
+      const res = extractPackageFile(noSource, 'tflint-no-source.hcl', {});
+      expect(res).toEqual({
+        deps: [
+          {
+            skipReason: 'no-source',
+          },
+          {
+            skipReason: 'no-source',
+          },
+        ],
+      });
+    });
+
+    it('extracts nothing if not from github', () => {
+      const res = extractPackageFile(notGithub, 'tflint-not-github.hcl', {});
+      expect(res).toEqual({
+        deps: [
+          {
+            depName: 'gitlab.com/terraform-linters/tflint-ruleset-aws',
+            depType: 'plugin',
+            skipReason: 'unsupported-datasource',
+          },
+        ],
+      });
+    });
+  });
+});
diff --git a/lib/modules/manager/tflint-plugin/extract.ts b/lib/modules/manager/tflint-plugin/extract.ts
new file mode 100644
index 0000000000..566d7c0ede
--- /dev/null
+++ b/lib/modules/manager/tflint-plugin/extract.ts
@@ -0,0 +1,54 @@
+import { logger } from '../../../logger';
+import { newlineRegex, regEx } from '../../../util/regex';
+import type { ExtractConfig, PackageDependency, PackageFile } from '../types';
+import { extractTFLintPlugin } from './plugins';
+import type { ExtractionResult } from './types';
+import { checkFileContainsPlugins } from './util';
+
+const dependencyBlockExtractionRegex = regEx(
+  /^\s*plugin\s+"(?<pluginName>[^"]+)"\s+{\s*$/
+);
+
+export function extractPackageFile(
+  content: string,
+  fileName: string,
+  config: ExtractConfig
+): PackageFile | null {
+  logger.trace({ content }, 'tflint.extractPackageFile()');
+  if (!checkFileContainsPlugins(content)) {
+    logger.trace(
+      { fileName },
+      'preflight content check has not found any relevant content'
+    );
+    return null;
+  }
+
+  let deps: PackageDependency[] = [];
+
+  try {
+    const lines = content.split(newlineRegex);
+
+    for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
+      const line = lines[lineNumber];
+      const tfLintPlugin = dependencyBlockExtractionRegex.exec(line);
+      if (tfLintPlugin?.groups) {
+        logger.trace(`Matched TFLint plugin on line ${lineNumber}`);
+        let result: ExtractionResult | null = null;
+        result = extractTFLintPlugin(
+          lineNumber,
+          lines,
+          tfLintPlugin.groups.pluginName
+        );
+        if (result) {
+          lineNumber = result.lineNumber;
+          deps = deps.concat(result.dependencies);
+          result = null;
+        }
+      }
+    }
+  } catch (err) /* istanbul ignore next */ {
+    logger.warn({ err }, 'Error extracting TFLint plugins');
+  }
+
+  return deps.length ? { deps } : null;
+}
diff --git a/lib/modules/manager/tflint-plugin/index.ts b/lib/modules/manager/tflint-plugin/index.ts
new file mode 100644
index 0000000000..2c17316cf3
--- /dev/null
+++ b/lib/modules/manager/tflint-plugin/index.ts
@@ -0,0 +1,12 @@
+import { GithubReleasesDatasource } from '../../datasource/github-releases';
+
+export { extractPackageFile } from './extract';
+
+// Only from GitHub Releases: https://github.com/terraform-linters/tflint/blob/master/docs/developer-guide/plugins.md#4-creating-a-github-release
+export const supportedDatasources = [GithubReleasesDatasource.id];
+
+export const defaultConfig = {
+  commitMessageTopic: 'TFLint plugin {{depName}}',
+  fileMatch: ['\\.tflint\\.hcl$'],
+  extractVersion: '^v(?<version>.*)$',
+};
diff --git a/lib/modules/manager/tflint-plugin/plugins.ts b/lib/modules/manager/tflint-plugin/plugins.ts
new file mode 100644
index 0000000000..726206bc45
--- /dev/null
+++ b/lib/modules/manager/tflint-plugin/plugins.ts
@@ -0,0 +1,87 @@
+import is from '@sindresorhus/is';
+import { logger } from '../../../logger';
+import { regEx } from '../../../util/regex';
+import { GithubReleasesDatasource } from '../../datasource/github-releases';
+import type { PackageDependency } from '../types';
+import type { ExtractionResult } from './types';
+import { keyValueExtractionRegex } from './util';
+
+export function extractTFLintPlugin(
+  startingLine: number,
+  lines: string[],
+  pluginName: string
+): ExtractionResult {
+  let lineNumber = startingLine;
+  const deps: PackageDependency[] = [];
+
+  let pluginSource: string | null = null;
+  let currentVersion: string | null = null;
+
+  let braceCounter = 0;
+  do {
+    // istanbul ignore if
+    if (lineNumber > lines.length - 1) {
+      logger.debug(`Malformed TFLint configuration file detected.`);
+    }
+
+    const line = lines[lineNumber];
+
+    // istanbul ignore else
+    if (is.string(line)) {
+      // `{` will be counted with +1 and `}` with -1.
+      // Therefore if we reach braceCounter == 0 then we found the end of the tflint configuration block.
+      const openBrackets = (line.match(regEx(/\{/g)) ?? []).length;
+      const closedBrackets = (line.match(regEx(/\}/g)) ?? []).length;
+      braceCounter = braceCounter + openBrackets - closedBrackets;
+
+      // only update fields inside the root block
+      if (braceCounter === 1) {
+        const kvMatch = keyValueExtractionRegex.exec(line);
+        if (kvMatch?.groups) {
+          if (kvMatch.groups.key === 'version') {
+            currentVersion = kvMatch.groups.value;
+          } else if (kvMatch.groups.key === 'source') {
+            pluginSource = kvMatch.groups.value;
+          }
+        }
+      }
+    } else {
+      // stop - something went wrong
+      braceCounter = 0;
+    }
+    lineNumber += 1;
+  } while (braceCounter !== 0);
+
+  const dep = analyseTFLintPlugin(pluginSource, currentVersion);
+  deps.push(dep);
+
+  // remove last lineNumber addition to not skip a line after the last bracket
+  lineNumber -= 1;
+  return { lineNumber, dependencies: deps };
+}
+
+function analyseTFLintPlugin(
+  source: string | null,
+  version: string | null
+): PackageDependency {
+  const dep: PackageDependency = {};
+
+  if (source) {
+    dep.depType = 'plugin';
+
+    const sourceParts = source.split('/');
+    if (sourceParts[0] === 'github.com') {
+      dep.currentValue = version;
+      dep.datasource = GithubReleasesDatasource.id;
+      dep.depName = sourceParts.slice(1).join('/');
+    } else {
+      dep.skipReason = 'unsupported-datasource';
+      dep.depName = source;
+    }
+  } else {
+    logger.debug({ dep }, 'tflint plugin has no source');
+    dep.skipReason = 'no-source';
+  }
+
+  return dep;
+}
diff --git a/lib/modules/manager/tflint-plugin/readme.md b/lib/modules/manager/tflint-plugin/readme.md
new file mode 100644
index 0000000000..65ae61b319
--- /dev/null
+++ b/lib/modules/manager/tflint-plugin/readme.md
@@ -0,0 +1,4 @@
+Renovate maintains your [TFLint configuration file](https://github.com/terraform-linters/tflint/blob/master/docs/user-guide/config.md), and updates the plugins in the file.
+
+Supports plugins hosted in _public_ repositories on github.com.
+This is because TFLint only supports _public_ repositories.
diff --git a/lib/modules/manager/tflint-plugin/types.ts b/lib/modules/manager/tflint-plugin/types.ts
new file mode 100644
index 0000000000..7b3f2c710c
--- /dev/null
+++ b/lib/modules/manager/tflint-plugin/types.ts
@@ -0,0 +1,6 @@
+import type { PackageDependency } from '../types';
+
+export interface ExtractionResult {
+  lineNumber: number;
+  dependencies: PackageDependency[];
+}
diff --git a/lib/modules/manager/tflint-plugin/util.ts b/lib/modules/manager/tflint-plugin/util.ts
new file mode 100644
index 0000000000..9a598f0dbd
--- /dev/null
+++ b/lib/modules/manager/tflint-plugin/util.ts
@@ -0,0 +1,10 @@
+import { regEx } from '../../../util/regex';
+
+export const keyValueExtractionRegex = regEx(
+  /^\s*(?<key>[^\s]+)\s+=\s+"(?<value>[^"]+)"\s*$/
+);
+
+export function checkFileContainsPlugins(content: string): boolean {
+  const checkList = ['plugin '];
+  return checkList.some((check) => content.includes(check));
+}
-- 
GitLab