Go: проверка списка публичных репозиториев в Github и уведомления в Slack. Сравнение списков в Go. Первый опыт с Golang.

Автор: | 04/13/2019
 

Задача — написать утилиту, которая будет запускаться по крону из Jenkins и проверять список публичных репозиториев организации в Github.

Создание Docker-образа и Jenkins-джобы — в посте Jenkins: проверка публичных репозиториев Github-организации.

Затем она должна сравнивать полученный список со списком разрешённых, и если списки не совпадают — слать алерт в Slack.

Идея состоит в том, что если девелоперы случайно создадут новый публичный репозиторий, или сменят тип существующего с приватного на публичный — получить об этом уведомление.

Можно было бы написать на bash с curl, или на Python с urllib и использовать Github API напрямую, но практики ради — хочется попробовать на Go.

По сути — это моя первая программа на Go, на котором я особо никогда не писал и даже ситаксиса толком не знаю, но попробуем использовать имеющиеся знания C/Python, приправить небольшой порцией логики и, конечно же с помощью Goolge и такой-то матери — написать что-то рабочее.

Документация — для ленивых, поэтому по большей части используем метод тыка (и ещё раз Google).

Для работы с API Github используем пакет go-github.

Для тестов создал организацию в Github, и два публичных репозитория.

Начинаем писать.

Добавляем импорт, функция подставилась в vim автоматом плагином vim-go:

package main
    
import (
    "fmt"
    "github.com/google/go-github/github"
)

func main() {
    fmt.Println("vim-go")
}

Добавим кастомный $GOPATH:

sudo mkdir /usr/local/go
sudo chown setevoy:setevoy /usr/local/go/
export GOPATH=/usr/local/go && export GOBIN=/usr/local/go/bin

Устанавливаем пакет:

go get
_/home/setevoy/Scripts/Go/GTHB
./gitrepos.go:5:2: imported and not used: "github.com/google/go-github/github"

Окей.

Получение репозиториев Github

Копипастим первый пример из примеров в README пакета, имя организации получаем из переменной $GITHUB_ORG_NAME, и попробуем вывести результат.

Задаём переменную:

export GITHUB_ORG_NAME="rtfmorg"

Код:

package main

import (
    "context"
    "fmt"
    "github.com/google/go-github/github"
)
 
func main() {

    client := github.NewClient(nil)

    opt := &github.RepositoryListByOrgOptions{Type: "public"}
    repos, _, _ := client.Repositories.ListByOrg(context.Background(), os.Getenv("GITHUB_ORG_NAME"), opt)

    fmt.Printf(repos)
}

В оригинале имя организации задаётся прямо в коде, мы выносим его в переменную $GITHUB_ORG_NAME, что бы передавать из Jenkins, и получаем через os.Getenv.

Добавляем импорт context и os.

Запускаем:

go run go-github-public-repos-checker.go
command-line-arguments
./go-github-public-repos-checker.go:17:15: cannot use repos (type []*github.Repository) as type string in argument to fmt.Printf

Ммм…

Окей.

Я думал client.Repositories.ListByOrg вернёт просто список — List ведь 🙂

Проверим — что за тип получаем в объекте repos. Используем reflect:

package main

import (
    "os"
    "context"
    "fmt"
    "reflect"
    "github.com/google/go-github/github"
)

func main() {

    client := github.NewClient(nil)

    opt := &github.RepositoryListByOrgOptions{Type: "public"}
    repos, _, _ := client.Repositories.ListByOrg(context.Background(), os.Getenv("GITHUB_ORG_NAME"), opt)

    fmt.Println(reflect.TypeOf(repos).String())
}

Запускаем:

go run go-github-public-repos-checker.go
[]*github.Repository

Хорошо… Всё-таки список ([]), указывающий на, судя по всему, структуру — github.Repository. См. Golang: struct — структуры в примерах.

Посмотрим, что вернёт go doc:

go doc github.Repository
type Repository struct {
ID               *int64           `json:"id,omitempty"`
NodeID           *string          `json:"node_id,omitempty"`
Owner            *User            `json:"owner,omitempty"`
Name             *string          `json:"name,omitempty"`
FullName         *string          `json:"full_name,omitempty"`
Description      *string          `json:"description,omitempty"`
...

Таки структура, и заодно — всё её поля.

Что бы Go не ругался с сообщением imported and not used: «reflect» на неиспользованный reflext — задаём его как _reflect:

package main

import (
    "os"
    "context"
    "fmt"
    _"reflect"
    "github.com/google/go-github/github"
)

func main() {

    client := github.NewClient(nil)

    opt := &github.RepositoryListByOrgOptions{Type: "public"}
    repos, _, _ := client.Repositories.ListByOrg(context.Background(), os.Getenv("GITHUB_ORG_NAME"), opt)

    for _, repo := range repos {
        fmt.Printf("Repo: %s\n", *repo.Name)
    }
}

Тут из списка repos получаем индекс элемента, и его значение.

Можно вывести и сами индексы:

...
    for id, repo := range repos {
        fmt.Printf("%d Repo: %s\n", id, *repo.Name)
    }
...
go run go-github-public-repos-checker.go
0 Repo: org-repo-1-pub
1 Repo: org-repo-2-pub

Сравнение списков в Go

Теперь надо добавить список, в который будем получать список разрешённых репозиториев.

Разрешённые будем передавать из параметров Jenkins, в виде списка с элементами, разделёнными пробелами:

export ALLOWED_REPOS="1 2"

Попытка номер раз

Добавляем получение списка разрешённых репозиториев, и сохраняем его в список allowedRepos типа string:

...
    allowedRepos := []string{os.Getenv("ALLOWED_REPOS")}

    for id, repo := range repos {
        fmt.Printf("%d Repo: %s\n", id, *repo.Name)
    }

    fmt.Printf("Allowed repos: %s\n", allowedRepos) 
...

Но — есть нюанс, с которым я столкнулся уже позже, когда начал выполнять сравнение списков, и о котором не подумал сразу.

В чём проблема — увидим дальше, пока оставим, как есть.

Проверяем:

go run go-github-public-repos-checker.go
0 Repo: org-repo-1-pub
1 Repo: org-repo-2-pub
Allowed repos: [1 2]

Окей, работает.

Теперь надо выполнить проверку между элементами двух списков — repos и allowedRepos.

Может есть более красивые решения, но у меня сначала получилось такое:

...
    allowedRepos := []string{os.Getenv("ALLOWED_REPOS")}
    fmt.Printf("Allowed repos: %s\n", allowedRepos)

    for id, repo := range repos {
        for _, i := range allowedRepos {
            if i == *repo.Name {
                fmt.Printf("Index: %d, repo %s found in Allowed as %s\n", id, *repo.Name, i)
            } else {
                fmt.Printf("ALARM: repo %s was NOT found in Allowed!\n", *repo.Name)
            }
        }
    }
...

Запускаем:

go run go-github-public-repos-checker.go
Allowed repos: [1 2]
ALARM: repo org-repo-1-pub was NOT found in Allowed!
ALARM: repo org-repo-2-pub was NOT found in Allowed!

Казалось бы — всё верно?

Репозитории с именами 1 и 2 != org-repo-1-pub и org-repo-2-pub.

Проблема, наверняка кому-то уже очевидная, обнаружилась, когда я выполнил обратную проверку, т.е. задал в $ALLOWED_REPOS реальные имена, что бы получить «ОК»:

export ALLOWED_REPOS="org-repo-1-pub org-repo-2-pub"

Проверяем:

go run go-github-public-repos-checker.go
Allowed repos: [org-repo-1-pub org-repo-2-pub]
ALARM: repo org-repo-1-pub was NOT found in Allowed!
ALARM: repo org-repo-2-pub was NOT found in Allowed!

Пачиму?

А потому, что в allowedRepos у нас не список aka slice в Go, а просто строка, string.

Добавим вывод значения самой переменной i, и номера индексов:

...
    for r_id, repo := range repos {
        for a_id, i := range allowedRepos {
            fmt.Printf("ID: %d Type: %T Value: %s\n", a_id, allowedRepos, i)
            if i == *repo.Name {
                fmt.Printf("Index: %d, repo %s found in Allowed as %s\n", r_id, *repo.Name, i)
            } else {
                fmt.Printf("ALARM: repo %s was NOT found in Allowed!\n", *repo.Name)
            }
        }
    }
...

Проверяем:

go run go-github-public-repos-checker.go
Allowed repos: [org-repo-1-pub org-repo-2-pub]
ID: 0 Type: []string Value: org-repo-1-pub org-repo-2-pub
ALARM: repo org-repo-1-pub was NOT found in Allowed!
ID: 0 Type: []string Value: org-repo-1-pub org-repo-2-pub
ALARM: repo org-repo-2-pub was NOT found in Allowed!

allowedRepos у нас таки список, но у него только один элемент с ID 0, в котором хранится значение «org-repo-1-pub org-repo-2-pub«.

Попытка номер два

Что бы наша схема сработала — надо превратить его в настоящий список.

Используем strings:

...
    // allowedRepos := []string{os.Getenv("ALLOWED_REPOS")}                                                                                                                                                     
    allowedRepos := strings.Fields(os.Getenv("ALLOWED_REPOS"))
    fmt.Printf("Allowed repos: %s\n", allowedRepos)
    
    for r_id, repo := range repos {
        for a_id, i := range allowedRepos {
            fmt.Printf("ID: %d Type: %T Value: %s\n", a_id, allowedRepos, i)
            if i == *repo.Name {
                fmt.Printf("Index: %d, repo %s found in Allowed as %s\n", r_id, *repo.Name, i)
            } else {
                fmt.Printf("ALARM: repo %s was NOT found in Allowed!\n", *repo.Name)
            }
        }
    }
...

allowedRepos теперь создаём с помощью strings.Fields().

Проверяем результат:

go run go-github-public-repos-checker.go
Allowed repos: [org-repo-1-pub org-repo-2-pub]
ID: 0 Type: []string Value: org-repo-1-pub
Index: 0, repo org-repo-1-pub found in Allowed as org-repo-1-pub
ID: 1 Type: []string Value: org-repo-2-pub
ALARM: repo org-repo-1-pub was NOT found in Allowed!
ID: 0 Type: []string Value: org-repo-1-pub
ALARM: repo org-repo-2-pub was NOT found in Allowed!
ID: 1 Type: []string Value: org-repo-2-pub
Index: 1, repo org-repo-2-pub found in Allowed as org-repo-2-pub

Уже лучше, но такой подход тоже не работает — есть «ложные» срабатывания, т.к. сравнивается каждый элемент repos с каждым элементом allowedRepos.

Попытка номер три

Перепишем, и используем перебор по индексам:

...
    for r_id, repo := range repos {
        fmt.Printf("%d %s\n", r_id, *repo.Name)
        if *repo.Name != allowedRepos[r_id] {
            fmt.Printf("ALARM: repo %s was NOT found in Allowed!\n", *repo.Name)
        } else {
            fmt.Printf("Repo %s found in Allowed\n", *repo.Name)
        }
    }
...

Т.е. из repos получаем id индекса, а потом проверяем allowedRepos с таким же индексом.

Проверяем:

go run go-github-public-repos-checker.go
Allowed repos: [org-repo-1-pub org-repo-2-pub]
0 org-repo-1-pub
Repo org-repo-1-pub found in Allowed
1 org-repo-2-pub
Repo org-repo-2-pub found in Allowed

Отлично!

Но снова проблема.

Что, если порядок репозиториев в обоих списках будет различаться?

Задаём вместо:

export ALLOWED_REPOS="org-repo-1-pub org-repo-2-pub"

Имена репозиториев в обратном порядке:

export ALLOWED_REPOS="org-repo-2-pub org-repo-1-pub"

Проверяем:

go run go-github-public-repos-checker.go
Allowed repos: [org-repo-2-pub org-repo-1-pub]
0 org-repo-1-pub
ALARM: repo org-repo-1-pub was NOT found in Allowed!
1 org-repo-2-pub
ALARM: repo org-repo-2-pub was NOT found in Allowed!

Кек…

Попытка номер четыре

Следующей попыткой было использовать reflect и DeepEqual.

Добавляем ещё один список actualRepos, в который через append добавляем репозитории, полученные из Github, а потом сравниваем списки:

...
    allowedRepos := strings.Fields(os.Getenv("ALLOWED_REPOS"))
    var actualRepos []string

    for _,  repo := range repos {
        actualRepos = append(actualRepos, *repo.Name)
    }

    fmt.Printf("Allowed: %s\n", allowedRepos)
    fmt.Printf("Actual: %s\n", actualRepos)

    fmt.Println("Slice equal: ", reflect.DeepEqual(allowedRepos, actualRepos))
...

Проверяем:

go run go-github-public-repos-checker.go
Allowed: [org-repo-2-pub org-repo-1-pub]
Actual: [org-repo-1-pub org-repo-2-pub]
Slice equal:  false

И снова нет… Хотя если вернуть одинаковый порядок — то этот метод сработает:

export ALLOWED_REPOS="org-repo-1-pub org-repo-2-pub"
go run go-github-public-repos-checker.go
Allowed: [org-repo-1-pub org-repo-2-pub]
Actual: [org-repo-1-pub org-repo-2-pub]
Slice equal:  true

Значит — надо вернуться к первому варианту с циклом, но каким-то образом проверять наличие repo во всём списке allowedRepos, и только при остутствии repo во всех элементах списка allowedRepos — создавать алерт.

Попытка номер пять

Решение нарисовалось следующее: создадим отдельную функцию, которая будет проверять в цикле все элементы allowedRepos, и если элемент найден — возвращать true, и прерывать выполнение. В противном случае, по окончании цикла — вернём false, и сможем его обработать в цикле функции main().

Пробуем.

Создаём функцию isAllowedRepo(), которая будет принимать имя репозитория для проверки, и список разрешённых репозиториев, и возвращать булево значение:

...
func isAllowedRepo(repoName string, allowedRepos []string) bool {

    for _, i := range allowedRepos {
        if i == repoName {
            return true
        }
    }

    return false
}
...

А в main() — в цикле перебираем элементы списка repos, и по одному передаём их в isAllowedRepo(), и проверяем полученный результат:

...
    for _, repo := range repos {
        fmt.Printf("Checking %s\n", *repo.Name)
        fmt.Println(isAllowedRepo(*repo.Name, allowedRepos))
    }
...

Проверяем.

Сначала список с репозиториями в том же порядке:

export ALLOWED_REPOS="org-repo-1-pub org-repo-2-pub"
go run go-github-public-repos-checker.go
Checking org-repo-1-pub
true
Checking org-repo-2-pub
true

И в обратном порядке:

export ALLOWED_REPOS="org-repo-2-pub org-repo-1-pub"
go run go-github-public-repos-checker.go
Checking org-repo-1-pub
true
Checking org-repo-2-pub
true

И уберём один репозиторий из списка разрешённых:

export ALLOWED_REPOS="org-repo-2-pub"
go run go-github-public-repos-checker.go
Checking org-repo-1-pub
false
Checking org-repo-2-pub
true

Отлично.

Теперь при получении false — можем создавать алерт.

Весь код сейчас выглядит так, оставляю старые попытки «для истории»:

package main
import (
    "os"
    "context"
    "fmt"
    _"reflect"
    "strings"
    "github.com/google/go-github/github"
)

func isAllowedRepo(repoName string, allowedRepos []string) bool {

    for _, i := range allowedRepos {
        if i == repoName {
            return true
        }
    }

    return false
}

func main() {

    client := github.NewClient(nil)

    opt := &github.RepositoryListByOrgOptions{Type: "public"}
    repos, _, _ := client.Repositories.ListByOrg(context.Background(), os.Getenv("GITHUB_ORG_NAME"), opt)

    // allowedRepos := []string{os.Getenv("ALLOWED_REPOS")}
    allowedRepos := strings.Fields(os.Getenv("ALLOWED_REPOS"))
//  var actualRepos []string

/*
    for _,  repo := range repos {
        actualRepos = append(actualRepos, *repo.Name)
    }

    fmt.Printf("Allowed: %s\n", allowedRepos)
    fmt.Printf("Actual: %s\n", actualRepos)

    fmt.Println("Slice equal: ", reflect.DeepEqual(allowedRepos, actualRepos))
*/

    for _, repo := range repos {
        fmt.Printf("Checking %s\n", *repo.Name)
        fmt.Println(isAllowedRepo(*repo.Name, allowedRepos))
    }

/*
    for r_id, repo := range repos {
        for _, i := range allowedRepos {
            fmt.Printf("Checking %s and %s\n", *repo.Name, i)
            if i == *repo.Name {
                fmt.Printf("Index: %d, repo %s found in Allowed as %s\n", r_id, *repo.Name, i)
                break
            } else {
                fmt.Printf("ALARM: repo %s was NOT found in Allowed!\n", *repo.Name)
            }
        }
    }
*/

/*
    for r_id, repo := range repos {
        fmt.Printf("%d %s\n", r_id, *repo.Name)
        if *repo.Name != allowedRepos[r_id] {
            fmt.Printf("ALARM: repo %s was NOT found in Allowed!\n", *repo.Name)
        } else {
            fmt.Printf("Repo %s found in Allowed\n", *repo.Name)
        }
    }
*/

/*
    for r_id, repo := range repos {
        for a_id, i := range allowedRepos {
            fmt.Printf("ID: %d Type: %T Value: %s\n", a_id, allowedRepos, i)
            if i == *repo.Name {
                fmt.Printf("Index: %d, repo %s found in Allowed as %s\n", r_id, *repo.Name, i)
            } else {
                fmt.Printf("ALARM: repo %s was NOT found in Allowed!\n", *repo.Name)
            }
        }
    }
*/
}

Golang Slack

Добавим отправку уведомления в Slack.

Используем ashwanthkumar/slack-go-webhook.

Настраиваем вебхук, см. пример в Nagios: настройка уведомлений в Slack-чат.

Добавляем функцию sendSlackAlarm(), которой тоже передаём два аргумента — имя репозитория, и его URL:

...
func sendSlackAlarm(repoName string, repoUrl string) {
    webhookUrl := os.Getenv("SLACK_URL")

    text := fmt.Sprintf(":scream: ALARM: repository *%s* was NOT found in Allowed!", repoName)

    attachment := slack.Attachment{}
    attachment.AddAction(slack.Action{Type: "button", Text: "RepoURL", Url: repoUrl, Style: "danger"}) 

    payload := slack.Payload{
        Username:    "Github checker",
        Text:        text,
        Channel:     os.Getenv("SLACK_CHANNEL"),
        IconEmoji:   ":scream:",
        Attachments: []slack.Attachment{attachment},
    }

    err := slack.Send(webhookUrl, "", payload)
    if len(err) > 0 {
        fmt.Printf("error: %s\n", err)
    }
}
...

И вызов в main(), если isAllowedRepo() вернул false:

...
    for _, repo := range repos {
        fmt.Printf("\nChecking %s\n", *repo.Name)
        if isAllowedRepo(*repo.Name, allowedRepos) {
            fmt.Printf("OK: repo %s found in Allowed\n", *repo.Name)
        } else {
            fmt.Printf("ALARM: repo %s was NOT found in Allowed!\n", *repo.Name)
            sendSlackAlarm(*repo.Name, *repo.HTMLURL)
        }
    }
...

repo.HTMLURL мы нашли в списке полей при вызове go doc github.Repository.

Задаём $SLACK_URL и $SLACK_CHANNEL:

export SLACK_URL="https://hooks.slack.com/services/T1641GRB9/BA***WRE"
export SLACK_CHANNEL="#general"

Возвращаем полный список репозиториев, в обратном порядке:

export ALLOWED_REPOS="org-repo-2-pub org-repo-1-pub"

Проверяем:

go run go-github-public-repos-checker.go
Checking org-repo-1-pub
OK: repo org-repo-1-pub found in Allowed
Checking org-repo-2-pub
OK: repo org-repo-2-pub found in Allowed

Убираем один из разрешённых:

export ALLOWED_REPOS="org-repo-2-pub"

Проверяем ещё раз:

go run go-github-public-repos-checker.go
Checking org-repo-1-pub
ALARM: repo org-repo-1-pub was NOT found in Allowed!
Checking org-repo-2-pub
OK: repo org-repo-2-pub found in Allowed

Slack:

Готово.