feat: add midori skeleton, bot scaffolding, CD seeds

This commit is contained in:
41666 2020-09-27 00:32:15 -04:00
parent 7c861b37ca
commit 49d44df231
30 changed files with 854 additions and 123 deletions

21
src/common/BUILD.bazel Normal file
View file

@ -0,0 +1,21 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "common",
srcs = [
"await-exit.go",
"envconfig.go",
"finders.go",
],
importpath = "github.com/roleypoly/roleypoly/src/common",
visibility = ["//visibility:public"],
)
go_test(
name = "common_test",
srcs = [
"envconfig_test.go",
"finders_test.go",
],
embed = [":common"],
)

19
src/common/await-exit.go Normal file
View file

@ -0,0 +1,19 @@
package common
import (
"os"
"os/signal"
"syscall"
)
func AwaitExit() {
syscallExit := make(chan os.Signal, 1)
signal.Notify(
syscallExit,
syscall.SIGINT,
syscall.SIGTERM,
os.Interrupt,
os.Kill,
)
<-syscallExit
}

View file

@ -0,0 +1,18 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "bot",
srcs = [
"commandmux.go",
"scaffolding.go",
],
importpath = "github.com/roleypoly/roleypoly/src/common/bot",
visibility = ["//visibility:public"],
deps = [
"//src/common",
"@com_github_bwmarrin_discordgo//:discordgo",
"@com_github_dghubble_trie//:trie",
"@com_github_lampjaw_discordclient//:discordclient",
"@io_k8s_klog//:klog",
],
)

View file

@ -0,0 +1,87 @@
package bot
import (
"context"
"regexp"
"strings"
"github.com/dghubble/trie"
"github.com/lampjaw/discordclient"
"k8s.io/klog"
)
type CommandMux struct {
commandTrie *trie.PathTrie
matcher *regexp.Regexp
}
func NewCommandMux(matcher *regexp.Regexp) CommandMux {
return CommandMux{
commandTrie: trie.NewPathTrie(),
matcher: matcher,
}
}
type CommandGroup []Command
func (cg CommandGroup) RegisterCommands(commandTrie *trie.PathTrie, prefix string) {
for _, command := range cg {
commandTrie.Put(prefix+"/"+command.CommandName, command.Handler)
}
}
type Command struct {
CommandName string
Handler HandlerFunc
}
func (c CommandMux) RegisterCommandGroup(prefix string, group CommandGroup) {
group.RegisterCommands(c.commandTrie, prefix)
}
func (c CommandMux) Handler(ctx context.Context, message discordclient.Message) {
if !c.matches(message) {
return
}
key := c.commandKeyFromMessage(message)
command := c.commandTrie.Get(key)
if command == nil {
return
}
handlerFn, ok := command.(HandlerFunc)
if !ok {
klog.Warning("CommandMux.Handler: " + key + " handler was not HandlerFunc")
return
}
c.logCommandRun(key, message)
handlerFn(ctx, message)
}
func (c CommandMux) commandKeyFromMessage(message discordclient.Message) string {
commandParts := strings.Split(message.RawMessage(), " ")[1:]
return commandParts[0] + "/" + commandParts[1]
}
func (c CommandMux) matches(message discordclient.Message) bool {
return c.matcher.MatchString(message.RawMessage())
}
func (c CommandMux) logCommandRun(key string, message discordclient.Message) {
klog.Info("CommandMux: " + key + " by " + message.UserName() + " <@" + message.UserID() + ">")
}
func PrefixMatcher(prefix string) *regexp.Regexp {
return regexp.MustCompile(`^` + prefix)
}
func MentionMatcher(userID string) *regexp.Regexp {
return regexp.MustCompile(`<@!?` + userID + ">")
}
func Tokenize(message discordclient.Message) []string {
return strings.SplitAfterN(message.RawMessage(), " ", 3)
}

View file

@ -0,0 +1,101 @@
package bot
import (
"context"
"github.com/bwmarrin/discordgo"
"github.com/lampjaw/discordclient"
"github.com/roleypoly/roleypoly/src/common"
)
type HandlerFunc func(context.Context, discordclient.Message)
type BotScaffolding struct {
BotToken string
BotClientID string
RootUsers []string
AllowBots bool
AllowEdits bool
AllowDeletes bool
GatewayIntents discordgo.Intent
Handler HandlerFunc
}
type Listener struct {
DiscordClient *discordclient.DiscordClient
config *BotScaffolding
handler HandlerFunc
}
type ctxKey string
var ListenerCtxKey ctxKey = "listener"
var MessageCtxKey ctxKey = "message"
func ScaffoldBot(config BotScaffolding) error {
discordClient := discordclient.NewDiscordClient(config.BotToken, config.RootUsers[0], config.BotClientID)
discordClient.GatewayIntents = config.GatewayIntents
discordClient.AllowBots = config.AllowBots
messageChannel, err := discordClient.Listen(-1)
if err != nil {
return err
}
defer common.AwaitExit()
listener := &Listener{
config: &config,
DiscordClient: discordClient,
handler: config.Handler,
}
go listener.processMessages(messageChannel)
return nil
}
func (l *Listener) processMessages(messageChannel <-chan discordclient.Message) {
listenerCtx := context.WithValue(context.Background(), ListenerCtxKey, l)
for {
message := <-messageChannel
if !l.config.AllowEdits && message.Type() == discordclient.MessageTypeUpdate {
continue
}
if !l.config.AllowDeletes && message.Type() == discordclient.MessageTypeDelete {
continue
}
localCtx := context.WithValue(listenerCtx, MessageCtxKey, message)
go l.handler(localCtx, message)
}
}
// ReplyToMessage will use the message context to reply to a message.
// Message may be one of two types:
// - *discordgo.MessageSend
// - string
func ReplyToMessage(listenerCtx context.Context, message interface{}) error {
l := listenerCtx.Value(ListenerCtxKey).(*Listener)
m := listenerCtx.Value(MessageCtxKey).(discordclient.Message)
channelID := m.Channel()
switch message.(type) {
case *discordgo.MessageSend:
_, err := l.DiscordClient.Session.ChannelMessageSendComplex(channelID, message.(*discordgo.MessageSend))
if err != nil {
return err
}
case string:
return l.DiscordClient.SendMessage(channelID, message.(string))
}
return nil
}
// NoOpHandlerFunc does no operations againdst a message. This can be an equivalent to nil.
func NoOpHandlerFunc(context.Context, discordclient.Message) {
}

65
src/common/envconfig.go Normal file
View file

@ -0,0 +1,65 @@
package common
import (
"encoding/json"
"os"
"strconv"
"strings"
)
// GetenvValue is a holder type for Getenv to translate any Getenv strings to real types
type GetenvValue struct {
value string
}
func Getenv(key string, defaultValue ...string) GetenvValue {
value := ""
if len(defaultValue) > 0 {
value = defaultValue[0]
}
envValue := os.Getenv(key)
if envValue != "" {
value = envValue
}
return GetenvValue{
value: strings.TrimSpace(value),
}
}
func (g GetenvValue) String() string {
return g.value
}
func (g GetenvValue) StringSlice(optionalDelimiter ...string) []string {
delimiter := ","
if len(optionalDelimiter) > 0 {
delimiter = optionalDelimiter[0]
}
return strings.Split(g.value, delimiter)
}
func (g GetenvValue) Bool() bool {
lowercaseValue := strings.ToLower(g.value)
if g.value == "1" || lowercaseValue == "true" || lowercaseValue == "yes" {
return true
} else {
return false
}
}
func (g GetenvValue) Number() int {
result, err := strconv.Atoi(g.value)
if err != nil {
return -999999
}
return result
}
func (g GetenvValue) JSON(target interface{}) error {
return json.Unmarshal([]byte(g.value), target)
}

View file

@ -0,0 +1,123 @@
package common_test
import (
"os"
"testing"
"github.com/roleypoly/roleypoly/src/common"
)
var (
testEnv = map[string]string{
"string": "hello world",
"slice": "hello,world",
"slice_no_delim": "hello world",
"slice_set_delim": "hello|world",
"number": "10005",
"number_bad": "abc123",
"bool": "true",
"bool_bad": "truth",
"bool_no": "false",
"bool_number": "1",
"json": `{"hello":"world","arr":[1,2,3]}`,
}
)
func TestMain(m *testing.M) {
for key, value := range testEnv {
os.Setenv("test__"+key, value)
}
m.Run()
}
func TestEnvconfigString(t *testing.T) {
testStr := common.Getenv("test__string").String()
if testStr != testEnv["string"] {
t.FailNow()
}
}
func TestEnvconfigStringSlice(t *testing.T) {
testSl := common.Getenv("test__slice").StringSlice()
if testSl[0] != "hello" || testSl[1] != "world" {
t.FailNow()
}
}
func TestEnvconfigStringSliceNoDelimeter(t *testing.T) {
testSl := common.Getenv("test__slice_no_delim").StringSlice()
if testSl[0] != testEnv["slice_no_delim"] {
t.FailNow()
}
}
func TestEnvconfigStringSliceSetDelimeter(t *testing.T) {
testSl := common.Getenv("test__slice_set_delim").StringSlice("|")
if testSl[0] != "hello" || testSl[1] != "world" {
t.FailNow()
}
}
func TestEnvconfigNumber(t *testing.T) {
testNum := common.Getenv("test__number").Number()
if testNum != 10005 {
t.FailNow()
}
}
func TestEnvconfigNumberBad(t *testing.T) {
testNum := common.Getenv("test__number_bad").Number()
if testNum != -999999 {
t.FailNow()
}
}
func TestEnvconfigBool(t *testing.T) {
testBool := common.Getenv("test__bool").Bool()
if !testBool {
t.FailNow()
}
}
func TestEnvconfigBoolBad(t *testing.T) {
testBool := common.Getenv("test__bool_bad").Bool()
if testBool {
t.FailNow()
}
}
func TestEnvconfigBoolFalse(t *testing.T) {
testBool := common.Getenv("test__bool_no").Bool()
if testBool {
t.FailNow()
}
}
func TestEnvconfigBoolNumber(t *testing.T) {
testBool := common.Getenv("test__bool_number").Bool()
if !testBool {
t.FailNow()
}
}
func TestEnvconfigDefault(t *testing.T) {
testBool := common.Getenv("test__thing_that_doesnt_exist", "yes").Bool()
if !testBool {
t.FailNow()
}
}
type testJSONData struct {
Hello string `json:"hello,omitempty"`
Arr []int `json:"arr,omitempty"`
}
func TestEnvconfigJSON(t *testing.T) {
data := testJSONData{}
err := common.Getenv("test__json").JSON(&data)
if err != nil || data.Hello != "world" || len(data.Arr) != 3 {
t.FailNow()
}
}

12
src/common/finders.go Normal file
View file

@ -0,0 +1,12 @@
package common
// FindString returns true if needle is in haystack
func FindString(needle string, haystack []string) bool {
for _, str := range haystack {
if str == needle {
return true
}
}
return false
}

View file

@ -0,0 +1,17 @@
package common_test
import (
"testing"
"github.com/roleypoly/roleypoly/src/common"
)
func TestFindString(t *testing.T) {
if !common.FindString("hello", []string{"hello", "world"}) {
t.FailNow()
}
if common.FindString("foo", []string{"a", "b", "c"}) {
t.FailNow()
}
}

View file

@ -11,6 +11,8 @@ go_library(
importpath = "github.com/roleypoly/roleypoly/src/discord-bot",
visibility = ["//visibility:private"],
deps = [
"//src/common",
"//src/common/bot",
"//src/common/version",
"//src/discord-bot/internal/strings",
"@com_github_bwmarrin_discordgo//:discordgo",

View file

@ -1,47 +1,37 @@
package main
import (
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"github.com/bwmarrin/discordgo"
_ "github.com/joho/godotenv/autoload"
"github.com/lampjaw/discordclient"
"github.com/roleypoly/roleypoly/src/common"
"github.com/roleypoly/roleypoly/src/common/bot"
"github.com/roleypoly/roleypoly/src/common/version"
"k8s.io/klog"
)
var (
botToken = os.Getenv("DISCORD_BOT_TOKEN")
botClientID = os.Getenv("DISCORD_CLIENT_ID")
rootUsers = strings.Split(os.Getenv("ROOT_USERS"), ",")
allowedBots = strings.Split(os.Getenv("ALLOWED_BOTS"), ",")
appURL = os.Getenv("PUBLIC_URL")
selfMention = regexp.MustCompile("<@!?" + botClientID + ">")
botToken = common.Getenv("DISCORD_BOT_TOKEN").String()
botClientID = common.Getenv("DISCORD_CLIENT_ID").String()
rootUsers = common.Getenv("ROOT_USERS").StringSlice()
allowedBots = common.Getenv("ALLOWED_BOTS").StringSlice()
appURL = common.Getenv("PUBLIC_URL").String()
selfMention = bot.MentionMatcher(botClientID)
)
func main() {
klog.Info(version.StartupInfo("discord-bot"))
discordClient := discordclient.NewDiscordClient(botToken, rootUsers[0], botClientID)
discordClient.GatewayIntents = discordgo.IntentsGuildMessages
messageChannel, err := discordClient.Listen(-1)
err := bot.ScaffoldBot(bot.BotScaffolding{
RootUsers: rootUsers,
AllowBots: true,
BotClientID: botClientID,
BotToken: botToken,
GatewayIntents: discordgo.IntentsGuildMessages,
Handler: handle,
})
if err != nil {
klog.Fatal(err)
}
defer awaitExit()
l := listener{
client: discordClient,
}
go l.processMessages(messageChannel)
}
func isBotAllowlisted(userID string) bool {
@ -53,15 +43,3 @@ func isBotAllowlisted(userID string) bool {
return false
}
func awaitExit() {
syscallExit := make(chan os.Signal, 1)
signal.Notify(
syscallExit,
syscall.SIGINT,
syscall.SIGTERM,
os.Interrupt,
os.Kill,
)
<-syscallExit
}

View file

@ -1,28 +1,16 @@
package main
import (
"context"
"github.com/lampjaw/discordclient"
"k8s.io/klog"
"github.com/roleypoly/roleypoly/src/common/bot"
"github.com/roleypoly/roleypoly/src/discord-bot/internal/strings"
)
type listener struct {
client *discordclient.DiscordClient
}
func (l *listener) processMessages(messageChannel <-chan discordclient.Message) {
for {
go l.handle(<-messageChannel)
}
}
func (l *listener) handle(message discordclient.Message) {
// Only if it's a message create
if message.Type() != discordclient.MessageTypeCreate {
return
}
func handle(ctx context.Context, message discordclient.Message) {
// Only if it's an allowed bot
if message.IsBot() && !isBotAllowlisted(message.UserID()) {
return
@ -33,21 +21,17 @@ func (l *listener) handle(message discordclient.Message) {
return
}
l.defaultResponse(message)
bot.ReplyToMessage(ctx, defaultResponse(message))
}
func (l *listener) defaultResponse(message discordclient.Message) {
channel := message.Channel()
func defaultResponse(message discordclient.Message) string {
guild, err := message.ResolveGuildID()
if err != nil {
klog.Warning("failed to fetch guild, ", err)
}
l.client.SendMessage(
channel,
strings.Render(
strings.MentionResponse,
strings.MentionResponseData{GuildID: guild, AppURL: appURL},
),
return strings.Render(
strings.MentionResponse,
strings.MentionResponseData{GuildID: guild, AppURL: appURL},
)
}

41
src/midori/BUILD.bazel Normal file
View file

@ -0,0 +1,41 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
load("@io_bazel_rules_docker//go:image.bzl", "go_image")
load("@io_bazel_rules_docker//container:container.bzl", "container_push")
go_library(
name = "midori_lib",
srcs = ["midori.go"],
importpath = "github.com/roleypoly/roleypoly/src/midori",
visibility = ["//visibility:private"],
deps = [
"//src/common/bot",
"//src/common/version",
"//src/midori/internal/commands/gh",
"//src/midori/internal/commands/tfc",
"//src/midori/internal/config",
"@com_github_bwmarrin_discordgo//:discordgo",
"@com_github_joho_godotenv//autoload",
"@io_k8s_klog//:klog",
],
)
go_binary(
name = "midori",
embed = [":midori_lib"],
visibility = ["//visibility:public"],
)
go_image(
name = "image",
embed = [":midori_lib"],
visibility = ["//visibility:private"],
)
container_push(
name = "+publish",
format = "Docker",
image = ":image",
registry = "docker.pkg.github.com",
repository = "roleypoly/roleypoly/midori",
tag = "{STABLE_GIT_BRANCH}",
)

5
src/midori/README.md Normal file
View file

@ -0,0 +1,5 @@
# Midori
Midori is a GitOps/GitHub Actions/Terraform Cloud helper bot. In it's simplest form, it is a hotline into GitHub Actions and Terraform Cloud to run and act upon the actual pipelines and such.
This is **not** a public bot for very obvious reasons. You may host it yourself, but it is not a required part of a Roleypoly deployment.

View file

@ -0,0 +1,16 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "gh",
srcs = ["gh.go"],
importpath = "github.com/roleypoly/roleypoly/src/midori/internal/commands/gh",
visibility = ["//src/midori:__subpackages__"],
deps = [
"//src/common/bot",
"//src/midori/internal/commands/helpers",
"//src/midori/internal/config",
"@com_github_google_go_github_v32//github:go_default_library",
"@com_github_lampjaw_discordclient//:discordclient",
"@org_golang_x_oauth2//:oauth2",
],
)

View file

@ -0,0 +1,51 @@
package gh
import (
"context"
"encoding/json"
"strings"
"github.com/google/go-github/v32/github"
"github.com/lampjaw/discordclient"
"github.com/roleypoly/roleypoly/src/common/bot"
"github.com/roleypoly/roleypoly/src/midori/internal/commands/helpers"
"github.com/roleypoly/roleypoly/src/midori/internal/config"
"golang.org/x/oauth2"
)
type GitHubCommands struct {
ghClient *github.Client
}
func NewGitHubCommands() GitHubCommands {
ctx := context.Background()
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: config.GitHubToken},
)
tc := oauth2.NewClient(ctx, ts)
ghClient := github.NewClient(tc)
return GitHubCommands{
ghClient: ghClient,
}
}
func (ghc GitHubCommands) CommandGroup() bot.CommandGroup {
return bot.CommandGroup{
{
CommandName: "dispatch",
Handler: helpers.MustHaveElevatedPermissions(ghc.dispatch),
},
}
}
func (ghc GitHubCommands) dispatch(ctx context.Context, message discordclient.Message) {
tokens := bot.Tokenize(message)
repo, webhookName := tokens[0], tokens[1]
payload := json.RawMessage(strings.Join(tokens[2:], " "))
ghc.ghClient.Repositories.Dispatch(ctx, config.GitHubOrg, repo, github.DispatchRequestOptions{
EventType: webhookName,
ClientPayload: &payload,
})
}

View file

@ -0,0 +1,14 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "helpers",
srcs = ["permissions.go"],
importpath = "github.com/roleypoly/roleypoly/src/midori/internal/commands/helpers",
visibility = ["//src/midori:__subpackages__"],
deps = [
"//src/common",
"//src/common/bot",
"//src/midori/internal/config",
"@com_github_lampjaw_discordclient//:discordclient",
],
)

View file

@ -0,0 +1,27 @@
package helpers
import (
"context"
"github.com/lampjaw/discordclient"
"github.com/roleypoly/roleypoly/src/common"
"github.com/roleypoly/roleypoly/src/common/bot"
"github.com/roleypoly/roleypoly/src/midori/internal/config"
)
// MustHaveElevatedPermissions ensures a command has either Developer or Root role conditions.
func MustHaveElevatedPermissions(next bot.HandlerFunc) bot.HandlerFunc {
return func(ctx context.Context, message discordclient.Message) {
if common.FindString(message.UserID(), config.RootUsers) || common.FindString(message.UserID(), config.AllowlistedUsers) {
next(ctx, message)
return
}
NoPermissionsResponse(ctx, message)
}
}
// NoPermissionsResponse responds with a simple message that shows why the command failed.
func NoPermissionsResponse(ctx context.Context, message discordclient.Message) {
bot.ReplyToMessage(ctx, "⛔ You do not have elevated permissions.")
}

View file

@ -0,0 +1,13 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "tfc",
srcs = ["tfc.go"],
importpath = "github.com/roleypoly/roleypoly/src/midori/internal/commands/tfc",
visibility = ["//src/midori:__subpackages__"],
deps = [
"//src/common/bot",
"//src/midori/internal/commands/helpers",
"@com_github_lampjaw_discordclient//:discordclient",
],
)

View file

@ -0,0 +1,25 @@
package tfc
import (
"context"
"github.com/lampjaw/discordclient"
"github.com/roleypoly/roleypoly/src/common/bot"
"github.com/roleypoly/roleypoly/src/midori/internal/commands/helpers"
)
type TerraformCloudCommands struct {
}
func (tfcc TerraformCloudCommands) CommandGroup() bot.CommandGroup {
return bot.CommandGroup{
{
CommandName: "dispatch",
Handler: helpers.MustHaveElevatedPermissions(tfcc.dispatch),
},
}
}
func (tfcc TerraformCloudCommands) dispatch(ctx context.Context, message discordclient.Message) {
bot.ReplyToMessage(ctx, "Ok!")
}

View file

@ -0,0 +1,9 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "config",
srcs = ["config.go"],
importpath = "github.com/roleypoly/roleypoly/src/midori/internal/config",
visibility = ["//src/midori:__subpackages__"],
deps = ["//src/common"],
)

View file

@ -0,0 +1,15 @@
package config
import (
"github.com/roleypoly/roleypoly/src/common"
)
var (
BotToken = common.Getenv("MIDORI_BOT_TOKEN").String()
ClientID = common.Getenv("MIDORI_CLIENT_ID").String()
AllowlistedUsers = common.Getenv("MIDORI_DEVELOPERS").StringSlice()
CommandPrefix = common.Getenv("MIDORI_PREFIX_OVERRIDE", "midori").String()
RootUsers = common.Getenv("ROOT_USERS").StringSlice()
GitHubOrg = common.Getenv("MIDORI_GITHUB_ORG").String()
GitHubToken = common.Getenv("MIDORI_GITHUB_TOKEN").String()
)

33
src/midori/midori.go Normal file
View file

@ -0,0 +1,33 @@
package main
import (
"github.com/bwmarrin/discordgo"
_ "github.com/joho/godotenv/autoload"
"k8s.io/klog"
"github.com/roleypoly/roleypoly/src/common/bot"
"github.com/roleypoly/roleypoly/src/common/version"
"github.com/roleypoly/roleypoly/src/midori/internal/commands/gh"
"github.com/roleypoly/roleypoly/src/midori/internal/commands/tfc"
"github.com/roleypoly/roleypoly/src/midori/internal/config"
)
func main() {
klog.Info(version.StartupInfo("midori"))
mux := bot.NewCommandMux(bot.MentionMatcher(config.ClientID))
mux.RegisterCommandGroup("gh", gh.GitHubCommands{}.CommandGroup())
mux.RegisterCommandGroup("tfc", tfc.TerraformCloudCommands{}.CommandGroup())
err := bot.ScaffoldBot(bot.BotScaffolding{
AllowBots: false,
BotToken: config.BotToken,
BotClientID: config.ClientID,
RootUsers: config.RootUsers,
GatewayIntents: discordgo.IntentsGuildMessages,
Handler: mux.Handler,
})
if err != nil {
klog.Fatal(err)
}
}