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

Atlantis unlock and discard all plans via VCS comment #1003

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ node_modules/
helm/test-values.yaml
*.swp
golangci-lint
atlantis
atlantis
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ docker run --rm -v $(pwd):/go/src/github.com/runatlantis/atlantis -w /go/src/git

## Calling Your Local Atlantis From GitHub
- Create a test terraform repository in your GitHub.
- Create a personal access token for Atlantis. See [Create a GitHub token](https://github.com/runatlantis/atlantis#create-a-github-token).
- 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).
lkysow marked this conversation as resolved.
Show resolved Hide resolved
- Start Atlantis in server mode using that token:
```
atlantis server --gh-user <your username> --gh-token <your token> --repo-whitelist <your repo> --gh-webhook-secret <your webhook secret> --log-level debug
Expand All @@ -53,7 +53,7 @@ atlantis server --gh-user <your username> --gh-token <your token> --repo-whiteli
```
ngrok http 4141
```
- 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).
- 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).
- 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.

## Code Style
Expand Down
15 changes: 15 additions & 0 deletions server/events/command_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ type DefaultCommandRunner struct {
WorkingDir WorkingDir
DB *db.BoltDB
Drainer *Drainer
DeleteLockCommand DeleteLockCommand
}

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

if cmd.Name == models.UnlockCommand {
vcsMessage := "`All Atlantis locks for this PR have been released - and plans discarded`"
_, err := c.DeleteLockCommand.DeleteLocksByPull(baseRepo.FullName, pullNum)
if err != nil {
vcsMessage = "Failed to delete PR locks"
log.Err("failed to delete locks by pull %s", err.Error())
}
if commentErr := c.VCSClient.CreateComment(baseRepo, pullNum, vcsMessage); commentErr != nil {
log.Err("unable to comment: %s", commentErr)
}
return
}

if cmd.CommandName() == models.ApplyCommand {
// Get the mergeable status before we set any build statuses of our own.
// We do this here because when we set a "Pending" status, if users have
Expand Down Expand Up @@ -302,6 +316,7 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead
c.deletePlans(ctx)
result.PlansDeleted = true
}

c.updatePull(
ctx,
cmd,
Expand Down
40 changes: 40 additions & 0 deletions server/events/command_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
. "github.com/petergtz/pegomock"
"github.com/runatlantis/atlantis/server/events"
"github.com/runatlantis/atlantis/server/events/mocks"
eventmocks "github.com/runatlantis/atlantis/server/events/mocks"
"github.com/runatlantis/atlantis/server/events/mocks/matchers"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/models/fixtures"
Expand All @@ -45,6 +46,7 @@ var pullLogger *logging.SimpleLogger
var workingDir events.WorkingDir
var pendingPlanFinder *mocks.MockPendingPlanFinder
var drainer *events.Drainer
var deleteLockCommand *mocks.MockDeleteLockCommand

func setup(t *testing.T) *vcsmocks.MockClient {
RegisterMockTestingT(t)
Expand All @@ -60,6 +62,7 @@ func setup(t *testing.T) *vcsmocks.MockClient {
workingDir = mocks.NewMockWorkingDir()
pendingPlanFinder = mocks.NewMockPendingPlanFinder()
drainer = &events.Drainer{}
deleteLockCommand = eventmocks.NewMockDeleteLockCommand()
When(logger.GetLevel()).ThenReturn(logging.Info)
When(logger.NewLogger("runatlantis/atlantis#1", true, logging.Info)).
ThenReturn(pullLogger)
Expand All @@ -80,6 +83,7 @@ func setup(t *testing.T) *vcsmocks.MockClient {
WorkingDir: workingDir,
DisableApplyAll: false,
Drainer: drainer,
DeleteLockCommand: deleteLockCommand,
}
return vcsClient
}
Expand Down Expand Up @@ -200,6 +204,42 @@ func TestRunCommentCommand_ClosedPull(t *testing.T) {
vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, modelPull.Num, "Atlantis commands can't be run on closed pull requests")
}

func TestRunUnlockCommand_VCSComment(t *testing.T) {
t.Log("if unlock PR command is run, atlantis should" +
" invoke the delete command and comment on PR accordingly")

vcsClient := setup(t)
pull := &github.PullRequest{
State: github.String("open"),
}
modelPull := models.PullRequest{State: models.OpenPullState}
When(githubGetter.GetPullRequest(fixtures.GithubRepo, fixtures.Pull.Num)).ThenReturn(pull, nil)
When(eventParsing.ParseGithubPull(pull)).ThenReturn(modelPull, modelPull.BaseRepo, fixtures.GithubRepo, nil)

ch.RunCommentCommand(fixtures.GithubRepo, &fixtures.GithubRepo, nil, fixtures.User, fixtures.Pull.Num, &events.CommentCommand{Name: models.UnlockCommand})

deleteLockCommand.VerifyWasCalledOnce().DeleteLocksByPull(fixtures.GithubRepo.FullName, fixtures.Pull.Num)
vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, fixtures.Pull.Num, "`All Atlantis locks for this PR have been released - and plans discarded`")
}

func TestRunUnlockCommandFail_VCSComment(t *testing.T) {
t.Log("if unlock PR command is run and delete fails, atlantis should" +
" invoke comment on PR with error message")

vcsClient := setup(t)
pull := &github.PullRequest{
State: github.String("open"),
}
modelPull := models.PullRequest{State: models.OpenPullState}
When(githubGetter.GetPullRequest(fixtures.GithubRepo, fixtures.Pull.Num)).ThenReturn(pull, nil)
When(eventParsing.ParseGithubPull(pull)).ThenReturn(modelPull, modelPull.BaseRepo, fixtures.GithubRepo, nil)
When(deleteLockCommand.DeleteLocksByPull(fixtures.GithubRepo.FullName, fixtures.Pull.Num)).ThenReturn(models.DeleteLockFail, errors.New("err"))

ch.RunCommentCommand(fixtures.GithubRepo, &fixtures.GithubRepo, nil, fixtures.User, fixtures.Pull.Num, &events.CommentCommand{Name: models.UnlockCommand})

vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, fixtures.Pull.Num, "Failed to delete PR locks")
}

// Test that if one plan fails and we are using automerge, that
// we delete the plans.
func TestRunAutoplanCommand_DeletePlans(t *testing.T) {
Expand Down
35 changes: 28 additions & 7 deletions server/events/comment_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ func (e *CommentParser) Parse(comment string, vcsHost models.VCSHostType) Commen
return CommentParseResult{CommentResponse: HelpComment}
}

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

Expand Down Expand Up @@ -186,6 +186,11 @@ func (e *CommentParser) Parse(comment string, vcsHost models.VCSHostType) Commen
flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Apply the plan for this directory, relative to root of repo, ex. 'child/dir'.")
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))
flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.")
case models.UnlockCommand.String():
name = models.UnlockCommand
flagSet = pflag.NewFlagSet(models.UnlockCommand.String(), pflag.ContinueOnError)
flagSet.SetOutput(ioutil.Discard)

default:
return CommentParseResult{CommentResponse: fmt.Sprintf("Error: unknown command %q – this is a bug", command)}
}
Expand All @@ -197,6 +202,9 @@ func (e *CommentParser) Parse(comment string, vcsHost models.VCSHostType) Commen
return CommentParseResult{CommentResponse: fmt.Sprintf("```\nUsage of %s:\n%s\n```", command, flagSet.FlagUsagesWrapped(usagesCols))}
}
if err != nil {
if command == models.UnlockCommand.String() {
return CommentParseResult{CommentResponse: UnlockUsage}
}
return CommentParseResult{CommentResponse: e.errMarkdown(err.Error(), command, flagSet)}
}

Expand Down Expand Up @@ -342,11 +350,13 @@ Examples:
atlantis apply -d . -w staging

Commands:
plan Runs 'terraform plan' for the changes in this pull request.
To plan a specific project, use the -d, -w and -p flags.
apply Runs 'terraform apply' on all unapplied plans from this pull request.
To only apply a specific plan, use the -d, -w and -p flags.
help View help.
plan Runs 'terraform plan' for the changes in this pull request.
To plan a specific project, use the -d, -w and -p flags.
apply Runs 'terraform apply' on all unapplied plans from this pull request.
To only apply a specific plan, use the -d, -w and -p flags.
unlock Removes all atlantis locks and discards all plans for this PR.
To unlock a specific plan you can use the Atlantis UI.
help View help.

Flags:
-h, --help help for atlantis
Expand All @@ -357,3 +367,14 @@ Use "atlantis [command] --help" for more information about a command.` +
// DidYouMeanAtlantisComment is the comment we add to the pull request when
// someone runs a command with terraform instead of atlantis.
var DidYouMeanAtlantisComment = "Did you mean to use `atlantis` instead of `terraform`?"

// UnlockUsage is the comment we add to the pull request when someone runs
// `atlantis unlock` with flags.

var UnlockUsage = "`Usage of unlock:`\n\n ```cmake\n" +
`atlantis unlock

Unlocks the entire PR and discards all plans in this PR.
Arguments or flags are not supported at the moment.
If you need to unlock a specific project please use the atlantis UI.` +
"\n```"
14 changes: 14 additions & 0 deletions server/events/comment_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ func TestParse_UnusedArguments(t *testing.T) {
}
}

func TestParse_UnknownShorthandFlag(t *testing.T) {
comment := "atlantis unlock -d ."
r := commentParser.Parse(comment, models.Github)

Equals(t, UnlockUsage, r.CommentResponse)
}

func TestParse_DidYouMeanAtlantis(t *testing.T) {
t.Log("given a comment that should result in a 'did you mean atlantis'" +
"response, should set CommentParseResult.CommentResult")
Expand Down Expand Up @@ -693,3 +700,10 @@ var ApplyUsage = `Usage of apply:
--verbose Append Atlantis log to comment.
-w, --workspace string Apply the plan for this Terraform workspace.
`
var UnlockUsage = "`Usage of unlock:`\n\n ```cmake\n" +
`atlantis unlock

Unlocks the entire PR and discards all plans in this PR.
Arguments or flags are not supported at the moment.
If you need to unlock a specific project please use the atlantis UI.` +
"\n```"
79 changes: 79 additions & 0 deletions server/events/delete_lock_command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package events

import (
"github.com/runatlantis/atlantis/server/events/db"
"github.com/runatlantis/atlantis/server/events/locking"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/logging"
)

//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_delete_lock_command.go DeleteLockCommand

// DeleteLockCommand is the first step after a command request has been parsed.
type DeleteLockCommand interface {
DeleteLock(id string) (models.DeleteLockCommandResult, *models.ProjectLock, error)
DeleteLocksByPull(repoFullName string, pullNum int) (models.DeleteLockCommandResult, error)
}

// DefaultDeleteLockCommand deletes a specific lock after a request from the LocksController.
type DefaultDeleteLockCommand struct {
Locker locking.Locker
Logger *logging.SimpleLogger
WorkingDir WorkingDir
WorkingDirLocker WorkingDirLocker
DB *db.BoltDB
}

// DeleteLock handles deleting the lock at id
func (l *DefaultDeleteLockCommand) DeleteLock(id string) (models.DeleteLockCommandResult, *models.ProjectLock, error) {
lock, err := l.Locker.Unlock(id)
if err != nil {
return models.DeleteLockFail, nil, err
}
if lock == nil {
return models.DeleteLockNotFound, nil, nil
}

l.deleteWorkingDir(*lock)

return models.DeleteLockSuccess, lock, nil
}

// DeleteLocksByPull handles deleting all locks for the pull request
func (l *DefaultDeleteLockCommand) DeleteLocksByPull(repoFullName string, pullNum int) (models.DeleteLockCommandResult, error) {
locks, err := l.Locker.UnlockByPull(repoFullName, pullNum)
if err != nil {
return models.DeleteLockFail, err
}
if len(locks) == 0 {
return models.DeleteLockNotFound, nil
}

for i := 0; i < len(locks); i++ {
lock := locks[i]
l.deleteWorkingDir(lock)
}

return models.DeleteLockSuccess, nil
}

func (l *DefaultDeleteLockCommand) deleteWorkingDir(lock models.ProjectLock) {
// NOTE: Because BaseRepo was added to the PullRequest model later, previous
// installations of Atlantis will have locks in their DB that do not have
// this field on PullRequest. We skip deleting the working dir in this case.
if lock.Pull.BaseRepo != (models.Repo{}) {
unlock, err := l.WorkingDirLocker.TryLock(lock.Pull.BaseRepo.FullName, lock.Pull.Num, lock.Workspace)
if err != nil {
l.Logger.Err("unable to obtain working dir lock when trying to delete old plans: %s", err)
} else {
defer unlock()
// nolint: vetshadow
if err := l.WorkingDir.DeleteForWorkspace(lock.Pull.BaseRepo, lock.Pull, lock.Workspace); err != nil {
l.Logger.Err("unable to delete workspace: %s", err)
}
}
if err := l.DB.DeleteProjectStatus(lock.Pull, lock.Workspace, lock.Project.Path); err != nil {
l.Logger.Err("unable to delete project status: %s", err)
}
}
}
lkysow marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great work on this command.

Loading