From cd6cbf9f490775d2d4ddee93c914b77d72237479 Mon Sep 17 00:00:00 2001 From: viktor-kurchenko <69600804+viktor-kurchenko@users.noreply.github.com> Date: Mon, 27 Mar 2023 15:48:31 +0300 Subject: [PATCH] Initial implementation. Signed-off-by: viktor-kurchenko <69600804+viktor-kurchenko@users.noreply.github.com> --- .github/workflows/lint-build.yaml | 36 +++++++++ .github/workflows/release.yaml | 21 ++++++ Makefile | 18 +++++ README.md | 59 ++++++++++++++- cmd/clean.go | 30 ++++++++ cmd/parser.go | 36 +++++++++ cmd/root.go | 19 +++++ cmd/slack.go | 32 ++++++++ go.mod | 17 +++++ go.sum | 21 ++++++ main.go | 7 ++ pkg/cleaner/cleaner.go | 22 ++++++ pkg/cloud/aws.go | 58 +++++++++++++++ pkg/cloud/gcp.go | 56 ++++++++++++++ pkg/converter/json.go | 15 ++++ pkg/dto/dto.go | 34 +++++++++ pkg/parser/parser.go | 110 +++++++++++++++++++++++++++ pkg/slack/slack.go | 119 ++++++++++++++++++++++++++++++ 18 files changed, 708 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/lint-build.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 Makefile create mode 100644 cmd/clean.go create mode 100644 cmd/parser.go create mode 100644 cmd/root.go create mode 100644 cmd/slack.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pkg/cleaner/cleaner.go create mode 100644 pkg/cloud/aws.go create mode 100644 pkg/cloud/gcp.go create mode 100644 pkg/converter/json.go create mode 100644 pkg/dto/dto.go create mode 100644 pkg/parser/parser.go create mode 100644 pkg/slack/slack.go diff --git a/.github/workflows/lint-build.yaml b/.github/workflows/lint-build.yaml new file mode 100644 index 0000000..6eb1085 --- /dev/null +++ b/.github/workflows/lint-build.yaml @@ -0,0 +1,36 @@ +name: Go Lint and Build + +on: + push: + pull_request: + +jobs: + + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out code + uses: actions/checkout@24cb9080177205b6e8c946b17badbe402adc938f # v3.4.0 + + - name: Lint + uses: reviewdog/action-golangci-lint@53f8eabb87b40b1a2c63ec75b0d418bd0f4aa919 # v2.2.2 + + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [ lint ] + steps: + - name: Check out code + uses: actions/checkout@24cb9080177205b6e8c946b17badbe402adc938f # v3.4.0 + + - name: Install Go + uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0 + with: + go-version: 1.18.x + id: go + + - name: Build tool + run: go build -v . diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..bb230f2 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,21 @@ +name: Release C7N helper + +on: + release: + types: [created] + +jobs: + + release-linux-amd64: + name: release linux/amd64 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@24cb9080177205b6e8c946b17badbe402adc938f # v3.4.0 + - uses: wangyoucao577/go-release-action@b98909985b9c1fd7b0aaa4c51257a7ba49995781 # v1.37 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + goos: linux + goarch: amd64 + goversion: 1.18 + binary_name: c7n-helper + extra_files: LICENSE README.md diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f4ee7d8 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +SHELL := /bin/zsh + +define PROJECT_HELP_MSG +Usage: + make help:\t show this message + make lint:\t run go linter + make compile:\t compile c7n-helper binary +endef +export PROJECT_HELP_MSG + +help: + echo -e $$PROJECT_HELP_MSG + +lint: + golangci-lint run + +compile: + go build . diff --git a/README.md b/README.md index 2660979..a7d0cab 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,57 @@ -# cloud-custodian-helper -Cloud Custodian helper tool. +# c7n-helper + +The tool helps to work with Cloud Custodian generated reports. + +## Installation + +To install the latest `c7n-helper` release run: + +```console +$ go install github.com/isovalent/cloud-custodian-helper@latest +``` + +## Lint + +To lint `c7n-helper` sources please run the following locally: + +```console +$ make lint +``` + +## Build + +To build `c7n-helper` from source please run the following locally: + +```console +$ make compile +``` + +## Usage + +* Help: + +```console +$ c7n-helper --help +``` + +* Parse C7N output directory into JSON file: + +```console +$ c7n-helper parse -d -p -t -r +``` + +* Send Slack notification: + +```console +$ c7n-helper slack -r -u -t "" +``` + +* Clean resources: + +```console +$ c7n-helper clean -r +``` + +## License + +Apache-2.0 diff --git a/cmd/clean.go b/cmd/clean.go new file mode 100644 index 0000000..5669dea --- /dev/null +++ b/cmd/clean.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "c7n-helper/pkg/cleaner" + "github.com/spf13/cobra" + "log" +) + +var cleanCmd = &cobra.Command{ + Use: "clean", + Short: "Clean all resources from resource file", + Aliases: []string{"c"}, + Args: cobra.ExactArgs(0), + Run: clean, +} + +var cleanFile *string + +func init() { + cleanFile = cleanCmd.Flags().StringP("resource-file", "r", "", "Resource JSON file") + _ = cleanCmd.MarkFlagRequired("resource-file") + _ = cleanCmd.MarkFlagFilename("resource-file") + rootCmd.AddCommand(cleanCmd) +} + +func clean(_ *cobra.Command, _ []string) { + if err := cleaner.Clean(*cleanFile); err != nil { + log.Fatal(err.Error()) + } +} diff --git a/cmd/parser.go b/cmd/parser.go new file mode 100644 index 0000000..a4d4f05 --- /dev/null +++ b/cmd/parser.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "c7n-helper/pkg/parser" + "github.com/spf13/cobra" + "log" +) + +var parserCmd = &cobra.Command{ + Use: "parse", + Short: "Parse C7N report directory and save result in resource JSON file", + Aliases: []string{"p"}, + Args: cobra.ExactArgs(0), + Run: parse, +} + +var parseType, parseDir, parsePolicy, parseResult *string + +func init() { + parseType = parserCmd.Flags().StringP("type", "t", "", "Cloud resource type (eks, ec2, gke, gce)") + _ = parserCmd.MarkFlagRequired("type") + parseDir = parserCmd.Flags().StringP("report-dir", "d", "", "C7N report directory") + _ = parserCmd.MarkFlagRequired("report-dir") + _ = parserCmd.MarkFlagDirname("report-dir") + parsePolicy = parserCmd.Flags().StringP("policy", "p", "", "C7N policy name") + _ = parserCmd.MarkFlagRequired("policy") + parseResult = parserCmd.Flags().StringP("resource-file", "r", "resources.json", "Resource JSON file") + _ = parserCmd.MarkFlagFilename("resource-file") + rootCmd.AddCommand(parserCmd) +} + +func parse(_ *cobra.Command, _ []string) { + if err := parser.Parse(*parseType, *parseDir, *parsePolicy, *parseResult); err != nil { + log.Fatal(err.Error()) + } +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..5c385b0 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "c7n-helper", + Short: "Cloud Custodian helper tool", +} + +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} diff --git a/cmd/slack.go b/cmd/slack.go new file mode 100644 index 0000000..c8df88e --- /dev/null +++ b/cmd/slack.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "c7n-helper/pkg/slack" + "github.com/spf13/cobra" + "log" +) + +var slackCmd = &cobra.Command{ + Use: "slack", + Short: "Send Slack notification (via webhook) with information from resource JSON file", + Aliases: []string{"s"}, + Args: cobra.ExactArgs(0), + Run: notify, +} + +var slackFile, slackURL, slackTitle *string + +func init() { + slackFile = slackCmd.Flags().StringP("resource-file", "r", "resources.json", "Resource JSON file") + _ = slackCmd.MarkFlagFilename("resource-file") + slackURL = slackCmd.Flags().StringP("url", "u", "", "Slack webhook URL") + _ = slackCmd.MarkFlagRequired("url") + slackTitle = slackCmd.Flags().StringP("title", "t", "", "Slack notification title") + rootCmd.AddCommand(slackCmd) +} + +func notify(_ *cobra.Command, _ []string) { + if err := slack.Notify(*slackFile, *slackURL, *slackTitle); err != nil { + log.Fatal(err.Error()) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9e49e6f --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module c7n-helper + +go 1.20 + +require ( + github.com/lensesio/tableprinter v0.0.0-20201125135848-89e81fc956e7 + github.com/spf13/cobra v1.6.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kataras/tablewriter v0.0.0-20180708051242-e063d29b7c23 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..33f8391 --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kataras/tablewriter v0.0.0-20180708051242-e063d29b7c23 h1:M8exrBzuhWcU6aoHJlHWPe4qFjVKzkMGRal78f5jRRU= +github.com/kataras/tablewriter v0.0.0-20180708051242-e063d29b7c23/go.mod h1:kBSna6b0/RzsOcOZf515vAXwSsXYusl2U7SA0XP09yI= +github.com/lensesio/tableprinter v0.0.0-20201125135848-89e81fc956e7 h1:k/1ku0yehLCPqERCHkIHMDqDg1R02AcCScRuHbamU3s= +github.com/lensesio/tableprinter v0.0.0-20201125135848-89e81fc956e7/go.mod h1:YR/zYthNdWfO8+0IOyHDcIDBBBS2JMnYUIwSsnwmRqU= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= +github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..fc3fb04 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "c7n-helper/cmd" + +func main() { + cmd.Execute() +} diff --git a/pkg/cleaner/cleaner.go b/pkg/cleaner/cleaner.go new file mode 100644 index 0000000..5b8abfd --- /dev/null +++ b/pkg/cleaner/cleaner.go @@ -0,0 +1,22 @@ +package cleaner + +import ( + "c7n-helper/pkg/dto" + "log" + "strings" +) + +func Clean(resourceFile string) error { + log.Println("Reading resource file...") + var report dto.PolicyReport + if err := report.ReadFromFile(resourceFile); err != nil { + return err + } + for _, account := range report.Accounts { + for region, resources := range account.RegionResources { + log.Printf("Cleaning %s [%d] in %s [%s] ...\n", strings.ToUpper(report.ResourceType), len(resources), account.Name, region) + //TODO: implement me + } + } + return nil +} diff --git a/pkg/cloud/aws.go b/pkg/cloud/aws.go new file mode 100644 index 0000000..c214d12 --- /dev/null +++ b/pkg/cloud/aws.go @@ -0,0 +1,58 @@ +package cloud + +import ( + "c7n-helper/pkg/converter" + "c7n-helper/pkg/dto" + "encoding/json" + "fmt" + "time" +) + +type EKS struct { + Name string `converter:"name"` + CreatedAt time.Time `converter:"createdAt"` +} + +type EC2 struct { + InstanceId string `converter:"InstanceId"` + LaunchTime time.Time `converter:"LaunchTime"` + InstanceType string `converter:"InstanceType"` +} + +func EksFromFile(file string) ([]dto.Resource, error) { + content, err := converter.JsonToBytes(file) + if err != nil { + return nil, err + } + var clusters []EKS + if err := json.Unmarshal(content, &clusters); err != nil { + return nil, err + } + result := make([]dto.Resource, 0, len(clusters)) + for _, cluster := range clusters { + result = append(result, dto.Resource{ + Name: cluster.Name, + Created: cluster.CreatedAt, + }) + } + return result, nil +} + +func Ec2FromFile(file string) ([]dto.Resource, error) { + content, err := converter.JsonToBytes(file) + if err != nil { + return nil, err + } + var vms []EC2 + if err := json.Unmarshal(content, &vms); err != nil { + return nil, err + } + result := make([]dto.Resource, 0, len(vms)) + for _, ec2 := range vms { + result = append(result, dto.Resource{ + Name: fmt.Sprintf("%s [%s]", ec2.InstanceId, ec2.InstanceType), + Created: ec2.LaunchTime, + }) + } + return result, nil +} diff --git a/pkg/cloud/gcp.go b/pkg/cloud/gcp.go new file mode 100644 index 0000000..e71e625 --- /dev/null +++ b/pkg/cloud/gcp.go @@ -0,0 +1,56 @@ +package cloud + +import ( + "c7n-helper/pkg/converter" + "c7n-helper/pkg/dto" + "encoding/json" + "time" +) + +type GKE struct { + Name string `converter:"name"` + CreatedAt time.Time `converter:"createTime"` +} + +type GCE struct { + Name string `converter:"name"` + LaunchTime time.Time `converter:"creationTimestamp"` +} + +func GkeFromFile(file string) ([]dto.Resource, error) { + content, err := converter.JsonToBytes(file) + if err != nil { + return nil, err + } + var clusters []GKE + if err := json.Unmarshal(content, &clusters); err != nil { + return nil, err + } + result := make([]dto.Resource, 0, len(clusters)) + for _, cluster := range clusters { + result = append(result, dto.Resource{ + Name: cluster.Name, + Created: cluster.CreatedAt, + }) + } + return result, nil +} + +func GceFromFile(file string) ([]dto.Resource, error) { + content, err := converter.JsonToBytes(file) + if err != nil { + return nil, err + } + var vms []GCE + if err := json.Unmarshal(content, &vms); err != nil { + return nil, err + } + result := make([]dto.Resource, 0, len(vms)) + for _, ec2 := range vms { + result = append(result, dto.Resource{ + Name: ec2.Name, + Created: ec2.LaunchTime, + }) + } + return result, nil +} diff --git a/pkg/converter/json.go b/pkg/converter/json.go new file mode 100644 index 0000000..8b4df79 --- /dev/null +++ b/pkg/converter/json.go @@ -0,0 +1,15 @@ +package converter + +import ( + "io" + "os" +) + +func JsonToBytes(file string) ([]byte, error) { + jsonFile, err := os.Open(file) + if err != nil { + return nil, err + } + defer jsonFile.Close() + return io.ReadAll(jsonFile) +} diff --git a/pkg/dto/dto.go b/pkg/dto/dto.go new file mode 100644 index 0000000..1583b59 --- /dev/null +++ b/pkg/dto/dto.go @@ -0,0 +1,34 @@ +package dto + +import ( + "encoding/json" + "os" + "time" +) + +type PolicyReport struct { + ResourceType string `json:"resourceType"` + C7NPolicy string `json:"policyName"` + Accounts []Account `json:"accounts"` +} + +type Account struct { + Name string `json:"name"` + RegionResources map[string][]Resource `json:"regionResources"` +} + +type Resource struct { + Name string `json:"name"` + Created time.Time `json:"created"` +} + +func (r *PolicyReport) ReadFromFile(reportFile string) error { + file, err := os.ReadFile(reportFile) + if err != nil { + return err + } + if err := json.Unmarshal(file, r); err != nil { + return err + } + return nil +} diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go new file mode 100644 index 0000000..662b74b --- /dev/null +++ b/pkg/parser/parser.go @@ -0,0 +1,110 @@ +package parser + +import ( + "c7n-helper/pkg/cloud" + "c7n-helper/pkg/dto" + "encoding/json" + "errors" + "log" + "os" + "path/filepath" + "strings" +) + +var resourceParsers = map[string]func(file string) ([]dto.Resource, error){ + "eks": cloud.EksFromFile, + "ec2": cloud.Ec2FromFile, + "gke": cloud.GkeFromFile, + "gce": cloud.GceFromFile, +} + +func Parse(resourceType, c7nDir, policy, outFile string) error { + log.Println("Processing C7N report directory...") + files, err := resourceFiles(c7nDir, policy) + if err != nil { + return err + } + log.Println("Parsing C7N resource files...") + report, err := reportFromFiles(files, resourceType, policy) + if err != nil { + return err + } + log.Println("Persisting JSON report...") + return persistReport(report, outFile) +} + +func resourceFiles(c7nDir, policy string) ([]string, error) { + files := make([]string, 0) + err := filepath.Walk(c7nDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && strings.HasSuffix(path, "/"+policy+"/resources.json") { + files = append(files, path) + } + return nil + }) + return files, err +} + +func reportFromFiles(files []string, resourceType, policy string) (dto.PolicyReport, error) { + accountMap := make(map[string]dto.Account) + for _, file := range files { + resources, err := resourcesFromFile(resourceType, file) + if err != nil { + return dto.PolicyReport{}, err + } + if len(resources) == 0 { + continue + } + accName, region := accountRegion(file) + account, ok := accountMap[accName] + if !ok { + account = dto.Account{ + Name: accName, + RegionResources: make(map[string][]dto.Resource), + } + accountMap[accName] = account + } + if _, ok := account.RegionResources[region]; !ok { + account.RegionResources[region] = make([]dto.Resource, 0) + } + account.RegionResources[region] = append(account.RegionResources[region], resources...) + } + return dto.PolicyReport{ + ResourceType: resourceType, + C7NPolicy: policy, + Accounts: accountsFromMap(accountMap), + }, nil +} + +func resourcesFromFile(resourceType, file string) ([]dto.Resource, error) { + parser, ok := resourceParsers[resourceType] + if !ok { + return nil, errors.New("unsupported resource type") + } + return parser(file) +} + +func accountsFromMap(accountMap map[string]dto.Account) []dto.Account { + accounts := make([]dto.Account, 0, len(accountMap)) + for _, accRegion := range accountMap { + accounts = append(accounts, accRegion) + } + return accounts +} + +func persistReport(report dto.PolicyReport, outFile string) error { + file, err := json.MarshalIndent(report, "", " ") + if err != nil { + return err + } + return os.WriteFile(outFile, file, 0644) +} + +// Parses C7N report path: `...////resources.json` +func accountRegion(file string) (string, string) { + parts := strings.Split(file, "/") + l := len(parts) + return parts[l-4] /* account */, parts[l-3] /* region */ +} diff --git a/pkg/slack/slack.go b/pkg/slack/slack.go new file mode 100644 index 0000000..46ce74a --- /dev/null +++ b/pkg/slack/slack.go @@ -0,0 +1,119 @@ +package slack + +import ( + "bytes" + "c7n-helper/pkg/dto" + "fmt" + "github.com/lensesio/tableprinter" + "log" + "net/http" + "sort" + "strings" + "unicode/utf8" +) + +const ( + MaxSlackMessageLength = 3_900 + SplitMessageThreshold = MaxSlackMessageLength - MaxSlackMessageLength/5 +) + +type Resource struct { + Index int `header:"#"` + Region string `header:"Region"` + Name string `header:"Name"` + Created string `header:"Created date"` +} + +func Notify(resourceFile, url, title string) error { + log.Println("Reading resource file...") + var report dto.PolicyReport + if err := report.ReadFromFile(resourceFile); err != nil { + return err + } + log.Println("Preparing slack messages...") + messages := reportToSlackMessages(title, report) + log.Println("Sending slack notification...") + return notifySlack(url, messages) +} + +func reportToSlackMessages(title string, report dto.PolicyReport) []string { + messages := make([]string, 0, len(report.Accounts)+1) + if title != "" { + messages = append(messages, fmt.Sprintf("{\"text\":\"%s\"}", title)) + } + for _, account := range report.Accounts { + index := 0 + resources := make([]Resource, 0) + for region, res := range account.RegionResources { + sort.Slice(res, func(i, j int) bool { + return res[i].Created.After(res[j].Created) + }) + resources = append(resources, resourcesFromDto(region, res, &index)...) + } + buf := bytes.NewBufferString("") + tableprinter.Print(buf, resources) + for _, message := range normalizeMessage(buf.String()) { + payload := fmt.Sprintf("*%s*\n```\n%s```\n", account.Name, message) + messages = append(messages, fmt.Sprintf("{\"text\":\"%s\"}", payload)) + } + } + return messages +} + +func resourcesFromDto(region string, resources []dto.Resource, index *int) []Resource { + result := make([]Resource, 0, len(resources)) + for _, r := range resources { + *index++ + result = append(result, Resource{ + Index: *index, + Region: region, + Name: r.Name, + Created: r.Created.Format("2006-01-02"), + }) + } + return result +} + +func normalizeMessage(message string) []string { + if utf8.RuneCountInString(message) > MaxSlackMessageLength { + messages := make([]string, 0) + builder := strings.Builder{} + lines := strings.Split(message, "\n") + for _, line := range lines { + builder.WriteString(line + "\n") + if utf8.RuneCountInString(builder.String()) > SplitMessageThreshold { + messages = append(messages, builder.String()) + builder.Reset() + } + } + if builder.Len() > 0 { + messages = append(messages, builder.String()) + } + return messages + } + return []string{message} +} + +func notifySlack(url string, messages []string) error { + client := &http.Client{} + for _, payload := range messages { + req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(payload))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if err := sendMessage(client, req); err != nil { + return err + } + } + return nil +} + +func sendMessage(client *http.Client, req *http.Request) error { + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + return nil +}