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