diff --git a/.gitignore b/.gitignore index 0d02278b1..9860aed55 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /vendor /dist tflint-ruleset-* +!tflint-ruleset-*/ diff --git a/Makefile b/Makefile index b071fe48e..997e1c30f 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ install: go install e2e: prepare install - go test -timeout 5m ./integrationtest/inspection ./integrationtest/langserver ./integrationtest/bundled + go test -timeout 5m ./integrationtest/inspection ./integrationtest/langserver ./integrationtest/bundled ./integrationtest/init lint: go run golang.org/x/lint/golint --set_exit_status $$(go list ./...) diff --git a/README.md b/README.md index 75f3f78ca..8f651c303 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,27 @@ For AWS users, you can use the bundled plugin built into the TFLint binary witho Rules for the Terraform Language is built into the TFLint binary, so you don't need to install any plugins. Please see [Rules](docs/rules) for a list of available rules. +If you want to extend TFLint with other plugins, you can declare the plugins in the config file and easily install them with `tflint --init`. + +```hcl +plugin "foo" { + enabled = true + version = "0.1.0" + source = "github.com/org/tflint-ruleset-foo" + + signing_key = <<-KEY + -----BEGIN PGP PUBLIC KEY BLOCK----- + + mQINBFzpPOMBEADOat4P4z0jvXaYdhfy+UcGivb2XYgGSPQycTgeW1YuGLYdfrwz + 9okJj9pMMWgt/HpW8WrJOLv7fGecFT3eIVGDOzyT8j2GIRJdXjv8ZbZIn1Q+1V72 + AkqlyThflWOZf8GFrOw+UAR1OASzR00EDxC9BqWtW5YZYfwFUQnmhxU+9Cd92e6i + ... + KEY +} +``` + +See also [Configuring Plugins](docs/user-guide/plugins.md). + ## Usage TFLint inspects files under the current directory by default. You can change the behavior with the following options/arguments: @@ -70,6 +91,7 @@ Usage: Application Options: -v, --version Print TFLint version + --init Install plugins --langserver Start language server -f, --format=[default|json|checkstyle|junit|compact] Output format (default: default) -c, --config=FILE Config file name (default: .tflint.hcl) @@ -83,7 +105,7 @@ Application Options: --module Inspect modules --force Return zero exit status even if issues found --no-color Disable colorized output - --loglevel=[trace|debug|info|warn|error] Change the loglevel (default: none) + --loglevel=[trace|debug|info|warn|error] Change the loglevel Help Options: -h, --help Show this help message diff --git a/cmd/cli.go b/cmd/cli.go index b89a0f09c..0c1491984 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -90,6 +90,8 @@ func (cli *CLI) Run(args []string) int { switch { case opts.Version: return cli.printVersion(opts) + case opts.Init: + return cli.init(opts) case opts.Langserver: return cli.startLanguageServer(opts.Config, opts.toConfig()) case opts.ActAsAwsPlugin: diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 000000000..059582a76 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/fatih/color" + tfplugin "github.com/terraform-linters/tflint/plugin" + "github.com/terraform-linters/tflint/tflint" +) + +func (cli *CLI) init(opts Options) int { + cfg, err := tflint.LoadConfig(opts.Config) + if err != nil { + cli.formatter.Print(tflint.Issues{}, tflint.NewContextError("Failed to load TFLint config", err), map[string][]byte{}) + return ExitCodeError + } + + for _, pluginCfg := range cfg.Plugins { + installCfg := tfplugin.NewInstallConfig(pluginCfg) + + // If version or source is not set, you need to install it manually + if installCfg.ManuallyInstalled() { + continue + } + + _, err := tfplugin.FindPluginPath(installCfg) + if os.IsNotExist(err) { + fmt.Fprintf(cli.outStream, "Installing `%s` plugin...\n", pluginCfg.Name) + + sigchecker := tfplugin.NewSignatureChecker(installCfg) + if !sigchecker.HasSigningKey() { + color.New(color.FgYellow).Fprintln(cli.outStream, "No signing key configured. Set `signing_key` to verify that the release is signed by the plugin developer") + } + + _, err = installCfg.Install() + if err != nil { + cli.formatter.Print(tflint.Issues{}, tflint.NewContextError("Failed to install a plugin", err), map[string][]byte{}) + return ExitCodeError + } + + fmt.Fprintf(cli.outStream, "Installed `%s` (source: %s, version: %s)\n", pluginCfg.Name, pluginCfg.Source, pluginCfg.Version) + continue + } + + if err != nil { + cli.formatter.Print(tflint.Issues{}, tflint.NewContextError("Failed to find a plugin", err), map[string][]byte{}) + return ExitCodeError + } + + fmt.Fprintf(cli.outStream, "Plugin `%s` is already installed\n", pluginCfg.Name) + } + + return ExitCodeOK +} diff --git a/cmd/option.go b/cmd/option.go index e5f8136b7..68360191a 100644 --- a/cmd/option.go +++ b/cmd/option.go @@ -11,6 +11,7 @@ import ( // Options is an option specified by arguments. type Options struct { Version bool `short:"v" long:"version" description:"Print TFLint version"` + Init bool `long:"init" description:"Install plugins"` Langserver bool `long:"langserver" description:"Start language server"` Format string `short:"f" long:"format" description:"Output format" choice:"default" choice:"json" choice:"checkstyle" choice:"junit" choice:"compact" default:"default"` Config string `short:"c" long:"config" description:"Config file name" value-name:"FILE" default:".tflint.hcl"` diff --git a/docs/developer-guide/plugins.md b/docs/developer-guide/plugins.md index 00a19e68d..56bae1388 100644 --- a/docs/developer-guide/plugins.md +++ b/docs/developer-guide/plugins.md @@ -1,5 +1,59 @@ # Writing Plugins -If you want to add or change rules, you need to write plugins. When changing plugins, refer to the repository of each plugin and refer to how to build and install. +If you want to add custom rules, you can write ruleset plugins. -If you want to create a new plugin, please refer to [tflint-ruleset-template](https://github.com/terraform-linters/tflint-ruleset-template). The plugin can use [tflint-plugin-sdk](https://github.com/terraform-linters/tflint-plugin-sdk) to communicate with the host process. You can easily create a new repository from "Use this template". +## Overview + +Plugins are independent binaries and use [go-plugin](https://github.com/hashicorp/go-plugin) to communicate with TFLint over RPC. TFLint executes the binary when the plugin is enabled, and the plugin process must act as an RPC server for TFLint. + +If you want to create a new plugin, [The template repository](https://github.com/terraform-linters/tflint-ruleset-template) is available to satisfy these specification. You can create your own repository from "Use this template" and easily add rules based on some reference rules. + +The template repository uses the [SDK](https://github.com/terraform-linters/tflint-plugin-sdk) that wraps the go-plugin for communication with TFLint. See also the [Architecture](https://github.com/terraform-linters/tflint-plugin-sdk#architecture) section for the architecture of the plugin system. + +## 1. Creating a repository from the template + +Visit [tflint-ruleset-template](https://github.com/terraform-linters/tflint-ruleset-template) and click the "Use this template" button. Repository name must be `tflint-ruleset-*`. + +## 2. Building and installing the plugin + +The created repository can be installed locally with `make install`. Enable the plugin as follows and verify that the installed plugin works. + +```hcl +plugin "template" { + enabled = true +} +``` + +```console +$ make install +go build +mkdir -p ~/.tflint.d/plugins +mv ./tflint-ruleset-template ~/.tflint.d/plugins +$ tflint -v +TFLint version 0.28.1 ++ ruleset.template (0.1.0) +``` + +## 3. Changing/Adding the rules + +Rename the ruleset and add/edit rules. After making changes, you can check the behavior with `make install`. See also the [tflint-plugin-sdk API reference](https://pkg.go.dev/github.com/terraform-linters/tflint-plugin-sdk) for communication with the host process. + +## 4. Creating a GitHub Release + +You can build and install your own ruleset locally as described above, but you can also install it automatically with `tflint --init`. + +The requirements to support automatic installation are as follows: + +- The built plugin binaries must be published on GitHub Release +- The release must be tagged with a name like `v1.1.1` +- The release must contain an asset with a name like `tflint-ruleset-{name}_{GOOS}_{GOARCH}.zip` +- The zip file must contain a binary named `tflint-ruleset-{name}` (`tflint-ruleset-{name}.exe` in Windows) +- The release must contain a checksum file for the zip file with the name `checksums.txt` +- The checksum file must contain a sha256 hash and filename + +When signing a release, the release must additionally meet the following requirements: + +- The release must contain a signature file for the checksum file with the name `checksums.txt.sig` +- The signature file must be binary OpenPGP format + +Releases that meet these requirements can be easily created by following the GoReleaser config in the template repository. diff --git a/docs/user-guide/README.md b/docs/user-guide/README.md index 5fa1ea91a..68b8b2e5a 100644 --- a/docs/user-guide/README.md +++ b/docs/user-guide/README.md @@ -6,6 +6,7 @@ This guide describes the various features of TFLint for end users. - [Introduction](../../README.md) (README) - [Configuring TFLint](config.md) +- [Configuring Plugins](plugins.md) - [Module Inspection](module-inspection.md) - [Annotations](annotations.md) - [Compatibility with Terraform](compatibility.md) diff --git a/docs/user-guide/config.md b/docs/user-guide/config.md index 956d8be49..701dc7d24 100644 --- a/docs/user-guide/config.md +++ b/docs/user-guide/config.md @@ -5,7 +5,7 @@ You can change the behavior not only in CLI flags but also in config files. By d - Current directory (`./.tflint.hcl`) - Home directory (`~/.tflint.hcl`) -The config file is written in [HCL](https://github.com/hashicorp/hcl/tree/hcl2). An example is shown below: +The config file is written in [HCL](https://github.com/hashicorp/hcl). An example is shown below: ```hcl config { @@ -22,12 +22,14 @@ config { variables = ["foo=bar", "bar=[\"baz\"]"] } -rule "aws_instance_invalid_type" { - enabled = false -} - plugin "aws" { enabled = true + version = "0.4.0" + source = "github.com/terraform-linters/tflint-ruleset-aws" +} + +rule "aws_instance_invalid_type" { + enabled = false } ``` @@ -146,18 +148,4 @@ Each rule can have its own configs. See the documentation for each rule for deta ## `plugin` blocks -You can enable each plugin in the `plugin` block. All plugins have the `enabled` attribute, and when it is true, the plugin is enabled. - -```hcl -plugin "NAME" { - enabled = true -} -``` - -When the plugin is enabled, TFLint invokes the `tflint-ruleset-` (`tflint-ruleset-.exe` on Windows) binary in the `~/.tflint.d/plugins` (or `./.tflint.d/plugins`) directory. So you should install the binary into the directory in advance. - -**NOTE:** AWS plugin is bundled with the TFLint binary for backward compatibility, so you can use it without installing it separately. And it is automatically enabled when your Terraform configuration requires AWS provider. - -You can also change the plugin directory with the `TFLINT_PLUGIN_DIR` environment variable. - -Each plugin can have its own configs. See the documentation for each plugin for details. +You can declare the plugin to use. See [Configuring Plugins](plugins.md) diff --git a/docs/user-guide/plugins.md b/docs/user-guide/plugins.md new file mode 100644 index 000000000..8761b8393 --- /dev/null +++ b/docs/user-guide/plugins.md @@ -0,0 +1,73 @@ +# Configuring Plugins + +You can extend TFLint by installing any plugin. Declare plugins you want to use in the config file as follows: + +```hcl +plugin "foo" { + enabled = true + version = "0.1.0" + source = "github.com/org/tflint-ruleset-foo" + + signing_key = <<-KEY + -----BEGIN PGP PUBLIC KEY BLOCK----- + + mQINBFzpPOMBEADOat4P4z0jvXaYdhfy+UcGivb2XYgGSPQycTgeW1YuGLYdfrwz + 9okJj9pMMWgt/HpW8WrJOLv7fGecFT3eIVGDOzyT8j2GIRJdXjv8ZbZIn1Q+1V72 + AkqlyThflWOZf8GFrOw+UAR1OASzR00EDxC9BqWtW5YZYfwFUQnmhxU+9Cd92e6i + ... + KEY +} +``` + +After declaring the `version` and `source`, `tflint --init` can automatically install the plugin. + +```console +$ tflint --init +Installing `foo` plugin... +Installed `foo` (source: github.com/org/tflint-ruleset-foo, version: 0.1.0) +$ tflint -v +TFLint version 0.28.1 ++ ruleset.foo (0.1.0) +``` + +See also [Configuring TFLint](config.md) for the config file schema. + +## Attributes + +This section describes the attributes reserved by TFLint. Except for these, each plugin can extend the schema by defining any attributes/blocks. See the documentation for each plugin for details. + +### `enabled` (required) + +Enable the plugin. If set to false, the rules will not be used even if the plugin is installed. + +### `source` + +The source URL to install the plugin. Must be in the format `github.com/org/repo`. + +### `version` + +Plugin version. Do not prefix with "v". This attribute cannot be omitted when the `source` is set. Version constraints (like `>= 0.3`) are not supported. + +### `signing_key` + +Plugin developer's PGP public signing key. When this attribute is set, TFLint will automatically verify the signature of the checksum file downloaded from GitHub. It is recommended to set it to prevent supply chain attacks. + +Plugins under the terraform-linters organization (AWS/GCP/Azure ruleset plugins) can use the built-in signing key, so this attribute can be omitted. + +## Compatibility Notice + +AWS plugin is bundled with the TFLint binary for backward compatibility, so you can use it without installing it separately. And it is automatically enabled when your Terraform configuration requires AWS provider. + +## Advanced Usage + +You can also install the plugin manually. This is mainly useful for plugin development and for plugins that are not published on GitHub. In that case, omit the `source` and `version` attributes. + +```hcl +plugin "foo" { + enabled = true +} +``` + +When the plugin is enabled, TFLint invokes the `tflint-ruleset-` (`tflint-ruleset-.exe` on Windows) binary in the `~/.tflint.d/plugins` (or `./.tflint.d/plugins`) directory. So you should move the binary into the directory in advance. + +You can also change the plugin directory with the `TFLINT_PLUGIN_DIR` environment variable. diff --git a/go.mod b/go.mod index b5b6c12d2..845f682db 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/fatih/color v1.10.0 github.com/golang/mock v1.5.0 github.com/google/go-cmp v0.5.5 + github.com/google/go-github/v35 v35.2.0 github.com/hashicorp/go-plugin v1.4.1 github.com/hashicorp/go-version v1.3.0 github.com/hashicorp/hcl/v2 v2.10.0 @@ -21,5 +22,6 @@ require ( github.com/terraform-linters/tflint-plugin-sdk v0.8.2 github.com/terraform-linters/tflint-ruleset-aws v0.4.0 github.com/zclconf/go-cty v1.8.3 + golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a golang.org/x/lint v0.0.0-20200302205851-738671d3881b ) diff --git a/go.sum b/go.sum index 256ede282..b8929eb85 100644 --- a/go.sum +++ b/go.sum @@ -238,6 +238,9 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-github/v35 v35.2.0 h1:s/soW8jauhjUC3rh8JI0FePuocj0DEI9DNBg/bVplE8= +github.com/google/go-github/v35 v35.2.0/go.mod h1:s0515YVTI+IMrDoy9Y4pHt9ShGpzHvHO8rZ7L7acgvs= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= @@ -300,7 +303,6 @@ github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHh github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.3.0/go.mod h1:F9eH4LrE/ZsRdbwhfjs9k9HoDUwAHnYtXdgmf1AVNs0= -github.com/hashicorp/go-plugin v1.4.0 h1:b0O7rs5uiJ99Iu9HugEzsM67afboErkHUWddUSpUO3A= github.com/hashicorp/go-plugin v1.4.0/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ= github.com/hashicorp/go-plugin v1.4.1 h1:6UltRQlLN9iZO513VveELp5xyaFxVD2+1OVylE+2E+w= github.com/hashicorp/go-plugin v1.4.1/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ= @@ -311,7 +313,6 @@ github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhE github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= github.com/hashicorp/go-slug v0.4.1/go.mod h1:I5tq5Lv0E2xcNXNkmx7BSfzi1PsJ2cNjs3cC3LwyhK8= github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-tfe v0.8.1/go.mod h1:XAV72S4O1iP8BDaqiaPLmL2B4EE6almocnOn8E8stHc= github.com/hashicorp/go-tfe v0.14.0/go.mod h1:B71izbwmCZdhEo/GzHopCXN3P74cYv2tsff1mxY4J6c= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= @@ -328,7 +329,6 @@ github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f h1:UdxlrJz4JOnY8W+Db github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= github.com/hashicorp/hcl/v2 v2.0.0/go.mod h1:oVVDG71tEinNGYCxinCYadcmKU9bglqW9pV3txagJ90= github.com/hashicorp/hcl/v2 v2.3.0/go.mod h1:d+FwDBbOLvpAM3Z6J7gPj/VoAGkNe/gm352ZhjJ/Zv8= -github.com/hashicorp/hcl/v2 v2.9.1 h1:eOy4gREY0/ZQHNItlfuEZqtcQbXIxzojlP301hDpnac= github.com/hashicorp/hcl/v2 v2.9.1/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg= github.com/hashicorp/hcl/v2 v2.10.0 h1:1S1UnuhDGlv3gRFV4+0EdwB+znNP5HmcGbIqwnSCByg= github.com/hashicorp/hcl/v2 v2.10.0/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg= @@ -337,10 +337,6 @@ github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/memberlist v0.1.0/go.mod h1:ncdBp14cuox2iFOq3kDiquKU6fqsTBc3W6JvZwjxxsE= github.com/hashicorp/serf v0.0.0-20160124182025-e4ec8cc423bb/go.mod h1:h/Ru6tmZazX7WO/GDmwdpS975F019L4t5ng5IgwbNrE= -github.com/hashicorp/terraform v0.15.0 h1:Q6GHeuUuobJ85NT85scFoYpLSp+tVylBi3D4yCIk+p4= -github.com/hashicorp/terraform v0.15.0/go.mod h1:rijD3l786NWzFS8xfpKrf1dCXyTP5xElJymcC2iBpE0= -github.com/hashicorp/terraform v0.15.1 h1:dfu1/x3kf8zOTi/zDX5HiOaCukj0n4XB8D7lSo2F8cU= -github.com/hashicorp/terraform v0.15.1/go.mod h1:i8pxtLjDNjiMELBM49hWs4ClAV00Fxtn2dfglLO+wDo= github.com/hashicorp/terraform v0.15.3 h1:2QWbTj2xJ/8W1gCyIrd0WAqVF4weKPMYjx8nKjbkQjA= github.com/hashicorp/terraform v0.15.3/go.mod h1:w4eBEsluZfYumXUTLe834eqHh969AabcLqbj2WAYlM8= github.com/hashicorp/terraform-config-inspect v0.0.0-20210209133302-4fd17a0faac2 h1:l+bLFvHjqtgNQwWxwrFX9PemGAAO2P1AGZM7zlMNvCs= @@ -534,7 +530,6 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/svanharmelen/jsonapi v0.0.0-20180618144545-0c0828c3f16d/go.mod h1:BSTlc8jOjh0niykqEGVXOLXdi9o0r0kR8tCYiMvjFgw= github.com/tencentcloud/tencentcloud-sdk-go v3.0.82+incompatible/go.mod h1:0PfYow01SHPMhKY31xa+EFz2RStxIqj6JFAJS+IkCi4= github.com/tencentyun/cos-go-sdk-v5 v0.0.0-20190808065407-f07404cefc8c/go.mod h1:wk2XFUg6egk4tSDNZtXeKfe2G6690UVyt163PuUxBZk= github.com/terraform-linters/tflint-plugin-sdk v0.8.2 h1:A46VFbdBH8K1JwFjC4X5hbW35oL6gIoRxjlzzz27q7I= @@ -567,10 +562,7 @@ github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLE github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= github.com/zclconf/go-cty v1.2.1/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= -github.com/zclconf/go-cty v1.8.1 h1:SI0LqNeNxAgv2WWqWJMlG2/Ad/6aYJ7IVYYMigmfkuI= github.com/zclconf/go-cty v1.8.1/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= -github.com/zclconf/go-cty v1.8.2 h1:u+xZfBKgpycDnTNjPhGiTEYZS5qS/Sb5MqSfm7vzcjg= -github.com/zclconf/go-cty v1.8.2/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= github.com/zclconf/go-cty v1.8.3 h1:48gwZXrdSADU2UW9eZKHprxAI7APZGW9XmExpJpSjT0= github.com/zclconf/go-cty v1.8.3/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= @@ -601,8 +593,9 @@ golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= diff --git a/integrationtest/init/basic/.tflint.hcl b/integrationtest/init/basic/.tflint.hcl new file mode 100644 index 000000000..670d52d34 --- /dev/null +++ b/integrationtest/init/basic/.tflint.hcl @@ -0,0 +1,6 @@ +plugin "aws" { + enabled = true + + version = "0.4.0" + source = "github.com/terraform-linters/tflint-ruleset-aws" +} diff --git a/integrationtest/init/init_test.go b/integrationtest/init/init_test.go new file mode 100644 index 000000000..fe5a53408 --- /dev/null +++ b/integrationtest/init/init_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/terraform-linters/tflint/cmd" +) + +func TestIntegration(t *testing.T) { + current, _ := os.Getwd() + dir := filepath.Join(current, "basic") + + defer os.Chdir(current) + os.Chdir(dir) + + pluginDir := t.TempDir() + os.Setenv("TFLINT_PLUGIN_DIR", pluginDir) + defer os.Setenv("TFLINT_PLUGIN_DIR", "") + + outStream, errStream := new(bytes.Buffer), new(bytes.Buffer) + cli := cmd.NewCLI(outStream, errStream) + + cli.Run([]string{"./tflint"}) + if !strings.Contains(errStream.String(), "Plugin `aws` not found. Did you run `tflint --init`?") { + t.Fatalf("Expected to contain an initialization error, but did not: stdout=%s, stderr=%s", outStream, errStream) + } + + cli.Run([]string{"./tflint", "--init"}) + if !strings.Contains(outStream.String(), "Installing `aws` plugin...") { + t.Fatalf("Expected to contain an installation log, but did not: stdout=%s, stderr=%s", outStream, errStream) + } + if !strings.Contains(outStream.String(), "Installed `aws` (source: github.com/terraform-linters/tflint-ruleset-aws, version: 0.4.0)") { + t.Fatalf("Expected to contain an installed log, but did not: stdout=%s, stderr=%s", outStream, errStream) + } + + cli.Run([]string{"./tflint", "-v"}) + if !strings.Contains(outStream.String(), "ruleset.aws (0.4.0)") { + t.Fatalf("Expected to contain a plugin version output, but did not: stdout=%s, stderr=%s", outStream, errStream) + } + + cli.Run([]string{"./tflint", "--init"}) + if !strings.Contains(outStream.String(), "Plugin `aws` is already installed") { + t.Fatalf("Expected to contain an already installed log, but did not: stdout=%s, stderr=%s", outStream, errStream) + } +} diff --git a/plugin/checksum.go b/plugin/checksum.go new file mode 100644 index 000000000..5f7fe1d8b --- /dev/null +++ b/plugin/checksum.go @@ -0,0 +1,68 @@ +package plugin + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "strings" +) + +// Checksummer validates checksums +type Checksummer struct { + checksums map[string][]byte +} + +// NewChecksummer returns a new Checksummer from passed checksums.txt file. +// The checksums.txt must contain multiple lines containing sha256 hashes and filenames separated by spaces. +// An example is shown below: +// +// 3a61fff3689f27c89bce22893219919c629d2e10b96e7eadd5fef9f0e90bb353 tflint-ruleset-aws_darwin_amd64.zip +// 482419fdeed00692304e59558b5b0d915d4727868b88a5adbbbb76f5ed1b537a tflint-ruleset-aws_linux_amd64.zip +// db4eed4c0abcfb0b851da5bbfe8d0c71e1c2b6afe4fd627638a462c655045902 tflint-ruleset-aws_windows_amd64.zip +// +func NewChecksummer(f io.Reader) (*Checksummer, error) { + scanner := bufio.NewScanner(f) + + var line int + checksummer := &Checksummer{checksums: map[string][]byte{}} + for scanner.Scan() { + line++ + fields := strings.Fields(scanner.Text()) + // checksums file should have "hash" and "filename" fields + if len(fields) != 2 { + return nil, fmt.Errorf("record on line %d: wrong number of fields: expected=2, actual=%d", line, len(fields)) + } + hash := fields[0] + filename := fields[1] + + checksum, err := hex.DecodeString(hash) + if err != nil { + return nil, err + } + checksummer.checksums[filename] = checksum + } + if err := scanner.Err(); err != nil { + return nil, err + } + + return checksummer, nil +} + +// Verify calculates the sha256 hash of the passed file and compares it to the expected hash value based on the filename. +func (c *Checksummer) Verify(filename string, f io.Reader) error { + hash := sha256.New() + if _, err := io.Copy(hash, f); err != nil { + return err + } + + expected := c.checksums[filename] + actual := hash.Sum(nil) + if !bytes.Equal(actual, expected) { + return fmt.Errorf("Failed to match checksums: expected=%x, actual=%x", expected, actual) + } + + return nil +} diff --git a/plugin/checksum_test.go b/plugin/checksum_test.go new file mode 100644 index 000000000..f9bd7140a --- /dev/null +++ b/plugin/checksum_test.go @@ -0,0 +1,113 @@ +package plugin + +import ( + "errors" + "strings" + "testing" +) + +func Test_NewChecksummer(t *testing.T) { + cases := []struct { + Name string + Input string + Expected error + }{ + { + Name: "valid checksums", + Input: `3a61fff3689f27c89bce22893219919c629d2e10b96e7eadd5fef9f0e90bb353 tflint-ruleset-aws_darwin_amd64.zip +482419fdeed00692304e59558b5b0d915d4727868b88a5adbbbb76f5ed1b537a tflint-ruleset-aws_linux_amd64.zip +db4eed4c0abcfb0b851da5bbfe8d0c71e1c2b6afe4fd627638a462c655045902 tflint-ruleset-aws_windows_amd64.zip +`, + Expected: nil, + }, + { + Name: "No enough fields", + Input: `3a61fff3689f27c89bce22893219919c629d2e10b96e7eadd5fef9f0e90bb353 +482419fdeed00692304e59558b5b0d915d4727868b88a5adbbbb76f5ed1b537a +db4eed4c0abcfb0b851da5bbfe8d0c71e1c2b6afe4fd627638a462c655045902 +`, + Expected: errors.New("record on line 1: wrong number of fields: expected=2, actual=1"), + }, + { + Name: "Too many fields", + Input: `3a61fff3689f27c89bce22893219919c629d2e10b96e7eadd5fef9f0e90bb353 tflint-ruleset-aws_darwin_amd64.zip valid +482419fdeed00692304e59558b5b0d915d4727868b88a5adbbbb76f5ed1b537a tflint-ruleset-aws_linux_amd64.zip valid +db4eed4c0abcfb0b851da5bbfe8d0c71e1c2b6afe4fd627638a462c655045902 tflint-ruleset-aws_windows_amd64.zip valid +`, + Expected: errors.New("record on line 1: wrong number of fields: expected=2, actual=3"), + }, + } + + for _, tc := range cases { + _, err := NewChecksummer(strings.NewReader(tc.Input)) + + if err != nil { + if tc.Expected == nil { + t.Fatalf("Failed `%s`: Unexpected error `%s`", tc.Name, err) + } + if err.Error() != tc.Expected.Error() { + t.Fatalf("Failed `%s`: expected=%s, actual=%s", tc.Name, tc.Expected, err) + } + } else { + if tc.Expected != nil { + t.Fatalf("Failed `%s`: expected=%s, actual=no errors", tc.Name, tc.Expected) + } + } + } +} + +func Test_Checksummer_Verify(t *testing.T) { + cases := []struct { + Name string + Checksums string + Input string + Filename string + Error error + }{ + { + Name: "valid checksums", + Checksums: "f6f24a11d7cbbbc6d9440aca2eba0f6498755ca90adea14c5e233bf4c04bd928 text.txt", + Input: "foo bar baz\n", + Filename: "text.txt", + Error: nil, + }, + { + Name: "checksum mismatched", + Checksums: "f6f24a11d7cbbbc6d9440aca2eba0f6498755ca90adea14c5e233bf4c04bd928 text.txt", + Input: "baz baz foo\n", + Filename: "text.txt", + Error: errors.New("Failed to match checksums: expected=f6f24a11d7cbbbc6d9440aca2eba0f6498755ca90adea14c5e233bf4c04bd928, actual=29f7c2ecd78d7d4583604a9b8ac9c75eb5cb7ef9ee792bdd241dea563baad1b1"), + }, + { + Name: "file not found in checksums", + Checksums: "f6f24a11d7cbbbc6d9440aca2eba0f6498755ca90adea14c5e233bf4c04bd928 text.txt", + Input: "foo bar baz\n", + Filename: "text.png", + Error: errors.New("Failed to match checksums: expected=, actual=f6f24a11d7cbbbc6d9440aca2eba0f6498755ca90adea14c5e233bf4c04bd928"), + }, + } + + for _, tc := range cases { + checksumReader := strings.NewReader(tc.Checksums) + inputReader := strings.NewReader(tc.Input) + + checksummer, err := NewChecksummer(checksumReader) + if err != nil { + t.Fatalf("Failed `%s`: Unexpected error `%s`", tc.Name, err) + } + + err = checksummer.Verify(tc.Filename, inputReader) + if err != nil { + if tc.Error == nil { + t.Fatalf("Failed `%s`: Unexpected error `%s`", tc.Name, err) + } + if err.Error() != tc.Error.Error() { + t.Fatalf("Failed `%s`: expected=%s, actual=%s", tc.Name, tc.Error, err) + } + } else { + if tc.Error != nil { + t.Fatalf("Failed `%s`: expected=%s, actual=no errors", tc.Name, tc.Error) + } + } + } +} diff --git a/plugin/discovery.go b/plugin/discovery.go index 858e563f6..2bf5da2a2 100644 --- a/plugin/discovery.go +++ b/plugin/discovery.go @@ -16,41 +16,20 @@ import ( "github.com/terraform-linters/tflint/tflint" ) -// Discovery searches plugins according the passed configuration -// The search priority of plugins is as follows: -// -// 1. Current directory (./.tflint.d/plugins) -// 2. Home directory (~/.tflint.d/plugins) -// -// Files under these directories that satisfy the "tflint-ruleset-*" naming rules -// enabled in the configuration are treated as plugins. -// -// If the `TFLINT_PLUGIN_DIR` environment variable is set, ignore the above and refer to that directory. +// Discovery searches and launches plugins according the passed configuration. +// If the plugin is not enabled, skip without starting. +// The AWS plugin is treated specially. Plugins for which no version is specified will launch the bundled plugin +// instead of returning an error. This is a process for backward compatibility. func Discovery(config *tflint.Config) (*Plugin, error) { - if dir := os.Getenv("TFLINT_PLUGIN_DIR"); dir != "" { - return findPlugins(config, dir) - } - - if _, err := os.Stat(localPluginRoot); !os.IsNotExist(err) { - return findPlugins(config, localPluginRoot) - } - - pluginDir, err := homedir.Expand(PluginRoot) - if err != nil { - return nil, err - } - return findPlugins(config, pluginDir) -} - -func findPlugins(config *tflint.Config, dir string) (*Plugin, error) { clients := map[string]*plugin.Client{} rulesets := map[string]*tfplugin.Client{} for _, cfg := range config.Plugins { - pluginPath, err := getPluginPath(dir, cfg.Name) + installCfg := NewInstallConfig(cfg) + pluginPath, err := FindPluginPath(installCfg) var cmd *exec.Cmd if os.IsNotExist(err) { - if cfg.Name == "aws" { + if cfg.Name == "aws" && installCfg.ManuallyInstalled() { log.Print("[INFO] Plugin `aws` is not installed, but bundled plugins are available.") self, err := os.Executable() if err != nil { @@ -58,7 +37,14 @@ func findPlugins(config *tflint.Config, dir string) (*Plugin, error) { } cmd = exec.Command(self, "--act-as-aws-plugin") } else { - return nil, fmt.Errorf("Plugin `%s` not found in %s", cfg.Name, dir) + if installCfg.ManuallyInstalled() { + pluginDir, err := getPluginDir() + if err != nil { + return nil, err + } + return nil, fmt.Errorf("Plugin `%s` not found in %s", cfg.Name, pluginDir) + } + return nil, fmt.Errorf("Plugin `%s` not found. Did you run `tflint --init`?", cfg.Name) } } else { cmd = exec.Command(pluginPath) @@ -90,18 +76,56 @@ func findPlugins(config *tflint.Config, dir string) (*Plugin, error) { return &Plugin{RuleSets: rulesets, clients: clients}, nil } -func getPluginPath(dir string, name string) (string, error) { - pluginPath := filepath.Join(dir, fmt.Sprintf("tflint-ruleset-%s", name)) +// FindPluginPath returns the plugin binary path. +func FindPluginPath(config *InstallConfig) (string, error) { + dir, err := getPluginDir() + if err != nil { + return "", err + } + + path, err := findPluginPath(filepath.Join(dir, config.InstallPath())) + if err != nil { + return "", err + } + log.Printf("[DEBUG] Find plugin path: %s", path) + return path, err +} + +// getPluginDir returns the base plugin directory. +// Adopted with the following priorities: +// +// 1. `TFLINT_PLUGIN_DIR` environment variable +// 2. Current directory (./.tflint.d/plugins) +// 3. Home directory (~/.tflint.d/plugins) +// +// If the environment variable is set, other directories will not be considered, +// but if the current directory does not exist, it will fallback to the home directory. +func getPluginDir() (string, error) { + if dir := os.Getenv("TFLINT_PLUGIN_DIR"); dir != "" { + return dir, nil + } + + _, err := os.Stat(localPluginRoot) + if os.IsNotExist(err) { + return homedir.Expand(PluginRoot) + } + + return localPluginRoot, err +} - _, err := os.Stat(pluginPath) +// findPluginPath returns the path of the existing plugin. +// Only in the case of Windows, the pattern with the `.exe` is also considered, +// and if it exists, the extension is added to the argument. +func findPluginPath(path string) (string, error) { + _, err := os.Stat(path) if os.IsNotExist(err) && runtime.GOOS != "windows" { return "", os.ErrNotExist } else if !os.IsNotExist(err) { - return pluginPath, nil + return path, nil } - if _, err := os.Stat(pluginPath + ".exe"); !os.IsNotExist(err) { - return pluginPath + ".exe", nil + if _, err := os.Stat(path + ".exe"); !os.IsNotExist(err) { + return path + ".exe", nil } return "", os.ErrNotExist diff --git a/plugin/discovery_test.go b/plugin/discovery_test.go index d5d4f02f7..12c94947c 100644 --- a/plugin/discovery_test.go +++ b/plugin/discovery_test.go @@ -28,6 +28,8 @@ func Test_Discovery(t *testing.T) { "bar": { Name: "bar", Enabled: false, + Source: "github.com/terraform-linters/tflint-ruleset-bar", + Version: "0.1.0", }, }, }) @@ -58,7 +60,13 @@ func Test_Discovery_local(t *testing.T) { Plugins: map[string]*tflint.PluginConfig{ "foo": { Name: "foo", + Enabled: false, + }, + "bar": { + Name: "bar", Enabled: true, + Source: "github.com/terraform-linters/tflint-ruleset-bar", + Version: "0.1.0", }, }, }) @@ -88,6 +96,12 @@ func Test_Discovery_envVar(t *testing.T) { Name: "foo", Enabled: true, }, + "bar": { + Name: "bar", + Enabled: false, + Source: "github.com/terraform-linters/tflint-ruleset-bar", + Version: "0.1.0", + }, }, }) defer plugin.Clean() @@ -134,3 +148,193 @@ func Test_Discovery_notFound(t *testing.T) { t.Fatalf("The error message is not matched: want=%s, got=%s", expected, err.Error()) } } + +func Test_Discovery_notFoundForAutoInstallation(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(cwd) + + err = os.Chdir(filepath.Join(cwd, "test-fixtures", "no_plugins")) + if err != nil { + t.Fatal(err) + } + + original := PluginRoot + PluginRoot = filepath.Join(cwd, "test-fixtures", "no_plugins") + defer func() { PluginRoot = original }() + + _, err = Discovery(&tflint.Config{ + Plugins: map[string]*tflint.PluginConfig{ + "foo": { + Name: "foo", + Enabled: true, + Source: "github.com/terraform-linters/tflint-ruleset-foo", + Version: "0.1.0", + }, + }, + }) + + if err == nil { + t.Fatal("An error should have occurred, but it did not occur") + } + expected := "Plugin `foo` not found. Did you run `tflint --init`?" + if err.Error() != expected { + t.Fatalf("Error message not matched: want=%s, got=%s", expected, err.Error()) + } +} + +func Test_FindPluginPath(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + original := PluginRoot + PluginRoot = filepath.Join(cwd, "test-fixtures", "plugins") + defer func() { PluginRoot = original }() + + cases := []struct { + Name string + Input *InstallConfig + Expected string + }{ + { + Name: "manually installed", + Input: NewInstallConfig(&tflint.PluginConfig{Name: "foo", Enabled: true}), + Expected: filepath.Join(PluginRoot, "tflint-ruleset-foo"+fileExt()), + }, + { + Name: "auto installed", + Input: NewInstallConfig(&tflint.PluginConfig{ + Name: "bar", + Enabled: true, + Source: "github.com/terraform-linters/tflint-ruleset-bar", + Version: "0.1.0", + }), + Expected: filepath.Join(PluginRoot, "github.com/terraform-linters/tflint-ruleset-bar", "0.1.0", "tflint-ruleset-bar"+fileExt()), + }, + } + + for _, tc := range cases { + got, err := FindPluginPath(tc.Input) + if err != nil { + t.Fatalf("Unexpected error occurred %s", err) + } + if got != tc.Expected { + t.Fatalf("Failed `%s`: want=%s got=%s", tc.Name, tc.Expected, got) + } + } +} + +func Test_FindPluginPath_locals(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(cwd) + + dir := filepath.Join(cwd, "test-fixtures", "locals") + err = os.Chdir(dir) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + Name string + Input *InstallConfig + Expected string + }{ + { + Name: "manually installed", + Input: NewInstallConfig(&tflint.PluginConfig{Name: "foo", Enabled: true}), + Expected: filepath.Join(localPluginRoot, "tflint-ruleset-foo"+fileExt()), + }, + { + Name: "auto installed", + Input: NewInstallConfig(&tflint.PluginConfig{ + Name: "bar", + Enabled: true, + Source: "github.com/terraform-linters/tflint-ruleset-bar", + Version: "0.1.0", + }), + Expected: filepath.Join(localPluginRoot, "github.com/terraform-linters/tflint-ruleset-bar", "0.1.0", "tflint-ruleset-bar"+fileExt()), + }, + } + + for _, tc := range cases { + got, err := FindPluginPath(tc.Input) + if err != nil { + t.Fatalf("Unexpected error occurred %s", err) + } + if got != tc.Expected { + t.Fatalf("Failed `%s`: want=%s got=%s", tc.Name, tc.Expected, got) + } + } +} + +func Test_FindPluginPath_envVar(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + dir := filepath.Join(cwd, "test-fixtures", "locals", ".tflint.d", "plugins") + os.Setenv("TFLINT_PLUGIN_DIR", dir) + defer os.Setenv("TFLINT_PLUGIN_DIR", "") + + cases := []struct { + Name string + Input *InstallConfig + Expected string + }{ + { + Name: "manually installed", + Input: NewInstallConfig(&tflint.PluginConfig{Name: "foo", Enabled: true}), + Expected: filepath.Join(dir, "tflint-ruleset-foo"+fileExt()), + }, + { + Name: "auto installed", + Input: NewInstallConfig(&tflint.PluginConfig{ + Name: "bar", + Enabled: true, + Source: "github.com/terraform-linters/tflint-ruleset-bar", + Version: "0.1.0", + }), + Expected: filepath.Join(dir, "github.com/terraform-linters/tflint-ruleset-bar", "0.1.0", "tflint-ruleset-bar"+fileExt()), + }, + } + + for _, tc := range cases { + got, err := FindPluginPath(tc.Input) + if err != nil { + t.Fatalf("Unexpected error occurred %s", err) + } + if got != tc.Expected { + t.Fatalf("Failed `%s`: want=%s got=%s", tc.Name, tc.Expected, got) + } + } +} + +func Test_FindPluginPath_withoutExtensionInWindows(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + original := PluginRoot + PluginRoot = filepath.Join(cwd, "test-fixtures", "plugins") + defer func() { PluginRoot = original }() + + config := NewInstallConfig(&tflint.PluginConfig{Name: "baz", Enabled: true}) + expected := filepath.Join(PluginRoot, "tflint-ruleset-baz") + + got, err := FindPluginPath(config) + if err != nil { + t.Fatalf("Unexpected error occurred %s", err) + } + if got != expected { + t.Fatalf("Failed: want=%s got=%s", expected, got) + } +} diff --git a/plugin/install.go b/plugin/install.go new file mode 100644 index 000000000..f1ee56d66 --- /dev/null +++ b/plugin/install.go @@ -0,0 +1,243 @@ +package plugin + +import ( + "archive/zip" + "context" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + + "github.com/google/go-github/v35/github" + "github.com/terraform-linters/tflint/tflint" +) + +// InstallConfig is a config for plugin installation. +// This is a wrapper for PluginConfig and manages naming conventions +// and directory names for installation. +type InstallConfig struct { + *tflint.PluginConfig +} + +// NewInstallConfig returns a new InstallConfig from passed PluginConfig. +func NewInstallConfig(config *tflint.PluginConfig) *InstallConfig { + return &InstallConfig{PluginConfig: config} +} + +// ManuallyInstalled returns whether the plugin should be installed manually. +// If source or version is omitted, you will have to install it manually. +func (c *InstallConfig) ManuallyInstalled() bool { + return c.Version == "" || c.Source == "" +} + +// InstallPath returns an installation path from the plugin directory. +func (c *InstallConfig) InstallPath() string { + return filepath.Join(c.Source, c.Version, fmt.Sprintf("tflint-ruleset-%s", c.Name)) +} + +// TagName returns a tag name that the GitHub release should meet. +// The version must not contain leading "v", as the prefix "v" is added here, +// and the release tag must be in a format similar to `v1.1.1`. +func (c *InstallConfig) TagName() string { + return fmt.Sprintf("v%s", c.Version) +} + +// AssetName returns a name that the asset contained in the release should meet. +// The name must be in a format similar to `tflint-ruleset-aws_darwin_amd64.zip`. +func (c *InstallConfig) AssetName() string { + return fmt.Sprintf("tflint-ruleset-%s_%s_%s.zip", c.Name, runtime.GOOS, runtime.GOARCH) +} + +// Install fetches the release from GitHub and puts the binary in the plugin directory. +// This installation process will automatically check the checksum of the downloaded zip file. +// Therefore, the release must always contain a checksum file. +// In addition, the release must meet the following conventions: +// +// - The release must be tagged with a name like v1.1.1 +// - The release must contain an asset with a name like tflint-ruleset-{name}_{GOOS}_{GOARCH}.zip +// - The zip file must contain a binary named tflint-ruleset-{name} (tflint-ruleset-{name}.exe in Windows) +// - The release must contain a checksum file for the zip file with the name checksums.txt +// - The checksum file must contain a sha256 hash and filename +// +// For security, you can also make sure that the checksum file is signed correctly. +// In that case, the release must additionally meet the following conventions: +// +// - The release must contain a signature file for the checksum file with the name checksums.txt.sig +// - The signature file must be binary OpenPGP format +// +func (c *InstallConfig) Install() (string, error) { + dir, err := getPluginDir() + if err != nil { + return "", fmt.Errorf("Failed to get plugin dir: %s", err) + } + + path := filepath.Join(dir, c.InstallPath()+fileExt()) + log.Printf("[DEBUG] Mkdir plugin dir: %s", filepath.Dir(path)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return "", fmt.Errorf("Failed to mkdir to %s: %s", filepath.Dir(path), err) + } + + assets, err := c.fetchReleaseAssets() + if err != nil { + return "", fmt.Errorf("Failed to fetch GitHub releases: %s", err) + } + + log.Printf("[DEBUG] Download checksums.txt") + checksumsFile, err := c.downloadToTempFile(assets["checksums.txt"]) + if checksumsFile != nil { + defer os.Remove(checksumsFile.Name()) + } + if err != nil { + return "", fmt.Errorf("Failed to download checksums.txt: %s", err) + } + + sigchecker := NewSignatureChecker(c) + if sigchecker.HasSigningKey() { + log.Printf("[DEBUG] Download checksums.txt.sig") + signatureFile, err := c.downloadToTempFile(assets["checksums.txt.sig"]) + if signatureFile != nil { + defer os.Remove(signatureFile.Name()) + } + if err != nil { + return "", fmt.Errorf("Failed to download checksums.txt.sig: %s", err) + } + + if err := sigchecker.Verify(checksumsFile, signatureFile); err != nil { + return "", fmt.Errorf("Failed to check checksums.txt signature: %s", err) + } + if _, err := checksumsFile.Seek(0, 0); err != nil { + return "", fmt.Errorf("Failed to check checksums.txt signature: %s", err) + } + log.Printf("[DEBUG] Verified signature successfully") + } + + log.Printf("[DEBUG] Download %s", c.AssetName()) + zipFile, err := c.downloadToTempFile(assets[c.AssetName()]) + if zipFile != nil { + defer os.Remove(zipFile.Name()) + } + if err != nil { + return "", fmt.Errorf("Failed to download %s: %s", c.AssetName(), err) + } + + checksummer, err := NewChecksummer(checksumsFile) + if err != nil { + return "", fmt.Errorf("Failed to parse checksums file: %s", err) + } + if err = checksummer.Verify(c.AssetName(), zipFile); err != nil { + return "", fmt.Errorf("Failed to verify checksums: %s", err) + } + log.Printf("[DEBUG] Matched checksum successfully") + + if err = extractFileFromZipFile(zipFile, path); err != nil { + return "", fmt.Errorf("Failed to extract binary from %s: %s", c.AssetName(), err) + } + + log.Printf("[DEBUG] Installed %s successfully", path) + return path, nil +} + +// fetchReleaseAssets fetches assets from the GitHub release. +// The release is determined by the source path and tag name. +func (c *InstallConfig) fetchReleaseAssets() (map[string]*github.ReleaseAsset, error) { + assets := map[string]*github.ReleaseAsset{} + + client := github.NewClient(nil) + ctx := context.Background() + + log.Printf("[DEBUG] Request to https://api.github.com/repos/%s/%s/releases/tags/%s", c.SourceOwner, c.SourceRepo, c.TagName()) + release, _, err := client.Repositories.GetReleaseByTag(ctx, c.SourceOwner, c.SourceRepo, c.TagName()) + if err != nil { + return assets, err + } + + for _, asset := range release.Assets { + log.Printf("[DEBUG] asset found: %s", asset.GetName()) + assets[asset.GetName()] = asset + } + return assets, nil +} + +// downloadToTempFile download assets from GitHub to a local temp file. +// It is the caller's responsibility to delete the generated the temp file. +func (c *InstallConfig) downloadToTempFile(asset *github.ReleaseAsset) (*os.File, error) { + if asset == nil { + return nil, fmt.Errorf("file not found in the GitHub release. Does the release contain the file with the correct name ?") + } + + client := github.NewClient(nil) + ctx := context.Background() + + log.Printf("[DEBUG] Request to https://api.github.com/repos/%s/%s/releases/assets/%d", c.SourceOwner, c.SourceRepo, asset.GetID()) + downloader, _, err := client.Repositories.DownloadReleaseAsset(ctx, c.SourceOwner, c.SourceRepo, asset.GetID(), http.DefaultClient) + if err != nil { + return nil, err + } + + file, err := ioutil.TempFile("", "tflint-download-temp-file-*") + if err != nil { + return nil, err + } + if _, err = io.Copy(file, downloader); err != nil { + return file, err + } + downloader.Close() + if _, err := file.Seek(0, 0); err != nil { + return file, err + } + + log.Printf("[DEBUG] Downloaded to %s", file.Name()) + return file, nil +} + +func extractFileFromZipFile(zipFile *os.File, savePath string) error { + zipFileStat, err := zipFile.Stat() + if err != nil { + return err + } + zipReader, err := zip.NewReader(zipFile, zipFileStat.Size()) + if err != nil { + return err + } + + var reader io.ReadCloser + for _, f := range zipReader.File { + log.Printf("[DEBUG] file found in zip: %s", f.Name) + if f.Name != filepath.Base(savePath) { + continue + } + + reader, err = f.Open() + if err != nil { + return err + } + break + } + if reader == nil { + return fmt.Errorf("file not found. Does the zip contain %s ?", filepath.Base(savePath)) + } + + file, err := os.OpenFile(savePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer file.Close() + + if _, err := io.Copy(file, reader); err != nil { + os.Remove(file.Name()) + return err + } + + return nil +} + +func fileExt() string { + if runtime.GOOS == "windows" { + return ".exe" + } + return "" +} diff --git a/plugin/install_test.go b/plugin/install_test.go new file mode 100644 index 000000000..63714c099 --- /dev/null +++ b/plugin/install_test.go @@ -0,0 +1,42 @@ +package plugin + +import ( + "os" + "testing" + + "github.com/terraform-linters/tflint/tflint" +) + +func Test_Install(t *testing.T) { + original := PluginRoot + PluginRoot = t.TempDir() + defer func() { PluginRoot = original }() + + config := NewInstallConfig(&tflint.PluginConfig{ + Name: "aws", + Enabled: true, + Version: "0.4.0", + Source: "github.com/terraform-linters/tflint-ruleset-aws", + SourceOwner: "terraform-linters", + SourceRepo: "tflint-ruleset-aws", + }) + + path, err := config.Install() + if err != nil { + t.Fatalf("Failed to install: %s", err) + } + file, err := os.Open(path) + if err != nil { + t.Fatalf("Failed to open installed binary: %s", err) + } + info, err := file.Stat() + if err != nil { + t.Fatalf("Failed to stat installed binary: %s", err) + } + file.Close() + + expected := "tflint-ruleset-aws" + fileExt() + if info.Name() != expected { + t.Fatalf("Installed binary name is invalid: expected=%s, got=%s", expected, info.Name()) + } +} diff --git a/plugin/signature.go b/plugin/signature.go new file mode 100644 index 000000000..3684f3118 --- /dev/null +++ b/plugin/signature.go @@ -0,0 +1,161 @@ +package plugin + +import ( + "fmt" + "io" + "strings" + + "golang.org/x/crypto/openpgp" +) + +// SignatureChecker checks the signature of GitHub releases. +// Determines whether to select a signing key or skip it based on the InstallConfig. +type SignatureChecker struct { + config *InstallConfig +} + +// NewSignatureChecker returns a new SignatureChecker from passed InstallConfig. +func NewSignatureChecker(config *InstallConfig) *SignatureChecker { + return &SignatureChecker{config: config} +} + +// GetSigningKey returns an ASCII armored signing key. +// If the plugin is under the terraform-linters organization, you can use the built-in key even if the signing_key is omitted. +func (c *SignatureChecker) GetSigningKey() string { + if c.config.SigningKey != "" { + return c.config.SigningKey + } + if c.config.SourceOwner == "terraform-linters" { + return builtinSigningKey + } + return c.config.SigningKey +} + +// HasSigningKey determines whether the checker should verify the signature. +// Skip verification if no signing key is set. +func (c *SignatureChecker) HasSigningKey() bool { + return c.GetSigningKey() != "" +} + +// Verify returns the results of signature verification. +// The signing key must be ASCII armored and the signature must be in binary OpenPGP format. +func (c *SignatureChecker) Verify(target, signature io.Reader) error { + key := c.GetSigningKey() + if key == "" { + return fmt.Errorf("No signing key configured") + } + + reader := strings.NewReader(key) + keyring, err := openpgp.ReadArmoredKeyRing(reader) + if err != nil { + return err + } + + _, err = openpgp.CheckDetachedSignature(keyring, target, signature) + if err != nil { + return err + } + return nil +} + +// builtinSigningKey is the default signing key that applies only to plugins under the terraform-linters organization. +// This makes it possible for the plugins we distribute to be used safely without having to set signing key. +var builtinSigningKey string = ` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=oseb +-----END PGP PUBLIC KEY BLOCK-----` diff --git a/plugin/signature_test.go b/plugin/signature_test.go new file mode 100644 index 000000000..7527902d2 --- /dev/null +++ b/plugin/signature_test.go @@ -0,0 +1,327 @@ +package plugin + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/terraform-linters/tflint/tflint" +) + +func Test_GetSigningKey(t *testing.T) { + cases := []struct { + Name string + Config *InstallConfig + Expected string + }{ + { + Name: "no signing key", + Config: NewInstallConfig(&tflint.PluginConfig{SigningKey: ""}), + Expected: "", + }, + { + Name: "configured singing key", + Config: NewInstallConfig(&tflint.PluginConfig{SigningKey: testSigningKey}), + Expected: testSigningKey, + }, + { + Name: "bulit-in signing key", + Config: NewInstallConfig(&tflint.PluginConfig{SigningKey: "", SourceOwner: "terraform-linters"}), + Expected: builtinSigningKey, + }, + { + Name: "bulit-in signing key and configured signing key", + Config: NewInstallConfig(&tflint.PluginConfig{SigningKey: testSigningKey, SourceOwner: "terraform-linters"}), + Expected: testSigningKey, + }, + } + + for _, tc := range cases { + sigchecker := NewSignatureChecker(tc.Config) + + got := sigchecker.GetSigningKey() + if got != tc.Expected { + t.Fatalf("Failed `%s`: expected=%s, got=%s", tc.Name, tc.Expected, got) + } + } +} + +func Test_HasSigningKey(t *testing.T) { + cases := []struct { + Name string + Config *InstallConfig + Expected bool + }{ + { + Name: "no signing key", + Config: NewInstallConfig(&tflint.PluginConfig{SigningKey: ""}), + Expected: false, + }, + { + Name: "configured singing key", + Config: NewInstallConfig(&tflint.PluginConfig{SigningKey: testSigningKey}), + Expected: true, + }, + { + Name: "bulit-in signing key", + Config: NewInstallConfig(&tflint.PluginConfig{SigningKey: "", SourceOwner: "terraform-linters"}), + Expected: true, + }, + { + Name: "bulit-in signing key and configured signing key", + Config: NewInstallConfig(&tflint.PluginConfig{SigningKey: testSigningKey, SourceOwner: "terraform-linters"}), + Expected: true, + }, + } + + for _, tc := range cases { + sigchecker := NewSignatureChecker(tc.Config) + + got := sigchecker.HasSigningKey() + if got != tc.Expected { + t.Fatalf("Failed `%s`: expected=%t, got=%t", tc.Name, tc.Expected, got) + } + } +} + +func Test_SignatureChecker_Verify(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + signature, err := os.Open(filepath.Join(cwd, "test-fixtures", "signatures", "checksums.txt.sig")) + if err != nil { + t.Fatalf("Unexpected error occurred: %s", err) + } + defer signature.Close() + + target := `003432556f0380963e6802e701d40a5ea303cfd8ad0cd34719b00c796abfe90e tflint-ruleset-aws_netbsd_amd64.zip +02caedcd1f0e9c331862b0babce83792d95021977595be9712b01dc0164c488d tflint-ruleset-aws_netbsd_arm.zip +03ed11c1deeb4dbfc1656b030e4172d1f6b03a1257f219f6265bfc100b20c7a8 tflint-ruleset-aws_netbsd_386.zip +043416079a7ea9e0f7888915278aecda7d6268b61066deacc50feefb6e57836c tflint-ruleset-aws_linux_arm.zip +30fb5e2d8d8ca3be7247cb0f9e644e3accf0b334d473ce6a5b83151302183e22 tflint-ruleset-aws_openbsd_amd64.zip +3a61fff3689f27c89bce22893219919c629d2e10b96e7eadd5fef9f0e90bb353 tflint-ruleset-aws_darwin_amd64.zip +482419fdeed00692304e59558b5b0d915d4727868b88a5adbbbb76f5ed1b537a tflint-ruleset-aws_linux_amd64.zip +7147440b689291870ffafd40652a9a623df6c43557da3d2c90e712065e0d093f tflint-ruleset-aws_freebsd_386.zip +7c7154183f3faf4c80c6703969f5f4903f795eebb881b06da40caa4827eb48e4 tflint-ruleset-aws_windows_386.zip +9df843eb85785246df1e24886b1112b324444dd02329ff45c5cffa597f674b6c tflint-ruleset-aws_openbsd_arm.zip +b6231a2b94a71841409bb28be0f22eb6ed9de744f0c06ca3a9dd6f80cbc63956 tflint-ruleset-aws_linux_386.zip +bd804df0ff957cda8210c74017211face850cd62445a7d0102493ab5cfdc4276 tflint-ruleset-aws_freebsd_amd64.zip +bee3591d764729769fd32dae7dc8147a890aadc45c00260fa6dafe2df1585a02 tflint-ruleset-aws_openbsd_386.zip +db4eed4c0abcfb0b851da5bbfe8d0c71e1c2b6afe4fd627638a462c655045902 tflint-ruleset-aws_windows_amd64.zip +dd536fed0ebe4c1115240574c5dd7a31b563d67bfe0d1111750438718f995d43 tflint-ruleset-aws_freebsd_arm.zip +` + reader := strings.NewReader(target) + + sigchecker := NewSignatureChecker(NewInstallConfig(&tflint.PluginConfig{SigningKey: builtinSigningKey})) + if err := sigchecker.Verify(reader, signature); err != nil { + t.Fatalf("Verify failed: %s", err) + } +} + +func Test_SignatureChecker_Verify_errors(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + signature, err := os.Open(filepath.Join(cwd, "test-fixtures", "signatures", "checksums.txt.sig")) + if err != nil { + t.Fatalf("Unexpected error occurred: %s", err) + } + defer signature.Close() + brokenSignature := strings.NewReader("broken") + + target := `003432556f0380963e6802e701d40a5ea303cfd8ad0cd34719b00c796abfe90e tflint-ruleset-aws_netbsd_amd64.zip +02caedcd1f0e9c331862b0babce83792d95021977595be9712b01dc0164c488d tflint-ruleset-aws_netbsd_arm.zip +03ed11c1deeb4dbfc1656b030e4172d1f6b03a1257f219f6265bfc100b20c7a8 tflint-ruleset-aws_netbsd_386.zip +043416079a7ea9e0f7888915278aecda7d6268b61066deacc50feefb6e57836c tflint-ruleset-aws_linux_arm.zip +30fb5e2d8d8ca3be7247cb0f9e644e3accf0b334d473ce6a5b83151302183e22 tflint-ruleset-aws_openbsd_amd64.zip +3a61fff3689f27c89bce22893219919c629d2e10b96e7eadd5fef9f0e90bb353 tflint-ruleset-aws_darwin_amd64.zip +482419fdeed00692304e59558b5b0d915d4727868b88a5adbbbb76f5ed1b537a tflint-ruleset-aws_linux_amd64.zip +7147440b689291870ffafd40652a9a623df6c43557da3d2c90e712065e0d093f tflint-ruleset-aws_freebsd_386.zip +7c7154183f3faf4c80c6703969f5f4903f795eebb881b06da40caa4827eb48e4 tflint-ruleset-aws_windows_386.zip +9df843eb85785246df1e24886b1112b324444dd02329ff45c5cffa597f674b6c tflint-ruleset-aws_openbsd_arm.zip +b6231a2b94a71841409bb28be0f22eb6ed9de744f0c06ca3a9dd6f80cbc63956 tflint-ruleset-aws_linux_386.zip +bd804df0ff957cda8210c74017211face850cd62445a7d0102493ab5cfdc4276 tflint-ruleset-aws_freebsd_amd64.zip +bee3591d764729769fd32dae7dc8147a890aadc45c00260fa6dafe2df1585a02 tflint-ruleset-aws_openbsd_386.zip +db4eed4c0abcfb0b851da5bbfe8d0c71e1c2b6afe4fd627638a462c655045902 tflint-ruleset-aws_windows_amd64.zip +dd536fed0ebe4c1115240574c5dd7a31b563d67bfe0d1111750438718f995d43 tflint-ruleset-aws_freebsd_arm.zip +` + + cases := []struct { + Name string + Config *InstallConfig + Target string + Signature io.Reader + Expected error + }{ + { + Name: "invalid signature", + Config: NewInstallConfig(&tflint.PluginConfig{SigningKey: builtinSigningKey}), + Target: "broken", + Signature: signature, + Expected: fmt.Errorf("openpgp: invalid signature: hash tag doesn't match"), + }, + { + Name: "no signing key", + Config: NewInstallConfig(&tflint.PluginConfig{SigningKey: ""}), + Target: target, + Signature: signature, + Expected: fmt.Errorf("No signing key configured"), + }, + { + Name: "broken signing key", + Config: NewInstallConfig(&tflint.PluginConfig{SigningKey: "broken"}), + Target: target, + Signature: signature, + Expected: fmt.Errorf("openpgp: invalid argument: no armored data found"), + }, + { + Name: "broken signature", + Config: NewInstallConfig(&tflint.PluginConfig{SigningKey: builtinSigningKey}), + Target: target, + Signature: brokenSignature, + Expected: fmt.Errorf("openpgp: invalid data: tag byte does not have MSB set"), + }, + } + + for _, tc := range cases { + sigchecker := NewSignatureChecker(tc.Config) + reader := strings.NewReader(tc.Target) + + err := sigchecker.Verify(reader, tc.Signature) + if err == nil { + t.Fatalf("Failed `%s`: expected=%s, actual=no errors", tc.Name, tc.Expected) + } + if err.Error() != tc.Expected.Error() { + t.Fatalf("Failed `%s`: expected=%s, actual=%s", tc.Name, tc.Expected, err) + } + } +} + +var testSigningKey string = ` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +ZF5q4h4I33PSGDdSvGXn9UMY5Isjpg== +=7pIB +-----END PGP PUBLIC KEY BLOCK-----` diff --git a/plugin/stub-generator/main.go b/plugin/stub-generator/main.go index 6ac9238fb..7f4b63367 100644 --- a/plugin/stub-generator/main.go +++ b/plugin/stub-generator/main.go @@ -22,7 +22,10 @@ func main() { // Package "plugin" testing execCommand("go", "build", "-o", "../test-fixtures/plugins/tflint-ruleset-foo"+fileExt(), "./sources/foo/main.go") execCommand("cp", "../test-fixtures/plugins/tflint-ruleset-foo"+fileExt(), "../test-fixtures/locals/.tflint.d/plugins/tflint-ruleset-foo"+fileExt()) - execCommand("go", "build", "-o", "../test-fixtures/plugins/tflint-ruleset-bar"+fileExt(), "./sources/bar/main.go") + execCommand("go", "build", "-o", "../test-fixtures/plugins/github.com/terraform-linters/tflint-ruleset-bar/0.1.0/tflint-ruleset-bar"+fileExt(), "./sources/bar/main.go") + execCommand("cp", "../test-fixtures/plugins/github.com/terraform-linters/tflint-ruleset-bar/0.1.0/tflint-ruleset-bar"+fileExt(), "../test-fixtures/locals/.tflint.d/plugins/github.com/terraform-linters/tflint-ruleset-bar/0.1.0/tflint-ruleset-bar"+fileExt()) + // Without .exe in Windows + execCommand("cp", "../test-fixtures/plugins/tflint-ruleset-foo"+fileExt(), "../test-fixtures/plugins/tflint-ruleset-baz") pluginDir, err := homedir.Expand("~/.tflint.d/plugins") if err != nil { diff --git a/plugin/test-fixtures/locals/.tflint.d/plugins/github.com/terraform-linters/tflint-ruleset-bar/0.1.0/.gitkeep b/plugin/test-fixtures/locals/.tflint.d/plugins/github.com/terraform-linters/tflint-ruleset-bar/0.1.0/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/plugin/test-fixtures/plugins/github.com/terraform-linters/tflint-ruleset-bar/0.1.0/.gitkeep b/plugin/test-fixtures/plugins/github.com/terraform-linters/tflint-ruleset-bar/0.1.0/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/plugin/test-fixtures/signatures/checksums.txt.sig b/plugin/test-fixtures/signatures/checksums.txt.sig new file mode 100644 index 000000000..884c32cb6 Binary files /dev/null and b/plugin/test-fixtures/signatures/checksums.txt.sig differ diff --git a/tflint/config.go b/tflint/config.go index 0ed66593a..e0e34bfbd 100644 --- a/tflint/config.go +++ b/tflint/config.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "strings" hcl "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" @@ -66,9 +67,17 @@ type RuleConfig struct { // PluginConfig is a TFLint's plugin config type PluginConfig struct { - Name string `hcl:"name,label"` - Enabled bool `hcl:"enabled"` - Body hcl.Body `hcl:",remain"` + Name string `hcl:"name,label"` + Enabled bool `hcl:"enabled"` + Version string `hcl:"version,optional"` + Source string `hcl:"source,optional"` + SigningKey string `hcl:"signing_key,optional"` + + Body hcl.Body `hcl:",remain"` + + // Parsed source attributes + SourceOwner string + SourceRepo string // file is the result of parsing the HCL file that declares the plugin configuration. file *hcl.File @@ -306,6 +315,10 @@ plugin "aws" { } for _, plugin := range cfg.Plugins { plugin.file = f + + if err := plugin.validate(); err != nil { + return nil, err + } } log.Printf("[DEBUG] Config loaded") @@ -432,3 +445,28 @@ func configBodyRange(body hcl.Body) hcl.Range { } return bodyRange } + +func (c *PluginConfig) validate() error { + if c.Version != "" && c.Source == "" { + return fmt.Errorf("plugin `%s`: `source` attribute cannot be omitted when specifying `version`", c.Name) + } + + if c.Source != "" { + if c.Version == "" { + return fmt.Errorf("plugin `%s`: `version` attribute cannot be omitted when specifying `source`", c.Name) + } + + parts := strings.Split(c.Source, "/") + // Expected `github.com/owner/repo` format + if len(parts) != 3 { + return fmt.Errorf("plugin `%s`: `source` is invalid. Must be in the format `github.com/owner/repo`", c.Name) + } + if parts[0] != "github.com" { + return fmt.Errorf("plugin `%s`: `source` is invalid. Hostname must be `github.com`", c.Name) + } + c.SourceOwner = parts[1] + c.SourceRepo = parts[2] + } + + return nil +} diff --git a/tflint/config_test.go b/tflint/config_test.go index a01b71501..d986e481e 100644 --- a/tflint/config_test.go +++ b/tflint/config_test.go @@ -54,8 +54,13 @@ func Test_LoadConfig(t *testing.T) { Enabled: true, }, "bar": { - Name: "bar", - Enabled: false, + Name: "bar", + Enabled: false, + Version: "0.1.0", + Source: "github.com/foo/bar", + SigningKey: "SIGNING_KEY", + SourceOwner: "foo", + SourceRepo: "bar", }, }, }, @@ -180,6 +185,26 @@ plugin "aws" { access_key = ... }`, }, + { + Name: "plugin without source", + File: filepath.Join(currentDir, "test-fixtures", "config", "plugin_without_source.hcl"), + Expected: "plugin `foo`: `source` attribute cannot be omitted when specifying `version`", + }, + { + Name: "plugin without version", + File: filepath.Join(currentDir, "test-fixtures", "config", "plugin_without_version.hcl"), + Expected: "plugin `foo`: `version` attribute cannot be omitted when specifying `source`", + }, + { + Name: "plugin with invalid source", + File: filepath.Join(currentDir, "test-fixtures", "config", "plugin_with_invalid_source.hcl"), + Expected: "plugin `foo`: `source` is invalid. Must be in the format `github.com/owner/repo`", + }, + { + Name: "plugin with invalid source host", + File: filepath.Join(currentDir, "test-fixtures", "config", "plugin_with_invalid_source_host.hcl"), + Expected: "plugin `foo`: `source` is invalid. Hostname must be `github.com`", + }, } for _, tc := range cases { diff --git a/tflint/test-fixtures/config/config.hcl b/tflint/test-fixtures/config/config.hcl index 406c255fe..3f6dbfab7 100644 --- a/tflint/test-fixtures/config/config.hcl +++ b/tflint/test-fixtures/config/config.hcl @@ -25,4 +25,7 @@ plugin "foo" { plugin "bar" { enabled = false + version = "0.1.0" + source = "github.com/foo/bar" + signing_key = "SIGNING_KEY" } diff --git a/tflint/test-fixtures/config/plugin_with_invalid_source.hcl b/tflint/test-fixtures/config/plugin_with_invalid_source.hcl new file mode 100644 index 000000000..4dc1d00f2 --- /dev/null +++ b/tflint/test-fixtures/config/plugin_with_invalid_source.hcl @@ -0,0 +1,6 @@ +plugin "foo" { + enabled = true + + version = "0.1.0" + source = "github.com/foo/bar/baz" +} diff --git a/tflint/test-fixtures/config/plugin_with_invalid_source_host.hcl b/tflint/test-fixtures/config/plugin_with_invalid_source_host.hcl new file mode 100644 index 000000000..e3a74140c --- /dev/null +++ b/tflint/test-fixtures/config/plugin_with_invalid_source_host.hcl @@ -0,0 +1,6 @@ +plugin "foo" { + enabled = true + + version = "0.1.0" + source = "gitlab.com/foo/bar" +} diff --git a/tflint/test-fixtures/config/plugin_without_source.hcl b/tflint/test-fixtures/config/plugin_without_source.hcl new file mode 100644 index 000000000..d59bf68cb --- /dev/null +++ b/tflint/test-fixtures/config/plugin_without_source.hcl @@ -0,0 +1,5 @@ +plugin "foo" { + enabled = true + + version = "0.1.0" +} diff --git a/tflint/test-fixtures/config/plugin_without_version.hcl b/tflint/test-fixtures/config/plugin_without_version.hcl new file mode 100644 index 000000000..f75bcd8e7 --- /dev/null +++ b/tflint/test-fixtures/config/plugin_without_version.hcl @@ -0,0 +1,5 @@ +plugin "foo" { + enabled = true + + source = "github.com/foo/bar" +}