Skip to content

Commit

Permalink
Merge pull request #1124 from mumoshu/lock-unlock
Browse files Browse the repository at this point in the history
Add lock and unlock gocat Slack commands
  • Loading branch information
pirlodog1125 authored Aug 14, 2024
2 parents 9aafe81 + eb7f860 commit 85055a8
Show file tree
Hide file tree
Showing 10 changed files with 474 additions and 12 deletions.
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

0 comments on commit 85055a8

Please sign in to comment.