diff --git a/.chglog/CHANGELOG.tpl.md b/.chglog/CHANGELOG.tpl.md new file mode 100644 index 0000000..1c61940 --- /dev/null +++ b/.chglog/CHANGELOG.tpl.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +This file is automatically generated by [git-chglog](https://github.com/git-chglog/git-chglog). Don't edit by hand. + +{{range .Versions}} + +## {{if .Tag.Previous}}[{{.Tag.Name}}]({{$.Info.RepositoryURL}}/compare/{{.Tag.Previous.Name}}...{{.Tag.Name}}){{else}}{{.Tag.Name}}{{end}} ({{datetime "2006-01-02" .Tag.Date}}) +{{range .CommitGroups}} +### {{.Title}} +{{range .Commits}} +* {{if ne .Scope ""}}**{{.Scope}}:** {{end}}{{.Subject}}{{end}} +{{end}}{{if .RevertCommits}} +### Reverts +{{range .RevertCommits}} +* {{.Revert.Header}}{{end}} +{{end}}{{if .MergeCommits}} +### Pull Requests +{{range .MergeCommits}} +* {{.Header}}{{end}} +{{end}}{{range .NoteGroups}} +### {{.Title}} +{{range .Notes}} +{{.Body}} +{{end}} +{{end}} +{{end}} diff --git a/.chglog/config.yml b/.chglog/config.yml new file mode 100644 index 0000000..0be5101 --- /dev/null +++ b/.chglog/config.yml @@ -0,0 +1,32 @@ +style: github +template: CHANGELOG.tpl.md +info: + title: CHANGELOG + repository_url: https://github.com/mercari/tfnotify +options: + commits: + filters: + Type: + - add + - change + - deprecate + - remove + - fix + - security + commit_groups: + title_maps: + add: Added + change: Changed + deprecate: Deprecated + remove: Removed + fix: Fixed + security: Security + header: + pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$" + pattern_maps: + - Type + - Scope + - Subject + notes: + keywords: + - BREAKING CHANGE diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..96c945a --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,53 @@ +version: 2 + +defaults: &defaults + working_directory: /go/src/github.com/mercari/tfnotify + +jobs: + build: + <<: *defaults + docker: + - image: golang:1.10-stretch + steps: + - checkout + - run: + name: Install dpendency tools and vendor + command: | + go get -u github.com/golang/dep/cmd/dep + dep ensure + - run: + name: Run test + command: | + make test + - run: + name: Run coverage + command: | + make coverage + bash <(curl -s https://codecov.io/bash) + + lint: + <<: *defaults + docker: + - image: golang:1.10-stretch + steps: + - checkout + - run: + name: Install dpendency tools and vendor + command: | + make dep + dep ensure + - run: + name: Run lint + command: | + make reviewdog + +workflows: + version: 2 + build-workflow: + jobs: + - build + - lint: + filters: + branches: + ignore: + - master diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..b52a5da --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,9 @@ +coverage: + precision: 2 + round: down + range: 70...90 + +ignore: + - "main.go" + - "notifier/github/github.go" + - "notifier/slack/slack.go" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..197aa0d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# https://help.github.com/articles/about-codeowners/ +* @b4b4r07 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d85346e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## WHAT + +(Write the change being made with this pull request) + +## WHY + +(Write the motivation why you submit this pull request) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..606c135 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Created by https://www.gitignore.io/api/go + +### Go ### +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + + +# End of https://www.gitignore.io/api/go + +tfnotify +vendor +dist diff --git a/.reviewdog.yml b/.reviewdog.yml new file mode 100644 index 0000000..7afd35f --- /dev/null +++ b/.reviewdog.yml @@ -0,0 +1,3 @@ +runner: + golint: + cmd: golint $(go list ./...) diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..ba59335 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,99 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/golang/protobuf" + packages = ["proto"] + revision = "925541529c1fa6821df4e44ce2723319eb2be768" + version = "v1.0.0" + +[[projects]] + name = "github.com/google/go-github" + packages = ["github"] + revision = "e48060a28fac52d0f1cb758bc8b87c07bac4a87d" + version = "v15.0.0" + +[[projects]] + branch = "master" + name = "github.com/google/go-querystring" + packages = ["query"] + revision = "53e6ce116135b80d037921a7fdd5138cf32d7a8a" + +[[projects]] + branch = "master" + name = "github.com/lestrrat-go/pdebug" + packages = ["."] + revision = "39f9a71bcabe9432cbdfe4d3d33f41988acd2ce6" + +[[projects]] + branch = "master" + name = "github.com/lestrrat-go/slack" + packages = [".","internal/option","objects"] + revision = "c4179c258775571c2e8ef70d1ac980ce92dca109" + +[[projects]] + name = "github.com/mattn/go-colorable" + packages = ["."] + revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" + version = "v0.0.9" + +[[projects]] + name = "github.com/mattn/go-isatty" + packages = ["."] + revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" + version = "v0.0.3" + +[[projects]] + name = "github.com/pkg/errors" + packages = ["."] + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" + +[[projects]] + name = "github.com/urfave/cli" + packages = ["."] + revision = "cfb38830724cc34fedffe9a2a29fb54fa9169cd1" + version = "v1.20.0" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = ["context","context/ctxhttp"] + revision = "24dd3780ca4f75fed9f321890729414a4b5d3f13" + +[[projects]] + branch = "master" + name = "golang.org/x/oauth2" + packages = [".","internal"] + revision = "fdc9e635145ae97e6c2cb777c48305600cf515cb" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = ["unix"] + revision = "01acb38716e021ed1fc03a602bdb5838e1358c5e" + +[[projects]] + name = "google.golang.org/appengine" + packages = ["internal","internal/base","internal/datastore","internal/log","internal/remote_api","internal/urlfetch","urlfetch"] + revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" + version = "v1.0.0" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "7f97868eec74b32b0982dd158a51a446d1da7eb5" + version = "v2.1.1" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "ed615b7a5cfcf343b8c17d34323b9585503511e563ab959430dd0a9f0fbf9b82" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..fdd1050 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,38 @@ + +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" + +[prune] + go-tests = true + unused-packages = true + non-go = true + +[[constraint]] + branch = "master" + name = "golang.org/x/oauth2" + +[[constraint]] + name = "gopkg.in/yaml.v2" + version = "2.1.1" + +[[constraint]] + name = "github.com/urfave/cli" + version = "1.20.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a07cd56 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2018 Mercari, Inc. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4eea1af --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +PACKAGES := $(shell go list ./...) +COMMIT = $$(git describe --tags --always) +DATE = $$(date -u '+%Y-%m-%d_%H:%M:%S') +BUILD_LDFLAGS = -X $(PKG).commit=$(COMMIT) -X $(PKG).date=$(DATE) +RELEASE_BUILD_LDFLAGS = -s -w $(BUILD_LDFLAGS) + +.PHONY: all +all: test + +.PHONY: build +build: + go build + +.PHONY: crossbuild +crossbuild: + $(eval version = $(shell gobump show -r)) + goxz -pv=v$(version) -os=linux,darwin -arch=386,amd64 -build-ldflags="$(RELEASE_BUILD_LDFLAGS)" \ + -d=./dist/v$(version) + +.PHONY: test +test: + go test -v -parallel=4 ./... + +.PHONY: dep +dep: devel-deps + dep ensure -v + +.PHONY: reviewdog +reviewdog: devel-deps + reviewdog -ci="circle-ci" + +.PHONY: coverage +coverage: devel-deps + goverage -v -covermode=atomic -coverprofile=coverage.txt $(PACKAGES) + +.PHONY: release +release: devel-deps + @./misc/bump-and-chglog.sh + @./misc/upload-artifacts.sh + +.PHONY: devel-deps +devel-deps: + @./misc/install-devel-deps.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ebbf98 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +tfnotify +======== + +[![][circleci-svg]][circleci] [![][codecov-svg]](codecov) [![][goreportcard-svg]][goreportcard] + +[circleci]: https://circleci.com/gh/mercari/tfnotify/tree/master +[circleci-svg]: https://circleci.com/gh/mercari/tfnotify/tree/master.svg?style=svg +[codecov]: https://codecov.io/gh/mercari/tfnotify +[codecov-svg]: https://codecov.io/gh/mercari/tfnotify/branch/master/graph/badge.svg +[goreportcard]: https://goreportcard.com/report/github.com/mercari/tfnotify +[goreportcard-svg]: https://goreportcard.com/badge/github.com/mercari/tfnotify + +tfnotify parses Terraform commands' execution result and applies it to an arbitrary template and then notifies it to GitHub comments etc. + +## Motivation + +There are commands such as `plan` and `apply` on Terraform command, but many developers think they would like to check if the execution of those commands succeeded. +Terraform commands are often executed via CI like Circle CI, but in that case you need to go to the CI page to check it. +This is very troublesome. It is very efficient if you can check it with GitHub comments or Slack etc. +You can do this by using this command. + + + + + +## Installation + +Grab the binary from GitHub Releases (Recommended) + +or + +```console +$ go get -u github.com/mercari/tfnotify +``` + + +### What tfnotify does + +1. Parse the execution result of Terraform +2. Bind parsed results to Go templates +3. Notify it to any platform (e.g. GitHub) as you like + +Detailed specifications such as templates and notification destinations can be customized from the configration files (described later). + +## Usage + +### Basic + +tfnotify is just CLI command. So you can run it from your local after grabbing the binary. + +Basically tfnotify waits for the input from Stdin. So tfnotify needs to pipe the output of Terraform command like the following: + +```console +$ terraform plan | tfnotify plan +``` + +For `plan` command, you also need to specify `plan` as the argument of tfnotify. In the case of `apply`, you need to do `apply`. Currently supported commands can be checked with `tfnotify --help`. + +### Configurations + +When running tfnotify, you can specify the configuration path via `--config` option (if it's omitted, it defaults to `{.,}tfnotify.y{,a}ml`). + +The example settings of GitHub and Slack are as follows. Incidentally, there is no need to replace TOKEN string such as `$GITHUB_TOKEN` with the actual token. Instead, it must be defined as environment variables in CI settings. + +[template](https://golang.org/pkg/text/template/) of Go can be used for `template`. The templates can be used in `tfnotify.yaml` are as follows: + +Placeholder | Usage +---|--- +`{{ .Title }}` | Like `## Plan result` +`{{ .Message }}` | A string that can be set from CLI with `--message` option +`{{ .Result }}` | Matched result by parsing like `Plan: 1 to add` or `No changes` +`{{ .Body }}` | The entire of Terraform execution result + +#### Template Examples + +
+For GitHub + +```yaml +--- +ci: circleci +notifier: + github: + token: $GITHUB_TOKEN + repository: + owner: "mercari" + name: "tfnotify" +terraform: + fmt: + {{ .Title }} + + {{ .Message }} + + {{ .Result }} + + {{ .Body }} + plan: + template: | + {{ .Title }} + {{ .Message }} + {{if .Result}} +
 {{ .Result }}
+      
+ {{end}} +
Details (Click me) +
 {{ .Body }}
+      
+ apply: + template: | + {{ .Title }} + {{ .Message }} + {{if .Result}} +
 {{ .Result }}
+      
+ {{end}} +
Details (Click me) +
 {{ .Body }}
+      
+``` + +
+ +
+For Slack + +```yaml +--- +ci: circleci +notifier: + slack: + token: $GITHUB_TOKEN +terraform: + plan: + template: | + {{ .Message }} + {{if .Result}} + ``` + {{ .Result }} + ``` + {{end}} + ``` + {{ .Body }} + ``` +``` + +
+ +### Supported CI + +Currently, supported CI are here: + +- Circle CI +- Travis CI + +## Committers + + * Masaki ISHIYAMA ([@b4b4r07](https://github.com/b4b4r07)) + +## Contribution + +Please read the CLA below carefully before submitting your contribution. + +https://www.mercari.com/cla/ + +## License + +Copyright 2018 Mercari, Inc. + +Licensed under the MIT License. diff --git a/ci.go b/ci.go new file mode 100644 index 0000000..ac04ac8 --- /dev/null +++ b/ci.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "os" + "regexp" + "strconv" +) + +// CI represents a common information obtained from all CI platforms +type CI struct { + PR PullRequest + URL string +} + +// PullRequest represents a GitHub pull request +type PullRequest struct { + Revision string + Number int +} + +func circleci() (ci CI, err error) { + ci.PR.Number = 0 + ci.PR.Revision = os.Getenv("CIRCLE_SHA1") + ci.URL = os.Getenv("CIRCLE_BUILD_URL") + pr := os.Getenv("CIRCLE_PULL_REQUEST") + if pr == "" { + pr = os.Getenv("CI_PULL_REQUEST") + } + if pr == "" { + pr = os.Getenv("CIRCLE_PR_NUMBER") + } + if pr == "" { + return ci, nil + } + re := regexp.MustCompile(`[1-9]\d*$`) + ci.PR.Number, err = strconv.Atoi(re.FindString(pr)) + if err != nil { + return ci, fmt.Errorf("%v: cannot get env", pr) + } + return ci, nil +} + +func travisci() (ci CI, err error) { + ci.PR.Revision = os.Getenv("TRAVIS_PULL_REQUEST_SHA") + ci.PR.Number, err = strconv.Atoi(os.Getenv("TRAVIS_PULL_REQUEST")) + return ci, err +} diff --git a/ci_test.go b/ci_test.go new file mode 100644 index 0000000..b82a2cf --- /dev/null +++ b/ci_test.go @@ -0,0 +1,211 @@ +package main + +import ( + "os" + "reflect" + "testing" +) + +func TestCircleci(t *testing.T) { + envs := []string{ + "CIRCLE_SHA1", + "CIRCLE_BUILD_URL", + "CIRCLE_PULL_REQUEST", + "CI_PULL_REQUEST", + "CIRCLE_PR_NUMBER", + } + saveEnvs := make(map[string]string) + for _, key := range envs { + saveEnvs[key] = os.Getenv(key) + os.Unsetenv(key) + } + defer func() { + for key, value := range saveEnvs { + os.Setenv(key, value) + } + }() + + testCases := []struct { + fn func() + ci CI + ok bool + }{ + { + fn: func() { + os.Setenv("CIRCLE_SHA1", "abcdefg") + os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234") + os.Setenv("CIRCLE_PULL_REQUEST", "") + os.Setenv("CI_PULL_REQUEST", "") + os.Setenv("CIRCLE_PR_NUMBER", "") + }, + ci: CI{ + PR: PullRequest{ + Revision: "abcdefg", + Number: 0, + }, + URL: "https://circleci.com/gh/owner/repo/1234", + }, + ok: true, + }, + { + fn: func() { + os.Setenv("CIRCLE_SHA1", "abcdefg") + os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234") + os.Setenv("CIRCLE_PULL_REQUEST", "https://github.com/owner/repo/pull/1") + os.Setenv("CI_PULL_REQUEST", "") + os.Setenv("CIRCLE_PR_NUMBER", "") + }, + ci: CI{ + PR: PullRequest{ + Revision: "abcdefg", + Number: 1, + }, + URL: "https://circleci.com/gh/owner/repo/1234", + }, + ok: true, + }, + { + fn: func() { + os.Setenv("CIRCLE_SHA1", "abcdefg") + os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234") + os.Setenv("CIRCLE_PULL_REQUEST", "") + os.Setenv("CI_PULL_REQUEST", "2") + os.Setenv("CIRCLE_PR_NUMBER", "") + }, + ci: CI{ + PR: PullRequest{ + Revision: "abcdefg", + Number: 2, + }, + URL: "https://circleci.com/gh/owner/repo/1234", + }, + ok: true, + }, + { + fn: func() { + os.Setenv("CIRCLE_SHA1", "abcdefg") + os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234") + os.Setenv("CIRCLE_PULL_REQUEST", "") + os.Setenv("CI_PULL_REQUEST", "") + os.Setenv("CIRCLE_PR_NUMBER", "3") + }, + ci: CI{ + PR: PullRequest{ + Revision: "abcdefg", + Number: 3, + }, + URL: "https://circleci.com/gh/owner/repo/1234", + }, + ok: true, + }, + { + fn: func() { + os.Setenv("CIRCLE_SHA1", "") + os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234") + os.Setenv("CIRCLE_PULL_REQUEST", "") + os.Setenv("CI_PULL_REQUEST", "") + os.Setenv("CIRCLE_PR_NUMBER", "") + }, + ci: CI{ + PR: PullRequest{ + Revision: "", + Number: 0, + }, + URL: "https://circleci.com/gh/owner/repo/1234", + }, + ok: true, + }, + { + fn: func() { + os.Setenv("CIRCLE_SHA1", "") + os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234") + os.Setenv("CIRCLE_PULL_REQUEST", "abcdefg") + os.Setenv("CI_PULL_REQUEST", "") + os.Setenv("CIRCLE_PR_NUMBER", "") + }, + ci: CI{ + PR: PullRequest{ + Revision: "", + Number: 0, + }, + URL: "https://circleci.com/gh/owner/repo/1234", + }, + ok: false, + }, + } + + for _, testCase := range testCases { + testCase.fn() + ci, err := circleci() + if !reflect.DeepEqual(ci, testCase.ci) { + t.Errorf("got %q but want %q", ci, testCase.ci) + } + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + } +} + +func TestTravisCI(t *testing.T) { + envs := []string{ + "TRAVIS_PULL_REQUEST_SHA", + "TRAVIS_PULL_REQUEST", + } + saveEnvs := make(map[string]string) + for _, key := range envs { + saveEnvs[key] = os.Getenv(key) + os.Unsetenv(key) + } + defer func() { + for key, value := range saveEnvs { + os.Setenv(key, value) + } + }() + + // https://docs.travis-ci.com/user/environment-variables/ + testCases := []struct { + fn func() + ci CI + ok bool + }{ + { + fn: func() { + os.Setenv("TRAVIS_PULL_REQUEST_SHA", "abcdefg") + os.Setenv("TRAVIS_PULL_REQUEST", "1") + }, + ci: CI{ + PR: PullRequest{ + Revision: "abcdefg", + Number: 1, + }, + URL: "", + }, + ok: true, + }, + { + fn: func() { + os.Setenv("TRAVIS_PULL_REQUEST_SHA", "abcdefg") + os.Setenv("TRAVIS_PULL_REQUEST", "false") + }, + ci: CI{ + PR: PullRequest{ + Revision: "abcdefg", + Number: 0, + }, + URL: "", + }, + ok: false, + }, + } + + for _, testCase := range testCases { + testCase.fn() + ci, err := travisci() + if !reflect.DeepEqual(ci, testCase.ci) { + t.Errorf("got %q but want %q", ci, testCase.ci) + } + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..951ed76 --- /dev/null +++ b/config/config.go @@ -0,0 +1,159 @@ +package config + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "strings" + + "gopkg.in/yaml.v2" +) + +// Config is for tfnotify config structure +type Config struct { + CI string `yaml:"ci"` + Notifier Notifier `yaml:"notifier"` + Terraform Terraform `yaml:"terraform"` + + path string +} + +// Notifier is a notification notifier +type Notifier struct { + Github GithubNotifier `yaml:"github"` + Slack SlackNotifier `yaml:"slack"` +} + +// GithubNotifier is a notifier for GitHub +type GithubNotifier struct { + Token string `yaml:"token"` + Repository Repository `yaml:"repository"` +} + +// Repository represents a GitHub repository +type Repository struct { + Owner string `yaml:"owner"` + Name string `yaml:"name"` +} + +// SlackNotifier is a notifier for Slack +type SlackNotifier struct { + Token string `yaml:"token"` + Channel string `yaml:"channel"` + Bot string `yaml:"bot"` +} + +// Terraform represents terraform configurations +type Terraform struct { + Default Default `yaml:"default"` + Fmt Fmt `yaml:"fmt"` + Plan Plan `yaml:"plan"` + Apply Apply `yaml:"apply"` +} + +// Default is a default setting for terraform commands +type Default struct { + Template string `yaml:"template"` +} + +// Fmt is a terraform fmt config +type Fmt struct { + Template string `yaml:"template"` +} + +// Plan is a terraform plan config +type Plan struct { + Template string `yaml:"template"` +} + +// Apply is a terraform apply config +type Apply struct { + Template string `yaml:"template"` +} + +// LoadFile binds the config file to Config structure +func (cfg *Config) LoadFile(path string) error { + cfg.path = path + _, err := os.Stat(cfg.path) + if err != nil { + return fmt.Errorf("%s: no config file", cfg.path) + } + raw, _ := ioutil.ReadFile(cfg.path) + return yaml.Unmarshal(raw, cfg) +} + +// Validation validates config file +func (cfg *Config) Validation() error { + switch strings.ToLower(cfg.CI) { + case "": + return errors.New("ci: need to be set") + case "circleci", "circle-ci": + // ok pattern + case "travis", "travisci", "travis-ci": + // ok pattern + default: + return fmt.Errorf("%s: not supported yet", cfg.CI) + } + if cfg.isDefinedGithub() { + if cfg.Notifier.Github.Repository.Owner == "" { + return fmt.Errorf("repository owner is missing") + } + if cfg.Notifier.Github.Repository.Name == "" { + return fmt.Errorf("repository name is missing") + } + } + if cfg.isDefinedSlack() { + if cfg.Notifier.Slack.Channel == "" { + return fmt.Errorf("slack channel id is missing") + } + } + notifier := cfg.GetNotifierType() + if notifier == "" { + return fmt.Errorf("notifier is missing") + } + return nil +} + +func (cfg *Config) isDefinedGithub() bool { + // not empty + return cfg.Notifier.Github != (GithubNotifier{}) +} + +func (cfg *Config) isDefinedSlack() bool { + // not empty + return cfg.Notifier.Slack != (SlackNotifier{}) +} + +// GetNotifierType return notifier type described in Config +func (cfg *Config) GetNotifierType() string { + if cfg.isDefinedGithub() { + return "github" + } + if cfg.isDefinedSlack() { + return "slack" + } + return "" +} + +// Find returns config path +func (cfg *Config) Find(file string) (string, error) { + var files []string + if file == "" { + files = []string{ + "tfnotify.yaml", + "tfnotify.yml", + ".tfnotify.yaml", + ".tfnotify.yml", + } + } else { + files = []string{file} + } + for _, file := range files { + _, err := os.Stat(file) + if err == nil { + return file, nil + } + } + return "", errors.New("config for tfnotify is not found at all") +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..803150c --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,319 @@ +package config + +import ( + "os" + "reflect" + "testing" + + yaml "gopkg.in/yaml.v2" +) + +func helperLoadConfig(contents []byte) (*Config, error) { + cfg := &Config{} + err := yaml.Unmarshal(contents, cfg) + return cfg, err +} + +func TestLoadFile(t *testing.T) { + testCases := []struct { + file string + cfg Config + ok bool + }{ + { + file: "../example.tfnotify.yaml", + cfg: Config{ + CI: "circleci", + Notifier: Notifier{ + Github: GithubNotifier{ + Token: "$GITHUB_TOKEN", + Repository: Repository{ + Owner: "mercari", + Name: "tfnotify", + }, + }, + Slack: SlackNotifier{ + Token: "", + Channel: "", + Bot: "", + }, + }, + Terraform: Terraform{ + Default: Default{ + Template: "", + }, + Fmt: Fmt{ + Template: "", + }, + Plan: Plan{ + Template: "{{ .Title }}\n{{ .Message }}\n{{if .Result}}\n
 {{ .Result }}\n
\n{{end}}\n
Details (Click me)\n
 {{ .Body }}\n
\n", + }, + Apply: Apply{ + Template: "", + }, + }, + path: "../example.tfnotify.yaml", + }, + ok: true, + }, + { + file: "no-such-config.yaml", + cfg: Config{ + CI: "circleci", + Notifier: Notifier{ + Github: GithubNotifier{ + Token: "$GITHUB_TOKEN", + Repository: Repository{ + Owner: "mercari", + Name: "tfnotify", + }, + }, + Slack: SlackNotifier{ + Token: "", + Channel: "", + Bot: "", + }, + }, + Terraform: Terraform{ + Default: Default{ + Template: "", + }, + Fmt: Fmt{ + Template: "", + }, + Plan: Plan{ + Template: "{{ .Title }}\n{{ .Message }}\n{{if .Result}}\n
 {{ .Result }}\n
\n{{end}}\n
Details (Click me)\n
 {{ .Body }}\n
\n", + }, + Apply: Apply{ + Template: "", + }, + }, + path: "no-such-config.yaml", + }, + ok: false, + }, + } + + var cfg Config + for _, testCase := range testCases { + err := cfg.LoadFile(testCase.file) + if !reflect.DeepEqual(cfg, testCase.cfg) { + t.Errorf("got %q but want %q", cfg, testCase.cfg) + } + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + } +} + +func TestValidation(t *testing.T) { + testCases := []struct { + contents []byte + expected string + }{ + { + contents: []byte(""), + expected: "ci: need to be set", + }, + { + contents: []byte("ci: rare-ci\n"), + expected: "rare-ci: not supported yet", + }, + { + contents: []byte("ci: circleci\n"), + expected: "notifier is missing", + }, + { + contents: []byte("ci: travisci\n"), + expected: "notifier is missing", + }, + { + contents: []byte("ci: circleci\nnotifier:\n github:\n"), + expected: "notifier is missing", + }, + { + contents: []byte("ci: circleci\nnotifier:\n github:\n token: token\n"), + expected: "repository owner is missing", + }, + { + contents: []byte(` +ci: circleci +notifier: + github: + token: token + repository: + owner: owner +`), + expected: "repository name is missing", + }, + { + contents: []byte(` +ci: circleci +notifier: + github: + token: token + repository: + owner: owner + name: name +`), + expected: "", + }, + { + contents: []byte(` +ci: circleci +notifier: + slack: +`), + expected: "notifier is missing", + }, + { + contents: []byte(` +ci: circleci +notifier: + slack: + token: token +`), + expected: "slack channel id is missing", + }, + { + contents: []byte(` +ci: circleci +notifier: + slack: + token: token + channel: channel +`), + expected: "", + }, + } + for _, testCase := range testCases { + cfg, err := helperLoadConfig(testCase.contents) + if err != nil { + t.Fatal(err) + } + err = cfg.Validation() + if err == nil { + if testCase.expected != "" { + t.Errorf("got no error but want %q", testCase.expected) + } + } else { + if err.Error() != testCase.expected { + t.Errorf("got %q but want %q", err.Error(), testCase.expected) + } + } + } +} + +func TestGetNotifierType(t *testing.T) { + testCases := []struct { + contents []byte + expected string + }{ + { + contents: []byte("repository:\n owner: a\n name: b\nci: circleci\nnotifier:\n github:\n token: token\n"), + expected: "github", + }, + { + contents: []byte("repository:\n owner: a\n name: b\nci: circleci\nnotifier:\n slack:\n token: token\n"), + expected: "slack", + }, + } + for _, testCase := range testCases { + cfg, err := helperLoadConfig(testCase.contents) + if err != nil { + t.Fatal(err) + } + actual := cfg.GetNotifierType() + if actual != testCase.expected { + t.Errorf("got %q but want %q", actual, testCase.expected) + } + } +} + +func createDummy(file string) { + validConfig := func(file string) bool { + for _, c := range []string{ + "tfnotify.yaml", + "tfnotify.yml", + ".tfnotify.yaml", + ".tfnotify.yml", + } { + if file == c { + return true + } + } + return false + } + if !validConfig(file) { + return + } + if _, err := os.Stat(file); err == nil { + return + } + f, err := os.OpenFile(file, os.O_RDONLY|os.O_CREATE, 0666) + if err != nil { + panic(err) + } + defer f.Close() +} + +func removeDummy(file string) { + os.Remove(file) +} + +func TestFind(t *testing.T) { + testCases := []struct { + file string + expect string + ok bool + }{ + { + // valid config + file: ".tfnotify.yaml", + expect: ".tfnotify.yaml", + ok: true, + }, + { + // valid config + file: "tfnotify.yaml", + expect: "tfnotify.yaml", + ok: true, + }, + { + // valid config + file: ".tfnotify.yml", + expect: ".tfnotify.yml", + ok: true, + }, + { + // valid config + file: "tfnotify.yml", + expect: "tfnotify.yml", + ok: true, + }, + { + // invalid config + file: "codecov.yml", + expect: "", + ok: false, + }, + { + // in case of no args passed + file: "", + expect: "tfnotify.yaml", + ok: true, + }, + } + var cfg Config + for _, testCase := range testCases { + createDummy(testCase.file) + defer removeDummy(testCase.file) + actual, err := cfg.Find(testCase.file) + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + if actual != testCase.expect { + t.Errorf("got %q but want %q", actual, testCase.expect) + } + } +} diff --git a/error.go b/error.go new file mode 100644 index 0000000..ab342f4 --- /dev/null +++ b/error.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + "os" +) + +// Exit codes are int values for the exit code that shell interpreter can interpret +const ( + ExitCodeOK int = 0 + ExitCodeError int = iota +) + +// ErrorFormatter is the interface for format +type ErrorFormatter interface { + Format(s fmt.State, verb rune) +} + +// ExitCoder is the wrapper interface for urfave/cli +type ExitCoder interface { + error + ExitCode() int +} + +// ExitError is the wrapper struct for urfave/cli +type ExitError struct { + exitCode int + err error +} + +// NewExitError makes a new ExitError +func NewExitError(exitCode int, err error) *ExitError { + return &ExitError{ + exitCode: exitCode, + err: err, + } +} + +// Error returns the string message, fulfilling the interface required by `error` +func (ee *ExitError) Error() string { + if ee.err == nil { + return "" + } + return fmt.Sprintf("%v", ee.err) +} + +// ExitCode returns the exit code, fulfilling the interface required by `ExitCoder` +func (ee *ExitError) ExitCode() int { + return ee.exitCode +} + +// HandleExit returns int value that shell interpreter can interpret as the exit code +// If err has error message, it will be displayed to stderr +// This function is heavily inspired by urfave/cli.HandleExitCoder +func HandleExit(err error) int { + if err == nil { + return ExitCodeOK + } + + if exitErr, ok := err.(ExitCoder); ok { + if err.Error() != "" { + if _, ok := exitErr.(ErrorFormatter); ok { + fmt.Fprintf(os.Stderr, "%+v\n", err) + } else { + fmt.Fprintln(os.Stderr, err) + } + } + return exitErr.ExitCode() + } + + if _, ok := err.(error); ok { + fmt.Fprintf(os.Stderr, "%v\n", err) + return ExitCodeError + } + + return ExitCodeOK +} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..67e28aa --- /dev/null +++ b/error_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "errors" + "testing" +) + +func TestHandleError(t *testing.T) { + testCases := []struct { + err error + exitCode int + }{ + { + err: NewExitError(1, errors.New("error")), + exitCode: 1, + }, + { + err: NewExitError(0, errors.New("error")), + exitCode: 0, + }, + { + err: errors.New("error"), + exitCode: 1, + }, + { + err: NewExitError(0, nil), + exitCode: 0, + }, + { + err: NewExitError(1, nil), + exitCode: 1, + }, + { + err: nil, + exitCode: 0, + }, + } + + for _, testCase := range testCases { + // TODO: test stderr + exitCode := HandleExit(testCase.err) + if exitCode != testCase.exitCode { + t.Errorf("got %q but want %q", exitCode, testCase.exitCode) + } + } +} diff --git a/example.tfnotify.yaml b/example.tfnotify.yaml new file mode 100644 index 0000000..b124dcc --- /dev/null +++ b/example.tfnotify.yaml @@ -0,0 +1,19 @@ +ci: circleci +notifier: + github: + token: $GITHUB_TOKEN + repository: + owner: "mercari" + name: "tfnotify" +terraform: + plan: + template: | + {{ .Title }} + {{ .Message }} + {{if .Result}} +
 {{ .Result }}
+      
+ {{end}} +
Details (Click me) +
 {{ .Body }}
+      
diff --git a/main.go b/main.go new file mode 100644 index 0000000..bf30758 --- /dev/null +++ b/main.go @@ -0,0 +1,212 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/mercari/tfnotify/config" + "github.com/mercari/tfnotify/notifier" + "github.com/mercari/tfnotify/notifier/github" + "github.com/mercari/tfnotify/notifier/slack" + "github.com/mercari/tfnotify/terraform" + + "github.com/urfave/cli" +) + +const ( + name = "tfnotify" + description = "Notify the execution result of terraform command" + version = "0.0.0" +) + +type tfnotify struct { + config config.Config + context *cli.Context + parser terraform.Parser + template terraform.Template +} + +// Run sends the notification with notifier +func (t *tfnotify) Run() error { + ciname := t.config.CI + if t.context.GlobalString("ci") != "" { + ciname = t.context.GlobalString("ci") + } + ciname = strings.ToLower(ciname) + var ci CI + var err error + switch ciname { + case "circleci", "circle-ci": + ci, err = circleci() + if err != nil { + return err + } + case "travis", "travisci", "travis-ci": + ci, err = travisci() + if err != nil { + return err + } + case "": + return fmt.Errorf("CI service: required (e.g. circleci)") + default: + return fmt.Errorf("CI service %v: not supported yet", ci) + } + + selectedNotifier := t.config.GetNotifierType() + if t.context.GlobalString("notifier") != "" { + selectedNotifier = t.context.GlobalString("notifier") + } + + var notifier notifier.Notifier + switch selectedNotifier { + case "github": + client, err := github.NewClient(github.Config{ + Token: t.config.Notifier.Github.Token, + Owner: t.config.Notifier.Github.Repository.Owner, + Repo: t.config.Notifier.Github.Repository.Name, + PR: github.PullRequest{ + Revision: ci.PR.Revision, + Number: ci.PR.Number, + Message: t.context.String("message"), + }, + CI: ci.URL, + Parser: t.parser, + Template: t.template, + }) + if err != nil { + return err + } + notifier = client.Notify + case "slack": + client, err := slack.NewClient(slack.Config{ + Token: t.config.Notifier.Slack.Token, + Channel: t.config.Notifier.Slack.Channel, + Botname: t.config.Notifier.Slack.Bot, + Message: t.context.String("message"), + CI: ci.URL, + Parser: t.parser, + Template: t.template, + }) + if err != nil { + return err + } + notifier = client.Notify + case "": + return fmt.Errorf("notifier is missing") + default: + return fmt.Errorf("%s: not supported notifier yet", t.context.GlobalString("notifier")) + } + + if notifier == nil { + return fmt.Errorf("no notifier specified at all") + } + + return NewExitError(notifier.Notify(tee(os.Stdin, os.Stdout))) +} + +func main() { + app := cli.NewApp() + app.Name = name + app.Usage = description + app.Version = version + app.Flags = []cli.Flag{ + cli.StringFlag{Name: "ci", Usage: "name of CI to run tfnotify"}, + cli.StringFlag{Name: "config", Usage: "config path"}, + cli.StringFlag{Name: "notifier", Usage: "notification destination"}, + } + app.Commands = []cli.Command{ + { + Name: "fmt", + Usage: "Parse stdin as a fmt result", + Action: cmdFmt, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "message, m", + Usage: "Specify the message to use for notification", + }, + }, + }, + { + Name: "plan", + Usage: "Parse stdin as a plan result", + Action: cmdPlan, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "message, m", + Usage: "Specify the message to use for notification", + }, + }, + }, + { + Name: "apply", + Usage: "Parse stdin as a apply result", + Action: cmdApply, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "message, m", + Usage: "Specify the message to use for notification", + }, + }, + }, + } + + err := app.Run(os.Args) + os.Exit(HandleExit(err)) +} + +func newConfig(ctx *cli.Context) (cfg config.Config, err error) { + confPath, err := cfg.Find(ctx.GlobalString("config")) + if err != nil { + return cfg, err + } + if err := cfg.LoadFile(confPath); err != nil { + return cfg, err + } + if err := cfg.Validation(); err != nil { + return cfg, err + } + return cfg, nil +} + +func cmdFmt(ctx *cli.Context) error { + cfg, err := newConfig(ctx) + if err != nil { + return err + } + t := &tfnotify{ + config: cfg, + context: ctx, + parser: terraform.NewFmtParser(), + template: terraform.NewFmtTemplate(cfg.Terraform.Fmt.Template), + } + return t.Run() +} + +func cmdPlan(ctx *cli.Context) error { + cfg, err := newConfig(ctx) + if err != nil { + return err + } + t := &tfnotify{ + config: cfg, + context: ctx, + parser: terraform.NewPlanParser(), + template: terraform.NewPlanTemplate(cfg.Terraform.Plan.Template), + } + return t.Run() +} + +func cmdApply(ctx *cli.Context) error { + cfg, err := newConfig(ctx) + if err != nil { + return err + } + t := &tfnotify{ + config: cfg, + context: ctx, + parser: terraform.NewApplyParser(), + template: terraform.NewApplyTemplate(cfg.Terraform.Apply.Template), + } + return t.Run() +} diff --git a/misc/1.png b/misc/1.png new file mode 100644 index 0000000..cebcb60 Binary files /dev/null and b/misc/1.png differ diff --git a/misc/2.png b/misc/2.png new file mode 100644 index 0000000..b192161 Binary files /dev/null and b/misc/2.png differ diff --git a/misc/bump-and-chglog.sh b/misc/bump-and-chglog.sh new file mode 100755 index 0000000..50d34d9 --- /dev/null +++ b/misc/bump-and-chglog.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +current_version="$(gobump show -r)" + +echo "current version: $current_version" +while true +do + read -p "Specify [major | minor | patch]: " semver + case "$semver" in + major | minor | patch ) + gobump "$semver" -w + next_version="$(gobump show -r)" + break + ;; + *) + echo "Invalid semver type" >&2 + continue + ;; + esac + shift +done + +git commit -am "Bump version $next_version" +git tag "v$next_version" + +git-chglog -o CHANGELOG.md +git commit -am "Update changelog" + +git push && git push --tags diff --git a/misc/install-devel-deps.sh b/misc/install-devel-deps.sh new file mode 100755 index 0000000..74865d8 --- /dev/null +++ b/misc/install-devel-deps.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +go get -v -u github.com/Songmu/ghch/cmd/ghch +go get -v -u github.com/Songmu/goxz/cmd/goxz +go get -v -u github.com/git-chglog/git-chglog/cmd/git-chglog +go get -v -u github.com/golang/dep/cmd/dep +go get -v -u github.com/golang/lint/golint +go get -v -u github.com/haya14busa/goverage +go get -v -u github.com/haya14busa/reviewdog/cmd/reviewdog +go get -v -u github.com/motemen/gobump/cmd/gobump +go get -v -u github.com/tcnksm/ghr diff --git a/misc/upload-artifacts.sh b/misc/upload-artifacts.sh new file mode 100755 index 0000000..e69769a --- /dev/null +++ b/misc/upload-artifacts.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -e + +version="v$(gobump show -r)" +make crossbuild +ghr -username mercari -replace "$version" "dist/$version" diff --git a/notifier/github/client.go b/notifier/github/client.go new file mode 100644 index 0000000..d36ecde --- /dev/null +++ b/notifier/github/client.go @@ -0,0 +1,91 @@ +package github + +import ( + "errors" + "os" + "strings" + + "github.com/google/go-github/github" + "github.com/mercari/tfnotify/terraform" + "golang.org/x/oauth2" +) + +// EnvToken is GitHub API Token +const EnvToken = "GITHUB_TOKEN" + +// Client is a API client for GitHub +type Client struct { + *github.Client + Debug bool + + Config Config + + common service + + Comment *CommentService + Commits *CommitsService + Notify *NotifyService + + API API +} + +// Config is a configuration for GitHub client +type Config struct { + Token string + Owner string + Repo string + PR PullRequest + CI string + Parser terraform.Parser + Template terraform.Template +} + +// PullRequest represents GitHub Pull Request metadata +type PullRequest struct { + Revision string + Message string + Number int +} + +type service struct { + client *Client +} + +// NewClient returns Client initialized with Config +func NewClient(cfg Config) (*Client, error) { + token := cfg.Token + token = strings.TrimPrefix(token, "$") + if token == EnvToken { + token = os.Getenv(EnvToken) + } + if token == "" { + return &Client{}, errors.New("github token is missing") + } + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + tc := oauth2.NewClient(oauth2.NoContext, ts) + client := github.NewClient(tc) + + c := &Client{ + Config: cfg, + Client: client, + } + c.common.client = c + c.Comment = (*CommentService)(&c.common) + c.Commits = (*CommitsService)(&c.common) + c.Notify = (*NotifyService)(&c.common) + + c.API = &GitHub{ + Client: client, + owner: cfg.Owner, + repo: cfg.Repo, + } + + return c, nil +} + +// IsNumber returns true if PullRequest is Pull Request build +func (pr *PullRequest) IsNumber() bool { + return pr.Number != 0 +} diff --git a/notifier/github/client_test.go b/notifier/github/client_test.go new file mode 100644 index 0000000..7c6e692 --- /dev/null +++ b/notifier/github/client_test.go @@ -0,0 +1,98 @@ +package github + +import ( + "os" + "testing" +) + +func TestNewClient(t *testing.T) { + githubToken := os.Getenv(EnvToken) + defer func() { + os.Setenv(EnvToken, githubToken) + }() + os.Setenv(EnvToken, "") + + testCases := []struct { + config Config + envToken string + expect string + }{ + { + // specify directly + config: Config{Token: "abcdefg"}, + envToken: "", + expect: "", + }, + { + // specify via env but not to be set env (part 1) + config: Config{Token: "GITHUB_TOKEN"}, + envToken: "", + expect: "github token is missing", + }, + { + // specify via env (part 1) + config: Config{Token: "GITHUB_TOKEN"}, + envToken: "abcdefg", + expect: "", + }, + { + // specify via env but not to be set env (part 2) + config: Config{Token: "$GITHUB_TOKEN"}, + envToken: "", + expect: "github token is missing", + }, + { + // specify via env (part 2) + config: Config{Token: "$GITHUB_TOKEN"}, + envToken: "abcdefg", + expect: "", + }, + { + // no specification (part 1) + config: Config{}, + envToken: "", + expect: "github token is missing", + }, + { + // no specification (part 2) + config: Config{}, + envToken: "abcdefg", + expect: "github token is missing", + }, + } + for _, testCase := range testCases { + os.Setenv(EnvToken, testCase.envToken) + _, err := NewClient(testCase.config) + if err == nil { + continue + } + if err.Error() != testCase.expect { + t.Errorf("got %q but want %q", err.Error(), testCase.expect) + } + } +} + +func TestIsNumber(t *testing.T) { + testCases := []struct { + pr PullRequest + isPR bool + }{ + { + pr: PullRequest{ + Number: 0, + }, + isPR: false, + }, + { + pr: PullRequest{ + Number: 123, + }, + isPR: true, + }, + } + for _, testCase := range testCases { + if testCase.pr.IsNumber() != testCase.isPR { + t.Errorf("got %v but want %v", testCase.pr.IsNumber(), testCase.isPR) + } + } +} diff --git a/notifier/github/comment.go b/notifier/github/comment.go new file mode 100644 index 0000000..5610ded --- /dev/null +++ b/notifier/github/comment.go @@ -0,0 +1,86 @@ +package github + +import ( + "context" + "fmt" + "regexp" + + "github.com/google/go-github/github" +) + +// CommentService handles communication with the comment related +// methods of GitHub API +type CommentService service + +// PostOptions specifies the optional parameters to post comments to a pull request +type PostOptions struct { + Number int + Revision string +} + +// Post posts comment +func (g *CommentService) Post(body string, opt PostOptions) error { + if opt.Number != 0 { + _, _, err := g.client.API.IssuesCreateComment( + context.Background(), + opt.Number, + &github.IssueComment{Body: &body}, + ) + return err + } + if opt.Revision != "" { + _, _, err := g.client.API.RepositoriesCreateComment( + context.Background(), + opt.Revision, + &github.RepositoryComment{Body: &body}, + ) + return err + } + return fmt.Errorf("github.comment.post: Number or Revision is required") +} + +// List lists comments on GitHub issues/pull requests +func (g *CommentService) List(number int) ([]*github.IssueComment, error) { + comments, _, err := g.client.API.IssuesListComments( + context.Background(), + number, + &github.IssueListCommentsOptions{}, + ) + return comments, err +} + +// Delete deletes comment on GitHub issues/pull requests +func (g *CommentService) Delete(id int) error { + _, err := g.client.API.IssuesDeleteComment( + context.Background(), + int64(id), + ) + return err +} + +// DeleteDuplicates deletes duplicate comments containing arbitrary character strings +func (g *CommentService) DeleteDuplicates(title string) { + var ids []int64 + comments := g.getDuplicates(title) + for _, comment := range comments { + ids = append(ids, *comment.ID) + } + for _, id := range ids { + // don't handle error + g.client.Comment.Delete(int(id)) + } +} + +func (g *CommentService) getDuplicates(title string) []*github.IssueComment { + var dup []*github.IssueComment + re := regexp.MustCompile(`(?m)^(\n+)?` + title + `\n+` + g.client.Config.PR.Message + `\n+`) + + comments, _ := g.client.Comment.List(g.client.Config.PR.Number) + for _, comment := range comments { + if re.MatchString(*comment.Body) { + dup = append(dup, comment) + } + } + + return dup +} diff --git a/notifier/github/comment_test.go b/notifier/github/comment_test.go new file mode 100644 index 0000000..5c6fdb3 --- /dev/null +++ b/notifier/github/comment_test.go @@ -0,0 +1,217 @@ +package github + +import ( + "context" + "reflect" + "testing" + + "github.com/google/go-github/github" +) + +func TestCommentPost(t *testing.T) { + testCases := []struct { + config Config + body string + opt PostOptions + ok bool + }{ + { + config: newFakeConfig(), + body: "", + opt: PostOptions{ + Number: 1, + Revision: "abcd", + }, + ok: true, + }, + { + config: newFakeConfig(), + body: "", + opt: PostOptions{ + Number: 0, + Revision: "abcd", + }, + ok: true, + }, + { + config: newFakeConfig(), + body: "", + opt: PostOptions{ + Number: 2, + Revision: "", + }, + ok: true, + }, + { + config: newFakeConfig(), + body: "", + opt: PostOptions{ + Number: 0, + Revision: "", + }, + ok: false, + }, + } + + for _, testCase := range testCases { + client, err := NewClient(testCase.config) + if err != nil { + t.Fatal(err) + } + api := newFakeAPI() + client.API = &api + err = client.Comment.Post(testCase.body, testCase.opt) + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + } +} + +func TestCommentList(t *testing.T) { + comments := []*github.IssueComment{ + &github.IssueComment{ + ID: github.Int64(371748792), + Body: github.String("comment 1"), + }, + &github.IssueComment{ + ID: github.Int64(371765743), + Body: github.String("comment 2"), + }, + } + testCases := []struct { + config Config + number int + ok bool + comments []*github.IssueComment + }{ + { + config: newFakeConfig(), + number: 1, + ok: true, + comments: comments, + }, + { + config: newFakeConfig(), + number: 12, + ok: true, + comments: comments, + }, + { + config: newFakeConfig(), + number: 123, + ok: true, + comments: comments, + }, + } + + for _, testCase := range testCases { + client, err := NewClient(testCase.config) + if err != nil { + t.Fatal(err) + } + api := newFakeAPI() + client.API = &api + comments, err := client.Comment.List(testCase.number) + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + if !reflect.DeepEqual(comments, testCase.comments) { + t.Errorf("got %v but want %v", comments, testCase.comments) + } + } +} + +func TestCommentDelete(t *testing.T) { + testCases := []struct { + config Config + id int + ok bool + }{ + { + config: newFakeConfig(), + id: 1, + ok: true, + }, + { + config: newFakeConfig(), + id: 12, + ok: true, + }, + { + config: newFakeConfig(), + id: 123, + ok: true, + }, + } + + for _, testCase := range testCases { + client, err := NewClient(testCase.config) + if err != nil { + t.Fatal(err) + } + api := newFakeAPI() + client.API = &api + err = client.Comment.Delete(testCase.id) + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + } +} + +func TestCommentGetDuplicates(t *testing.T) { + api := newFakeAPI() + api.FakeIssuesListComments = func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) { + var comments []*github.IssueComment + comments = []*github.IssueComment{ + &github.IssueComment{ + ID: github.Int64(371748792), + Body: github.String("## Plan result\nfoo message\n"), + }, + &github.IssueComment{ + ID: github.Int64(371765743), + Body: github.String("## Plan result\nbar message\n"), + }, + &github.IssueComment{ + ID: github.Int64(371765744), + Body: github.String("## Plan result\nbaz message\n"), + }, + } + return comments, nil, nil + } + + testCases := []struct { + title string + message string + comments []*github.IssueComment + }{ + { + title: "## Plan result", + message: "foo message", + comments: []*github.IssueComment{ + &github.IssueComment{ + ID: github.Int64(371748792), + Body: github.String("## Plan result\nfoo message\n"), + }, + }, + }, + { + title: "## Plan result", + message: "hoge message", + comments: nil, + }, + } + + for _, testCase := range testCases { + cfg := newFakeConfig() + cfg.PR.Message = testCase.message + client, err := NewClient(cfg) + if err != nil { + t.Fatal(err) + } + client.API = &api + comments := client.Comment.getDuplicates(testCase.title) + if !reflect.DeepEqual(comments, testCase.comments) { + t.Errorf("got %q but want %q", comments, testCase.comments) + } + } +} diff --git a/notifier/github/commits.go b/notifier/github/commits.go new file mode 100644 index 0000000..dffa87b --- /dev/null +++ b/notifier/github/commits.go @@ -0,0 +1,47 @@ +package github + +import ( + "context" + "errors" + + "github.com/google/go-github/github" +) + +// CommitsService handles communication with the commits related +// methods of GitHub API +type CommitsService service + +// List lists commits on a repository +func (g *CommitsService) List(revision string) ([]string, error) { + if revision == "" { + return []string{}, errors.New("no revision specified") + } + var s []string + commits, _, err := g.client.API.RepositoriesListCommits( + context.Background(), + &github.CommitsListOptions{SHA: revision}, + ) + if err != nil { + return s, err + } + for _, commit := range commits { + s = append(s, *commit.SHA) + } + return s, nil +} + +// Last returns the hash of the previous commit of the given commit +func (g *CommitsService) lastOne(commits []string, revision string) (string, error) { + if revision == "" { + return "", errors.New("no revision specified") + } + if len(commits) == 0 { + return "", errors.New("no commits") + } + // e.g. + // a0ce5bf 2018/04/05 20:50:01 (HEAD -> master, origin/master) + // 5166cfc 2018/04/05 20:40:12 + // 74c4d6e 2018/04/05 20:34:31 + // 9260c54 2018/04/05 20:16:20 + return commits[1], nil +} diff --git a/notifier/github/commits_test.go b/notifier/github/commits_test.go new file mode 100644 index 0000000..31f55e1 --- /dev/null +++ b/notifier/github/commits_test.go @@ -0,0 +1,91 @@ +package github + +import ( + "testing" +) + +func TestCommitsList(t *testing.T) { + testCases := []struct { + revision string + ok bool + }{ + { + revision: "04e0917e448b662c2b16330fad50e97af16ff27a", + ok: true, + }, + { + revision: "", + ok: false, + }, + } + + for _, testCase := range testCases { + cfg := newFakeConfig() + client, err := NewClient(cfg) + if err != nil { + t.Fatal(err) + } + api := newFakeAPI() + client.API = &api + _, err = client.Commits.List(testCase.revision) + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + } +} + +func TestCommitsLastOne(t *testing.T) { + testCases := []struct { + commits []string + revision string + lastRev string + ok bool + }{ + { + // ok + commits: []string{ + "04e0917e448b662c2b16330fad50e97af16ff27a", + "04e0917e448b662c2b16330fad50e97af16ff27b", + "04e0917e448b662c2b16330fad50e97af16ff27c", + }, + revision: "04e0917e448b662c2b16330fad50e97af16ff27a", + lastRev: "04e0917e448b662c2b16330fad50e97af16ff27b", + ok: true, + }, + { + // no revision + commits: []string{ + "04e0917e448b662c2b16330fad50e97af16ff27a", + "04e0917e448b662c2b16330fad50e97af16ff27b", + "04e0917e448b662c2b16330fad50e97af16ff27c", + }, + revision: "", + lastRev: "", + ok: false, + }, + { + // no commits + commits: []string{}, + revision: "04e0917e448b662c2b16330fad50e97af16ff27a", + lastRev: "", + ok: false, + }, + } + + for _, testCase := range testCases { + cfg := newFakeConfig() + client, err := NewClient(cfg) + if err != nil { + t.Fatal(err) + } + api := newFakeAPI() + client.API = &api + commit, err := client.Commits.lastOne(testCase.commits, testCase.revision) + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + if commit != testCase.lastRev { + t.Errorf("got %q but want %q", commit, testCase.lastRev) + } + } +} diff --git a/notifier/github/github.go b/notifier/github/github.go new file mode 100644 index 0000000..a4ee463 --- /dev/null +++ b/notifier/github/github.go @@ -0,0 +1,47 @@ +package github + +import ( + "context" + + "github.com/google/go-github/github" +) + +// API is GitHub API interface +type API interface { + IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) + IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error) + IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) + RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) + RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) +} + +// GitHub represents the attribute information necessary for requesting GitHub API +type GitHub struct { + *github.Client + owner, repo string +} + +// IssuesCreateComment is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.CreateComment +func (g *GitHub) IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) { + return g.Client.Issues.CreateComment(ctx, g.owner, g.repo, number, comment) +} + +// IssuesDeleteComment is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.DeleteComment +func (g *GitHub) IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error) { + return g.Client.Issues.DeleteComment(ctx, g.owner, g.repo, int(commentID)) +} + +// IssuesListComments is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.ListComments +func (g *GitHub) IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) { + return g.Client.Issues.ListComments(ctx, g.owner, g.repo, number, opt) +} + +// RepositoriesCreateComment is a wrapper of https://godoc.org/github.com/google/go-github/github#RepositoriesService.CreateComment +func (g *GitHub) RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) { + return g.Client.Repositories.CreateComment(ctx, g.owner, g.repo, sha, comment) +} + +// RepositoriesListCommits is a wrapper of https://godoc.org/github.com/google/go-github/github#RepositoriesService.ListCommits +func (g *GitHub) RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) { + return g.Client.Repositories.ListCommits(ctx, g.owner, g.repo, opt) +} diff --git a/notifier/github/github_test.go b/notifier/github/github_test.go new file mode 100644 index 0000000..98cfdf4 --- /dev/null +++ b/notifier/github/github_test.go @@ -0,0 +1,102 @@ +package github + +import ( + "context" + + "github.com/google/go-github/github" + "github.com/mercari/tfnotify/terraform" +) + +type fakeAPI struct { + API + FakeIssuesCreateComment func(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) + FakeIssuesDeleteComment func(ctx context.Context, commentID int64) (*github.Response, error) + FakeIssuesListComments func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) + FakeRepositoriesCreateComment func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) + FakeRepositoriesListCommits func(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) +} + +func (g *fakeAPI) IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) { + return g.FakeIssuesCreateComment(ctx, number, comment) +} + +func (g *fakeAPI) IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error) { + return g.FakeIssuesDeleteComment(ctx, commentID) +} + +func (g *fakeAPI) IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) { + return g.FakeIssuesListComments(ctx, number, opt) +} + +func (g *fakeAPI) RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) { + return g.FakeRepositoriesCreateComment(ctx, sha, comment) +} + +func (g *fakeAPI) RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) { + return g.FakeRepositoriesListCommits(ctx, opt) +} + +func newFakeAPI() fakeAPI { + return fakeAPI{ + FakeIssuesCreateComment: func(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) { + return &github.IssueComment{ + ID: github.Int64(371748792), + Body: github.String("comment 1"), + }, nil, nil + }, + FakeIssuesDeleteComment: func(ctx context.Context, commentID int64) (*github.Response, error) { + return nil, nil + }, + FakeIssuesListComments: func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) { + var comments []*github.IssueComment + comments = []*github.IssueComment{ + &github.IssueComment{ + ID: github.Int64(371748792), + Body: github.String("comment 1"), + }, + &github.IssueComment{ + ID: github.Int64(371765743), + Body: github.String("comment 2"), + }, + } + return comments, nil, nil + }, + FakeRepositoriesCreateComment: func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) { + return &github.RepositoryComment{ + ID: github.Int64(28427394), + CommitID: github.String("04e0917e448b662c2b16330fad50e97af16ff27a"), + Body: github.String("comment 1"), + }, nil, nil + }, + FakeRepositoriesListCommits: func(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) { + var commits []*github.RepositoryCommit + commits = []*github.RepositoryCommit{ + &github.RepositoryCommit{ + SHA: github.String("04e0917e448b662c2b16330fad50e97af16ff27a"), + }, + &github.RepositoryCommit{ + SHA: github.String("04e0917e448b662c2b16330fad50e97af16ff27b"), + }, + &github.RepositoryCommit{ + SHA: github.String("04e0917e448b662c2b16330fad50e97af16ff27c"), + }, + } + return commits, nil, nil + }, + } +} + +func newFakeConfig() Config { + return Config{ + Token: "token", + Owner: "owner", + Repo: "repo", + PR: PullRequest{ + Revision: "abcd", + Number: 1, + Message: "message", + }, + Parser: terraform.NewPlanParser(), + Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), + } +} diff --git a/notifier/github/notify.go b/notifier/github/notify.go new file mode 100644 index 0000000..cc19cba --- /dev/null +++ b/notifier/github/notify.go @@ -0,0 +1,55 @@ +package github + +import ( + "github.com/mercari/tfnotify/terraform" +) + +// NotifyService handles communication with the notification related +// methods of GitHub API +type NotifyService service + +// Notify posts comment optimized for notifications +func (g *NotifyService) Notify(body string) (exit int, err error) { + cfg := g.client.Config + parser := g.client.Config.Parser + template := g.client.Config.Template + + result := parser.Parse(body) + if result.Error != nil { + return result.ExitCode, result.Error + } + if result.Result == "" { + return result.ExitCode, result.Error + } + + template.SetValue(terraform.CommonTemplate{ + Message: cfg.PR.Message, + Result: result.Result, + Body: body, + }) + body, err = template.Execute() + if err != nil { + return result.ExitCode, err + } + + value := template.GetValue() + + if cfg.PR.IsNumber() { + g.client.Comment.DeleteDuplicates(value.Title) + } + + _, isApply := parser.(*terraform.ApplyParser) + if !cfg.PR.IsNumber() && isApply { + commits, err := g.client.Commits.List(cfg.PR.Revision) + if err != nil { + return result.ExitCode, err + } + lastRevision, _ := g.client.Commits.lastOne(commits, cfg.PR.Revision) + cfg.PR.Revision = lastRevision + } + + return result.ExitCode, g.client.Comment.Post(body, PostOptions{ + Number: cfg.PR.Number, + Revision: cfg.PR.Revision, + }) +} diff --git a/notifier/github/notify_test.go b/notifier/github/notify_test.go new file mode 100644 index 0000000..7f7bb32 --- /dev/null +++ b/notifier/github/notify_test.go @@ -0,0 +1,141 @@ +package github + +import ( + "testing" + + "github.com/mercari/tfnotify/terraform" +) + +func TestNotifyNotify(t *testing.T) { + testCases := []struct { + config Config + body string + ok bool + exitCode int + }{ + { + // invalid body (cannot parse) + config: Config{ + Token: "token", + Owner: "owner", + Repo: "repo", + PR: PullRequest{ + Revision: "abcd", + Number: 1, + Message: "message", + }, + Parser: terraform.NewPlanParser(), + Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), + }, + body: "body", + ok: false, + exitCode: 1, + }, + { + // invalid pr + config: Config{ + Token: "token", + Owner: "owner", + Repo: "repo", + PR: PullRequest{ + Revision: "", + Number: 0, + Message: "message", + }, + Parser: terraform.NewPlanParser(), + Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), + }, + body: "Plan: 1 to add", + ok: false, + exitCode: 0, + }, + { + // valid, error + config: Config{ + Token: "token", + Owner: "owner", + Repo: "repo", + PR: PullRequest{ + Revision: "", + Number: 1, + Message: "message", + }, + Parser: terraform.NewPlanParser(), + Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), + }, + body: "Error: hoge", + ok: true, + exitCode: 1, + }, + { + // valid, and isPR + config: Config{ + Token: "token", + Owner: "owner", + Repo: "repo", + PR: PullRequest{ + Revision: "", + Number: 1, + Message: "message", + }, + Parser: terraform.NewPlanParser(), + Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), + }, + body: "Plan: 1 to add", + ok: true, + exitCode: 0, + }, + { + // valid, and isRevision + config: Config{ + Token: "token", + Owner: "owner", + Repo: "repo", + PR: PullRequest{ + Revision: "revision-revision", + Number: 0, + Message: "message", + }, + Parser: terraform.NewPlanParser(), + Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), + }, + body: "Plan: 1 to add", + ok: true, + exitCode: 0, + }, + { + // apply case + config: Config{ + Token: "token", + Owner: "owner", + Repo: "repo", + PR: PullRequest{ + Revision: "revision", + Number: 0, // For apply, it is always 0 + Message: "message", + }, + Parser: terraform.NewApplyParser(), + Template: terraform.NewApplyTemplate(terraform.DefaultApplyTemplate), + }, + body: "Apply complete!", + ok: true, + exitCode: 0, + }, + } + + for _, testCase := range testCases { + client, err := NewClient(testCase.config) + if err != nil { + t.Fatal(err) + } + api := newFakeAPI() + client.API = &api + exitCode, err := client.Notify.Notify(testCase.body) + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + if exitCode != testCase.exitCode { + t.Errorf("got %q but want %q", exitCode, testCase.exitCode) + } + } +} diff --git a/notifier/notifier.go b/notifier/notifier.go new file mode 100644 index 0000000..f6488ea --- /dev/null +++ b/notifier/notifier.go @@ -0,0 +1,6 @@ +package notifier + +// Notifier is a notification interface +type Notifier interface { + Notify(body string) (exit int, err error) +} diff --git a/notifier/notifier_test.go b/notifier/notifier_test.go new file mode 100644 index 0000000..ed45f23 --- /dev/null +++ b/notifier/notifier_test.go @@ -0,0 +1 @@ +package notifier diff --git a/notifier/slack/client.go b/notifier/slack/client.go new file mode 100644 index 0000000..6736feb --- /dev/null +++ b/notifier/slack/client.go @@ -0,0 +1,66 @@ +package slack + +import ( + "errors" + "os" + "strings" + + "github.com/mercari/tfnotify/terraform" + "github.com/lestrrat-go/slack" +) + +// EnvToken is Slack API Token +const EnvToken = "SLACK_TOKEN" + +// Client is a API client for Slack +type Client struct { + *slack.Client + + Config Config + + common service + + Notify *NotifyService + + API API +} + +// Config is a configuration for GitHub client +type Config struct { + Token string + Channel string + Botname string + Message string + CI string + Parser terraform.Parser + Template terraform.Template +} + +type service struct { + client *Client +} + +// NewClient returns Client initialized with Config +func NewClient(cfg Config) (*Client, error) { + token := cfg.Token + token = strings.TrimPrefix(token, "$") + if token == EnvToken { + token = os.Getenv(EnvToken) + } + if token == "" { + return &Client{}, errors.New("slack token is missing") + } + client := slack.New(token) + c := &Client{ + Config: cfg, + Client: client, + } + c.common.client = c + c.Notify = (*NotifyService)(&c.common) + c.API = &Slack{ + Client: client, + Channel: cfg.Channel, + Botname: cfg.Botname, + } + return c, nil +} diff --git a/notifier/slack/client_test.go b/notifier/slack/client_test.go new file mode 100644 index 0000000..b925107 --- /dev/null +++ b/notifier/slack/client_test.go @@ -0,0 +1,73 @@ +package slack + +import ( + "os" + "testing" +) + +func TestNewClient(t *testing.T) { + slackToken := os.Getenv(EnvToken) + defer func() { + os.Setenv(EnvToken, slackToken) + }() + os.Setenv(EnvToken, "") + + testCases := []struct { + config Config + envToken string + expect string + }{ + { + // specify directly + config: Config{Token: "abcdefg"}, + envToken: "", + expect: "", + }, + { + // specify via env but not to be set env (part 1) + config: Config{Token: "SLACK_TOKEN"}, + envToken: "", + expect: "slack token is missing", + }, + { + // specify via env (part 1) + config: Config{Token: "SLACK_TOKEN"}, + envToken: "abcdefg", + expect: "", + }, + { + // specify via env but not to be set env (part 2) + config: Config{Token: "$SLACK_TOKEN"}, + envToken: "", + expect: "slack token is missing", + }, + { + // specify via env (part 2) + config: Config{Token: "$SLACK_TOKEN"}, + envToken: "abcdefg", + expect: "", + }, + { + // no specification (part 1) + config: Config{}, + envToken: "", + expect: "slack token is missing", + }, + { + // no specification (part 2) + config: Config{}, + envToken: "abcdefg", + expect: "slack token is missing", + }, + } + for _, testCase := range testCases { + os.Setenv(EnvToken, testCase.envToken) + _, err := NewClient(testCase.config) + if err == nil { + continue + } + if err.Error() != testCase.expect { + t.Errorf("got %q but want %q", err.Error(), testCase.expect) + } + } +} diff --git a/notifier/slack/notify.go b/notifier/slack/notify.go new file mode 100644 index 0000000..5076b75 --- /dev/null +++ b/notifier/slack/notify.go @@ -0,0 +1,64 @@ +package slack + +import ( + "context" + "errors" + + "github.com/mercari/tfnotify/terraform" + "github.com/lestrrat-go/slack/objects" +) + +// NotifyService handles communication with the notification related +// methods of Slack API +type NotifyService service + +// Notify posts comment optimized for notifications +func (s *NotifyService) Notify(body string) (exit int, err error) { + cfg := s.client.Config + parser := s.client.Config.Parser + template := s.client.Config.Template + + if cfg.Channel == "" { + return terraform.ExitFail, errors.New("channel id is required") + } + + result := parser.Parse(body) + if result.Error != nil { + return result.ExitCode, result.Error + } + if result.Result == "" { + return result.ExitCode, result.Error + } + + color := "warning" + switch result.ExitCode { + case terraform.ExitPass: + color = "good" + case terraform.ExitFail: + color = "danger" + } + + template.SetValue(terraform.CommonTemplate{ + Message: cfg.Message, + Result: result.Result, + Body: body, + }) + text, err := template.Execute() + if err != nil { + return result.ExitCode, err + } + + var attachments objects.AttachmentList + attachment := &objects.Attachment{ + Color: color, + Fallback: text, + Footer: cfg.CI, + Text: text, + Title: template.GetValue().Title, + } + + attachments.Append(attachment) + // _, err = s.client.Chat().PostMessage(cfg.Channel).Username(cfg.Botname).SetAttachments(attachments).Do(cfg.Context) + _, err = s.client.API.ChatPostMessage(context.Background(), attachments) + return result.ExitCode, err +} diff --git a/notifier/slack/notify_test.go b/notifier/slack/notify_test.go new file mode 100644 index 0000000..1c49cc0 --- /dev/null +++ b/notifier/slack/notify_test.go @@ -0,0 +1,65 @@ +package slack + +import ( + "context" + "testing" + + "github.com/mercari/tfnotify/terraform" + "github.com/lestrrat-go/slack/objects" +) + +func TestNotify(t *testing.T) { + testCases := []struct { + config Config + body string + exitCode int + ok bool + }{ + { + config: Config{ + Token: "token", + Channel: "channel", + Botname: "botname", + Message: "", + Parser: terraform.NewPlanParser(), + Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), + }, + body: "Plan: 1 to add", + exitCode: 0, + ok: true, + }, + { + config: Config{ + Token: "token", + Channel: "", + Botname: "botname", + Message: "", + Parser: terraform.NewPlanParser(), + Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), + }, + body: "Plan: 1 to add", + exitCode: 1, + ok: false, + }, + } + fake := fakeAPI{ + FakeChatPostMessage: func(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) { + return nil, nil + }, + } + + for _, testCase := range testCases { + client, err := NewClient(testCase.config) + if err != nil { + t.Fatal(err) + } + client.API = &fake + exitCode, err := client.Notify.Notify(testCase.body) + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + if exitCode != testCase.exitCode { + t.Errorf("got %q but want %q", exitCode, testCase.exitCode) + } + } +} diff --git a/notifier/slack/slack.go b/notifier/slack/slack.go new file mode 100644 index 0000000..65e1def --- /dev/null +++ b/notifier/slack/slack.go @@ -0,0 +1,25 @@ +package slack + +import ( + "context" + + "github.com/lestrrat-go/slack" + "github.com/lestrrat-go/slack/objects" +) + +// API is Slack API interface +type API interface { + ChatPostMessage(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) +} + +// Slack represents the attribute information necessary for requesting Slack API +type Slack struct { + *slack.Client + Channel string + Botname string +} + +// ChatPostMessage is a wrapper of https://godoc.org/github.com/lestrrat-go/slack#ChatPostMessageCall +func (s *Slack) ChatPostMessage(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) { + return s.Client.Chat().PostMessage(s.Channel).Username(s.Botname).SetAttachments(attachments).Do(ctx) +} diff --git a/notifier/slack/slack_test.go b/notifier/slack/slack_test.go new file mode 100644 index 0000000..f23a556 --- /dev/null +++ b/notifier/slack/slack_test.go @@ -0,0 +1,16 @@ +package slack + +import ( + "context" + + "github.com/lestrrat-go/slack/objects" +) + +type fakeAPI struct { + API + FakeChatPostMessage func(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) +} + +func (g *fakeAPI) ChatPostMessage(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) { + return g.FakeChatPostMessage(ctx, attachments) +} diff --git a/tee.go b/tee.go new file mode 100644 index 0000000..ef858da --- /dev/null +++ b/tee.go @@ -0,0 +1,26 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "io" + + "github.com/mattn/go-colorable" +) + +func tee(stdin io.Reader, stdout io.Writer) string { + var b1 bytes.Buffer + var b2 bytes.Buffer + + tee := io.TeeReader(stdin, &b1) + s := bufio.NewScanner(tee) + for s.Scan() { + fmt.Fprintln(stdout, s.Text()) + } + + uncolorize := colorable.NewNonColorable(&b2) + uncolorize.Write(b1.Bytes()) + + return b2.String() +} diff --git a/tee_test.go b/tee_test.go new file mode 100644 index 0000000..075180d --- /dev/null +++ b/tee_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "bytes" + "io" + "testing" +) + +func TestTee(t *testing.T) { + testCases := []struct { + stdin io.Reader + stdout string + body string + }{ + { + // Regular + stdin: bytes.NewBufferString("Plan: 1 to add\n"), + stdout: "Plan: 1 to add\n", + body: "Plan: 1 to add\n", + }, + { + // ANSI color codes are included + stdin: bytes.NewBufferString("\033[mPlan: 1 to add\033[m\n"), + stdout: "\033[mPlan: 1 to add\033[m\n", + body: "Plan: 1 to add\n", + }, + } + + for _, testCase := range testCases { + stdout := new(bytes.Buffer) + body := tee(testCase.stdin, stdout) + if body != testCase.body { + t.Errorf("got %q but want %q", body, testCase.body) + } + if stdout.String() != testCase.stdout { + t.Errorf("got %q but want %q", stdout.String(), testCase.stdout) + } + } +} diff --git a/terraform/parser.go b/terraform/parser.go new file mode 100644 index 0000000..b70594f --- /dev/null +++ b/terraform/parser.go @@ -0,0 +1,160 @@ +package terraform + +import ( + "fmt" + "regexp" + "strings" +) + +// Parser is an interface for parsing terraform execution result +type Parser interface { + Parse(body string) ParseResult +} + +// ParseResult represents the result of parsed terraform execution +type ParseResult struct { + Result string + ExitCode int + Error error +} + +// DefaultParser is a parser for terraform commands +type DefaultParser struct { +} + +// FmtParser is a parser for terraform fmt +type FmtParser struct { + Pass *regexp.Regexp + Fail *regexp.Regexp +} + +// PlanParser is a parser for terraform plan +type PlanParser struct { + Pass *regexp.Regexp + Fail *regexp.Regexp +} + +// ApplyParser is a parser for terraform apply +type ApplyParser struct { + Pass *regexp.Regexp + Fail *regexp.Regexp +} + +// NewDefaultParser is DefaultParser initializer +func NewDefaultParser() *DefaultParser { + return &DefaultParser{} +} + +// NewFmtParser is FmtParser initialized with its Regexp +func NewFmtParser() *FmtParser { + return &FmtParser{ + Fail: regexp.MustCompile(`(?m)^(diff a/)`), + } +} + +// NewPlanParser is PlanParser initialized with its Regexp +func NewPlanParser() *PlanParser { + return &PlanParser{ + Pass: regexp.MustCompile(`(?m)^(Plan: \d|No changes.)`), + Fail: regexp.MustCompile(`(?m)^(Error: )`), + } +} + +// NewApplyParser is ApplyParser initialized with its Regexp +func NewApplyParser() *ApplyParser { + return &ApplyParser{ + Pass: regexp.MustCompile(`(?m)^(Apply complete!)`), + Fail: regexp.MustCompile(`(?m)^(Error: Error applying plan:)`), + } +} + +// Parse returns ParseResult related with terraform commands +func (p *DefaultParser) Parse(body string) ParseResult { + return ParseResult{ + Result: body, + ExitCode: ExitPass, + Error: nil, + } +} + +// Parse returns ParseResult related with terraform fmt +func (p *FmtParser) Parse(body string) ParseResult { + result := ParseResult{} + if p.Fail.MatchString(body) { + result.Result = "There is diff in your .tf file (need to be formatted)" + result.ExitCode = ExitFail + } + return result +} + +// Parse returns ParseResult related with terraform plan +func (p *PlanParser) Parse(body string) ParseResult { + var exitCode int + switch { + case p.Pass.MatchString(body): + exitCode = ExitPass + case p.Fail.MatchString(body): + exitCode = ExitFail + default: + return ParseResult{ + Result: "", + ExitCode: ExitFail, + Error: fmt.Errorf("cannot parse plan result"), + } + } + var result string + lines := strings.Split(body, "\n") + for _, line := range lines { + if p.Pass.MatchString(line) || p.Fail.MatchString(line) { + result = line + } + } + return ParseResult{ + Result: result, + ExitCode: exitCode, + Error: nil, + } +} + +// Parse returns ParseResult related with terraform apply +func (p *ApplyParser) Parse(body string) ParseResult { + var exitCode int + switch { + case p.Pass.MatchString(body): + exitCode = ExitPass + case p.Fail.MatchString(body): + exitCode = ExitFail + default: + return ParseResult{ + Result: "", + ExitCode: ExitFail, + Error: fmt.Errorf("cannot parse apply result"), + } + } + var result string + lines := strings.Split(body, "\n") + var i int + for idx, line := range lines { + if p.Pass.MatchString(line) || p.Fail.MatchString(line) { + i = idx + break + } + } + result = strings.Join(trimLastNewline(lines[i:]), "\n") + return ParseResult{ + Result: result, + ExitCode: exitCode, + Error: nil, + } +} + +func trimLastNewline(s []string) []string { + if len(s) == 0 { + return s + } + last := len(s) - 1 + if s[last] == "" { + return s[:last] + } + return s +} diff --git a/terraform/parser_test.go b/terraform/parser_test.go new file mode 100644 index 0000000..c38f07e --- /dev/null +++ b/terraform/parser_test.go @@ -0,0 +1,378 @@ +package terraform + +import ( + "errors" + "reflect" + "testing" +) + +const fmtSuccessResult = ` +google_spanner_database.tf +diff a/google_spanner_database.tf b/google_spanner_database.tf +--- /tmp/398669432 ++++ /tmp/536670071 +@@ -9,3 +9,4 @@ + # instance = "${google_spanner_instance.my_service_dev.name}" + # name = "my-service-dev" + # } ++ + +google_spanner_instance.tf +diff a/google_spanner_instance.tf b/google_spanner_instance.tf +--- /tmp/314409578 ++++ /tmp/686207681 +@@ -13,3 +13,4 @@ + # name = "my-service-dev" + # num_nodes = 1 + # } ++ +` + +const planSuccessResult = ` +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +data.terraform_remote_state.teams_platform_development: Refreshing state... +google_project.my_project: Refreshing state... +aws_iam_policy.datadog_aws_integration: Refreshing state... +aws_iam_user.teams_terraform: Refreshing state... +aws_iam_role.datadog_aws_integration: Refreshing state... +google_project_services.my_project: Refreshing state... +google_bigquery_dataset.gateway_access_log: Refreshing state... +aws_iam_role_policy_attachment.datadog_aws_integration: Refreshing state... +google_logging_project_sink.gateway_access_log_bigquery_sink: Refreshing state... +google_project_iam_member.gateway_access_log_bigquery_sink_writer_is_bigquery_data_editor: Refreshing state... +google_dns_managed_zone.tfnotifyapps_com: Refreshing state... +google_dns_record_set.dev_tfnotifyapps_com: Refreshing state... + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + google_compute_global_address.my_another_project + id: + address: + ip_version: "IPV4" + name: "my-another-project" + project: "my-project" + self_link: + + +Plan: 1 to add, 0 to change, 0 to destroy. + +------------------------------------------------------------------------ + +Note: You didn't specify an "-out" parameter to save this plan, so Terraform +can't guarantee that exactly these actions will be performed if +"terraform apply" is subsequently run. +` + +const planFailureResult = ` +xxxxxxxxx +xxxxxxxxx +xxxxxxxxx +Error: Required variable not set: my_service_dev_google_sql_user_proxyuser_password +` + +const planNoChanges = ` +google_bigquery_dataset.tfnotify_echo: Refreshing state... +google_project.team: Refreshing state... +pagerduty_team.team: Refreshing state... +data.pagerduty_vendor.datadog: Refreshing state... +data.pagerduty_user.service_owner[1]: Refreshing state... +data.pagerduty_user.service_owner[2]: Refreshing state... +data.pagerduty_user.service_owner[0]: Refreshing state... +google_project_services.team: Refreshing state... +google_project_iam_member.team[1]: Refreshing state... +google_project_iam_member.team[2]: Refreshing state... +google_project_iam_member.team[0]: Refreshing state... +google_project_iam_member.team_platform[1]: Refreshing state... +google_project_iam_member.team_platform[2]: Refreshing state... +google_project_iam_member.team_platform[0]: Refreshing state... +pagerduty_team_membership.team[2]: Refreshing state... +pagerduty_schedule.secondary: Refreshing state... +pagerduty_schedule.primary: Refreshing state... +pagerduty_team_membership.team[0]: Refreshing state... +pagerduty_team_membership.team[1]: Refreshing state... +pagerduty_escalation_policy.team: Refreshing state... +pagerduty_service.team: Refreshing state... +pagerduty_service_integration.datadog: Refreshing state... + +------------------------------------------------------------------------ + +No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. +` + +const applySuccessResult = ` +data.terraform_remote_state.teams_platform_development: Refreshing state... +google_project.my_service: Refreshing state... +google_storage_bucket.chartmuseum: Refreshing state... +google_storage_bucket.ark_tfnotify_prod: Refreshing state... +google_bigquery_dataset.gateway_access_log: Refreshing state... +google_compute_global_address.chartmuseum_tfnotifyapps_com: Refreshing state... +google_compute_global_address.reviews_web_tfnotify_in: Refreshing state... +google_compute_global_address.reviews_api_tfnotify_in: Refreshing state... +google_compute_global_address.teams_web_tfnotify_in: Refreshing state... +google_project_services.my_service: Refreshing state... +google_logging_project_sink.gateway_access_log_bigquery_sink: Refreshing state... +google_project_iam_member.gateway_access_log_bigquery_sink_writer_is_bigquery_data_editor: Refreshing state... +aws_s3_bucket.teams_terraform_private_modules: Refreshing state... +aws_iam_role.datadog_aws_integration: Refreshing state... +aws_s3_bucket.terraform_backend: Refreshing state... +aws_iam_user.teams_terraform: Refreshing state... +aws_iam_policy.datadog_aws_integration: Refreshing state... +aws_iam_user_policy.teams_terraform: Refreshing state... +aws_iam_role_policy_attachment.datadog_aws_integration: Refreshing state... +google_dns_managed_zone.tfnotifyapps_com: Refreshing state... +google_dns_record_set.dev_tfnotifyapps_com: Refreshing state... + +Apply complete! Resources: 0 added, 0 changed, 0 destroyed. +` + +const applyFailureResult = ` +data.terraform_remote_state.teams_platform_development: Refreshing state... +google_project.tfnotify_jp_tfnotify_prod: Refreshing state... +google_project_services.tfnotify_jp_tfnotify_prod: Refreshing state... +google_bigquery_dataset.gateway_access_log: Refreshing state... +google_compute_global_address.reviews_web_tfnotify_in: Refreshing state... +google_compute_global_address.chartmuseum_tfnotifyapps_com: Refreshing state... +google_storage_bucket.chartmuseum: Refreshing state... +google_storage_bucket.ark_tfnotify_prod: Refreshing state... +google_compute_global_address.reviews_api_tfnotify_in: Refreshing state... +google_logging_project_sink.gateway_access_log_bigquery_sink: Refreshing state... +google_project_iam_member.gateway_access_log_bigquery_sink_writer_is_bigquery_data_editor: Refreshing state... +aws_s3_bucket.terraform_backend: Refreshing state... +aws_s3_bucket.teams_terraform_private_modules: Refreshing state... +aws_iam_policy.datadog_aws_integration: Refreshing state... +aws_iam_role.datadog_aws_integration: Refreshing state... +aws_iam_user.teams_terraform: Refreshing state... +aws_iam_user_policy.teams_terraform: Refreshing state... +aws_iam_role_policy_attachment.datadog_aws_integration: Refreshing state... +google_dns_managed_zone.tfnotifyapps_com: Refreshing state... +google_dns_record_set.dev_tfnotifyapps_com: Refreshing state... +google_compute_global_address.teams_web_tfnotify_in: Creating... + address: "" => "" + ip_version: "" => "IPV4" + name: "" => "web-tfnotify-in" + project: "" => "tfnotify-jp-tfnotify-prod" + self_link: "" => "" + +Error: Error applying plan: + +1 error(s) occurred: + +* google_compute_global_address.teams_web_tfnotify_in: 1 error(s) occurred: + +* google_compute_global_address.teams_web_tfnotify_in: Error creating address: googleapi: Error 409: The resource 'projects/tfnotify-jp-tfnotify-prod/global/addresses/teams-web-tfnotify-in' already exists, alreadyExists + +Terraform does not automatically rollback in the face of errors. +Instead, your Terraform state file has been partially updated with +any resources that successfully completed. Please address the error +above and apply again to incrementally change your infrastructure. +` + +func TestDefaultParserParse(t *testing.T) { + testCases := []struct { + body string + result ParseResult + }{ + { + body: "", + result: ParseResult{ + Result: "", + ExitCode: 0, + Error: nil, + }, + }, + } + for _, testCase := range testCases { + result := NewDefaultParser().Parse(testCase.body) + if !reflect.DeepEqual(result, testCase.result) { + t.Errorf("got %v but want %v", result, testCase.result) + } + } +} + +func TestFmtParserParse(t *testing.T) { + testCases := []struct { + name string + body string + result ParseResult + }{ + { + name: "diff", + body: fmtSuccessResult, + result: ParseResult{ + Result: "There is diff in your .tf file (need to be formatted)", + ExitCode: 1, + Error: nil, + }, + }, + { + name: "no stdin", + body: "", + result: ParseResult{ + Result: "", + ExitCode: 0, + Error: nil, + }, + }, + } + for _, testCase := range testCases { + result := NewFmtParser().Parse(testCase.body) + if !reflect.DeepEqual(result, testCase.result) { + t.Errorf("got %v but want %v", result, testCase.result) + } + } +} + +func TestPlanParserParse(t *testing.T) { + testCases := []struct { + name string + body string + result ParseResult + }{ + { + name: "plan ok pattern", + body: planSuccessResult, + result: ParseResult{ + Result: "Plan: 1 to add, 0 to change, 0 to destroy.", + ExitCode: 0, + Error: nil, + }, + }, + { + name: "no stdin", + body: "", + result: ParseResult{ + Result: "", + ExitCode: 1, + Error: errors.New("cannot parse plan result"), + }, + }, + { + name: "plan ng pattern", + body: planFailureResult, + result: ParseResult{ + Result: "Error: Required variable not set: my_service_dev_google_sql_user_proxyuser_password", + ExitCode: 1, + Error: nil, + }, + }, + { + name: "plan no changes", + body: planNoChanges, + result: ParseResult{ + Result: "No changes. Infrastructure is up-to-date.", + ExitCode: 0, + Error: nil, + }, + }, + } + for _, testCase := range testCases { + result := NewPlanParser().Parse(testCase.body) + if !reflect.DeepEqual(result, testCase.result) { + t.Errorf("got %v but want %v", result, testCase.result) + } + } +} + +func TestApplyParserParse(t *testing.T) { + testCases := []struct { + name string + body string + result ParseResult + }{ + { + name: "no stdin", + body: "", + result: ParseResult{ + Result: "", + ExitCode: 1, + Error: errors.New("cannot parse apply result"), + }, + }, + { + name: "apply ok pattern", + body: applySuccessResult, + result: ParseResult{ + Result: "Apply complete! Resources: 0 added, 0 changed, 0 destroyed.", + ExitCode: 0, + Error: nil, + }, + }, + { + name: "apply ng pattern", + body: applyFailureResult, + result: ParseResult{ + Result: `Error: Error applying plan: + +1 error(s) occurred: + +* google_compute_global_address.teams_web_tfnotify_in: 1 error(s) occurred: + +* google_compute_global_address.teams_web_tfnotify_in: Error creating address: googleapi: Error 409: The resource 'projects/tfnotify-jp-tfnotify-prod/global/addresses/teams-web-tfnotify-in' already exists, alreadyExists + +Terraform does not automatically rollback in the face of errors. +Instead, your Terraform state file has been partially updated with +any resources that successfully completed. Please address the error +above and apply again to incrementally change your infrastructure.`, + ExitCode: 1, + Error: nil, + }, + }, + } + for _, testCase := range testCases { + result := NewApplyParser().Parse(testCase.body) + if !reflect.DeepEqual(result, testCase.result) { + t.Errorf("got %v but want %v", result, testCase.result) + } + } +} + +func TestTrimLastNewline(t *testing.T) { + testCases := []struct { + data []string + expected []string + }{ + { + data: []string{}, + expected: []string{}, + }, + { + data: []string{"a", "b", "c", ""}, + expected: []string{"a", "b", "c"}, + }, + { + data: []string{"a", ""}, + expected: []string{"a"}, + }, + { + data: []string{""}, + expected: []string{}, + }, + { + data: []string{"a", "b", "c"}, + expected: []string{"a", "b", "c"}, + }, + { + data: []string{"a"}, + expected: []string{"a"}, + }, + } + for _, testCase := range testCases { + actual := trimLastNewline(testCase.data) + if !reflect.DeepEqual(actual, testCase.expected) { + t.Errorf("got %v but want %v", actual, testCase.expected) + } + } +} diff --git a/terraform/template.go b/terraform/template.go new file mode 100644 index 0000000..35ebef3 --- /dev/null +++ b/terraform/template.go @@ -0,0 +1,287 @@ +package terraform + +import ( + "bytes" + "html/template" +) + +const ( + // DefaultDefaultTitle is a default title for terraform commands + DefaultDefaultTitle = "## Terraform result" + // DefaultFmtTitle is a default title for terraform fmt + DefaultFmtTitle = "## Fmt result" + // DefaultPlanTitle is a default title for terraform plan + DefaultPlanTitle = "## Plan result" + // DefaultApplyTitle is a default title for terraform apply + DefaultApplyTitle = "## Apply result" + + // DefaultDefaultTemplate is a default template for terraform commands + DefaultDefaultTemplate = ` +{{ .Title }} + +{{ .Message }} + +{{if .Result}} +
{{ .Result }}
+
+{{end}} + +
Details (Click me) +
{{ .Body }}
+
+` + + // DefaultFmtTemplate is a default template for terraform fmt + DefaultFmtTemplate = ` +{{ .Title }} + +{{ .Message }} + +{{ .Result }} + +{{ .Body }} +` + + // DefaultPlanTemplate is a default template for terraform plan + DefaultPlanTemplate = ` +{{ .Title }} + +{{ .Message }} + +{{if .Result}} +
{{ .Result }}
+
+{{end}} + +
Details (Click me) +
{{ .Body }}
+
+` + + // DefaultApplyTemplate is a default template for terraform apply + DefaultApplyTemplate = ` +{{ .Title }} + +{{ .Message }} + +{{if .Result}} +
{{ .Result }}
+
+{{end}} + +
Details (Click me) +
{{ .Body }}
+
+` +) + +// Template is an template interface for parsed terraform execution result +type Template interface { + Execute() (resp string, err error) + SetValue(template CommonTemplate) + GetValue() CommonTemplate +} + +// CommonTemplate represents template entities +type CommonTemplate struct { + Title string + Message string + Result string + Body string +} + +// DefaultTemplate is a default template for terraform commands +type DefaultTemplate struct { + Template string + + CommonTemplate +} + +// FmtTemplate is a default template for terraform fmt +type FmtTemplate struct { + Template string + + CommonTemplate +} + +// PlanTemplate is a default template for terraform plan +type PlanTemplate struct { + Template string + + CommonTemplate +} + +// ApplyTemplate is a default template for terraform apply +type ApplyTemplate struct { + Template string + + CommonTemplate +} + +// NewDefaultTemplate is DefaultTemplate initializer +func NewDefaultTemplate(template string) *DefaultTemplate { + if template == "" { + template = DefaultDefaultTemplate + } + return &DefaultTemplate{ + Template: template, + } +} + +// NewFmtTemplate is FmtTemplate initializer +func NewFmtTemplate(template string) *FmtTemplate { + if template == "" { + template = DefaultFmtTemplate + } + return &FmtTemplate{ + Template: template, + } +} + +// NewPlanTemplate is PlanTemplate initializer +func NewPlanTemplate(template string) *PlanTemplate { + if template == "" { + template = DefaultPlanTemplate + } + return &PlanTemplate{ + Template: template, + } +} + +// NewApplyTemplate is ApplyTemplate initializer +func NewApplyTemplate(template string) *ApplyTemplate { + if template == "" { + template = DefaultApplyTemplate + } + return &ApplyTemplate{ + Template: template, + } +} + +// Execute binds the execution result of terraform command into tepmlate +func (t *DefaultTemplate) Execute() (resp string, err error) { + tpl, err := template.New("default").Parse(t.Template) + if err != nil { + return resp, err + } + var b bytes.Buffer + if err := tpl.Execute(&b, map[string]interface{}{ + "Title": t.Title, + "Message": t.Message, + "Result": "", + "Body": t.Result, + }); err != nil { + return resp, err + } + resp = b.String() + return resp, err +} + +// Execute binds the execution result of terraform fmt into tepmlate +func (t *FmtTemplate) Execute() (resp string, err error) { + tpl, err := template.New("fmt").Parse(t.Template) + if err != nil { + return resp, err + } + var b bytes.Buffer + if err := tpl.Execute(&b, map[string]interface{}{ + "Title": t.Title, + "Message": t.Message, + "Result": "", + "Body": t.Result, + }); err != nil { + return resp, err + } + resp = b.String() + return resp, err +} + +// Execute binds the execution result of terraform plan into tepmlate +func (t *PlanTemplate) Execute() (resp string, err error) { + tpl, err := template.New("plan").Parse(t.Template) + if err != nil { + return resp, err + } + var b bytes.Buffer + if err := tpl.Execute(&b, map[string]interface{}{ + "Title": t.Title, + "Message": t.Message, + "Result": t.Result, + "Body": t.Body, + }); err != nil { + return resp, err + } + resp = b.String() + return resp, err +} + +// Execute binds the execution result of terraform apply into tepmlate +func (t *ApplyTemplate) Execute() (resp string, err error) { + tpl, err := template.New("apply").Parse(t.Template) + if err != nil { + return resp, err + } + var b bytes.Buffer + if err := tpl.Execute(&b, map[string]interface{}{ + "Title": t.Title, + "Message": t.Message, + "Result": t.Result, + "Body": t.Body, + }); err != nil { + return resp, err + } + resp = b.String() + return resp, err +} + +// SetValue sets template entities to CommonTemplate +func (t *DefaultTemplate) SetValue(ct CommonTemplate) { + if ct.Title == "" { + ct.Title = DefaultDefaultTitle + } + t.CommonTemplate = ct +} + +// SetValue sets template entities about terraform fmt to CommonTemplate +func (t *FmtTemplate) SetValue(ct CommonTemplate) { + if ct.Title == "" { + ct.Title = DefaultFmtTitle + } + t.CommonTemplate = ct +} + +// SetValue sets template entities about terraform plan to CommonTemplate +func (t *PlanTemplate) SetValue(ct CommonTemplate) { + if ct.Title == "" { + ct.Title = DefaultPlanTitle + } + t.CommonTemplate = ct +} + +// SetValue sets template entities about terraform apply to CommonTemplate +func (t *ApplyTemplate) SetValue(ct CommonTemplate) { + if ct.Title == "" { + ct.Title = DefaultApplyTitle + } + t.CommonTemplate = ct +} + +// GetValue gets template entities +func (t *DefaultTemplate) GetValue() CommonTemplate { + return t.CommonTemplate +} + +// GetValue gets template entities +func (t *FmtTemplate) GetValue() CommonTemplate { + return t.CommonTemplate +} + +// GetValue gets template entities +func (t *PlanTemplate) GetValue() CommonTemplate { + return t.CommonTemplate +} + +// GetValue gets template entities +func (t *ApplyTemplate) GetValue() CommonTemplate { + return t.CommonTemplate +} diff --git a/terraform/template_test.go b/terraform/template_test.go new file mode 100644 index 0000000..86c8ba2 --- /dev/null +++ b/terraform/template_test.go @@ -0,0 +1,293 @@ +package terraform + +import ( + "reflect" + "testing" +) + +func TestDefaultTemplateExecute(t *testing.T) { + testCases := []struct { + template string + value CommonTemplate + resp string + }{ + { + template: DefaultDefaultTemplate, + value: CommonTemplate{}, + resp: "\n## Terraform result\n\n\n\n\n\n
Details (Click me)\n
\n
\n", + }, + { + template: DefaultDefaultTemplate, + value: CommonTemplate{ + Message: "message", + }, + resp: "\n## Terraform result\n\nmessage\n\n\n\n
Details (Click me)\n
\n
\n", + }, + { + template: DefaultDefaultTemplate, + value: CommonTemplate{ + Title: "a", + Message: "b", + Result: "c", + Body: "d", + }, + resp: "\na\n\nb\n\n\n\n
Details (Click me)\n
c\n
\n", + }, + + { + template: "", + value: CommonTemplate{ + Title: "a", + Message: "b", + Result: "c", + Body: "d", + }, + resp: "\na\n\nb\n\n\n\n
Details (Click me)\n
c\n
\n", + }, + { + template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`, + value: CommonTemplate{ + Title: "a", + Message: "b", + Result: "should be used as body", + Body: "should be empty", + }, + resp: "a-b--should be used as body", + }, + } + for _, testCase := range testCases { + template := NewDefaultTemplate(testCase.template) + template.SetValue(testCase.value) + resp, err := template.Execute() + if err != nil { + t.Fatal(err) + } + if resp != testCase.resp { + t.Errorf("got %q but want %q", resp, testCase.resp) + } + } +} + +func TestFmtTemplateExecute(t *testing.T) { + testCases := []struct { + template string + value CommonTemplate + resp string + }{ + { + template: DefaultFmtTemplate, + value: CommonTemplate{}, + resp: "\n## Fmt result\n\n\n\n\n\n\n", + }, + { + template: DefaultFmtTemplate, + value: CommonTemplate{ + Message: "message", + }, + resp: "\n## Fmt result\n\nmessage\n\n\n\n\n", + }, + { + template: DefaultFmtTemplate, + value: CommonTemplate{ + Title: "a", + Message: "b", + Result: "c", + Body: "d", + }, + resp: "\na\n\nb\n\n\n\nc\n", + }, + + { + template: "", + value: CommonTemplate{ + Title: "a", + Message: "b", + Result: "c", + Body: "d", + }, + resp: "\na\n\nb\n\n\n\nc\n", + }, + { + template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`, + value: CommonTemplate{ + Title: "a", + Message: "b", + Result: "should be used as body", + Body: "should be empty", + }, + resp: "a-b--should be used as body", + }, + } + for _, testCase := range testCases { + template := NewFmtTemplate(testCase.template) + template.SetValue(testCase.value) + resp, err := template.Execute() + if err != nil { + t.Fatal(err) + } + if resp != testCase.resp { + t.Errorf("got %q but want %q", resp, testCase.resp) + } + } +} + +func TestPlanTemplateExecute(t *testing.T) { + testCases := []struct { + template string + value CommonTemplate + resp string + }{ + { + template: DefaultPlanTemplate, + value: CommonTemplate{}, + resp: "\n## Plan result\n\n\n\n\n\n
Details (Click me)\n
\n
\n", + }, + { + template: DefaultPlanTemplate, + value: CommonTemplate{ + Title: "title", + Message: "message", + Result: "result", + Body: "body", + }, + resp: "\ntitle\n\nmessage\n\n\n
result\n
\n\n\n
Details (Click me)\n
body\n
\n", + }, + { + template: DefaultPlanTemplate, + value: CommonTemplate{ + Title: "title", + Message: "message", + Result: "", + Body: "body", + }, + resp: "\ntitle\n\nmessage\n\n\n\n
Details (Click me)\n
body\n
\n", + }, + { + template: "", + value: CommonTemplate{ + Title: "title", + Message: "message", + Result: "", + Body: "body", + }, + resp: "\ntitle\n\nmessage\n\n\n\n
Details (Click me)\n
body\n
\n", + }, + { + template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`, + value: CommonTemplate{ + Title: "a", + Message: "b", + Result: "c", + Body: "d", + }, + resp: "a-b-c-d", + }, + } + for _, testCase := range testCases { + template := NewPlanTemplate(testCase.template) + template.SetValue(testCase.value) + resp, err := template.Execute() + if err != nil { + t.Fatal(err) + } + if resp != testCase.resp { + t.Errorf("got %q but want %q", resp, testCase.resp) + } + } +} + +func TestApplyTemplateExecute(t *testing.T) { + testCases := []struct { + template string + value CommonTemplate + resp string + }{ + { + template: DefaultApplyTemplate, + value: CommonTemplate{}, + resp: "\n## Apply result\n\n\n\n\n\n
Details (Click me)\n
\n
\n", + }, + { + template: DefaultApplyTemplate, + value: CommonTemplate{ + Title: "title", + Message: "message", + Result: "result", + Body: "body", + }, + resp: "\ntitle\n\nmessage\n\n\n
result\n
\n\n\n
Details (Click me)\n
body\n
\n", + }, + { + template: DefaultApplyTemplate, + value: CommonTemplate{ + Title: "title", + Message: "message", + Result: "", + Body: "body", + }, + resp: "\ntitle\n\nmessage\n\n\n\n
Details (Click me)\n
body\n
\n", + }, + { + template: "", + value: CommonTemplate{ + Title: "title", + Message: "message", + Result: "", + Body: "body", + }, + resp: "\ntitle\n\nmessage\n\n\n\n
Details (Click me)\n
body\n
\n", + }, + { + template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`, + value: CommonTemplate{ + Title: "a", + Message: "b", + Result: "c", + Body: "d", + }, + resp: "a-b-c-d", + }, + } + for _, testCase := range testCases { + template := NewApplyTemplate(testCase.template) + template.SetValue(testCase.value) + resp, err := template.Execute() + if err != nil { + t.Error(err) + } + if resp != testCase.resp { + t.Errorf("got %q but want %q", resp, testCase.resp) + } + } +} + +func TestGetValue(t *testing.T) { + testCases := []struct { + template Template + expected CommonTemplate + }{ + { + template: NewDefaultTemplate(""), + expected: CommonTemplate{}, + }, + { + template: NewFmtTemplate(""), + expected: CommonTemplate{}, + }, + { + template: NewPlanTemplate(""), + expected: CommonTemplate{}, + }, + { + template: NewApplyTemplate(""), + expected: CommonTemplate{}, + }, + } + for _, testCase := range testCases { + template := testCase.template + value := template.GetValue() + if !reflect.DeepEqual(value, testCase.expected) { + t.Errorf("got %q but want %q", value, testCase.expected) + } + } +} diff --git a/terraform/terraform.go b/terraform/terraform.go new file mode 100644 index 0000000..3135da5 --- /dev/null +++ b/terraform/terraform.go @@ -0,0 +1,9 @@ +package terraform + +const ( + // ExitPass is status code zero + ExitPass int = iota + + // ExitFail is status code non-zero + ExitFail +) diff --git a/terraform/terraform_test.go b/terraform/terraform_test.go new file mode 100644 index 0000000..cc3ee2f --- /dev/null +++ b/terraform/terraform_test.go @@ -0,0 +1 @@ +package terraform