Skip to content

Commit 906033e

Browse files
authored
Implement local plan storage. Refactor S3 storage (runatlantis#33)
* added new flags `plan-backend`, `plan-s3-bucket`, `plan-s3-prefix` and deleted `s3-bucket` * interface `plan.Backend` that is implemented by `file` and `s3` * simplified s3 code * didn't end up following my suggestions in runatlantis#30 since storing stuff in metadata requires you to `Get` the object *first* and then use the metadata. By parsing the `Key` to get repo, pull, path, and env, it skips an initial `Get` step, and I can download directly to the correct directory * allow users to specify `application/json` or `application/x-www-form-urlencoded` for the webhook delivery type * remove sending of special header for pull request api (fixes runatlantis#11) Closes runatlantis#30 and runatlantis#17 and runatlantis#11
1 parent dc8f0c1 commit 906033e

19 files changed

+577
-511
lines changed

circle.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ test:
3030
post:
3131
- cd "${WORKDIR}" && ./scripts/e2e-deps.sh
3232
# Start atlantis server
33-
- cd "${WORKDIR}/e2e" && ./atlantis server --gh-user="$GITHUB_USERNAME" --gh-password="$GITHUB_PASSWORD" --data-dir="/tmp" --require-approval=false --s3-bucket="$ATLANTIS_S3_BUCKET_NAME" --log-level="debug" &> /tmp/atlantis-server.log:
33+
- cd "${WORKDIR}/e2e" && ./atlantis server --gh-user="$GITHUB_USERNAME" --gh-password="$GITHUB_PASSWORD" --data-dir="/tmp" --require-approval=false --plan-backend="file" --log-level="debug" &> /tmp/atlantis-server.log:
3434
background: true
3535
- sleep 2
3636
- cd "${WORKDIR}/e2e" && ./ngrok http 4141:

cmd/root.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77

88
var RootCmd = &cobra.Command{
99
Use: "atlantis",
10-
Short: "Terraform collaboration tool",
10+
Short: "Terraform collaboration tool", // todo: decide on name #opensource
1111
}
1212

1313
func Execute() {

cmd/server.go

+20-5
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ const (
2525
lockingBackendFlag = "locking-backend"
2626
lockingTableFlag = "locking-dynamodb-table"
2727
logLevelFlag = "log-level"
28+
planBackendFlag = "plan-backend"
29+
planS3BucketFlag = "plan-s3-bucket"
30+
planS3PrefixFlag = "plan-s3-prefix"
2831
portFlag = "port"
2932
requireApprovalFlag = "require-approval"
30-
s3BucketFlag = "s3-bucket"
3133
scratchDirFlag = "scratch-dir"
3234
sshKeyFlag = "ssh-key"
3335
)
@@ -77,7 +79,7 @@ var stringFlags = []stringFlag{
7779
},
7880
{
7981
name: lockingTableFlag,
80-
description: "Name of table in DynamoDB to use for locking. Only read if locking-backend is set to dynamodb.",
82+
description: "Name of table in DynamoDB to use for locking. Only read if " + lockingBackendFlag + " is set to dynamodb.",
8183
value: "atlantis-locks",
8284
},
8385
{
@@ -86,10 +88,20 @@ var stringFlags = []stringFlag{
8688
value: "warn",
8789
},
8890
{
89-
name: s3BucketFlag,
90-
description: "The S3 bucket name to store atlantis data (terraform plans, terraform state, etc).",
91+
name: planS3BucketFlag,
92+
description: "S3 bucket for storing plan files. Only read if " + planBackendFlag + " is set to s3",
9193
value: "atlantis",
9294
},
95+
{
96+
name: planS3PrefixFlag,
97+
description: "Prefix of plan file names stored in S3. Only read if " + planBackendFlag + " is set to s3",
98+
value: "",
99+
},
100+
{
101+
name: planBackendFlag,
102+
description: "How to store plan files: file or s3. If set to file, will store plan files on disk in the directory specified by data-dir.",
103+
value: "file",
104+
},
93105
{
94106
name: scratchDirFlag,
95107
description: "Path to directory to use as a temporary workspace for checking out repos.",
@@ -147,7 +159,7 @@ Config values are overridden by environment variables which in turn are overridd
147159
if configFile != "" {
148160
viper.SetConfigFile(configFile)
149161
if err := viper.ReadInConfig(); err != nil {
150-
return fmt.Errorf("invalid config: reading %s: %s", configFile, err)
162+
return errors.Wrapf(err, "invalid config: reading %s", configFile)
151163
}
152164
}
153165
return nil
@@ -212,6 +224,9 @@ func validate(config server.ServerConfig) error {
212224
if config.LockingBackend != server.LockingFileBackend && config.LockingBackend != server.LockingDynamoDBBackend {
213225
return fmt.Errorf("unsupported locking backend %q: not one of %q or %q", config.LockingBackend, server.LockingFileBackend, server.LockingDynamoDBBackend)
214226
}
227+
if config.PlanBackend != server.PlanFileBackend && config.PlanBackend != server.PlanS3Backend {
228+
return fmt.Errorf("unsupported plan backend %q: not one of %q or %q", config.PlanBackend, server.PlanFileBackend, server.PlanS3Backend)
229+
}
215230
return nil
216231
}
217232

cmd/version.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88

99
var versionCmd = &cobra.Command{
1010
Use: "version",
11-
Short: "Print the current atlantis version",
11+
Short: "Print the current Atlantis version",
1212
Run: func(cmd *cobra.Command, args []string) {
1313
fmt.Printf("atlantis %s\n", viper.Get("version"))
1414
},

e2e/main.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import (
1414

1515
var defaultAtlantisURL = "http://localhost:4141"
1616
var projectTypes = []Project{
17-
Project{"standalone", "run plan", "run apply"},
18-
Project{"standalone-with-env", "run plan staging", "run apply staging"},
17+
{"standalone", "run plan", "run apply"},
18+
{"standalone-with-env", "run plan staging", "run apply staging"},
1919
}
2020

2121
type Project struct {

e2e/secrets-envs

-96 Bytes
Binary file not shown.

plan/backend.go

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package plan
2+
3+
import (
4+
"github.com/hootsuite/atlantis/models"
5+
)
6+
7+
type Backend interface {
8+
SavePlan(path string, project models.Project, env string, pullNum int) error
9+
CopyPlans(dstRepoPath string, repoFullName string, env string, pullNum int) ([]Plan, error)
10+
}
11+
12+
type Plan struct {
13+
Project models.Project
14+
// LocalPath is the path to the plan on disk
15+
LocalPath string
16+
}

plan/file/file.go

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package file
2+
3+
import (
4+
"github.com/hootsuite/atlantis/models"
5+
"github.com/hootsuite/atlantis/plan"
6+
"github.com/pkg/errors"
7+
"io/ioutil"
8+
"os"
9+
"path/filepath"
10+
"strconv"
11+
)
12+
13+
type Backend struct {
14+
// baseDir is the root at which all plans will be stored
15+
baseDir string
16+
}
17+
18+
func New(baseDir string) (*Backend, error) {
19+
baseDir = filepath.Clean(baseDir)
20+
if err := os.MkdirAll(baseDir, 0755); err != nil {
21+
return nil, err
22+
}
23+
return &Backend{baseDir}, nil
24+
}
25+
26+
// save plans to baseDir/owner/repo/pullNum/path/env.tfplan
27+
func (b *Backend) SavePlan(path string, project models.Project, env string, pullNum int) error {
28+
savePath := b.path(project, pullNum)
29+
if err := os.MkdirAll(savePath, 0755); err != nil {
30+
return errors.Wrap(err, "creating save directory")
31+
}
32+
if err := b.copy(path, filepath.Join(savePath, env+".tfplan")); err != nil {
33+
return errors.Wrap(err, "saving plan")
34+
}
35+
return nil
36+
}
37+
38+
func (b *Backend) CopyPlans(dstRepo string, repoFullName string, env string, pullNum int) ([]plan.Plan, error) {
39+
// Look in the directory for this repo/pull and get plans for all projects.
40+
// Then filter to the plans for this environment
41+
var toCopy []string // will contain paths to the plan files relative to repo root
42+
root := filepath.Join(b.baseDir, repoFullName, strconv.Itoa(pullNum))
43+
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
44+
if err != nil {
45+
return err
46+
}
47+
// if the plan is for the right env,
48+
if info.Name() == env+".tfplan" {
49+
rel, err := filepath.Rel(root, path)
50+
if err == nil {
51+
toCopy = append(toCopy, rel)
52+
}
53+
}
54+
return nil
55+
})
56+
57+
var plans []plan.Plan
58+
if err != nil {
59+
return plans, errors.Wrap(err, "listing plans")
60+
}
61+
62+
// copy the plans to the destination repo
63+
for _, file := range toCopy {
64+
dst := filepath.Join(dstRepo, file)
65+
if err := b.copy(filepath.Join(root, file), dst); err != nil {
66+
return plans, errors.Wrap(err, "copying plan")
67+
}
68+
plans = append(plans, plan.Plan{
69+
Project: models.Project{
70+
Path: filepath.Dir(file),
71+
RepoFullName: repoFullName,
72+
},
73+
LocalPath: dst,
74+
})
75+
}
76+
return plans, nil
77+
}
78+
79+
func (b *Backend) copy(src string, dst string) error {
80+
data, err := ioutil.ReadFile(src)
81+
if err != nil {
82+
return errors.Wrapf(err, "reading %s", src)
83+
}
84+
85+
if err = ioutil.WriteFile(dst, data, 0644); err != nil {
86+
return errors.Wrapf(err, "writing %s", dst)
87+
}
88+
return nil
89+
}
90+
91+
func (b *Backend) path(p models.Project, pullNum int) string {
92+
return filepath.Join(b.baseDir, p.RepoFullName, strconv.Itoa(pullNum), p.Path)
93+
}

plan/s3/s3.go

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package s3
2+
3+
import (
4+
"os"
5+
pathutil "path"
6+
"path/filepath"
7+
"strconv"
8+
9+
"github.com/aws/aws-sdk-go/aws"
10+
"github.com/aws/aws-sdk-go/aws/client"
11+
"github.com/aws/aws-sdk-go/service/s3"
12+
"github.com/aws/aws-sdk-go/service/s3/s3manager"
13+
"github.com/hootsuite/atlantis/models"
14+
"github.com/hootsuite/atlantis/plan"
15+
"github.com/pkg/errors"
16+
)
17+
18+
type Backend struct {
19+
s3 *s3.S3
20+
uploader *s3manager.Uploader
21+
downloader *s3manager.Downloader
22+
bucket string
23+
keyPrefix string
24+
}
25+
26+
func New(p client.ConfigProvider, bucket string, keyPrefix string) *Backend {
27+
return &Backend{
28+
s3: s3.New(p),
29+
uploader: s3manager.NewUploader(p),
30+
downloader: s3manager.NewDownloader(p),
31+
bucket: bucket,
32+
keyPrefix: keyPrefix,
33+
}
34+
}
35+
36+
func (b *Backend) CopyPlans(repoDir string, repoFullName string, env string, pullNum int) ([]plan.Plan, error) {
37+
// first list the plans with the correct prefix
38+
prefix := pathutil.Join(b.keyPrefix, repoFullName, strconv.Itoa(pullNum))
39+
list, err := b.s3.ListObjects(&s3.ListObjectsInput{Bucket: aws.String(b.bucket), Prefix: &prefix})
40+
if err != nil {
41+
return nil, errors.Wrap(err, "listing plans")
42+
}
43+
44+
var plans []plan.Plan
45+
for _, obj := range list.Contents {
46+
planName := pathutil.Base(*obj.Key)
47+
48+
// only get plans from the correct env
49+
if planName != env+".tfplan" {
50+
continue
51+
}
52+
53+
// determine the path relative to the repo
54+
relPath, err := filepath.Rel(prefix, *obj.Key)
55+
if err != nil {
56+
continue
57+
}
58+
downloadPath := filepath.Join(repoDir, relPath)
59+
file, err := os.Create(downloadPath)
60+
if err != nil {
61+
return nil, errors.Wrapf(err, "creating file %s to download plan to", downloadPath)
62+
}
63+
defer file.Close()
64+
65+
_, err = b.downloader.Download(file,
66+
&s3.GetObjectInput{
67+
Bucket: aws.String(b.bucket),
68+
Key: obj.Key,
69+
})
70+
if err != nil {
71+
return nil, errors.Wrapf(err, "downloading file at %s", *obj.Key)
72+
}
73+
plans = append(plans, plan.Plan{
74+
Project: models.Project{
75+
Path: pathutil.Dir(relPath),
76+
RepoFullName: repoFullName,
77+
},
78+
LocalPath: downloadPath,
79+
})
80+
}
81+
return plans, nil
82+
}
83+
84+
func (b *Backend) SavePlan(path string, project models.Project, env string, pullNum int) error {
85+
f, err := os.Open(path)
86+
if err != nil {
87+
return errors.Wrapf(err, "opening plan at %s", path)
88+
}
89+
90+
key := pathutil.Join(b.keyPrefix, project.RepoFullName, strconv.Itoa(pullNum), project.Path, env+".tfplan")
91+
_, err = b.uploader.Upload(&s3manager.UploadInput{
92+
Bucket: aws.String(b.bucket),
93+
Key: &key,
94+
Body: f,
95+
Metadata: map[string]*string{
96+
"repoFullName": aws.String(project.RepoFullName),
97+
"path": aws.String(project.Path),
98+
"env": aws.String(env),
99+
"pullNum": aws.String(strconv.Itoa(pullNum)),
100+
},
101+
})
102+
if err != nil {
103+
return errors.Wrap(err, "uploading plan to s3")
104+
}
105+
return nil
106+
}

0 commit comments

Comments
 (0)