Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add lock and unlock gocat Slack commands #1124

Merged
merged 12 commits into from
Aug 14, 2024
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create kind cluster
uses: helm/kind-action@v1
- name: Set up Go
uses: actions/setup-go@v4
with:
Expand Down
4 changes: 3 additions & 1 deletion bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log"
"net/http"
"os"
"sync"

"github.com/slack-go/slack"
)
Expand All @@ -19,7 +20,7 @@ func main() {
config.SlackOAuthToken,
slack.OptionLog(log.New(os.Stdout, "slack-bot: ", log.Lshortfile|log.LstdFlags)),
)
github := CreateGitHubInstance(config.GitHubAccessToken, config.ManifestRepositoryOrg, config.ManifestRepositoryName, config.GitHubDefaultBranch)
github := CreateGitHubInstance("", config.GitHubAccessToken, config.ManifestRepositoryOrg, config.ManifestRepositoryName, config.GitHubDefaultBranch)
git := CreateGitOperatorInstance(
config.GitHubUserName,
config.GitHubAccessToken,
Expand All @@ -44,6 +45,7 @@ func main() {
projectList: &projectList,
userList: &userList,
interactorFactory: &interactorFactory,
mu: &sync.Mutex{},
})
http.Handle("/interaction", interactionHandler{
verificationToken: config.SlackVerificationToken,
Expand Down
6 changes: 3 additions & 3 deletions deploy/configmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func (c *Coordinator) getOrCreateConfigMap(ctx context.Context) (*corev1.ConfigM

// getConfigMap creates a Kubernetes API client, and use it to retrieve the ConfigMap.
func (c *Coordinator) getConfigMap(ctx context.Context) (*corev1.ConfigMap, error) {
clientset, err := c.kubernetesClientSet()
clientset, err := c.ClientSet()
if err != nil {
return nil, err
}
Expand All @@ -90,7 +90,7 @@ func (c *Coordinator) getConfigMap(ctx context.Context) (*corev1.ConfigMap, erro
}

func (c *Coordinator) createConfigMap(ctx context.Context) (*corev1.ConfigMap, error) {
clientset, err := c.kubernetesClientSet()
clientset, err := c.ClientSet()
if err != nil {
return nil, err
}
Expand All @@ -114,7 +114,7 @@ func (c *Coordinator) createConfigMap(ctx context.Context) (*corev1.ConfigMap, e
}

func (c *Coordinator) updateConfigMap(ctx context.Context, configMap *corev1.ConfigMap) (*corev1.ConfigMap, error) {
clientset, err := c.kubernetesClientSet()
clientset, err := c.ClientSet()
if err != nil {
return nil, err
}
Expand Down
32 changes: 30 additions & 2 deletions deploy/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,38 @@ import (
"k8s.io/client-go/tools/clientcmd"
)

// Kubernetes is a helper struct that provides a way to create and cache a Kubernetes API client.
// The client is created using the KUBECONFIG file if exists, or the in-cluster configuration.
// The client is cached to avoid creating multiple clients.
//
// Usage:
//
// k := Kubernetes{}
// clientset, err := k.ClientSet()
// if err != nil {
// return err
// }
// // Use the clientset
//
// // Or you can embed Kubernetes in your struct:
// type MyStruct struct {
// Kubernetes
// }
// func (m *MyStruct) MyMethod() error {
// clientset, err := m.ClientSet()
// if err != nil {
// return err
// }
// // Use the clientset
// }
type Kubernetes struct {
clientset clientset.Interface
}

// kubeconfigPath returns the path to the KUBECONFIG file,
// which is either specified by the KUBECONFIG environment variable,
// or the default path ~/.kube/config.
func (c *Coordinator) kubeconfigPath() string {
func (c *Kubernetes) kubeconfigPath() string {
kubeconfig := clientcmd.NewDefaultClientConfigLoadingRules().GetDefaultFilename()
if path := os.Getenv("KUBECONFIG"); path != "" {
kubeconfig = path
Expand All @@ -22,7 +50,7 @@ func (c *Coordinator) kubeconfigPath() string {

// kubernetesClientSet creates a Kubernetes API client
// that uses either the KUBECONFIG file if exists, or the in-cluster configuration.
func (c *Coordinator) kubernetesClientSet() (clientset.Interface, error) {
func (c *Kubernetes) ClientSet() (clientset.Interface, error) {
if c.clientset != nil {
return c.clientset, nil
}
Expand Down
3 changes: 1 addition & 2 deletions deploy/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (

kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientset "k8s.io/client-go/kubernetes"
)

// Coordinator provides a way to lock and unlock deployments made via gocat.
Expand All @@ -29,7 +28,7 @@ type Coordinator struct {
// ConfigMapName is the name of the ConfigMap.
ConfigMapName string

clientset clientset.Interface
Kubernetes
}

func NewCoordinator(ns, configMap string) *Coordinator {
Expand Down
9 changes: 9 additions & 0 deletions deploy/lock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"os"
"testing"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/stretchr/testify/require"
)

Expand All @@ -17,6 +19,13 @@ func TestLockUnlock(t *testing.T) {
}

c := NewCoordinator("default", "gocat-test")
defer func() {
if c, _ := c.ClientSet(); c != nil {
if err := c.CoreV1().ConfigMaps("default").Delete(context.Background(), "gocat-test", metav1.DeleteOptions{}); err != nil {
t.Log(err)
}
}
}()

prevKubeconfig := os.Getenv("KUBECONFIG")
os.Setenv("KUBECONFIG", kubeconfigPath)
Expand Down
9 changes: 7 additions & 2 deletions github.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,18 @@ type GitHubInput struct {
Branch string
}

func CreateGitHubInstance(token, org, repo, defaultBranch string) GitHub {
func CreateGitHubInstance(url, token, org, repo, defaultBranch string) GitHub {
src := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
)
httpClient := oauth2.NewClient(context.Background(), src)

client := githubv4.NewClient(httpClient)
var client *githubv4.Client
if url != "" {
client = githubv4.NewEnterpriseClient(url, httpClient)
} else {
client = githubv4.NewClient(httpClient)
}
return GitHub{*client, httpClient, org, repo, defaultBranch}
}

Expand Down
4 changes: 4 additions & 0 deletions project.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ type DeployPhase struct {
Destination Destination `yaml:"destination"`
}

func (p DeployPhase) None() bool {
return p.Name == ""
}

type DeployProject struct {
// ID is the name of the configmap that defines the project.
ID string
Expand Down
105 changes: 103 additions & 2 deletions slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@ package main

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"sync"

"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/zaiminc/gocat/deploy"
"github.com/zaiminc/gocat/slackcmd"
)

Expand All @@ -23,6 +27,9 @@ type SlackListener struct {
projectList *ProjectList
userList *UserList
interactorFactory *InteractorFactory

coordinator *deploy.Coordinator
mu *sync.Mutex
}

func (s SlackListener) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -182,8 +189,7 @@ func (s *SlackListener) handleMessageEvent(ev *slackevents.AppMentionEvent) erro
}
if cmd, _ := slackcmd.Parse(ev.Text); cmd != nil {
log.Printf("[INFO] %s command is Called", cmd.Name())
// TODO run the command and post the result to slack
return nil
return s.runCommand(cmd, ev.User, ev.Channel)
}
return nil
}
Expand Down Expand Up @@ -247,6 +253,101 @@ func createDeployButtonSection(pj DeployProject, phaseName string) *slack.Sectio
return section
}

// runCommand runs the given command.
//
// triggeredBy is the ID of the Slack user who triggered the command,
// and replyIn is the ID of the Slack channel to reply to.
func (s *SlackListener) runCommand(cmd slackcmd.Command, triggeredBy string, replyIn string) error {
var msgOpt slack.MsgOption

switch cmd := cmd.(type) {
case *slackcmd.Lock:
msgOpt = s.lock(cmd, triggeredBy, replyIn)
case *slackcmd.Unlock:
msgOpt = s.unlock(cmd, triggeredBy, replyIn, false)
default:
panic("unreachable")
}

if _, _, err := s.client.PostMessage(replyIn, msgOpt); err != nil {
log.Println("[ERROR] ", err)
}

return nil
}

// lock locks the given project and environment, and replies to the given channel.
func (s *SlackListener) lock(cmd *slackcmd.Lock, triggeredBy string, replyIn string) slack.MsgOption {
if err := s.validateProjectEnvUser(cmd.Project, cmd.Env, triggeredBy, replyIn); err != nil {
return s.errorMessage(err.Error())
}

if err := s.getOrCreateCoordinator().Lock(context.Background(), cmd.Project, cmd.Env, triggeredBy, cmd.Reason); err != nil {
return s.errorMessage(err.Error())
}

return s.infoMessage(fmt.Sprintf("Locked %s %s", cmd.Project, cmd.Env))
}

// unlock unlocks the given project and environment, and replies to the given channel.
func (s *SlackListener) unlock(cmd *slackcmd.Unlock, triggeredBy string, replyIn string, force bool) slack.MsgOption {
if err := s.validateProjectEnvUser(cmd.Project, cmd.Env, triggeredBy, replyIn); err != nil {
return s.errorMessage(err.Error())
}

if err := s.getOrCreateCoordinator().Unlock(context.Background(), cmd.Project, cmd.Env, triggeredBy, force); err != nil {
return s.errorMessage(err.Error())
}

return s.infoMessage(fmt.Sprintf("Unlocked %s %s", cmd.Project, cmd.Env))
}

func (s *SlackListener) validateProjectEnvUser(projectID, env, userID, replyIn string) error {
pj, err := s.projectList.FindByAlias(projectID)
if err != nil {
log.Println("[ERROR] ", err)
if _, _, err := s.client.PostMessage(replyIn, s.errorMessage(err.Error())); err != nil {
log.Println("[ERROR] ", err)
}
return fmt.Errorf("find by alias %q: %w", projectID, err)
}

if phase := pj.FindPhase(env); phase.None() {
err = fmt.Errorf("phase %s is not found", env)
log.Println("[ERROR] ", err)
if _, _, err := s.client.PostMessage(replyIn, s.errorMessage(err.Error())); err != nil {
log.Println("[ERROR] ", err)
}
return fmt.Errorf("find phase %q: %w", env, err)
}

if user := s.userList.FindBySlackUserID(userID); !user.IsDeveloper() {
err = errors.New("you are not allowed to lock this project")
log.Println("[ERROR] ", err)
if _, _, err := s.client.PostMessage(replyIn, s.errorMessage(err.Error())); err != nil {
log.Println("[ERROR] ", err)
}
return fmt.Errorf("find by slack user id %q: %w", userID, err)
}

return nil
}

func (s *SlackListener) getOrCreateCoordinator() *deploy.Coordinator {
s.mu.Lock()
defer s.mu.Unlock()
if s.coordinator == nil {
s.coordinator = deploy.NewCoordinator("gocat", "deploylocks")
}
return s.coordinator
}

func (s *SlackListener) infoMessage(message string) slack.MsgOption {
txt := slack.NewTextBlockObject("mrkdwn", message, false, false)
section := slack.NewSectionBlock(txt, nil, nil)
return slack.MsgOptionBlocks(section)
}

func (s *SlackListener) errorMessage(message string) slack.MsgOption {
txt := slack.NewTextBlockObject("mrkdwn", message, false, false)
section := slack.NewSectionBlock(txt, nil, nil)
Expand Down
Loading
Loading