Skip to content

Commit 4455e8b

Browse files
authored
Merge pull request #1091 from runatlantis/unlock-cmd
Unlock cmd
2 parents c70dd5f + aed8d22 commit 4455e8b

33 files changed

+618
-78
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ node_modules/
1212
helm/test-values.yaml
1313
*.swp
1414
golangci-lint
15-
atlantis
15+
atlantis

CONTRIBUTING.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ docker run --rm -v $(pwd):/go/src/github.com/runatlantis/atlantis -w /go/src/git
4343
4444
## Calling Your Local Atlantis From GitHub
4545
- Create a test terraform repository in your GitHub.
46-
- Create a personal access token for Atlantis. See [Create a GitHub token](https://github.com/runatlantis/atlantis#create-a-github-token).
46+
- Create a personal access token for Atlantis. See [Create a GitHub token](https://github.com/runatlantis/atlantis/tree/master/runatlantis.io/docs/access-credentials.md#generating-an-access-token).
4747
- Start Atlantis in server mode using that token:
4848
```
4949
atlantis server --gh-user <your username> --gh-token <your token> --repo-whitelist <your repo> --gh-webhook-secret <your webhook secret> --log-level debug
@@ -53,7 +53,7 @@ atlantis server --gh-user <your username> --gh-token <your token> --repo-whiteli
5353
```
5454
ngrok http 4141
5555
```
56-
- Create a Webhook in your repo and use the `https` url that `ngrok` printed out after running `ngrok http 4141`. Be sure to append `/events` so your webhook url looks something like `https://efce3bcd.ngrok.io/events`. See [Add GitHub Webhook](https://github.com/runatlantis/atlantis#add-github-webhook).
56+
- Create a Webhook in your repo and use the `https` url that `ngrok` printed out after running `ngrok http 4141`. Be sure to append `/events` so your webhook url looks something like `https://efce3bcd.ngrok.io/events`. See [Add GitHub Webhook](https://github.com/runatlantis/atlantis/blob/master/runatlantis.io/docs/configuring-webhooks.md#configuring-webhooks).
5757
- Create a pull request and type `atlantis help`. You should see the request in the `ngrok` and Atlantis logs and you should also see Atlantis comment back.
5858
5959
## Code Style

server/events/command_runner.go

+15
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ type DefaultCommandRunner struct {
105105
WorkingDir WorkingDir
106106
DB *db.BoltDB
107107
Drainer *Drainer
108+
DeleteLockCommand DeleteLockCommand
108109
}
109110

110111
// RunAutoplanCommand runs plan when a pull request is opened or updated.
@@ -247,6 +248,19 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead
247248
return
248249
}
249250

251+
if cmd.Name == models.UnlockCommand {
252+
vcsMessage := "All Atlantis locks for this PR have been unlocked and plans discarded"
253+
err := c.DeleteLockCommand.DeleteLocksByPull(baseRepo.FullName, pullNum)
254+
if err != nil {
255+
vcsMessage = "Failed to delete PR locks"
256+
log.Err("failed to delete locks by pull %s", err.Error())
257+
}
258+
if commentErr := c.VCSClient.CreateComment(baseRepo, pullNum, vcsMessage); commentErr != nil {
259+
log.Err("unable to comment: %s", commentErr)
260+
}
261+
return
262+
}
263+
250264
if cmd.CommandName() == models.ApplyCommand {
251265
// Get the mergeable status before we set any build statuses of our own.
252266
// We do this here because when we set a "Pending" status, if users have
@@ -302,6 +316,7 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead
302316
c.deletePlans(ctx)
303317
result.PlansDeleted = true
304318
}
319+
305320
c.updatePull(
306321
ctx,
307322
cmd,

server/events/command_runner_test.go

+40
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
. "github.com/petergtz/pegomock"
2727
"github.com/runatlantis/atlantis/server/events"
2828
"github.com/runatlantis/atlantis/server/events/mocks"
29+
eventmocks "github.com/runatlantis/atlantis/server/events/mocks"
2930
"github.com/runatlantis/atlantis/server/events/mocks/matchers"
3031
"github.com/runatlantis/atlantis/server/events/models"
3132
"github.com/runatlantis/atlantis/server/events/models/fixtures"
@@ -45,6 +46,7 @@ var pullLogger *logging.SimpleLogger
4546
var workingDir events.WorkingDir
4647
var pendingPlanFinder *mocks.MockPendingPlanFinder
4748
var drainer *events.Drainer
49+
var deleteLockCommand *mocks.MockDeleteLockCommand
4850

4951
func setup(t *testing.T) *vcsmocks.MockClient {
5052
RegisterMockTestingT(t)
@@ -60,6 +62,7 @@ func setup(t *testing.T) *vcsmocks.MockClient {
6062
workingDir = mocks.NewMockWorkingDir()
6163
pendingPlanFinder = mocks.NewMockPendingPlanFinder()
6264
drainer = &events.Drainer{}
65+
deleteLockCommand = eventmocks.NewMockDeleteLockCommand()
6366
When(logger.GetLevel()).ThenReturn(logging.Info)
6467
When(logger.NewLogger("runatlantis/atlantis#1", true, logging.Info)).
6568
ThenReturn(pullLogger)
@@ -80,6 +83,7 @@ func setup(t *testing.T) *vcsmocks.MockClient {
8083
WorkingDir: workingDir,
8184
DisableApplyAll: false,
8285
Drainer: drainer,
86+
DeleteLockCommand: deleteLockCommand,
8387
}
8488
return vcsClient
8589
}
@@ -200,6 +204,42 @@ func TestRunCommentCommand_ClosedPull(t *testing.T) {
200204
vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, modelPull.Num, "Atlantis commands can't be run on closed pull requests")
201205
}
202206

207+
func TestRunUnlockCommand_VCSComment(t *testing.T) {
208+
t.Log("if unlock PR command is run, atlantis should" +
209+
" invoke the delete command and comment on PR accordingly")
210+
211+
vcsClient := setup(t)
212+
pull := &github.PullRequest{
213+
State: github.String("open"),
214+
}
215+
modelPull := models.PullRequest{State: models.OpenPullState}
216+
When(githubGetter.GetPullRequest(fixtures.GithubRepo, fixtures.Pull.Num)).ThenReturn(pull, nil)
217+
When(eventParsing.ParseGithubPull(pull)).ThenReturn(modelPull, modelPull.BaseRepo, fixtures.GithubRepo, nil)
218+
219+
ch.RunCommentCommand(fixtures.GithubRepo, &fixtures.GithubRepo, nil, fixtures.User, fixtures.Pull.Num, &events.CommentCommand{Name: models.UnlockCommand})
220+
221+
deleteLockCommand.VerifyWasCalledOnce().DeleteLocksByPull(fixtures.GithubRepo.FullName, fixtures.Pull.Num)
222+
vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, fixtures.Pull.Num, "All Atlantis locks for this PR have been unlocked and plans discarded")
223+
}
224+
225+
func TestRunUnlockCommandFail_VCSComment(t *testing.T) {
226+
t.Log("if unlock PR command is run and delete fails, atlantis should" +
227+
" invoke comment on PR with error message")
228+
229+
vcsClient := setup(t)
230+
pull := &github.PullRequest{
231+
State: github.String("open"),
232+
}
233+
modelPull := models.PullRequest{State: models.OpenPullState}
234+
When(githubGetter.GetPullRequest(fixtures.GithubRepo, fixtures.Pull.Num)).ThenReturn(pull, nil)
235+
When(eventParsing.ParseGithubPull(pull)).ThenReturn(modelPull, modelPull.BaseRepo, fixtures.GithubRepo, nil)
236+
When(deleteLockCommand.DeleteLocksByPull(fixtures.GithubRepo.FullName, fixtures.Pull.Num)).ThenReturn(errors.New("err"))
237+
238+
ch.RunCommentCommand(fixtures.GithubRepo, &fixtures.GithubRepo, nil, fixtures.User, fixtures.Pull.Num, &events.CommentCommand{Name: models.UnlockCommand})
239+
240+
vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, fixtures.Pull.Num, "Failed to delete PR locks")
241+
}
242+
203243
// Test that if one plan fails and we are using automerge, that
204244
// we delete the plans.
205245
func TestRunAutoplanCommand_DeletePlans(t *testing.T) {

server/events/comment_parser.go

+28-7
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,8 @@ func (e *CommentParser) Parse(comment string, vcsHost models.VCSHostType) Commen
156156
return CommentParseResult{CommentResponse: HelpComment}
157157
}
158158

159-
// Need to have a plan or apply at this point.
160-
if !e.stringInSlice(command, []string{models.PlanCommand.String(), models.ApplyCommand.String()}) {
159+
// Need to have a plan, apply or unlock at this point.
160+
if !e.stringInSlice(command, []string{models.PlanCommand.String(), models.ApplyCommand.String(), models.UnlockCommand.String()}) {
161161
return CommentParseResult{CommentResponse: fmt.Sprintf("```\nError: unknown command %q.\nRun 'atlantis --help' for usage.\n```", command)}
162162
}
163163

@@ -186,6 +186,11 @@ func (e *CommentParser) Parse(comment string, vcsHost models.VCSHostType) Commen
186186
flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Apply the plan for this directory, relative to root of repo, ex. 'child/dir'.")
187187
flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", fmt.Sprintf("Apply the plan for this project. Refers to the name of the project configured in %s. Cannot be used at same time as workspace or dir flags.", yaml.AtlantisYAMLFilename))
188188
flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.")
189+
case models.UnlockCommand.String():
190+
name = models.UnlockCommand
191+
flagSet = pflag.NewFlagSet(models.UnlockCommand.String(), pflag.ContinueOnError)
192+
flagSet.SetOutput(ioutil.Discard)
193+
189194
default:
190195
return CommentParseResult{CommentResponse: fmt.Sprintf("Error: unknown command %q – this is a bug", command)}
191196
}
@@ -197,6 +202,9 @@ func (e *CommentParser) Parse(comment string, vcsHost models.VCSHostType) Commen
197202
return CommentParseResult{CommentResponse: fmt.Sprintf("```\nUsage of %s:\n%s\n```", command, flagSet.FlagUsagesWrapped(usagesCols))}
198203
}
199204
if err != nil {
205+
if command == models.UnlockCommand.String() {
206+
return CommentParseResult{CommentResponse: UnlockUsage}
207+
}
200208
return CommentParseResult{CommentResponse: e.errMarkdown(err.Error(), command, flagSet)}
201209
}
202210

@@ -342,11 +350,13 @@ Examples:
342350
atlantis apply -d . -w staging
343351
344352
Commands:
345-
plan Runs 'terraform plan' for the changes in this pull request.
346-
To plan a specific project, use the -d, -w and -p flags.
347-
apply Runs 'terraform apply' on all unapplied plans from this pull request.
348-
To only apply a specific plan, use the -d, -w and -p flags.
349-
help View help.
353+
plan Runs 'terraform plan' for the changes in this pull request.
354+
To plan a specific project, use the -d, -w and -p flags.
355+
apply Runs 'terraform apply' on all unapplied plans from this pull request.
356+
To only apply a specific plan, use the -d, -w and -p flags.
357+
unlock Removes all atlantis locks and discards all plans for this PR.
358+
To unlock a specific plan you can use the Atlantis UI.
359+
help View help.
350360
351361
Flags:
352362
-h, --help help for atlantis
@@ -357,3 +367,14 @@ Use "atlantis [command] --help" for more information about a command.` +
357367
// DidYouMeanAtlantisComment is the comment we add to the pull request when
358368
// someone runs a command with terraform instead of atlantis.
359369
var DidYouMeanAtlantisComment = "Did you mean to use `atlantis` instead of `terraform`?"
370+
371+
// UnlockUsage is the comment we add to the pull request when someone runs
372+
// `atlantis unlock` with flags.
373+
374+
var UnlockUsage = "`Usage of unlock:`\n\n ```cmake\n" +
375+
`atlantis unlock
376+
377+
Unlocks the entire PR and discards all plans in this PR.
378+
Arguments or flags are not supported at the moment.
379+
If you need to unlock a specific project please use the atlantis UI.` +
380+
"\n```"

server/events/comment_parser_test.go

+14
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ func TestParse_UnusedArguments(t *testing.T) {
126126
}
127127
}
128128

129+
func TestParse_UnknownShorthandFlag(t *testing.T) {
130+
comment := "atlantis unlock -d ."
131+
r := commentParser.Parse(comment, models.Github)
132+
133+
Equals(t, UnlockUsage, r.CommentResponse)
134+
}
135+
129136
func TestParse_DidYouMeanAtlantis(t *testing.T) {
130137
t.Log("given a comment that should result in a 'did you mean atlantis'" +
131138
"response, should set CommentParseResult.CommentResult")
@@ -693,3 +700,10 @@ var ApplyUsage = `Usage of apply:
693700
--verbose Append Atlantis log to comment.
694701
-w, --workspace string Apply the plan for this Terraform workspace.
695702
`
703+
var UnlockUsage = "`Usage of unlock:`\n\n ```cmake\n" +
704+
`atlantis unlock
705+
706+
Unlocks the entire PR and discards all plans in this PR.
707+
Arguments or flags are not supported at the moment.
708+
If you need to unlock a specific project please use the atlantis UI.` +
709+
"\n```"

server/events/delete_lock_command.go

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package events
2+
3+
import (
4+
"github.com/runatlantis/atlantis/server/events/db"
5+
"github.com/runatlantis/atlantis/server/events/locking"
6+
"github.com/runatlantis/atlantis/server/events/models"
7+
"github.com/runatlantis/atlantis/server/logging"
8+
)
9+
10+
//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_delete_lock_command.go DeleteLockCommand
11+
12+
// DeleteLockCommand is the first step after a command request has been parsed.
13+
type DeleteLockCommand interface {
14+
DeleteLock(id string) (*models.ProjectLock, error)
15+
DeleteLocksByPull(repoFullName string, pullNum int) error
16+
}
17+
18+
// DefaultDeleteLockCommand deletes a specific lock after a request from the LocksController.
19+
type DefaultDeleteLockCommand struct {
20+
Locker locking.Locker
21+
Logger *logging.SimpleLogger
22+
WorkingDir WorkingDir
23+
WorkingDirLocker WorkingDirLocker
24+
DB *db.BoltDB
25+
}
26+
27+
// DeleteLock handles deleting the lock at id
28+
func (l *DefaultDeleteLockCommand) DeleteLock(id string) (*models.ProjectLock, error) {
29+
lock, err := l.Locker.Unlock(id)
30+
if err != nil {
31+
return nil, err
32+
}
33+
if lock == nil {
34+
return nil, nil
35+
}
36+
37+
l.deleteWorkingDir(*lock)
38+
return lock, nil
39+
}
40+
41+
// DeleteLocksByPull handles deleting all locks for the pull request
42+
func (l *DefaultDeleteLockCommand) DeleteLocksByPull(repoFullName string, pullNum int) error {
43+
locks, err := l.Locker.UnlockByPull(repoFullName, pullNum)
44+
if err != nil {
45+
return err
46+
}
47+
if len(locks) == 0 {
48+
return nil
49+
}
50+
51+
for i := 0; i < len(locks); i++ {
52+
lock := locks[i]
53+
l.deleteWorkingDir(lock)
54+
}
55+
56+
return nil
57+
}
58+
59+
func (l *DefaultDeleteLockCommand) deleteWorkingDir(lock models.ProjectLock) {
60+
// NOTE: Because BaseRepo was added to the PullRequest model later, previous
61+
// installations of Atlantis will have locks in their DB that do not have
62+
// this field on PullRequest. We skip deleting the working dir in this case.
63+
if lock.Pull.BaseRepo == (models.Repo{}) {
64+
return
65+
}
66+
unlock, err := l.WorkingDirLocker.TryLock(lock.Pull.BaseRepo.FullName, lock.Pull.Num, lock.Workspace)
67+
if err != nil {
68+
l.Logger.Err("unable to obtain working dir lock when trying to delete old plans: %s", err)
69+
} else {
70+
defer unlock()
71+
// nolint: vetshadow
72+
if err := l.WorkingDir.DeleteForWorkspace(lock.Pull.BaseRepo, lock.Pull, lock.Workspace); err != nil {
73+
l.Logger.Err("unable to delete workspace: %s", err)
74+
}
75+
}
76+
if err := l.DB.DeleteProjectStatus(lock.Pull, lock.Workspace, lock.Project.Path); err != nil {
77+
l.Logger.Err("unable to delete project status: %s", err)
78+
}
79+
}

0 commit comments

Comments
 (0)