From 3802c924c3918fdb28617ac9561f0a4d21ca3da9 Mon Sep 17 00:00:00 2001 From: Kazuma Watanabe Date: Tue, 30 Apr 2024 16:52:14 +0900 Subject: [PATCH] plugin: Add support for host-specific GitHub tokens (#2025) * Add support for host-specific GitHub tokens * Update docs/user-guide/plugins.md Co-authored-by: Ben Drucker --------- Co-authored-by: Ben Drucker --- docs/user-guide/plugins.md | 14 ++++ plugin/install.go | 53 ++++++++++++++- plugin/install_test.go | 132 +++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/plugins.md b/docs/user-guide/plugins.md index 94d579416..1a6fbe8ec 100644 --- a/docs/user-guide/plugins.md +++ b/docs/user-guide/plugins.md @@ -74,6 +74,20 @@ To increase the rate limit, you can send an authenticated request by authenticat It's also a good idea to cache the plugin directory, as TFLint will only send requests if plugins aren't installed. The [setup-tflint action](https://github.com/terraform-linters/setup-tflint#usage) includes an example of caching in GitHub Actions. +If you host your plugins on GitHub Enterprise Server (GHES), you may need to use a different token than on GitHub.com. In this case, you can use a host-specific token like `GITHUB_TOKEN_example_com`. The hostname must be normalized with Punycode. Use "_" instead of "." and "__" instead of "-". + +```hcl +# GITHUB_TOKEN will be used +plugin "foo" { + source = "github.com/org/tflint-ruleset-foo" +} + +# GITHUB_TOKEN_example_com will be used preferentially and will fall back to GITHUB_TOKEN if not set. +plugin "bar" { + source = "example.com/org/tflint-ruleset-bar" +} +``` + ## Keeping plugins up to date We recommend using automatic updates to keep your plugin version up-to-date. [Renovate supports TFLint plugins](https://docs.renovatebot.com/modules/manager/tflint-plugin/) to easily set up automated update workflows. diff --git a/plugin/install.go b/plugin/install.go index c34f43741..33b49becc 100644 --- a/plugin/install.go +++ b/plugin/install.go @@ -10,9 +10,11 @@ import ( "os" "path/filepath" "runtime" + "strings" "github.com/google/go-github/v53/github" "github.com/terraform-linters/tflint/tflint" + "golang.org/x/net/idna" "golang.org/x/oauth2" ) @@ -204,6 +206,53 @@ func (c *InstallConfig) downloadToTempFile(asset *github.ReleaseAsset) (*os.File return file, nil } +// getGitHubToken gets a GitHub access token from environment variables. +// Environment variables are used in the following order of priority: +// +// - GITHUB_TOKEN_{source_host} (e.g. GITHUB_TOKEN_example_com) +// - GITHUB_TOKEN +// +// In most cases, GITHUB_TOKEN will meet your requirements, but GITHUB_TOKEN_{source_host} +// can be useful, for example if you are hosting your plugin on GHES. +// The host name must be normalized with Punycode, and "-" can be converted to "__" and "." to "-". +func (c *InstallConfig) getGitHubToken() string { + prefix := "GITHUB_TOKEN_" + for _, env := range os.Environ() { + eqIdx := strings.Index(env, "=") + if eqIdx < 0 { + continue + } + name := env[:eqIdx] + value := env[eqIdx+1:] + + if !strings.HasPrefix(name, prefix) { + continue + } + + rawHost := name[len(prefix):] + rawHost = strings.ReplaceAll(rawHost, "__", "-") + rawHost = strings.ReplaceAll(rawHost, "_", ".") + host, err := idna.Lookup.ToUnicode(rawHost) + if err != nil { + log.Printf(`[DEBUG] Failed to convert "%s" to Unicode format: %s`, rawHost, err) + continue + } + + if host != c.SourceHost { + continue + } + log.Printf("[DEBUG] %s set, plugin requests to %s will be authenticated", name, c.SourceHost) + return value + } + + if t := os.Getenv("GITHUB_TOKEN"); t != "" { + log.Printf("[DEBUG] GITHUB_TOKEN set, plugin requests to the GitHub API will be authenticated") + return t + } + + return "" +} + func extractFileFromZipFile(zipFile *os.File, savePath string) error { zipFileStat, err := zipFile.Stat() if err != nil { @@ -250,9 +299,7 @@ func newGitHubClient(ctx context.Context, config *InstallConfig) (*github.Client Transport: http.DefaultTransport, } - if t := os.Getenv("GITHUB_TOKEN"); t != "" { - log.Printf("[DEBUG] GITHUB_TOKEN set, plugin requests to the GitHub API will be authenticated") - + if t := config.getGitHubToken(); t != "" { hc = oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{ AccessToken: t, })) diff --git a/plugin/install_test.go b/plugin/install_test.go index 67e5279a9..1d9ef07fa 100644 --- a/plugin/install_test.go +++ b/plugin/install_test.go @@ -84,3 +84,135 @@ func TestNewGitHubClient(t *testing.T) { }) } } + +func TestGetGitHubToken(t *testing.T) { + tests := []struct { + name string + config *InstallConfig + envs map[string]string + want string + }{ + { + name: "no token", + config: &InstallConfig{ + PluginConfig: &tflint.PluginConfig{ + SourceHost: "github.com", + }, + }, + want: "", + }, + { + name: "GITHUB_TOKEN", + config: &InstallConfig{ + PluginConfig: &tflint.PluginConfig{ + SourceHost: "github.com", + }, + }, + envs: map[string]string{ + "GITHUB_TOKEN": "github_com_token", + }, + want: "github_com_token", + }, + { + name: "GITHUB_TOKEN_example_com", + config: &InstallConfig{ + PluginConfig: &tflint.PluginConfig{ + SourceHost: "example.com", + }, + }, + envs: map[string]string{ + "GITHUB_TOKEN_example_com": "example_com_token", + }, + want: "example_com_token", + }, + { + name: "GITHUB_TOKEN and GITHUB_TOKEN_example_com", + config: &InstallConfig{ + PluginConfig: &tflint.PluginConfig{ + SourceHost: "example.com", + }, + }, + envs: map[string]string{ + "GITHUB_TOKEN": "github_com_token", + "GITHUB_TOKEN_example_com": "example_com_token", + }, + want: "example_com_token", + }, + { + name: "GITHUB_TOKEN_example_com and GITHUB_TOKEN_example_org", + config: &InstallConfig{ + PluginConfig: &tflint.PluginConfig{ + SourceHost: "example.com", + }, + }, + envs: map[string]string{ + "GITHUB_TOKEN_example_com": "example_com_token", + "GITHUB_TOKEN_example_org": "example_org_token", + }, + want: "example_com_token", + }, + { + name: "GITHUB_TOKEN_{source_host} found, but source host is not matched", + config: &InstallConfig{ + PluginConfig: &tflint.PluginConfig{ + SourceHost: "example.org", + }, + }, + envs: map[string]string{ + "GITHUB_TOKEN_example_com": "example_com_token", + }, + want: "", + }, + { + name: "GITHUB_TOKEN_{source_host} and GITHUB_TOKEN found, but source host is not matched", + config: &InstallConfig{ + PluginConfig: &tflint.PluginConfig{ + SourceHost: "example.org", + }, + }, + envs: map[string]string{ + "GITHUB_TOKEN_example_com": "example_com_token", + "GITHUB_TOKEN": "github_com_token", + }, + want: "github_com_token", + }, + { + name: "GITHUB_TOKEN_xn--lhr645fjve.jp", + config: &InstallConfig{ + PluginConfig: &tflint.PluginConfig{ + SourceHost: "総務省.jp", + }, + }, + envs: map[string]string{ + "GITHUB_TOKEN_xn--lhr645fjve.jp": "mic_jp_token", + }, + want: "mic_jp_token", + }, + { + name: "GITHUB_TOKEN_xn____lhr645fjve_jp", + config: &InstallConfig{ + PluginConfig: &tflint.PluginConfig{ + SourceHost: "総務省.jp", + }, + }, + envs: map[string]string{ + "GITHUB_TOKEN_xn____lhr645fjve_jp": "mic_jp_token", + }, + want: "mic_jp_token", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "") + for k, v := range test.envs { + t.Setenv(k, v) + } + + got := test.config.getGitHubToken() + if got != test.want { + t.Errorf("got %q, want %q", got, test.want) + } + }) + } +}