From 0acf7b326811f82d6a454766d97b5fc328d3c3df Mon Sep 17 00:00:00 2001 From: Yusuke Kuoka Date: Wed, 11 Sep 2024 01:16:19 +0000 Subject: [PATCH] Enhance `@bot deploy` to respect locks --- deploy/lock.go | 18 +++++++++++++++--- deploy/lock_test.go | 4 ++-- project.go | 3 +++ slack.go | 39 +++++++++++++++++++++++++++++++++++++++ slack_test.go | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 5 deletions(-) diff --git a/deploy/lock.go b/deploy/lock.go index 3b65977..58f7624 100644 --- a/deploy/lock.go +++ b/deploy/lock.go @@ -209,7 +209,7 @@ type ProjectDesc struct { } func (c *Coordinator) DescribeLocks(ctx context.Context) ([]ProjectDesc, error) { - locks, err := c.describeLocks(ctx) + locks, err := c.FetchLocks(ctx, "", "") if err != nil { return nil, err } @@ -254,8 +254,11 @@ func (c *Coordinator) DescribeLocks(ctx context.Context) ([]ProjectDesc, error) return projects, nil } -// describeLocks returns a map of project names to a map of environment names to the lock information. -func (c *Coordinator) describeLocks(ctx context.Context) (map[string]map[string]Phase, error) { +// FetchLocks returns a map of project names to a map of environment names to the lock information. +// +// Filter can be used to filter the results by `project/phase`. +// If you specify `myproject/production`, only the lock information for the `production` phase of the `myproject` project is returned. +func (c *Coordinator) FetchLocks(ctx context.Context, projectFilter, phaseFilter string) (map[string]map[string]Phase, error) { configMap, err := c.getOrCreateConfigMap(ctx) if err != nil { return nil, err @@ -269,6 +272,15 @@ func (c *Coordinator) describeLocks(ctx context.Context) (map[string]map[string] } project, environment := splitConfigMapKey(k) + + if projectFilter != "" && project != projectFilter { + continue + } + + if phaseFilter != "" && environment != phaseFilter { + continue + } + if locks[project] == nil { locks[project] = make(map[string]Phase) } diff --git a/deploy/lock_test.go b/deploy/lock_test.go index f7fa366..1142f57 100644 --- a/deploy/lock_test.go +++ b/deploy/lock_test.go @@ -94,7 +94,7 @@ func TestDescribeLocks(t *testing.T) { require.NoError(t, c.Lock(ctx, "myproject1", "prod", "user1", "for deployment of revision a")) - locks, err := c.describeLocks(ctx) + locks, err := c.FetchLocks(ctx, "", "") require.NoError(t, err) require.Len(t, locks, 1) @@ -114,7 +114,7 @@ func TestDescribeLocks(t *testing.T) { require.NoError(t, c.Unlock(ctx, "myproject1", "prod", "user1", false)) - locks, err = c.describeLocks(ctx) + locks, err = c.FetchLocks(ctx, "", "") require.NoError(t, err) require.Len(t, locks, 1) require.Equal(t, map[string]Phase{ diff --git a/project.go b/project.go index 92de781..27757dd 100644 --- a/project.go +++ b/project.go @@ -152,6 +152,9 @@ func (p *ProjectList) Reload() { pj.filterRegexp = cm.Data["FilterRegexp"] pj.targetRegexp = cm.Data["TargetRegexp"] pj.funcName = cm.Data["FuncName"] + // Note that, although this is named Alias, it is actually treated as a + // mandatory ID of the project, which is used to identify the project. + // to be deployed in some places. pj.Alias = cm.Data["Alias"] pj.DisableBranchDeploy = cm.Data["DisableBranchDeploy"] == "true" if err := yaml.Unmarshal([]byte(cm.Data["Steps"]), &pj.steps); err != nil { diff --git a/slack.go b/slack.go index 65fbf03..e65db7e 100644 --- a/slack.go +++ b/slack.go @@ -150,6 +150,14 @@ func (s *SlackListener) handleMessageEvent(ev *slackevents.AppMentionEvent) erro } phase := s.toPhase(commands[2]) + + if msg, locked := s.checkDeploymentLock(target.ID, phase, ev.User, ev.Channel); locked { + if _, _, err := s.client.PostMessage(ev.Channel, msg); err != nil { + log.Println("[ERROR] ", err) + } + return nil + } + interactor := s.interactorFactory.Get(target, phase) blocks, err := interactor.Request(target, phase, target.DefaultBranch(), ev.User, ev.Channel) if err != nil { @@ -346,6 +354,37 @@ func (s *SlackListener) describeLocks() slack.MsgOption { return s.infoMessage(buf.String()) } +func (s *SlackListener) checkDeploymentLock(projectID, env string, triggeredBy string, replyIn string) (slack.MsgOption, bool) { + locks, err := s.getOrCreateCoordinator().FetchLocks(context.Background(), projectID, env) + if err != nil { + log.Println("[ERROR] ", err) + if _, _, err := s.client.PostMessage(replyIn, s.infoMessage(err.Error())); err != nil { + log.Println("[ERROR] ", err) + } + return nil, false + } + + if len(locks) == 0 { + // Missing lock means it has never been locked. + return nil, false + } + + lock, ok := locks[projectID][env] + if !ok { + // Missing lock means it has never been locked. + return nil, false + } + + user := s.userList.FindBySlackUserID(triggeredBy) + + if lock.Locked && lock.LockHistory[len(lock.LockHistory)-1].User != user.SlackDisplayName { + cause := fmt.Sprintf("locked by %s", lock.LockHistory[len(lock.LockHistory)-1].User) + return s.infoMessage(fmt.Sprintf("Deployment failed: %s", cause)), true + } + + return nil, false +} + func (s *SlackListener) validateProjectEnvUser(projectID, env string, user User, replyIn string) error { pj, err := s.projectList.FindByAlias(projectID) if err != nil { diff --git a/slack_test.go b/slack_test.go index 1188979..1b57ebe 100644 --- a/slack_test.go +++ b/slack_test.go @@ -32,6 +32,7 @@ var project1ConfigMap = corev1.ConfigMap{ }, }, Data: map[string]string{ + "Alias": "myproject1", "Phases": `- name: production - name: staging `, @@ -46,6 +47,7 @@ var project2ConfigMap = corev1.ConfigMap{ }, }, Data: map[string]string{ + "Alias": "myproject2", "Phases": `- name: production - name: staging `, @@ -380,6 +382,38 @@ myproject2 require.Equal(t, `myproject1 staging: Locked (by user2, for deployment of revision b) `, nextMessage().Text()) + + // Deployment to myproject1/staging by user1 should fail because it is locked by user2 + require.NoError(t, l.handleMessageEvent(&slackevents.AppMentionEvent{ + User: "U1234", + Channel: "C1234", + Text: "deploy myproject1 staging", + })) + require.Equal(t, "Deployment failed: locked by user2", nextMessage().Text()) + + // Deployment to myproject1/staging by user2 should succeed because it is locked by user2 + require.NoError(t, l.handleMessageEvent(&slackevents.AppMentionEvent{ + User: "U1235", + Channel: "C1234", + Text: "deploy myproject1 staging", + })) + require.Equal(t, "**\n*staging*\n*master* ブランチ\nをデプロイしますか?", nextMessage().Text()) + + // Deployment to myproject1/production by user1 should fail because it is not locked + require.NoError(t, l.handleMessageEvent(&slackevents.AppMentionEvent{ + User: "U1234", + Channel: "C1234", + Text: "deploy myproject1 production", + })) + require.Equal(t, "**\n*production*\n*master* ブランチ\nをデプロイしますか?", nextMessage().Text()) + + // Deployment to myproject2/staging by user1 should succeed because it is not locked + require.NoError(t, l.handleMessageEvent(&slackevents.AppMentionEvent{ + User: "U1234", + Channel: "C1234", + Text: "deploy myproject2 staging", + })) + require.Equal(t, "**\n*staging*\n*master* ブランチ\nをデプロイしますか?", nextMessage().Text()) } // Message is a message posted to the fake Slack API's chat.postMessage endpoint