From 810505e83a502ca169c6a99119a58a9c0c609f54 Mon Sep 17 00:00:00 2001 From: b4b4r07 Date: Fri, 6 Apr 2018 20:01:47 +0900 Subject: [PATCH] add(core): Initial commit --- .chglog/CHANGELOG.tpl.md | 31 +++ .chglog/config.yml | 32 +++ .circleci/config.yml | 53 +++++ .codecov.yml | 9 + .github/CODEOWNERS | 2 + .github/PULL_REQUEST_TEMPLATE.md | 7 + .gitignore | 22 ++ .reviewdog.yml | 3 + Gopkg.lock | 99 ++++++++ Gopkg.toml | 38 ++++ LICENSE | 22 ++ Makefile | 43 ++++ README.md | 169 ++++++++++++++ ci.go | 48 ++++ ci_test.go | 211 +++++++++++++++++ config/config.go | 159 +++++++++++++ config/config_test.go | 319 ++++++++++++++++++++++++++ error.go | 77 +++++++ error_test.go | 46 ++++ example.tfnotify.yaml | 19 ++ main.go | 212 +++++++++++++++++ misc/1.png | Bin 0 -> 27558 bytes misc/2.png | Bin 0 -> 29376 bytes misc/bump-and-chglog.sh | 31 +++ misc/install-devel-deps.sh | 13 ++ misc/upload-artifacts.sh | 7 + notifier/github/client.go | 91 ++++++++ notifier/github/client_test.go | 98 ++++++++ notifier/github/comment.go | 86 +++++++ notifier/github/comment_test.go | 217 ++++++++++++++++++ notifier/github/commits.go | 47 ++++ notifier/github/commits_test.go | 91 ++++++++ notifier/github/github.go | 47 ++++ notifier/github/github_test.go | 102 +++++++++ notifier/github/notify.go | 55 +++++ notifier/github/notify_test.go | 141 ++++++++++++ notifier/notifier.go | 6 + notifier/notifier_test.go | 1 + notifier/slack/client.go | 66 ++++++ notifier/slack/client_test.go | 73 ++++++ notifier/slack/notify.go | 64 ++++++ notifier/slack/notify_test.go | 65 ++++++ notifier/slack/slack.go | 25 ++ notifier/slack/slack_test.go | 16 ++ tee.go | 26 +++ tee_test.go | 39 ++++ terraform/parser.go | 160 +++++++++++++ terraform/parser_test.go | 378 +++++++++++++++++++++++++++++++ terraform/template.go | 287 +++++++++++++++++++++++ terraform/template_test.go | 293 ++++++++++++++++++++++++ terraform/terraform.go | 9 + terraform/terraform_test.go | 1 + 52 files changed, 4156 insertions(+) create mode 100644 .chglog/CHANGELOG.tpl.md create mode 100644 .chglog/config.yml create mode 100644 .circleci/config.yml create mode 100644 .codecov.yml create mode 100644 .github/CODEOWNERS create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitignore create mode 100644 .reviewdog.yml create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 ci.go create mode 100644 ci_test.go create mode 100644 config/config.go create mode 100644 config/config_test.go create mode 100644 error.go create mode 100644 error_test.go create mode 100644 example.tfnotify.yaml create mode 100644 main.go create mode 100644 misc/1.png create mode 100644 misc/2.png create mode 100755 misc/bump-and-chglog.sh create mode 100755 misc/install-devel-deps.sh create mode 100755 misc/upload-artifacts.sh create mode 100644 notifier/github/client.go create mode 100644 notifier/github/client_test.go create mode 100644 notifier/github/comment.go create mode 100644 notifier/github/comment_test.go create mode 100644 notifier/github/commits.go create mode 100644 notifier/github/commits_test.go create mode 100644 notifier/github/github.go create mode 100644 notifier/github/github_test.go create mode 100644 notifier/github/notify.go create mode 100644 notifier/github/notify_test.go create mode 100644 notifier/notifier.go create mode 100644 notifier/notifier_test.go create mode 100644 notifier/slack/client.go create mode 100644 notifier/slack/client_test.go create mode 100644 notifier/slack/notify.go create mode 100644 notifier/slack/notify_test.go create mode 100644 notifier/slack/slack.go create mode 100644 notifier/slack/slack_test.go create mode 100644 tee.go create mode 100644 tee_test.go create mode 100644 terraform/parser.go create mode 100644 terraform/parser_test.go create mode 100644 terraform/template.go create mode 100644 terraform/template_test.go create mode 100644 terraform/terraform.go create mode 100644 terraform/terraform_test.go 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 0000000000000000000000000000000000000000..cebcb60ffff32e7972122f6b1f51cb7a05eb60c6 GIT binary patch literal 27558 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 literal 0 HcmV?d00001 diff --git a/misc/2.png b/misc/2.png new file mode 100644 index 0000000000000000000000000000000000000000..b192161ae6bd134b4384e35054bb4ad3b46e91e6 GIT binary patch literal 29376 zcma&O1yq)8_vVd-2uhby(jqA-t+yZ`DJd=8A>D{{i-0spcXy+JlyrBCba#E5_dhe= z%$k|C=3XqGN8-M&>x{jR{oBWhzl@Z~Bh)9TNJvPJ#6*Q;k&tdnBO%>-f^r+a;)_l- z2mi1)dho~1{?~+f+4y)g-4 o{oFM)US>nK2@!(OG9cAxTPD2dy51`clK8CAo&KyQ#v-%>_p0K4JCh$#)91lv;h$4YvZG-holm(k zarN&%D|3+DM|i&8LehSWjDbvmd=J?g`3|x;vO03xx1j*+uZWe4FP$lW4uY`niF|C& zc>Z!_1wZ0@8vg1thriV8tIr4@6F&Jo%5LCbQ1DYzQd(r>5$Ee`cSRV? zq?V=~`-SW4MuN-hYx@^{ZXe@G+HWCI*&EFjYaYEvLVAiMCiGg~VPa$2{uRzB8Pblu z@~h89nW(5)FENE${aHz#rQBwcZ>mvJSj$o@E66c~FDNEg1$@ckMqI!`zJwZ_Q z8L~f*L_L3ogmjU4ZpTRc8pl_2sGZ4tp>Og;qGAq*(XVY04-ap2WW+7+b`qZ$Jsn+G z{J_LSY*f_jz`$P5tXqVPFD5psmjn%toeUDviL?lB;wu|&Una~S5BNEzos9`|vWMl`=_QDuJ+f4O`3f|V_zEW`8n}@|`$$k5MjNSrEBr_4J$crQ+%vm0+&5WCiSMq6j_%{D z%F41@4Mp=id+rF*)6-YpH8(LcOJFxjX`HP)rf1_h3%E3FtE#$tAsr^;cc1$r!MM1X zt2e3f6;1fXg^Rjcf>!7)q|eO#z8aN_sKU6nM?TdsMY0!1Wh_O!{++`xGuiXbmA=uN zej{0Ubz^~)Uut>(bU`pl(EaDizO9YT(fv)=?dNGzjm)PjWkzzfcAGi5xs&B4N?tNf zZ;=u0gus77M|IUYIcw1O3B~jSVsj3PLag z8mxw$K1X_cd+W|~aFp9`%_MMGzpeGYjnp(syt$@&8Yj25rBwV#0Yxyl&ze=?vQdE2 z2V0X1V>2g1n%X~esxvjt__qC>7iF*c!Q!^W%x<|FPm52jO*(*M2LTsaC(kL?UVXXiy0?gyeP>_OA*D3 zzb8XWq$S?TzJD)pIZ5~C%^R=2H4YQay7s-}{W#uHku{#i&aP%mV-p<$d;;a@m_-~$ zM#fAB2_{D7^F{5n;^I$jboEY$5h_mtTTv$(XU99TEoKE?6j#RnJ1`o|c)ph}O2vbN zukqmnyV)9UhaoKU?^f3^9cbfb8wAW6zgwmzY7S``CA)`)XnQqv&Co9VU!7zgli}ed z9PTaKSTMsT{LI|r9HNUVY&gFeAm?~7g>0@}ZaEQIGQ|~dx8L{7D^CRD16l zP{X@7BQ-TV!hx1J~ z&u~!@hPx@P-}Dyv_@;a@$O14+%FFqQu(kw@QYtG65dY2&*HMz=xGc!M;^T>T-84kt z4GYD^&F)W(p_kD;WB7RGYTX^@!{wfy7T-wduk~Sjranz7%((CAOP+)3vK=7j5>P_f!T+F1>;Fm9|nXn+I5;@!og#hh=hW2HFcEfkZ+3F!l*h*t z7+sF?V(#8fE-T|(Tpd)cT);$FyXbetax+qTic--~XY#p31YmZy|8bdQ!$%n>mXb!> zU+(&DIKU1MnwpWJzuoXdB$Tu}kspU#hzgsov$LeZ%-o#Y`RL-})Xl0!kB>C8Ra;NM z`Do)5&4kFrMO$f+=}umrd0Q)`7b|NbwwRFWq{j}%9i$UP{fccZ)7p}jdRAqZgCFxN zLLZ^JDP-JOF?WsTMJr*IC+}=LVjDcNGCW$%OlbNHZ{H$Cc|dr}#89{25Lf7_w(fG? z=))TBqip()mE9eTckgh$FnzRiSs#;AIW`A=2?`GWUBfdQB909m=%#-wFtR+_GAmu^kB z4$?{|mg%f)y{8mWtG!yW-*=sFyoa>YeYnZurGM1_>aG{d!xt}?ZqpQ|ar{$seIy+z zoz8*M&Q6VvZ8fa$;CZL+LGMY7jGVZFobT@iER(ni;sb}PJE9u(MhWUQKf>SVC~a#H z`jC4i@`=4c!6S%b=j5dDVsJjb%9QX((d$Xzy-$Adk^_&N-MQjVh`OCrfhg4z4r8zC zn3(FA@vQfh;~Nzh7r!vZRU#uJC1}FoyHB3fNGBdpZTc_G>9$#%x(Gz|io5PDje7dJ z@jLka{OIH5^>=x>oZjyIDBr_FkWsy+YWnH(;J4OxRu_kJS~!E|{7wg-ukn$ZxJE1A zpkR6X9mMuzejQrlCj8|<(V}B%gv&%D^s8|;7LoC_y%WJ1SD2DPft}1L@LIiPtB_hE zKdrZ-KYCO%zZ8vwl@)+EQC zabv>^la-Z~fzHwO;DhK!w<>vCNJDAaYft3E%|TjPE+qwpVV#oNyz#vJJospF&&kfY ztkSxJ$Xe9nPEsz#%@eh=liJEv>WX5d4in9m9ta8wVtJ^;8ne8NcX@WWdBPYYaiz8S zWVe^gY-aEBn4b5Lla+vAr86goj?}Uh9chPAnAbxzLbkIrYw@v+Sn_Hf6YlZ=jU8HK z5GH~`{--V%d(q*6{0{nkAM)G&EsLLKSiC*U?^+O=S-gy%eKqXHlXi9H6{8rtU0kz& zc}XhhCala;(gV(v5E2m+UsD>DUJa z1z3(6%k{Ep>?Z5CxT~w>G90pUaw@hNa2}AYlRUdik*u?kol`JV^a;lsG4jLc&++ja z6g_=`Q2*dZ6O|%sm1H&UUqxrTkA1IkkeZ^LIZ=Va!jOHue$ISNZ2 zvl)LqS+(*o{K#x(V!}`UizUpDo{I1}8mlJT1JjwpM0LjU4qOT!EkfyM@0_}AR+ByI zx9`>d&Y+Qel0V%_d+!C|LtEQJyGA@zZwdXfu=NS2sbL%Z3ldEd&!<}+uM7$Pv?NQdRf00*!Lp#xV9_Wy z{Fhzig}_3HQeo$0e|uyrACRMh3HvaMkG!@*(z|tH({!pmMP-d{@ij|j17mGNcWz-t zRhAPzb8(*I43hc=nkg&)ol7#e59@SAt znDw(hRR#OVe{aAJ%u7iXVU?kNl6AFjqlCej9+ab)_)3VTA=sQI_T3W>hKg5XeSchc zVrQBbFt#$ZMuU%p*<&iBXg9c!kaqM|TWn!{;>PyJe~?z7E_rb~`+JX>p{dGCp~cK~ zYFmU?mXJ^rW|*m1GwTIo*3xw?%Sb1ljOg9HS*4SlnD{h`vxEySY5Hi1Teq71?qa#; zmS*=p)fuCLQMu}FAi-*;iO!{pXfRqtTX{6nDTniNd>w;bzCj_wwLCho%1o3B;bJb} zd65>d{PE0Pn!xgIigV%P=Ai6j@mTYfVL>_r_F}IK8ct!=2M?+#yo7{0i+%OBTn~*( znE!f5W?DGdqLG)4T77Y^{8_XM*L1mPNFbDcN+mu;?9Nncw6*Efa?Wc{B5WHOxu-Si z?qqbBUw7Ub+_n%%NSLzEg5{&-k|;_wH&p($TpmT20)f)(_gt4js{`|mEwysju=Lpb zUNd2o)%Wu2YL!jDp}gAJJ-5?dx^_qZGcKkSry*M6&~+x3uvOF8lIYA4i0~Ru~1Te0Xv4V@s3^baYR@Y)Ae50%v3zXL(jli zSS^mJ>Q3C0sWp<%=B`M}yo4U1dLR&LAJ-w5&Jicsc-P0zFC_P*#EI@a_rVOj<`jN~rt~9Q zV&mPpgvE=BauPqxoV+|!uFKuUA#Q8e`xuWoUA!)@E*nw35ng@s%|8eEG6h{u+6PT) z%DaCkm#*LvM3tRuS;>|Ju2&r#1aX+$LB@D|cF~H4?ptbL(Kq&K5#FQ1a(?04w@Sfk z$Ndqi;||U{_oWm5+FqU|p(G1l2Ys@#3l6CH(rd{_HPkVVCyh;C;nJGp#cPj+NK72e zl-$=Z{!r8f_3KKY9jCfchVfkqnx_UiYw>m#js_pudQ9# zok=XEccG9YdxnLFm%{bkF3%njak8`RkNX4*R?rs(cNb)3Jv`BJeb;%1gv2bi<}3g& zmx7{aV(g(>xv?;mwr*Ew9~cy0zy67_&iRq>)8U$e>-mvnwL2O4&;%%e9CIVHpMlye zF`zQyIV*Hep)73)-x+^YUP@#vLiO!q)IFrn$2*B4()`bty($&joA=%!*G46-jU}p7 zOtX6vlAp@E+2&$CIE8?)LRI&dqxfEIr%=Z%`c}$>f+vDp8}X&RG_)f5$*G*u-oSF! zQsWiOWsU7RC&F<4%eA<4PJX`Yu~d9P0h5mwDe2Z$cqR=sb#E;9o$I5hY2LO_GG9z9 zE4%Jar}2r2i5H%^0RdK~e{2CF1Z7IgZ3z-`jp)tJXksGx85u5Cn5&$RNfDyYaCvw_ zcJ~uTvpKHahC3%K7o#Qq`Wy;2*#eb1nd(O;ACe1o>PxK@^`gS~)srz~eahFeOzHJi zRA!tZ%VM!I&<-WJ7jvFUz1j-zrdD8N_@h+tVQX%#PrzY)RY6{!&-th>qXM5?$l@YX z{CSVBfw8d)$e52Tyl-oZb<~H4hA68nK8Xnw9>*TyAEnzGLFipx)}D<)al{0 z?zQgM^AU961`&93G%~YNym162hSe|7exDrVVr$ViX*jSdDu&&fFgYS8`avGv;01Ex^ty(NpmdcN$O zobGN3O3F93wzl-SS9fpEPm~j#IXN93T3nsHIZJUqUQ?FcJ8KV1g__dMt>Hi0^xP{` z`n{nbzutYEH3F-Ps}O!tQ#a=C?VnJHF)9{wK7|!ByCOxSeg)jzZ6HIXO6t zjE&zR7ybRKf7_#Pi-y4CRg-=x@673dl(ltfO%2aRA%s%5xzqg0va&J({iv8%eIq00 zTp}IhX)`XI zKR+8De;1|tq5;_0C6d3cXD0a8u`ay*$=U1wfaCv-B>zuO0pOV|T;^HbS*1n(7SNAM zi&;h86bewtc6N5k%Hxf)QGfpYnVOoq*5~D_Gjh1va8y;DTv7XXxIPN(`NH##pWSR0 zFvF_aT4yJxvp+}NHvhi9dWwm9x3#sEo144Ve*5a7MHoJ+Hy}70+fT}+`rW(+Ozq5m zetsu5^l~aHPl$>0+FR_OGcX+N?7VclIPrNz^qpC=N553iy54-M>eEV3!u1>z0WR*+ z_BPokPVK#=4lyyYJ%a}4<1OBm_kD@{&i`zj4BQX1?YWVjPPhv2+HVAfgb0v2@i8%N zU7YSyv2s6s`f+t2J;`A8{CL~_aBaA1cM&h9IbkW1NjO#?;)L6c^X#;^dR0q@Hu`5vlR2;dY$qobjrp_(PS9htdG`ucx8?%eO}>N-3;oSvGxxV+q51?2V!1EY<_ zv@L{0QBe_voF~EU>|hmc*}7WIrCCo`mx_wY!O@ZArR9&B8YdWCxlE~6cemYzR?utm zN=msoIZVnWE#u>l$#EY)evF4FoH;l>J>7VHbuLvd0k7ExSA}7DA|j&2#l;R?D_PlZ zadE0`+{z4!u=do|NnkgtW~f%0`}p`!g#Byr$Kf)cnzO2=6bXSvks1&Xkk?xLAupU( zTEOWbD|na&6Q!m3!RyzrJ32b<-qu>~j9mO3{08My^%NxU^vp~yE-tv%_0gooyLazO zIJ+TAWlANSEp>!XR$5rUjNf1B1x9N#r2*>=Q0iFKC(4GYC}z#Xm!B5+e?LI_+%VnN zqRYz0# z_dYW6&G5hl5=iv(^7C7jEP509tylg4j?B-_4&zw?y87$auf4rJ*cQ9r9j`99Nk78? z+_`fH;7(U(CnQ)#gHP~NOmuYPr3TWXq9|daxDSL+QUU`4q+nPvF^hECLVo{#>(y6o zG74`5Z+CUE->ZUYFc0{>+gmrUE3DD|CXYMz+cW&uXA+{Kehv%iT~Q2-jQsU{ScqFDcjDs{NCjLB zEi9PJy_$V7YHMp3NpNA?C(HPe^|#~hmFRV0BHq4z>i`H@Ud_ZLHzWkzM@!J++7aRr z`;*|VWl-kXt}PgaN?AftBTgir`?kq`Ct z6#)~uH)d)F-})9Fo|~JS=_pm}yloEmG{h|_A%Q?3j<%-7sj%Ho=I`$w&?^;4M6+m( z6=|W63ko(uhf zH5)JSZq=ryr4@Pm_E$gttFrUap^=e^u`yE!)lHvoS?PCmbc8vTftrC`*M{NeB5=)C z=%S#YaD8>T+nZyYlaY}Dy-wMcC97&!+i{#`Yaa^3C`F#LzW)R@-b3U?NDQE0fUY&z zZ9b7yua|314j4rvmseEebQ!Z0=9E-a{2m-E*_JFQF5U&BOz!0;MBy*Auvc|@dV1TV z$xZ&8QA15lB6E;Tz{T&{$i!qa`+NVu04Vi~&B|FA!9cAKkV3QGr}EU+s;H_iCP5N` zrUV2=$f-O%F_DqOu;M>uHRIvq!5q$|TKM8*%RhG;Slrx{%pA1e7~5ShY-CccF#YBA zU{9&Js|yX|8iG|+Y5m2;1wNBnmFa4Uv;T2kUf#op59bWgo=#kv==a8&ndw~ijDKLj z4}RM$INRuct*NYl>iuhRpDOd;zkd-C5q^uxwzlPoiHSqA73JmrLKKf4J#y*Elum*H zo+EkcLV^iR96o+U~VZquBxfIX=hAeYFqzz zFTilF5=Gdv=g$KW$qz|*xOZH&QghP3+B zYo{S3k6R>&Ff1l!d?#amcd_lnEahRwf&5qK#qD$|Dkzvul!;BffF^aF<<;%0SHi-M z_Zno>3=RMBTKtTL{T9`AcyJ(uqF(Q$AR;2tldkP0V?Vf6U0qE9F%x@!RGB1!7_u(W z413Y|#=n1t@Bp7gO$>(G-;a-v!zPM}i4pk{`jL`aN5>OLB4mem5M(w zKrse7>1b^@H#zzBNJiMZo}<0}{lJhAb5m1dey6e}=@3%>*yw1K`}jz%wbyrPX=%M= zOidXg-zBi>b%MT&Pe|~6__)M=R>R%h-33MTJB);StsOj;(Kn@UJg5&JN{ETg?>fLX z>+kPxZ~ynp8?8itYal%kqE%E>6zJZ_cl_>Gb@la^e`44!FE7i>%d6G43e;2={NTgS%4NLnT7 z>gwVW6K|}nC@Cw`U21-jNaQu>fi)2s5iyddYz^>$!ru?`%>1Xl-m>0SI8FATAtJf2 zt<9kC$Lj?~4KO*lp4o6O+Pa<{D3@8zcSh1f(hz_5?r3|K+5Piq>+tegd9GZB1Oyl& zUb_tp!HXh?-G%NrF6kH^VP!EfX-#<4%Mv%2F`E$ZDNJQPtK9SdIQP4Rc0;da;8&p65cj6sF%|IeldmNNz0zGEFU zpYieW$?ES#OX$nV;RsN9FIv7lv^`X>v2$G6q%!4HeTFNWJ!CfsVvKi0d`&1!^kCY4 zwmX))%Qi3fLhph1uev%u2uP>BA6+7x=AYBkLy(8TT7&cnmQrza;uA|jRRo{AAlhDJu&IoZE<30(Z3 zL42Xe`ft*8=vAN=Dk=4D&(BO0TNLsq!6REu$!Td+*Pqy5WOHqkd!@lcwZIq9ec7D; z{Ih2YeD$<_d(^GP$)A@Z;7>;T+Y5*gAn6P&EDAF+>=u7NLPJCAAQ!>Iiwvo(@$&KE z;pJ^)8zT3DniXESg-UHb+HDnUrh4g8T&%3JmV=_h=FWP0dZ4l9UH^p$ZV!FGH>cxa zc%O%yo&6x;GLw0F?x*ME!~`^z{%98?kE1^k)Vkp1EvX02!)wQ|0X7_qnI+4Yw{G3C zDyY7_Q_aiB^6kM5-WVJYlc=J_0opu@@gEz~|J9dtOwRH!Jp9Qc_)&s)Pcdx(NP;N^ z)|YNN<_%6?Ha0SPzqBbl`>u?Qg(WLLTaSgXulYYEZBiR-uAJQbb<=^C!qaXo`RBub zrt)ASK{B8@={G%Ja+8%jeRGl9`P(f9->m!R01(vBPl-2`Ugc-@nV90xwE=xXf ziL(|E7;t!S2x%BZ!Ui=AKspY}T>v|+!mav$^%GDXH1|dI&CK!}JQ^}GXuNpfJ$Nwv zg4Vbo|7CpT8b5o_mBgM16UA`Gd7-aqy9l;}Q{_ z?=6e(R*HXT=7JI z*M9pJIk2^)vhsS{^k-fkgV$H+Q|3K>LPYdmlJBHf&il*7Z3PYm$acN6xKJ{H`UF)C z>exLr_}t_03}cJPO0CgtdSapmG5{vh4~ygcs!xkh@x6r%?{v84y?qAqXZPTstg_M` z^y0xmo!cc3qJ4DK9_rE)>F1sFAmjb7uQ498w(MKXx?l4PypfUl+J+nY#up=)gqM?! z?g2T3=%IBBsA$d1%p$FuN=i#}v$L_JcXxMxj=p{;VP^P@i76x~2voh{v4XI0D-;L1 zjMQ{=tB~RLFwC&9ut+iu3=A3}?h2OQ1NG~8eq_eY7V-T%^8NeniO<=g+EK3c5uzaX zs#4nmfpfw)vtr~6wKB7MO+>49c6K&wR@WM*jyeBk#d-xo6y|bE4`Pswz#OHuRpT20u?9?<`uV&C@>ERiJ*W0R(5uYckd>EL_iLJ z2ZC2?u}n{AbWDux>Hsx4xfTEc2ZH>nsM*w|pR5lQXGM1cN) z$^2w*pTuc40W@3pGQL9>YCOn%t5CU`9}h#lIa;8;{`arl>7L$hOGv9eBv*X=l8F!S zuJD2?7bW>V2I*WYl%0qRRh|U+WgDJ^2B$+~n4k_yKL=%ywIU(JVKq6SqylHlQ9!xF z!omPav}DE$x|5PeL`VNyUFFvQ6D=HwuK@MoJ^+brc)Du91fV4#&Lm)7yYs7riI$m+ z7Hm#dqIo0gqUzwg9pSVV6J-yQUK`_KBA~jIfW3;24tJ6g7#MiMUkVF+u`RUGdL`rw z(bj3{3gEAXh6aa~m;96@x5;SaJe-ji;YMuulfIsgC)Cw)&7M7MfklHgPVr1WI%r7pM0{BrrRH=8e z1sV*rKuAbPS9b|^5HKvdTrDg_|NDMYQc_p}P|(-AUpM4A`3t4=^~nGXAs~p#SFHs7 zKQAaPC8beglbxEH>g(G^=6=zi%_-q0eKPM$)?)H&-tupE-00Yt!`hOVi`niWR`dA+ zP!4NbTlF$StOg=cbwx3;4wz%uki3BJ;7xxOW2H=w{_gIE2M~m~Gkws~82SA>g_p63 zNpMh5%j^?ELQ!<_=f5Ch^YYdf7h^HI{E2>X;yyi5Zqohd53C7(SQI5CtjdB)a&l;> zsC&D+MqJX0iX*V>K|E5rT~D{R-g9q^AJPVu|1~0FXl#rSbO*c$p!xCSrR7H%nZD1r z?yRk?wdkRqTzvcX4MaR7MITZONm0=zjP-9_Pgr&R^6>ixMt)7Q!Cbv~5!Sn?XJC-~ zEEji)z;wJs?XQaah(IA2%WPNKV*Wly8F|Fhvd*!4Bjlb#giHorDrWDepsaYAR)} z2H;yTSUrLa_vjHLJ$sEZP2(AQ3J1frWd!5GpDvCyg~j+8D$y zE#WEHGJCzey!_my(q$p(8L_LhMTLaIPCwchM@fe3&wEI&mGG4bd;&Z& z4E_Qz`rbEX^{z7=XZ89T7KWw4%&h=u@%nTn>BU1LekXQ?$mgUA_!d+4H4eKeK!6hx zNUm0ZC8?;YkPwpqWlRI!P!WD`;i#vuJm2ig@-PlKHKdYNo0{+6zvnsiuO5)_+C6;C zN(D7NvIfi=SoRaPE<1lq_I7tx>%rncwLcH-sk0i!6R4V*G!y+*;|K63% zj12V0tU@ll{tIT%>)Kja*t-cf!15eC8PscV$)=iEMNqJKkrxa*4E=%Gy@a`9s?o~=r_ynTeEkThfIKT-o5=3tmH#O@;%B?qEtBr z1r=pw8z%~FPQxQ3{~Kk3^cUe~7a<)8Q-qyZHfa~P`0m+_65;+_wyIVu0PQRADk3&E z0Hd<=`FBL}&dIhTTXLSB*f4B~_0huG>gt%-Sg4Mao?^mr1`-mI-AgMqrplTcmF;ad z!))JCFnxfLe6~bS+`-|cn^oV$#B;abl(4kJ*n%*|U!Y$&eT15S(|qA+m>8kYr)ss` z_q{DGLaM4nWb^X6_jsh_UAX?45;c$zKE<9*uCQu12rI50E}Gx3d+-l+k(>yyB^s+8ulAi45zgBdy@n!L3LDAm^P_+lKU8x<9m=SUBVbFj}Q3c812lze&_4=&8|va-2eam(tdr6pbb zz~O8KSr;ycP)_+GjQ|*#8mGhOZZAT+^`WEq)zN8XiI9wJb|=H58@eDOkfH|+%L4XL zFSI%n>z&Td&)EncLKk$L^Wf;{Mx=|2hhHbN9(L42Pi_*S*I zi^BWbRR%!{;^E;5V9xK2!qPVKx0%96czIC_ zn@>QEFGEjmW@ZKwLoAEdFHc`qG9>jnhvLM;?e=0k|X%jf6w!Pof=*pEKKkj%Sre$K1DqZ~Oo~r_hU|_NC#mva>_y zCw+ceH!boybFizc3-;xt`a=m}r*Oz{v2_GLC z@)G%RtT6)m7E~$pxBADiUlL2Xs=G!NsLwb@Ltl3HO)RtozG{9n*J4`n>(yo5-``*6 zn`mFQqJREeqQ*AdWvw75r?aVPF2U1-tbwS`o~WTzBLOvjSYi0LdeH3SmoXAFaoy%z zmYwsKP#N>0`?!k|b^g>Yh66)acf;auco#q@O3KvZmr?h~-a}4;E))3#-IGnQBtr)W zkp+{ukl5D-HTluMe#(n|XjDRRd{$VeytZ#>Zm!YjRuAwQ&c~>zOhCCpNkK8;{Q(HV zMgLY@gE;kpL)NQjO@eUP%QYqarjY{;n+3tc>jura+_a30zw7I9pq=95O`+_^#=e~l z-e%1mNs-!60Ux?ZU>vo@VTB-7n|&TNCnelQ`h1N}Xb<*={}X_@j6(7LmFs@qloAja z2>0>D(~~zt^28F#Hh{`xm4Bd(cWU&Wck&zb8t_-V0lxUzU8ft;V#0Blm8B(E7^-1y zn0m`6SED#$jlOo=4R{2kq#E+_L+0Ahg@L;KpAFB3Jlq6|1AUp0>eminSOD`tvdZbY zDRVbm9|{d54ZO77hFgIk>2v@syzToKGYu?7I-~~3p2biJR8_r%&IJTHM%4-j2ZuR}oK`WK zZz2Bv4^UA%+uNHo$7$VCQ-{>X@)`4iA;W@9gypP@9u>mf0L+672glQ0N?d%hNbAm3 zwT{&a3^+&=KwiJTAnLGolnx4%OOd597-M!MjNR6th|~lM4QdB!x4{faXmxqGivrK8 z4fSuHhkCf+5egxP@euq#C<(8ksw%ySeyg>MxFl8gvK0vSet7s?r2^Q{X5eLactEoM z67B962nQfA9zk0g7TAAWDGKq3EOiPao`frCf@@-2)EM@sfWF(FZ6r&QG-le!pR8zb z-2Z#9ItZCLG$`l>y^zN7{yhQJ`kU7q}bJX}&esZc zVA%+R0P-{of-O2_TI6kNKL|+76q#ql=prNWWYLSY5F5bY6OWM$k`d_G$%>t0Jb-T? z+Hg})fx*ct@>3t%Z2vUqhkT(WCJz$@?~MzFF5DxuSP6an2j5JNjkUD4Cig6ze2<-~ zmV<$Oi&9-vv(RCfC2wrNHU~6tVJ=YuD&Xj-C<8q`Y7-xfKfC#P;&ZUsP5`%<}Pbo}&OuTV;Jce^PH7b0p|T zD4=0cR>;xQ35KzUcYx>N*HE^cp57#Is}0DrfR?J&%mG?yYf~iRsT+eAA=pRLir4+h z#mb6R;JvYxL7gRt&BbBI>6B5W%y9mfH*B(mfVL8BF+qo&kI+YjRRw9ZiTQ442_!}6 z@%ZD=IypH(FK2%AY5L!j#1}fh0l|QhC*yM{G9G^ZiK|%0%E09;p4%GELPFwv0j8v& zpobmaQVCFL4P~Dgz%AZFnkzkhTUrej3B-tvUcqasFi{e=Ap3)P?gnp!mawolbXq6^ zO_Umjga)9~cLTlPr=Zj+_-M5Ty6t)7M1kD!i#D#LHXW8|=%MsWUB}HB3CRKDYj-)$PTn%=J(Y*yPr#SQ{zNkDHTP|gogTkuxfdbHQgw<6PcR~4 z^$T_CMRiMtWPB?>w7qR+k@%TIH>L=5y5BXm9|Dp|X9S(54IW5Uv@oFdPbWH33kw^; zUcy|1>WTd53A-WY#!n=qA1DsDZ;WsM+xg^^W7k3Hr^Es-A1=(r{vQA{R%7A%jdiH# z1H-NfAG85T`JKEl2m1RTL)UO9X=!%k7Wq_1tzzildC3Snwg$zpk@7jneAQ%`G^jY?E(J9YHV#5^sElkN zfI;^Rn9|l*ar4igKSAupr<#NL3cySx&L$|GBdA~jCvvdGfE5~oYNxv{6k1TlD7!@K z6n_9B2tYuBMozH|I(pE`!Xe9swQa;c(H3k6xPltn|C>LS013EpV5LEG6@Is-25f;l zk5klZIpy#AHjhKs=45wq*ZN0j?zB1Vb;Hrb(r{}qgtfZEZQUXFGSFznL76k3sasuK z^rR*JY6I-FL+%jvJ=BR1)ZV;lPD@KOo2&q<`QXTi;zdr;j~_qm3%-H70Z_=Rr_q3g zKsUO$xGepdVzRD@iBW#_stE)x(Cf61Xmi?-1CJDpDZo=1_l6EIyJ%*3bFdti7@r4n1jze;R8rB8Yp*b0YX`=H-IQ!5QcS;17QhNE)0R%mS4TltU za2Da?gW%!)v>*gY9%^VW8FTZkXM5wDgi>zd`$G$vbv)ZMX@#Wr@0!k zlQJb5{|!r**TW1R9|9li0?scjZNbIAYXNHnB^e%!l9F<_5>}Cw6=geAS9^QQU7+{- z*$qJh!=3p9rDA#Z%nsjAv{vr zW&dwCtSRs^Jb3Vcj+uGIs!EpN0CoWcKu`manD~;R!tcdGK-ebYG>rx<53&f{3t)tD z+kqa%0~C~hlajX05@_YHAih=EK;VVE)_)&1D8iruX2iUYbZ=5-q=YBKi69DSQmOH94)_Tc z#4?~Fm6MmZfzbH*^Jkz|b3`>d8qUfl)V5q-6_~Tlo+%fCcWBNzZ)o!#Z1+MkZ^Z85 zAwO77WvC+J-PT9)!F&g=U%wDum^X~@Y9%MAq{Pa^#Ka&jfa2LH{C_X$c6n}dJ-@(RYVAi(u?JnIgi$sNKM%t#$7$gbo4eRwLOhP-)7A4-3| zRR~6IZf?-IT5t}Vzze(~b5P3X7Zhj!ABLL(Wyv$_BOB$N=>*II(E1BFtVy!|-0d+P z>DrACU~4M`b79}%#fukLZdbsvAj}U855t+y2LiMSQku1P`RgB)x`45PZXKWgp;ND0 zo;>8#F|L8+k}TSH7jXPJgirw|sLM3EU4rsg zW*8Yr7Z;}J2Sp~}vJ8tsFTHI&OGjWA7A}OT{9G{7Ylt(VCJ+7^ zfpYeYr>7_Q*P#5|5kw0N04254|MAnmU*TfO=#HDbxjOaG`b)3)Z_fj!vI*4yiKD~A zU_ZaFY|;?b_V@O7dT)FDfKam-%!mqS0^wu;NYSqEjN!4;YM<1YqPmtBqL3h=LkoM! zGWUyT?!ruSs&DAs+a4FE9lET(&G08RfRKQz08l+$Gg@@SEgw6N%=C2ccj*gUs^23c z!NS+B%#%n-3U_tx?@>=8M zw5R%`lxIZTn5Z)+Dt_}`69MX5R^Fe3gK}37L6N$h>}V+~KSn&`;ZXtSSx?W_<#)Im z6j!6UO3YN0NmUEaa0ANA4FmYr;f;!ljV`3bsPTF}fTBapy z!5tL<>`~N9QyNs(8Mqc8{0G}R@;dML>}WNw+JJ0wGBR3~7QBR5#IVxfFC9j!u1NGw z{d>IpHhd4-aMfU(1IFu-G&nXfk@(UP`ux#P)4r>^9|oSNtVpHHFC$) z%&fky?)IHKyT0s8_C9KA>Tp;;f=&)3hVQWt$W;Qc+gbj+4QT^Hm4L&}4>_}xoD3f8 zzkeWmGQd66ZZ{CIe0-&+m!20R_&(7FqLqnqiEKTk7r6bvA!`pC9YExN3_0MI+I40< z-WaD``IYPLE~P_p>w zDTGiz=mT?dZkfhd?fKLDbuELV4r6tBPPqrbA^aeyp4k|$`icttW|j_#Wkhlj6#D!* zp`f@@`VCPNU%3SEt(Htc3{J~%iW;r}GfY4xftv+nDz!pb97K;F=YJ?(NHt|f5}<2W zpWz0Z>SMtco6U(s9rbj`?eN0BevFUr)7Xthe$Yd&!CD!<;j(}Vwa<2tbP2dv_D(Ne zS9rcu>jB{Pok=}M^!d-M6hR|m{^pyc8h>$57j%pYc2Q*Ja)`Z-pk5D6Q(Wm!zKvlj(IvXYwK?< zgG!oS09()LjtAKWjM!sA!k3n^U97l}0OEu{l>skD$7+Zqxw-USTs!4Qn+>Zse_v;g z$luN~3eFM!A59gt*ELY5f&B+R@UXB@3@S`_Ee=cI2tD21c#WmSa?owx&uF-+Cs2Q{ z5%%pH#bGYQ;P&?R-YHx)rsG*BT5#imhTDS5dN<-;lKJb~9#46BiQMn^W%^`(fe8G? zllkON!}Q7~_;p-1#$G^ic#%#B-PGnx_kg-d=mdkU2f{#C6mt@rfmGXL=&ywm3s@aZ zjl*StiGZ$L90XH3EVAQydP}$==-490HC)h_1CZs{_D` z*UlBdA`8~z=D)=Jc0-^RVe_@MiCpM^hrI_@b1+AWua$t8?+mY82puvjd&_pr=k5Fa z{&=42_gufHf4VM_bALYf{rn{(_?f^ogURFJt4_h4U9=OGQzxaCK-$6*ews&WamSuu}Qg%|z-LzXDtKk(E z-i3n^z6mxoZY%0>;E%IMgxi3%mwiYT*d+kQ8{?@S|DGH%cAmKm;`#m(bOfADd=~flz_k$HIm8k@z7lNcVS^ybE!VaF;fx|5dlZQqgVFo>$5*s z0A19)7@1mIlLU*U>+;b1<+bT)=!D}J2Xm$Tb`1?%fIxZa;{#g928|Sdg0lF-d#R$* znh*?uwiomia3sSRj5nguXmM6nR(5uFkR(6}9~(nYlbo&I$?&94hn%A=9zs&UU_s?W zxWeUfvS6a}OJR7N)Lidb-L7Ot&d{R2QJP=S$b59=vYaf7Te~rP#wRnmV5VS2`-;T14%R;}uQGv&vR9{?8fBt4YPO>JwZrC3G3!IuUG@YTMOWKPi2 zT)N}{yf##Gr`+OmvlG4Fzmrl>oHf4ySg1&e?)M4H02>RQaf`mxo|q5FL8!ZVeL@_; z8CHoUYHsnMm+=hND==#wFHBB&gGUB7>v}66^nAj?!dc@#yR~jzLZU43q+#DwVPr>3 zI>a$1FbA$%rIhcH@O+a7F+pJPW!&i+dL0h2*8u^K!sz69E`#?$5ZV?v594@@kHCdd zRK&S;X?A}8%oBCAzv0}JM!&E0NttzUco=lKW6Lz395>rV*h>gk(cLv`18wh&&CZY` zvr<^w1l9sR8R5xw5!ify^7g;V9=Ohdo?n!gcTLbr{b9s~JeK1Fv8d>j4ED8GUA?{3 zB!sHHsu2tl;laVABqW`u^uvbB=3sjQusB-bssz>w?>%+Kz}pGyEEE(G&`mX(@SXkZ3LxxvlFg}KjF9GE*^h{_gz`D)s zBLcR|0cz0DoT^d!Ivg>Og?V~-So3`FO;STynL|&a5G>w%lLZ1YR4#&7fT$y7c+x}=XT&-b-VkbTrr`{QHK*W=+VNR1l|b|52-oB6EC$sQ z>RIAF`-=$JerV17{r!=t7bJuQc?vBUyV$-wA7qV6F%)7ugv1%xA!=;LzV(znSlxuO zxVd@e_W~1nMn*>MwYh}_kGXDIyVTj)S%^7M$#I*Mi{HA{3{{^s`Y!q4yYU-4g2>Jf zu-7Kg6@Ux*ftb7S!2yrVwN<+2kr7WoJ|Hfi5#dnn9$WV$T4Sccz%1V@1dJ|owEjcr zGy}lwllh zG{5!;rqVfLKzk-33^=nB4G-&X)_cp>QGwkC~~m8v{z-eSOHNxa>h4MnYV?Q~sr7TF&!)EyeG^^i2Y)1&ma0 z@Kip0JrNV&&dY{Q$Olh!7kKahb%BA<6%N#g1h4PWEOiFsHG`4l_~eNu zJqrygA=sh3yu74)c2QQtpBlzw0WZ@&w1H7<)Yq=nLNypweFQ|n-ui^!pnw)px@8sxQ&(P%L$>g!Ob_Y#6=Q(m*x_9(!iW$6yNy+oy+) z72yC|2~dguWOt*3f#&K}A(v`6tDC&{>||u{7vD#rUFjmxhRl>O(+(o}p)NMCm;$i( ziCtcjuaZ}oBCW6m+HmbN#UN)v4S{icmf+rwRNyiBBL1uyQL+i;+U=zLbALz7Xa(XA zz=;|8WQdMHJ8M+$^7RQ{%a@5HmKeocm86{FY-~$A3paN$tdL|4LtzPSqS(*Y60|hcqV(ndJw^I z?*!;<>$-ltKfuZX`Cn6B?U{h90Ijq1#(dpN+-A9X2Sn_8jjF7ZgUG1EPhTGG*bGY@ zZVv_q5<=R+N8*@Np9qJ;16x(5*W=O$5aj_#He6$G0;RNSc}O)`B+@h<#6NgCwmigx zGvJhuVo@>{OY81dhV}za+bBjfn2~(w|E&fPky$0iVrI|61CZqesfLF8>CC-uH3Ox> z{5sE`4FhdZF<}=Ge-;-X^S6UTu#BtFYheE8>90J$j>MTOFec6z#3pcvEcbR9pSodL zg@krshv5-Nl29QaWh4UsIgnbjGc#a_v}=2J*>D!@K9DU`OXW}4rV`-iXH||zAfOmM z$x%&z?geEK%p4{93c}BOP~3WD(p=PHA|mjFP}~&%)z?P>x9V!x%(11w2?U0KgUi>l zwzoGCSl+hTvLhxWge37H4kyHeS^#Q_GwUjyUod2g5@Ta6;Jebu(@5)%^dEh*;pG)F z+kxmoNQjcp+HMhBED8iX8{vYitU%PrIg~Z_8WBh=(8z0vQJ3}@fq*m#9#~$JFUj7W z2m~`LD|pd{@BTeZjXvJU?11C0AjJF{Tssy>-s1C2k$FW$E9=OHCkC;a`QW!@QWNm7 zIsc;nEva+daGW~(jPI1r6Q51HEsusrmK`Ol;nxs+;B!CanIR)Q^~fvq-l0211!V}P z{(+r5Iq9x5QE^yGKqqIgS6N$|W5UFQ>1Md+k)^mBAvw>(%n45SD?G%FZ=5b%^2*$= zJx_PWG}$vTf2zp`xaPO@BK#7REB{udiwv*+x9o7fhCt?&D1L*OG4RqOvA|0*B$+p4 zNEicQPD3Dk?^~cNVI$#qJ)I(@f)R%j-qn&p*CQ3$BrNJvR}aMQ^k0S_negW2=dZX- zk){k8R`?e1e@@FsESGfgb;I2*wKg0f&Xbh`mymb|DMalA^hq5RUa74v3}pbt3@}6k z@aSOjtZI|^&+mTz$m!AE>R24ow0I11K9F{mgrZk7N>=pbA9YW0yb)JS)^>`1D7ng*O+JF;l$_`DJUtO#ck%({EQ9Iy=hWXcH<%l zz}G&&4Z}H3N=B;Eqr4ISfW-9o+HrJpviQsWe^BcNq4<5YFcjfK+e3TK*isQF|6n*)QLzbQhjiI9w^z`eK@%ehd_o029U0!U_Ta1F z2qM3D5lF|fQw2YNX#~Ql?g)K)+=sz%LZ%(4L0~xO#^f|JKsQj#9)PNPRJp_^nWm3ql~fGU$`t|G9gI@rTL!1z5lX)~VYfdm8;OZg=PAgM1<1wrOG zTQlHX0g%BK(#64yiA*TXg;%Y+gNpBN)I8$CSXf%Z`V8el$ei-=g@e%+(C=U|yuH1v zoZyC_TtY~*Q$;D~3Z1Q_Nk}6#^b|L5rpCm;QBtRzb#`m7*)Br@vZvMzg{lHl>wt<9 z+Av6`3ke7u0ykzu<_c{SYjot>-vZ=U7#P$7vcDPrs$w}Qd7r@!R|vHS0Lw{L7&q|n zn-pgbK8=N$8Ri(p=%^@b%qK`>fGzF-f}=x2JfH$Ko8SX0no(Go^x?y&?Cc%)sB^ah z)gS(KMf(jjqW1s_R(lT4otcSs_Ml$nVh>c)!*8F zSK$^iBcP909u)$bkc*l@(q$idCM1y{wJ>;$KydIo+92+F4z7!= zMD7&RcmT{0iL8cGwAu}KZ~*PH?{>dD!U3ilU_UPWXKf@t-*Z`qpzChXYGGgV89;zS zyE46;nVl^Jfk&sHP)yJ&c$lE${Pjx@@o#Gj)6)Yk4SPtZkGk3bh*y{b*$)1!1G9AJ zNv?;tcXT8cB>H-#I3DP{q4C%Rp0<&i3;|!*+1;H4ga;3PV`C#w22T*z_#B`gS_ZA6 z4pN7pb8qWffflfB0RQldbkPr{Nbil_xvkAGF+1WXLn44?WLV>%V0F8FIYB=fqjz!9sg~4 zHu*4$#p8%aVwC@^F}cB)WEPqT*1KfS?t%Y0cK*kYj6kUKuW**jRL#gJ!T_Z^Yrbcf zxyR_iq7(YtS?$!1MtRP-?5PK8HOPgi6~DRj6WTIr({b-MtYaJT2~7R|`@Y8HV`@r& zo&J@S_{xlwVUyk!+di62hYqNAkI)o^trha?v&X{qD(loLh~}?9=eA0OYcZE)7Niy) z-t5Y{qo~%2-mF!h&SlCs)3nNH55Rbhq~7Y1Rs}-=}IIK z8TsL`{<4ULXEwF#7SsvZs2fob9{XGo2bs6*7x(Sn*v$_F2qFlVy{h zLonT(JFFW1V6k}Ud-3&HB89b$WNk4nB`$mU$WU~Y?nva99Fw`7M5&EF(#g_!qB!%Z z+D2teXNt=ZNv3OE}wX#Hy!`AxV^0hbB28%SsD*Sl3vzHEv{iS zHA`6XUZATYV^gV{6u5|Ac_(D2hO>@WPI4>L#y;-mS)CnkQ%~|^%$6=es6%k@r#f4D3K(7dhhM84)o}8KX<8IB^ zAno~7k7LUD9VNrxCid+5yU!!9MHmHMDv&zy%Sz$q-fqdQrS$oKhZiWA#Ixk=k;5~^zfxE#ms{PUY{TFy}?CSO$dRZOjG#%71GAy3=Jbs$^g*~>q;wJ$jI6z}f$FJ?n!tg+m zW1((G#ZN+?QJy~znulp-vx?c};SLt-7NYnsoA1DdBHF)5-TVCH@>bpOO0&>TTA5bC z4@$vHCEG9q>d)Cb0)EIC6EMjpDCcv2qQ56QqP}BD-CEIZCU?c?tAYL=l98|F5$D_p17TmDxjg@Pw8a?K$^21<9muxZZAn z_EW2k7E$ZAdT`vDvOGNPwmRB)`pg5+h^{|s zWYDrNCr47#Kpk|$udn}tJHdla?iv^~o$8o)d3B3{z_Q)P(QJzH@~%guJn*x;9LHng zXgd;s8o}r?85tS=lmxP_~*oT#aqxKc~ZVhI)E!736yd>)nb> zkuYcn%;XT76cZJN!7o*$78VwtEqssrO8nl2uZp?6IAyISA5K1}5)&1bQNC*bWCd#j z{dIwUYC=LgO5O}li;_iys{J_bh(S(+N>2MrNw8$hwKqh0Cj&A$s{`$?bMgWL@Dp?H z1e@W#Leaiqk*6V(j`SR$B5JN^<_nF|;Gjh{ubh#9U-6YxPV)yLJ1YH5?< zJxOB1_NqE3+asS-CBz{sX1%sefA0#uMIR(&Z71rn#YyL3 z8>g66Ybb8k#wY%kDb#J9FgxnL)67C^7V^EU_}>s-h@F9h1!(CDNuf5Got?M5-PhAZZ!l6>)6&wbBnWP7Ojqvj@ApYDs9D^k zq4`~9LxXr1{6*C26E6ay&I}?B>Rgc|gh!^WM@3x4I>(h>Jxkg+9FF+n8=x(~FMs#X zn7#2;8hP2|^yi8uYNsK*-wJ)va0g5ct%|`cnTr>9Pj1VrQ@PHs8DYQorCPN8uD@^l z9T`)SzTfQsFD@YgU$V(y&u-Gk3(Os-Gx>v>&x8E^Cx6||1krA4YC0`jij#|{x4Zi~ zzlXY;Dh#jK(_;~M{#}+B`jfejadGRTRp`nhEuJ`oc!O#ON9)1*u+dl-$NUw1C)?6B zAAV(rf2pzDMqb~B5VxMb#7$+(a;#4My^HL;m6(HfOH%Trf0_-4tAoss^)Pl`Lyd9iC}t3DXFmdMCBWkVSYUL+?Xm<0S<1C?| z$`e7A&w@Sbkp7l?gL;K3w2o3&(3Tg=KDzHtRIg5`^_+s!O?u;M6@*KgQe0wZ!nfINV!DIA>7#T&8Ba z*1wP;Uj6!Iv&SI5zl2#tT)N@2)4Pg>AbCo9`rq~GF{v&3}$WKX8QKMgs z4%6-xadUBvQ_@vCd=&@d#nhCUkB^Vto8gj<>@3Nr6;^m@eH0_~bPRs2{r!yFr7R+xOXzjyLl=qCFR%J2d?eepUKJNi3U;R z`H|IyZ|YOf0o*-3jSXIH?Hy7I%FIHor{a}TE6tUQPugb9%=N6|eS)cNL2{+EHI<*MU=|P| z!u?)NZeJ>%G$xkkEBYbGG|v<95tf#@cQcP3&u+7+fkz#b;Zh^?WTrd?qO+$bMuBB* z%???2?%Y9v0Q@@8&1SZ6U0rv0xQ!rK{vXz*qfm9$>-5AYTa%W$T8bHI?`wB57#a%p znNbesGiw?Q6?Iw@5VDO*P9A81aIZ2xJV(%cObjU~7jX1} z9V#~tUI;+3v_LBE9qV|X-)Sna?@n<2>8f7ZjXcZDp@p z(?zxAADJh7m(~$_KgAne7Ss6fnLi5e*V0{0yZ)JnJlCBY`y!sXP*mv>huuT~uoV)H61Ka!{TY~c2RJKm?icUv`0f47wqyCt)Y zjR)?%jLVx7Ml9?(BVM7FpOQw%I}=z}X-{w9QYGrW-IjHP&hzqf98}qqR2#lQ)%C*R zEEs`5Yhp%TYF!x~T9z;Twqb1)dwXsP`@4Gm2Ns(m>|LN3QNV!QV4j z1X+Vm$ht#~=Qn$}Lx^_bb#-*AIX(6&^P{_{E%d7!8Y<2UstkulBDE+Gl$4aBb!hCk zEY_#0Kt%mM#ekjq`cJ&YW$kcId@W-BO?<76b9WLm3QszJa*<(EN(xN-3JL>a2JsLo z8eDSaR6PUCPc^i+h!C;auUwe)?Z_f)<+M$-m>I(grH2wrN;Gb8bREK5jka>;vSyBO zWN_nx3&6UgY{?Cs7VokDum6?3MZxar&j!jE!Ox#>o6H5ur27aQLb}D_tMeT|!D29c zs1wQWq^Rw40nyIFLf#5SwEca05DJ)XyFz3;%|}#+{U_ynMKBDuzI}|iMI8coD>)@4 zIx70cjg(FaI&kia-{jr+`w;O#F&pd+bh@9iLzJu)IqK}uWo7C+d*@fjs>9H}@TQTM zUlg>S!oeng_T)j}5bsmA4F+OV(K-#?-7jWG`6Z6;)V)4}gH}Az(#FzYPxx=*!%0?F zNh*^43AF3p%A}DzYDOo_daUl$bSrE%c2&$Bs8iZEbznd%)G48o zJF_ZD`J6)lFu~!1kX5hUh$#N;{Cy-lR%z!W!K0e572G~$aNb=DZD(y(V!Yhn`gy3- zNnA3b5AGWHOYOu%{AQZ2zM+2eymrQYD!DcC^yi6l{elrC1)37{Rrw14G7Ha%7Akqn zlv+DgtbE7*-GX-{m^9On><1{y_;Y-Db*jrD)HR`;e@XMv$HL#!HWM=4lc9b z>veQ;%3}9gq`)*b9(uT}xg53SXG?MI(Tfry+9)^r{OQ5b3E9&e<XdqHdD;F!~rZ98zaskJ%m@4x$+_`*!R*^^x5(JPc*GKX23>DycM*7m=P=12iy zUbw-J>=|{1p)Z0xX(H(dJTc(GM_Qnc%U%RQ5M1u-zGE7=gRnZdh4<6dLqm{Nq+6BZ zXJ_|l|E}F!0kbyg_?q`Vt96`Quhn4xIkmB{IG95k6i{FDeyNxg@$8w^CwOyEY%JUm zvB^>VX8U~JDm3<5VyENH_U1*p4=^ z+`r#G&@w(WbPi!Rp$NhPH;1q5+_)zqego^`cN*!v8_4yvQZ`+y7Utr3@AcP?@(KuC z*xp&HB`{{E%5$mi#0usuUCf_|mC<&O(MLR^jm@EbV`>%ipyj*BHzL`@@#e>Pm@=yD zqNV$qGnzL`ITUmzE4u)V!vRO$^N*v0`A$r#iT2*5g5m0d`CO;g1rs}YjYS8YT|#ZC z4EA6&@zP4&R_X*gvNP8)7erT>XQG;zL&IgLtO%B#qc<1d2M50oe}9LI%PVS^dCvMp z^NKz!HYk=Ck5E@knsqy`ns-@Df#aL0?{(f(v6x*+e}l6TiHf18uuA{d}3ed`w6 zwQS9<`K`>%(j4Yo2X1r`3;*QDC-?t6I_-)6g2U;S|iUm>6qVOPM5bwSNo*!d^+JrPFUetSae_=UAin zY_?EX?%_c)K6`LM@zb6NK^+~mdA7yBJj=W%wio*gb^C6Jomg4?R%VLryt>&H%{r7E zn6r2gC>myTeT;F z?FN3-!^DUProc)4^+AaQ8zY}2(w#)oouT@JsZ^kawfv|g56|?!-TyZ+{NL$=|M18l k4*riUEilb_H%}-wTD&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