diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 61ab8a736..730638ef8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -139,3 +139,40 @@ jobs: - run: | make build-service ./scripts/e2e.sh + e2e-gitlab: + runs-on: ubuntu-latest + # dont run e2e tests on forked PRs + if: github.event.pull_request.head.repo.fork == false + env: + TERRAFORM_VERSION: 1.9.2 + ATLANTIS_GITLAB_USER: ${{ secrets.ATLANTISBOT_GITLAB_USERNAME }} + ATLANTIS_GITLAB_TOKEN: ${{ secrets.ATLANTISBOT_GITLAB_TOKEN }} + NGROK_AUTH_TOKEN: ${{ secrets.ATLANTISBOT_NGROK_AUTH_TOKEN }} + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5 + with: + go-version-file: go.mod + + # This version of TF will be downloaded before Atlantis is started. + # We do this instead of setting --default-tf-version because setting + # that flag starts the download asynchronously so we'd have a race + # condition. + - uses: hashicorp/setup-terraform@651471c36a6092792c552e8b1bef71e592b462d8 # v3 + with: + terraform_version: ${{ env.TERRAFORM_VERSION }} + + - name: Setup ngrok + run: | + wget -q -O ngrok.tar.gz https://bin.equinox.io/a/4no1PS1PoRF/ngrok-v3-3.13.0-linux-amd64.tar.gz + tar -xzf ngrok.tar.gz + chmod +x ngrok + ./ngrok version + - name: Setup gitconfig + run: | + git config --global user.email "maintainers@runatlantis.io" + git config --global user.name "atlantisbot" + + - run: | + make build-service + ./scripts/e2e.sh diff --git a/e2e/gitlab.go b/e2e/gitlab.go new file mode 100644 index 000000000..2226aa299 --- /dev/null +++ b/e2e/gitlab.go @@ -0,0 +1,181 @@ +// Copyright 2017 HootSuite Media Inc. +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// Modified hereafter by contributors to runatlantis/atlantis. + +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + + "github.com/xanzy/go-gitlab" +) + +type GitlabClient struct { + client *gitlab.Client + username string + ownerName string + repoName string + token string + projectId int + // A mapping from branch names to MR IDs + branchToMR map[string]int +} + +func NewGitlabClient() *GitlabClient { + + gitlabUsername := os.Getenv("ATLANTIS_GITLAB_USER") + if gitlabUsername == "" { + log.Fatalf("ATLANTIS_GITLAB_USER cannot be empty") + } + gitlabToken := os.Getenv("ATLANTIS_GITLAB_TOKEN") + if gitlabToken == "" { + log.Fatalf("ATLANTIS_GITLAB_TOKEN cannot be empty") + } + ownerName := os.Getenv("GITLAB_REPO_OWNER_NAME") + if ownerName == "" { + ownerName = "run-atlantis" + } + repoName := os.Getenv("GITLAB_REPO_NAME") + if repoName == "" { + repoName = "atlantis-tests" + } + + gitlabClient, err := gitlab.NewClient(gitlabToken) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + project, _, err := gitlabClient.Projects.GetProject(fmt.Sprintf("%s/%s", ownerName, repoName), &gitlab.GetProjectOptions{}) + if err != nil { + log.Fatalf("Failed to find project: %v", err) + } + + return &GitlabClient{ + client: gitlabClient, + username: gitlabUsername, + ownerName: ownerName, + repoName: repoName, + token: gitlabToken, + projectId: project.ID, + branchToMR: make(map[string]int), + } + +} + +func (g GitlabClient) Clone(cloneDir string) error { + + repoURL := fmt.Sprintf("https://%s:%s@gitlab.com/%s/%s.git", g.username, g.token, g.ownerName, g.repoName) + cloneCmd := exec.Command("git", "clone", repoURL, cloneDir) + // git clone the repo + log.Printf("git cloning into %q", cloneDir) + if output, err := cloneCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to clone repository: %v: %s", err, string(output)) + } + return nil + +} + +func (g GitlabClient) CreateAtlantisWebhook(ctx context.Context, hookURL string) (int64, error) { + hook, _, err := g.client.Projects.AddProjectHook(g.projectId, &gitlab.AddProjectHookOptions{ + URL: &hookURL, + IssuesEvents: gitlab.Ptr(true), + MergeRequestsEvents: gitlab.Ptr(true), + PushEvents: gitlab.Ptr(true), + }) + if err != nil { + return 0, err + } + log.Printf("created webhook for %s", hook.URL) + return int64(hook.ID), err +} + +func (g GitlabClient) DeleteAtlantisHook(ctx context.Context, hookID int64) error { + _, err := g.client.Projects.DeleteProjectHook(g.projectId, int(hookID)) + if err != nil { + return err + } + log.Printf("deleted webhook id %d", hookID) + return nil +} + +func (g GitlabClient) CreatePullRequest(ctx context.Context, title, branchName string) (string, int, error) { + + mr, _, err := g.client.MergeRequests.CreateMergeRequest(g.projectId, &gitlab.CreateMergeRequestOptions{ + Title: gitlab.Ptr(title), + SourceBranch: gitlab.Ptr(branchName), + TargetBranch: gitlab.Ptr("main"), + }) + if err != nil { + return "", 0, fmt.Errorf("error while creating new pull request: %v", err) + } + g.branchToMR[branchName] = mr.IID + return mr.WebURL, mr.IID, nil + +} + +func (g GitlabClient) GetAtlantisStatus(ctx context.Context, branchName string) (string, error) { + + pipelineInfos, _, err := g.client.MergeRequests.ListMergeRequestPipelines(g.projectId, g.branchToMR[branchName]) + if err != nil { + return "", err + } + // Possible todo: determine which status in the pipeline we care about? + if len(pipelineInfos) != 1 { + return "", fmt.Errorf("unexpected pipelines: %d", len(pipelineInfos)) + } + pipelineInfo := pipelineInfos[0] + pipeline, _, err := g.client.Pipelines.GetPipeline(g.projectId, pipelineInfo.ID) + if err != nil { + return "", err + } + + return pipeline.Status, nil +} + +func (g GitlabClient) ClosePullRequest(ctx context.Context, pullRequestNumber int) error { + // clean up + _, _, err := g.client.MergeRequests.UpdateMergeRequest(g.projectId, pullRequestNumber, &gitlab.UpdateMergeRequestOptions{ + StateEvent: gitlab.Ptr("close"), + }) + if err != nil { + return fmt.Errorf("error while closing new pull request: %v", err) + } + return nil + +} +func (g GitlabClient) DeleteBranch(ctx context.Context, branchName string) error { + _, err := g.client.Branches.DeleteBranch(g.projectId, branchName) + + if err != nil { + return fmt.Errorf("error while deleting branch %s: %v", branchName, err) + } + return nil + +} + +func (g GitlabClient) IsAtlantisInProgress(state string) bool { + // From https://docs.gitlab.com/ee/api/pipelines.html + // created, waiting_for_resource, preparing, pending, running, success, failed, canceled, skipped, manual, scheduled + for _, s := range []string{"success", "failed", "canceled", "skipped"} { + if state == s { + return false + } + } + return true +} + +func (g GitlabClient) DidAtlantisSucceed(state string) bool { + return state == "success" +} diff --git a/e2e/go.mod b/e2e/go.mod index eec0c972a..95bd64d87 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -8,6 +8,15 @@ require ( ) require ( + github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/xanzy/go-gitlab v0.107.0 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/oauth2 v0.6.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.29.1 // indirect ) diff --git a/e2e/go.sum b/e2e/go.sum index 0bb192692..56586358b 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -1,4 +1,9 @@ +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v59 v59.0.0 h1:7h6bgpF5as0YQLLkEiVqpgtJqjimMYhBkD4jT5aN3VA= @@ -8,6 +13,30 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/xanzy/go-gitlab v0.107.0 h1:P2CT9Uy9yN9lJo3FLxpMZ4xj6uWcpnigXsjvqJ6nd2Y= +github.com/xanzy/go-gitlab v0.107.0/go.mod h1:wKNKh3GkYDMOsGmnfuX+ITCmDuSDWFO0G+C4AygL9RY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.29.1 h1:7QBf+IK2gx70Ap/hDsOmam3GE0v9HicjfEdAxE62UoM= +google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/e2e/main.go b/e2e/main.go index 808ca7e9e..dacee3045 100644 --- a/e2e/main.go +++ b/e2e/main.go @@ -15,6 +15,7 @@ package main import ( "context" + "errors" "log" "os" @@ -34,6 +35,20 @@ type Project struct { ApplyCommand string } +func getVCSClient() (VCSClient, error) { + + if os.Getenv("ATLANTIS_GH_USER") != "" { + log.Print("Running tests for github") + return NewGithubClient(), nil + } + if os.Getenv("ATLANTIS_GITLAB_USER") != "" { + log.Print("Running tests for gitlab") + return NewGitlabClient(), nil + } + + return nil, errors.New("could not determine which vcs client") +} + func main() { atlantisURL := os.Getenv("ATLANTIS_URL") @@ -55,18 +70,21 @@ func main() { log.Fatalf("failed to clean dir %q before cloning, attempting to continue: %v", cloneDirRoot, err) } - githubClient := NewGithubClient() + vcsClient, err := getVCSClient() + if err != nil { + log.Fatalf("failed to get vcs client: %v", err) + } ctx := context.Background() // we create atlantis hook once for the repo, since the atlantis server can handle multiple requests log.Printf("creating atlantis webhook with %s url", atlantisURL) - hookID, err := githubClient.CreateAtlantisWebhook(ctx, atlantisURL) + hookID, err := vcsClient.CreateAtlantisWebhook(ctx, atlantisURL) if err != nil { log.Fatalf("error creating atlantis webhook: %v", err) } // create e2e test e2e := E2ETester{ - vcsClient: githubClient, + vcsClient: vcsClient, hookID: hookID, cloneDirRoot: cloneDirRoot, } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index cec9f22c4..8e531d956 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -37,7 +37,7 @@ function cleanupPid() { ./atlantis server \ --data-dir="/tmp" \ --log-level="debug" \ - --repo-allowlist="github.com/runatlantis/atlantis-tests" \ + --repo-allowlist="github.com/runatlantis/atlantis-tests,gitlab.com/run-atlantis/atlantis-tests" \ --repo-config-json='{"repos":[{"id":"/.*/", "allowed_overrides":["apply_requirements","workflow"], "allow_custom_workflows":true}]}' \ &> /tmp/atlantis-server.log & ATLANTIS_PID=$!