Repository: chanzuckerberg/fogg
Pull request #380 (2019-11-21 00:10:59)

[feature] CircleCI to run tests
Run tests in circleci

codecov[bot]: # [Codecov](https://codecov.io/gh/chanzuckerberg/fogg/pull/380?src=pr&el=h1) Report
> Merging [#380](https://codecov.io/gh/chanzuckerberg/fogg/pull/380?src=pr&el=desc) into [master](https://codecov.io/gh/chanzuckerberg/fogg/commit/6ba377a3b63a968d9466801979dd25e3f7863245?src=pr&el=desc) will **increase** coverage by `0.52%`.
> The diff coverage is `85.57%`.

[![Impacted file tree graph](https://codecov.io/gh/chanzuckerberg/fogg/pull/380/graphs/tree.svg?width=650&token=57DhtZ7g2A&height=150&src=pr)](https://codecov.io/gh/chanzuckerberg/fogg/pull/380?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #380      +/-   ##
==========================================
+ Coverage   77.85%   78.38%   +0.52%     
==========================================
  Files          25       25              
  Lines        1906     2008     +102     
==========================================
+ Hits         1484     1574      +90     
- Misses        285      290       +5     
- Partials      137      144       +7
```


| [Impacted Files](https://codecov.io/gh/chanzuckerberg/fogg/pull/380?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [config/v1/config.go](https://codecov.io/gh/chanzuckerberg/fogg/pull/380/diff?src=pr&el=tree#diff-Y29uZmlnL3YxL2NvbmZpZy5nbw==) | `84.61% <ø> (ø)` | :arrow_up: |
| [apply/apply.go](https://codecov.io/gh/chanzuckerberg/fogg/pull/380/diff?src=pr&el=tree#diff-YXBwbHkvYXBwbHkuZ28=) | `63.36% <0%> (-0.95%)` | :arrow_down: |
| [config/config.go](https://codecov.io/gh/chanzuckerberg/fogg/pull/380/diff?src=pr&el=tree#diff-Y29uZmlnL2NvbmZpZy5nbw==) | `88.54% <100%> (+0.05%)` | :arrow_up: |
| [config/v2/config.go](https://codecov.io/gh/chanzuckerberg/fogg/pull/380/diff?src=pr&el=tree#diff-Y29uZmlnL3YyL2NvbmZpZy5nbw==) | `96.89% <100%> (+0.04%)` | :arrow_up: |
| [config/v2/resolvers.go](https://codecov.io/gh/chanzuckerberg/fogg/pull/380/diff?src=pr&el=tree#diff-Y29uZmlnL3YyL3Jlc29sdmVycy5nbw==) | `93.92% <76.47%> (-0.94%)` | :arrow_down: |
| [plan/ci.go](https://codecov.io/gh/chanzuckerberg/fogg/pull/380/diff?src=pr&el=tree#diff-cGxhbi9jaS5nbw==) | `88.33% <88.33%> (ø)` | |
| [plan/plan.go](https://codecov.io/gh/chanzuckerberg/fogg/pull/380/diff?src=pr&el=tree#diff-cGxhbi9wbGFuLmdv) | `92.37% <92.1%> (-0.22%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/chanzuckerberg/fogg/pull/380?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/chanzuckerberg/fogg/pull/380?src=pr&el=footer). Last update [6ba377a...5836e63](https://codecov.io/gh/chanzuckerberg/fogg/pull/380?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/apply/apply.go b/apply/apply.go
index a03155de..fba50e98 100644
--- a/apply/apply.go
+++ b/apply/apply.go
@@ -56,6 +56,13 @@ func Apply(fs afero.Fs, conf *v2.Config, tmp *templates.T, upgrade bool) error {
 		}
 	}
 
+	if p.CircleCI.Enabled {
+		e = applyTree(fs, &tmp.CircleCI, &tmp.Common, "", p.CircleCI)
+		if e != nil {
+			return errs.WrapUser(e, "unable to apply CircleCI")
+		}
+	}
+
 	if p.Atlantis.Enabled {
 		e = applyTree(fs, &tmp.Atlantis, &tmp.Common, "", p.Atlantis)
 		if e != nil {
diff --git a/config/v1/config.go b/config/v1/config.go
index d43155df..65c3199e 100644
--- a/config/v1/config.go
+++ b/config/v1/config.go
@@ -125,12 +125,21 @@ type Module struct {
 	TerraformVersion *string `json:"terraform_version,omitempty" yaml:"terraform_version,omitempty"`
 }
 
+type CIProviderConfig struct {
+	Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty"`
+}
+
+type CommonCI struct {
+	Enabled        *bool                       `json:"enabled,omitempty" yaml:"enabled,omitempty"`
+	AWSIAMRoleName *string                     `json:"aws_iam_role_name" yaml:"aws_iam_role_name,omitempty"`
+	TestBuckets    *int                        `json:"test_buckets" yaml:"test_buckets,omitempty"`
+	Command        *string                     `json:"command,omitempty" yaml:"command,omitempty"`
+	Buildevents    *bool                       `json:"buildevents,omitempty" yaml:"buildevents,omitempty"`
+	Providers      map[string]CIProviderConfig `json:"providers,omitempty" yaml:"providers,omitempty"`
+}
+
 type TravisCI struct {
-	Enabled        *bool   `json:"enabled,omitempty" yaml:"enabled,omitempty"`
-	AWSIAMRoleName *string `json:"aws_iam_role_name" yaml:"aws_iam_role_name,omitempty"`
-	TestBuckets    *int    `json:"test_buckets" yaml:"test_buckets,omitempty"`
-	Command        *string `json:"command,omitempty" yaml:"command,omitempty"`
-	Buildevents    *bool   `json:"buildevents,omitempty" yaml:"buildevents,omitempty"`
+	CommonCI `json:",inline" yaml:",inline"`
 }
 
 type Config struct {
diff --git a/config/v2/config.go b/config/v2/config.go
index 571e92ec..70c63a01 100644
--- a/config/v2/config.go
+++ b/config/v2/config.go
@@ -70,9 +70,16 @@ type Account struct {
 type Tools struct {
 	Atlantis *Atlantis    `json:"atlantis,omitempty" yaml:"atlantis,omitempty"`
 	TravisCI *v1.TravisCI `json:"travis_ci,omitempty" yaml:"travis_ci,omitempty"`
+	CircleCI *CircleCI    `json:"circle_ci,omitempty" yaml:"circle_ci,omitempty"`
 	TfLint   *v1.TfLint   `json:"tflint,omitempty" yaml:"tflint,omitempty"`
 }
 
+type CircleCI struct {
+	v1.CommonCI `json:",inline" yaml:",inline"`
+
+	SSHKeyFingerprints []string `json:"ssh_key_fingerprints" yaml:"ssh_key_fingerprints"`
+}
+
 type Atlantis struct {
 	Enabled  *bool   `json:"enabled,omitempty"`
 	RoleName *string `json:"role_name,omitempty"`
@@ -261,8 +268,10 @@ func (c *Config) Generate(r *rand.Rand, size int) reflect.Value {
 			c.Tools = &Tools{}
 			if r.Float32() < 0.5 {
 				c.Tools.TravisCI = &v1.TravisCI{
-					Enabled:     randBoolPtr(r, s),
-					TestBuckets: randIntPtr(r, s),
+					CommonCI: v1.CommonCI{
+						Enabled:     randBoolPtr(r, s),
+						TestBuckets: randIntPtr(r, s),
+					},
 				}
 			}
 			if r.Float32() < 0.5 {
diff --git a/config/v2/resolvers.go b/config/v2/resolvers.go
index b8e91d0a..4d0d3bf8 100644
--- a/config/v2/resolvers.go
+++ b/config/v2/resolvers.go
@@ -216,6 +216,7 @@ func ResolveTravis(commons ...Common) *v1.TravisCI {
 	enabled := false
 	buildevents := false
 	testCommand := "check"
+
 	for _, c := range commons {
 		if c.Tools != nil && c.Tools.TravisCI != nil && c.Tools.TravisCI.Enabled != nil {
 			enabled = *c.Tools.TravisCI.Enabled
@@ -232,10 +233,43 @@ func ResolveTravis(commons ...Common) *v1.TravisCI {
 	roleName := lastNonNil(TravisRoleNameGetter, commons...)
 
 	return &v1.TravisCI{
-		Enabled:        &enabled,
-		Buildevents:    &buildevents,
-		AWSIAMRoleName: roleName,
-		Command:        &testCommand,
+		CommonCI: v1.CommonCI{
+			Enabled:        &enabled,
+			Buildevents:    &buildevents,
+			AWSIAMRoleName: roleName,
+			Command:        &testCommand,
+		},
+	}
+}
+
+func ResolveCircleCI(commons ...Common) *CircleCI {
+	enabled := false
+	buildevents := false
+	testCommand := "check"
+
+	for _, c := range commons {
+		if c.Tools != nil && c.Tools.CircleCI != nil && c.Tools.CircleCI.Enabled != nil {
+			enabled = *c.Tools.CircleCI.Enabled
+		}
+		if c.Tools != nil && c.Tools.CircleCI != nil && c.Tools.CircleCI.Command != nil {
+			testCommand = *c.Tools.CircleCI.Command
+		}
+		if c.Tools != nil && c.Tools.CircleCI != nil && c.Tools.CircleCI.Buildevents != nil {
+			buildevents = *c.Tools.CircleCI.Buildevents
+		}
+	}
+
+	sshFingerprints := ResolveOptionalStringSlice(CircleCISSHFingerprintsGetter, commons...)
+	roleName := lastNonNil(CircleCIRoleNameGetter, commons...)
+
+	return &CircleCI{
+		CommonCI: v1.CommonCI{
+			Enabled:        &enabled,
+			Buildevents:    &buildevents,
+			AWSIAMRoleName: roleName,
+			Command:        &testCommand,
+		},
+		SSHKeyFingerprints: sshFingerprints,
 	}
 }
 
@@ -441,9 +475,16 @@ func TravisRoleNameGetter(comm Common) *string {
 	return comm.Tools.TravisCI.AWSIAMRoleName
 }
 
-func TravisTestCommandGetter(comm Common) *string {
-	if comm.Tools == nil || comm.Tools.TravisCI == nil {
+func CircleCIRoleNameGetter(comm Common) *string {
+	if comm.Tools == nil || comm.Tools.CircleCI == nil {
+		return nil
+	}
+	return comm.Tools.CircleCI.AWSIAMRoleName
+}
+
+func CircleCISSHFingerprintsGetter(comm Common) []string {
+	if comm.Tools == nil || comm.Tools.CircleCI == nil {
 		return nil
 	}
-	return comm.Tools.TravisCI.Command
+	return comm.Tools.CircleCI.SSHKeyFingerprints
 }
diff --git a/plan/ci.go b/plan/ci.go
new file mode 100644
index 00000000..73577b74
--- /dev/null
+++ b/plan/ci.go
@@ -0,0 +1,218 @@
+package plan
+
+import (
+	"fmt"
+	"sort"
+
+	v2 "github.com/chanzuckerberg/fogg/config/v2"
+)
+
+type CIProject struct {
+	Name    string
+	Dir     string
+	Command string
+}
+type CIConfig struct {
+	Enabled     bool
+	FoggVersion string
+	projects    []CIProject
+
+	TestBuckets [][]CIProject
+	AWSProfiles ciAwsProfiles
+	Buildevents bool
+}
+
+type CircleCIConfig struct {
+	CIConfig
+	SSHFingerprints []string
+}
+
+type TravisCIConfig struct {
+	CIConfig
+}
+
+func (c *CIConfig) populateBuckets(numBuckets int) *CIConfig {
+	sort.SliceStable(c.projects, func(i, j int) bool {
+		return c.projects[i].Name < c.projects[j].Name
+	})
+
+	c.TestBuckets = make([][]CIProject, numBuckets)
+	for i, proj := range c.projects {
+		bucket := i % numBuckets
+		c.TestBuckets[bucket] = append(c.TestBuckets[bucket], proj)
+	}
+	return c
+}
+
+func (c *CIConfig) addProjects(projects ...CIProject) *CIConfig {
+	if c == nil {
+		c = &CIConfig{}
+	}
+
+	c.projects = append(c.projects, projects...)
+	return c
+}
+
+func (c *CIConfig) merge(other *CIConfig) *CIConfig {
+	if c == nil {
+		c = &CIConfig{}
+	}
+	if other == nil {
+		return c
+	}
+
+	c.Enabled = c.Enabled || other.Enabled
+	c.Buildevents = c.Buildevents || other.Buildevents
+	c.AWSProfiles = c.AWSProfiles.merge(other.AWSProfiles)
+	c.projects = append(c.projects, other.projects...)
+
+	return c
+}
+
+type ciAwsProfiles map[string]AWSRole
+
+func (p ciAwsProfiles) merge(other ciAwsProfiles) ciAwsProfiles {
+	if p == nil {
+		p = ciAwsProfiles{}
+	}
+
+	for profile, role := range other {
+		p[profile] = role
+	}
+
+	return p
+}
+
+func (p *Plan) buildTravisCIConfig(c *v2.Config, foggVersion string) TravisCIConfig {
+	ciConfig := &CIConfig{
+		FoggVersion: foggVersion,
+	}
+
+	globalConfig := p.Global.TravisCI.generateCIConfig(
+		p.Global.Backend,
+		p.Global.Providers.AWS,
+		"global",
+		"terraform/global")
+	ciConfig = ciConfig.merge(globalConfig)
+
+	for name, acct := range p.Accounts {
+		accountConfig := acct.TravisCI.generateCIConfig(
+			acct.Backend,
+			acct.Providers.AWS,
+			fmt.Sprintf("accounts/%s", name),
+			fmt.Sprintf("terraform/accounts/%s", name),
+		)
+		ciConfig = ciConfig.merge(accountConfig)
+	}
+
+	for envName, env := range p.Envs {
+		for cName, c := range env.Components {
+			envConfig := c.TravisCI.generateCIConfig(
+				c.Backend,
+				c.Providers.AWS,
+				fmt.Sprintf("%s/%s", envName, cName),
+				fmt.Sprintf("terraform/envs/%s/%s", envName, cName),
+			)
+			ciConfig = ciConfig.merge(envConfig)
+		}
+	}
+
+	for moduleName := range p.Modules {
+		proj := CIProject{
+			Name:    fmt.Sprintf("modules/%s", moduleName),
+			Dir:     fmt.Sprintf("terraform/modules/%s", moduleName),
+			Command: "check",
+		}
+		ciConfig = ciConfig.addProjects(proj)
+	}
+
+	numBuckets := 1
+	if c.Defaults.Tools != nil &&
+		c.Defaults.Tools.TravisCI != nil {
+
+		if c.Defaults.Tools.TravisCI.TestBuckets != nil &&
+			*c.Defaults.Tools.TravisCI.TestBuckets > 0 {
+			numBuckets = *c.Defaults.Tools.TravisCI.TestBuckets
+		}
+
+		// If aws is disabled, reset the providers
+		aws, ok := c.Defaults.Tools.TravisCI.Providers["aws"]
+		if ok && aws.Disabled {
+			ciConfig.AWSProfiles = ciAwsProfiles{}
+		}
+	}
+
+	ciConfig = ciConfig.populateBuckets(numBuckets)
+	return TravisCIConfig{
+		CIConfig: *ciConfig,
+	}
+}
+
+func (p *Plan) buildCircleCIConfig(c *v2.Config, foggVersion string) CircleCIConfig {
+	ciConfig := &CIConfig{
+		FoggVersion: foggVersion,
+	}
+
+	globalConfig := p.Global.CircleCI.generateCIConfig(
+		p.Global.Backend,
+		p.Global.Providers.AWS,
+		"global",
+		"terraform/global")
+	ciConfig = ciConfig.merge(globalConfig)
+
+	for name, acct := range p.Accounts {
+		accountConfig := acct.CircleCI.generateCIConfig(
+			acct.Backend,
+			acct.Providers.AWS,
+			fmt.Sprintf("accounts/%s", name),
+			fmt.Sprintf("terraform/accounts/%s", name),
+		)
+		ciConfig = ciConfig.merge(accountConfig)
+	}
+
+	for envName, env := range p.Envs {
+		for cName, c := range env.Components {
+			envConfig := c.CircleCI.generateCIConfig(
+				c.Backend,
+				c.Providers.AWS,
+				fmt.Sprintf("%s/%s", envName, cName),
+				fmt.Sprintf("terraform/envs/%s/%s", envName, cName),
+			)
+			ciConfig = ciConfig.merge(envConfig)
+		}
+	}
+
+	for moduleName := range p.Modules {
+		proj := CIProject{
+			Name:    fmt.Sprintf("modules/%s", moduleName),
+			Dir:     fmt.Sprintf("terraform/modules/%s", moduleName),
+			Command: "check",
+		}
+		ciConfig = ciConfig.addProjects(proj)
+	}
+
+	numBuckets := 1
+	sshFingerprints := []string{}
+
+	if c.Defaults.Tools != nil && c.Defaults.Tools.CircleCI != nil {
+
+		if c.Defaults.Tools.CircleCI.TestBuckets != nil &&
+			*c.Defaults.Tools.CircleCI.TestBuckets > 0 {
+			numBuckets = *c.Defaults.Tools.CircleCI.TestBuckets
+		}
+
+		sshFingerprints = append(sshFingerprints, c.Defaults.Tools.CircleCI.SSHKeyFingerprints...)
+
+		// If aws is disabled, reset the providers
+		aws, ok := c.Defaults.Tools.CircleCI.Providers["aws"]
+		if ok && aws.Disabled {
+			ciConfig.AWSProfiles = ciAwsProfiles{}
+		}
+	}
+
+	ciConfig = ciConfig.populateBuckets(numBuckets)
+	return CircleCIConfig{
+		CIConfig:        *ciConfig,
+		SSHFingerprints: sshFingerprints,
+	}
+}
diff --git a/plan/plan.go b/plan/plan.go
index 6fad417a..ebfb7e3a 100644
--- a/plan/plan.go
+++ b/plan/plan.go
@@ -9,6 +9,7 @@ import (
 	v2 "github.com/chanzuckerberg/fogg/config/v2"
 	"github.com/chanzuckerberg/fogg/errs"
 	"github.com/chanzuckerberg/fogg/util"
+	"github.com/sirupsen/logrus"
 	"gopkg.in/yaml.v3"
 )
 
@@ -19,7 +20,8 @@ type Plan struct {
 	Envs     map[string]Env     `json:"envs" yaml:"envs"`
 	Global   Component          `json:"global" yaml:"global"`
 	Modules  map[string]Module  `json:"modules" yaml:"modules"`
-	TravisCI TravisCI           `json:"travis_ci" yaml:"travis_ci"`
+	TravisCI TravisCIConfig     `json:"travis_ci" yaml:"travis_ci"`
+	CircleCI CircleCIConfig     `json:"circle_ci" yaml:"circle_ci"`
 	Version  string             `json:"version" yaml:"version"`
 }
 
@@ -40,7 +42,9 @@ type ComponentCommon struct {
 	Project   string            `json:"project" yaml:"project"`
 	Providers Providers         `json:"providers" yaml:"providers"`
 	TfLint    TfLint            `json:"tf_lint" yaml:"tf_lint"`
-	TravisCI  TravisComponent
+
+	TravisCI TravisCIComponent
+	CircleCI CircleCIComponent
 }
 
 type AtlantisComponent struct {
@@ -49,7 +53,17 @@ type AtlantisComponent struct {
 	RolePath string `yaml:"role_path"`
 }
 
-type TravisComponent struct {
+type TravisCIComponent struct {
+	CIComponent
+}
+
+type CircleCIComponent struct {
+	CIComponent
+
+	SSHFingerprints []string
+}
+
+type CIComponent struct {
 	Enabled     bool
 	Buildevents bool
 
@@ -59,6 +73,45 @@ type TravisComponent struct {
 	Command        string
 }
 
+// generateCIConfig generates the config for ci
+func (c CIComponent) generateCIConfig(
+	backend AWSBackend,
+	provider *AWSProvider,
+	projName string,
+	projDir string) *CIConfig {
+
+	if !c.Enabled {
+		return nil
+	}
+
+	ciConfig := &CIConfig{
+		AWSProfiles: ciAwsProfiles{},
+		Enabled:     true,
+		Buildevents: c.Buildevents,
+	}
+
+	ciConfig.projects = append(ciConfig.projects, CIProject{
+		Name:    projName,
+		Dir:     projDir,
+		Command: c.Command,
+	})
+
+	if backend.AccountID != nil {
+		ciConfig.AWSProfiles[backend.Profile] = AWSRole{
+			AccountID: *backend.AccountID,
+			RoleName:  c.AWSRoleName,
+		}
+	}
+
+	if provider != nil {
+		ciConfig.AWSProfiles[provider.Profile] = AWSRole{
+			AccountID: provider.AccountID.String(),
+			RoleName:  c.AWSRoleName,
+		}
+	}
+	return ciConfig
+}
+
 type Providers struct {
 	AWS       *AWSProvider       `yaml:"aws"`
 	Github    *GithubProvider    `yaml:"github"`
@@ -178,7 +231,8 @@ func Eval(c *v2.Config) (*Plan, error) {
 
 	p.Modules = p.buildModules(c)
 	p.Atlantis = p.buildAtlantis()
-	p.TravisCI = p.buildTravisCI(c, v)
+	p.TravisCI = p.buildTravisCIConfig(c, v)
+	p.CircleCI = p.buildCircleCIConfig(c, v)
 
 	return p, nil
 }
@@ -360,15 +414,33 @@ func resolveComponentCommon(commons ...v2.Common) ComponentCommon {
 	}
 
 	travisConfig := v2.ResolveTravis(commons...)
-	travisPlan := TravisComponent{
-		Enabled:     *travisConfig.Enabled,
-		Buildevents: *travisConfig.Buildevents,
+	travisPlan := TravisCIComponent{
+		CIComponent: CIComponent{
+			Enabled:     *travisConfig.Enabled,
+			Buildevents: *travisConfig.Buildevents,
+		},
 	}
 	if travisPlan.Enabled {
 		travisPlan.AWSRoleName = *travisConfig.AWSIAMRoleName
 		travisPlan.Command = *travisConfig.Command
 	}
 
+	circleConfig := v2.ResolveCircleCI(commons...)
+	circlePlan := CircleCIComponent{
+		CIComponent: CIComponent{
+			Enabled:     *circleConfig.Enabled,
+			Buildevents: *circleConfig.Buildevents,
+		},
+	}
+	if circlePlan.Enabled {
+		circlePlan.AWSRoleName = *circleConfig.AWSIAMRoleName
+		circlePlan.Command = *circleConfig.Command
+	}
+
+	if travisPlan.Enabled && circlePlan.Enabled {
+		logrus.Warn("Detected both travisCI and circleCI are enabled, is this intentional?")
+	}
+
 	return ComponentCommon{
 		Atlantis: atlantisPlan,
 		Backend: AWSBackend{
@@ -391,6 +463,7 @@ func resolveComponentCommon(commons ...v2.Common) ComponentCommon {
 		Project:   v2.ResolveRequiredString(v2.ProjectGetter, commons...),
 		Common:    Common{TerraformVersion: v2.ResolveRequiredString(v2.TerraformVersionGetter, commons...)},
 		TravisCI:  travisPlan,
+		CircleCI:  circlePlan,
 	}
 }
 
diff --git a/plan/travisci.go b/plan/travisci.go
deleted file mode 100644
index b23f08c3..00000000
--- a/plan/travisci.go
+++ /dev/null
@@ -1,155 +0,0 @@
-package plan
-
-import (
-	"fmt"
-	"sort"
-
-	v2 "github.com/chanzuckerberg/fogg/config/v2"
-)
-
-type TravisProject struct {
-	Name    string
-	Dir     string
-	Command string
-}
-type TravisCI struct {
-	Enabled     bool
-	FoggVersion string
-	TestBuckets [][]TravisProject
-	AWSProfiles map[string]AWSRole
-	Buildevents bool
-}
-
-// TODO(el): mostly a duplicate of buildAtlantis(). refactor later
-func (p *Plan) buildTravisCI(c *v2.Config, foggVersion string) TravisCI {
-	enabled := false
-	buildeventsEnabled := false
-	projects := []TravisProject{}
-	awsProfiles := map[string]AWSRole{}
-
-	if p.Global.TravisCI.Enabled {
-		enabled = true
-		buildeventsEnabled = buildeventsEnabled || p.Global.TravisCI.Buildevents
-
-		proj := TravisProject{
-			Name:    "global",
-			Dir:     "terraform/global",
-			Command: p.Global.TravisCI.Command,
-		}
-		projects = append(projects, proj)
-
-		if p.Global.Backend.AccountID != nil {
-			awsProfiles[p.Global.Backend.Profile] = AWSRole{
-				AccountID: *p.Global.Backend.AccountID,
-				RoleName:  p.Global.TravisCI.AWSRoleName,
-			}
-			if p.Global.Providers.AWS != nil {
-				a := *p.Global.Providers.AWS
-				awsProfiles[a.Profile] = AWSRole{
-					AccountID: *p.Global.Backend.AccountID,
-					RoleName:  p.Global.TravisCI.AWSRoleName,
-				}
-			}
-		}
-	}
-
-	for name, acct := range p.Accounts {
-		if acct.TravisCI.Enabled {
-			enabled = true
-			buildeventsEnabled = buildeventsEnabled || p.Global.TravisCI.Buildevents
-
-			proj := TravisProject{
-				Name:    fmt.Sprintf("accounts/%s", name),
-				Dir:     fmt.Sprintf("terraform/accounts/%s", name),
-				Command: acct.TravisCI.Command,
-			}
-
-			projects = append(projects, proj)
-
-			// Grab all profiles from accounts
-			if acct.Backend.AccountID != nil {
-				awsProfiles[acct.Backend.Profile] = AWSRole{
-					AccountID: *acct.Backend.AccountID,
-					RoleName:  acct.TravisCI.AWSRoleName,
-				}
-			}
-			if acct.Providers.AWS != nil {
-				awsProfiles[acct.Providers.AWS.Profile] = AWSRole{
-					AccountID: acct.Providers.AWS.AccountID.String(),
-					RoleName:  acct.TravisCI.AWSRoleName,
-				}
-			}
-		}
-	}
-
-	for envName, env := range p.Envs {
-		for cName, c := range env.Components {
-			if c.TravisCI.Enabled {
-				enabled = true
-				buildeventsEnabled = buildeventsEnabled || p.Global.TravisCI.Buildevents
-
-				proj := TravisProject{
-					Name:    fmt.Sprintf("%s/%s", envName, cName),
-					Dir:     fmt.Sprintf("terraform/envs/%s/%s", envName, cName),
-					Command: c.TravisCI.Command,
-				}
-
-				projects = append(projects, proj)
-
-				if c.Backend.AccountID != nil {
-					awsProfiles[c.Backend.Profile] = AWSRole{
-						AccountID: *c.Backend.AccountID,
-						RoleName:  c.TravisCI.AWSRoleName,
-					}
-				}
-
-				if c.Providers.AWS != nil {
-					a := *c.Providers.AWS
-					awsProfiles[a.Profile] = AWSRole{
-						AccountID: a.AccountID.String(),
-						RoleName:  c.TravisCI.AWSRoleName,
-					}
-				}
-			}
-		}
-	}
-
-	for moduleName := range p.Modules {
-		proj := TravisProject{
-			Name:    fmt.Sprintf("modules/%s", moduleName),
-			Dir:     fmt.Sprintf("terraform/modules/%s", moduleName),
-			Command: "check",
-		}
-		projects = append(projects, proj)
-	}
-
-	var buckets int
-	if c.Defaults.Tools != nil &&
-		c.Defaults.Tools.TravisCI != nil &&
-		c.Defaults.Tools.TravisCI.TestBuckets != nil &&
-		*c.Defaults.Tools.TravisCI.TestBuckets > 0 {
-
-		buckets = *c.Defaults.Tools.TravisCI.TestBuckets
-	} else {
-		buckets = 1
-	}
-
-	sort.SliceStable(projects, func(i, j int) bool {
-		return projects[i].Name < projects[j].Name
-	})
-
-	testBuckets := make([][]TravisProject, buckets)
-	for i, proj := range projects {
-		bucket := i % buckets
-		testBuckets[bucket] = append(testBuckets[bucket], proj)
-	}
-
-	tr := TravisCI{
-		Enabled:     enabled,
-		Buildevents: buildeventsEnabled,
-		FoggVersion: foggVersion,
-		TestBuckets: testBuckets,
-		AWSProfiles: awsProfiles,
-	}
-	return tr
-}
diff --git a/templates/circleci/.circleci/config.yml.tmpl b/templates/circleci/.circleci/config.yml.tmpl
new file mode 100644
index 00000000..49dcb351
--- /dev/null
+++ b/templates/circleci/.circleci/config.yml.tmpl
@@ -0,0 +1,54 @@
+{{- $circleCI := . }}
+version: 2
+
+working_directory: &working_directory ~/fogg
+
+jobs:
+  build:
+    docker:
+      - image: circleci/python:3.8
+    working_directory: *working_directory
+    parallelism: {{ len .TestBuckets }}
+    steps:
+      - checkout
+{{- if not (eq (len .SSHFingerprints) 0) }}
+      # HACK(el):Switch to key that reads private tf modules after checking out our code
+      - add_ssh_keys:
+          fingerprints:
+          {{- range $sshFingerprint := .SSHFingerprints }}
+            - {{ $sshFingerprint }}
+          {{- end }}
+{{- end }}
+      - run: python3 -m venv venv
+      - run: . venv/bin/activate
+      - run: pip install awscli --upgrade --user
+      - run: aws --version
+      - run: FOGG_VERSION="v{{ .FoggVersion }}" make setup
+      - run:
+          command: |
+            .fogg/bin/fogg apply
+            if [ "$(git status --porcelain)" != "" ]; then
+              echo "git tree is dirty after running fogg apply"
+              echo "ensure you run fogg apply on your branch"
+              git status
+              exit 1
+            fi
+{{- if not (eq (len .AWSProfiles) 0) }}
+      - aws configure set aws_access_key_id     $IDACCT_AWS_ACCESS_KEY_ID     --profile _idacct
+      - aws configure set aws_secret_access_key $IDACCT_AWS_SECRET_ACCESS_KEY --profile _idacct
+      - aws --profile _idacct sts get-caller-identity
+{{- end }}
+{{- range $profileName, $profile := .AWSProfiles }}
+      - aws configure set profile.{{ $profileName }}.role_arn arn:aws:iam::{{ $profile.AccountID }}:role/{{ $profile.RoleName}}
+      - aws configure set profile.{{ $profileName }}.source_profile _idacct
+      - aws --profile {{ $profileName }} sts get-caller-identity
+{{- end }}
+
+{{- range $testBucket := .TestBuckets }}
+  {{- range $component := $testBucket }}
+      - run:
+          command: |
+            cd {{ $component.Dir }}
+            make  {{ $component.Command }}
+  {{- end }}
+{{- end }}
diff --git a/templates/repo/.gitignore b/templates/repo/.gitignore
index eed4e6e8..69565ae8 100644
--- a/templates/repo/.gitignore
+++ b/templates/repo/.gitignore
@@ -30,3 +30,5 @@
 
 buildevents.plan
 check-plan.output
+
+venv
diff --git a/templates/repo/scripts/common.mk b/templates/repo/scripts/common.mk
index 65de6506..6dbaa0c7 100644
--- a/templates/repo/scripts/common.mk
+++ b/templates/repo/scripts/common.mk
@@ -13,7 +13,7 @@ REFRESH_ENABLED ?= true # Should terraform refresh during plan/apply
 TF=$(wildcard *.tf)
 
 TFENV_DIR ?= $(REPO_ROOT)/.fogg/tfenv
-export PATH :=$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
+export PATH :=$(TFENV_DIR)/libexec:$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
 export TF_PLUGIN_CACHE_DIR=$(REPO_ROOT)/.terraform.d/plugin-cache
 export TF_IN_AUTOMATION=1
 terraform_command ?= $(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/terraform
diff --git a/templates/repo/scripts/component.mk b/templates/repo/scripts/component.mk
index e8174ffb..08c359e8 100644
--- a/templates/repo/scripts/component.mk
+++ b/templates/repo/scripts/component.mk
@@ -25,7 +25,7 @@ fmt: terraform ## format code in this component
 lint: lint-terraform-fmt lint-tflint ## run all linters for this component
 .PHONY: lint
 
-lint-tflint: init ## run the tflint linter for this component
+lint-tflint: ## run the tflint linter for this component
 	@printf "tflint: "
 ifeq ($(TFLINT_ENABLED),1)
 	@tflint || exit $$?;
diff --git a/templates/templates.go b/templates/templates.go
index bb3c1c0c..2530e475 100644
--- a/templates/templates.go
+++ b/templates/templates.go
@@ -1,7 +1,7 @@
 package templates
 
 import (
-	"github.com/chanzuckerberg/fogg/config/v1"
+	v1 "github.com/chanzuckerberg/fogg/config/v1"
 	"github.com/gobuffalo/packr"
 )
 
@@ -16,6 +16,7 @@ type T struct {
 	ModuleInvocation packr.Box
 	Repo             packr.Box
 	TravisCI         packr.Box
+	CircleCI         packr.Box
 }
 
 var Templates = &T{
@@ -32,4 +33,5 @@ var Templates = &T{
 	ModuleInvocation: packr.NewBox("module-invocation"),
 	Repo:             packr.NewBox("repo"),
 	TravisCI:         packr.NewBox("travis-ci"),
+	CircleCI:         packr.NewBox("circleci"),
 }


Test patch:
diff --git a/config/config_test.go b/config/config_test.go
index 7bbcb8fd..c1296981 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -126,10 +126,12 @@ func TestUpgradeConfigVersion(t *testing.T) {
 						Enabled: boolptr(true),
 					},
 					TravisCI: &v1.TravisCI{
-						Enabled:        boolptr(true),
-						AWSIAMRoleName: util.StrPtr("travis-role"),
-						TestBuckets:    intptr(13),
-						Buildevents:    boolptr(true),
+						CommonCI: v1.CommonCI{
+							Enabled:        boolptr(true),
+							AWSIAMRoleName: util.StrPtr("travis-role"),
+							TestBuckets:    intptr(13),
+							Buildevents:    boolptr(true),
+						},
 					},
 				},
 			},
diff --git a/plan/travisci_test.go b/plan/ci_test.go
similarity index 51%
rename from plan/travisci_test.go
rename to plan/ci_test.go
index 2c02495e..ed5c9e23 100644
--- a/plan/travisci_test.go
+++ b/plan/ci_test.go
@@ -30,7 +30,9 @@ func Test_buildTravisCI_Disabled(t *testing.T) {
 				Common: v2.Common{
 					Tools: &v2.Tools{
 						TravisCI: &v1.TravisCI{
-							Enabled: &f,
+							CommonCI: v1.CommonCI{
+								Enabled: &f,
+							},
 						},
 					},
 				},
@@ -38,7 +40,7 @@ func Test_buildTravisCI_Disabled(t *testing.T) {
 		}
 		p := &Plan{}
 		p.Accounts = p.buildAccounts(c)
-		tr := p.buildTravisCI(c, "0.1.0")
+		tr := p.buildTravisCIConfig(c, "0.1.0")
 		a.NotNil(tr)
 		a.False(tr.Enabled)
 	}
@@ -70,8 +72,10 @@ func Test_buildTravisCI_Profiles(t *testing.T) {
 				},
 				Tools: &v2.Tools{
 					TravisCI: &v1.TravisCI{
-						Enabled:        &tr,
-						AWSIAMRoleName: util.StrPtr("rollin"),
+						CommonCI: v1.CommonCI{
+							Enabled:        &tr,
+							AWSIAMRoleName: util.StrPtr("rollin"),
+						},
 					}},
 			},
 		},
@@ -88,7 +92,7 @@ func Test_buildTravisCI_Profiles(t *testing.T) {
 
 	p := &Plan{}
 	p.Accounts = p.buildAccounts(c)
-	tr := p.buildTravisCI(c, "0.1.0")
+	tr := p.buildTravisCIConfig(c, "0.1.0")
 	a.Len(tr.AWSProfiles, 2)
 	a.Contains(tr.AWSProfiles, "profile")
 	a.Contains(tr.AWSProfiles, "foo")
@@ -121,8 +125,10 @@ func Test_buildTravisCI_TestBuckets(t *testing.T) {
 					AccountID: util.StrPtr("some account id"),
 				},
 				Tools: &v2.Tools{TravisCI: &v1.TravisCI{
-					Enabled:        &tr,
-					AWSIAMRoleName: util.StrPtr("rollin"),
+					CommonCI: v1.CommonCI{
+						Enabled:        &tr,
+						AWSIAMRoleName: util.StrPtr("rollin"),
+					},
 				}},
 			},
 		},
@@ -142,9 +148,118 @@ func Test_buildTravisCI_TestBuckets(t *testing.T) {
 
 	p := &Plan{}
 	p.Accounts = p.buildAccounts(c)
-	tr := p.buildTravisCI(c, "0.1.0")
+	tr := p.buildTravisCIConfig(c, "0.1.0")
 	a.NotNil(p.Accounts["foo"].Providers.AWS)
 	a.Equal(id1, p.Accounts["foo"].Providers.AWS.AccountID)
 	a.Len(tr.TestBuckets, 1)
 	a.Len(tr.TestBuckets[0], 2)
 }
+
+func Test_buildCircleCI_Profiles(t *testing.T) {
+	a := assert.New(t)
+
+	c := &v2.Config{
+		Version: 2,
+		Defaults: v2.Defaults{
+			Common: v2.Common{
+				Project:          util.StrPtr("foo"),
+				Owner:            util.StrPtr("bar"),
+				TerraformVersion: util.StrPtr("0.1.0"),
+				Providers: &v2.Providers{
+					AWS: &v2.AWSProvider{
+						AccountID: util.JsonNumberPtr(123),
+						Region:    util.StrPtr("us-west-2"),
+						Profile:   util.StrPtr("foo"),
+						Version:   util.StrPtr("0.12.0"),
+					},
+				},
+				Backend: &v2.Backend{
+					Bucket:    util.StrPtr("bucket"),
+					Region:    util.StrPtr("us-west-2"),
+					Profile:   util.StrPtr("profile"),
+					AccountID: util.StrPtr("some account id"),
+				},
+				Tools: &v2.Tools{
+					CircleCI: &v2.CircleCI{
+						CommonCI: v1.CommonCI{
+							Enabled:        &tr,
+							AWSIAMRoleName: util.StrPtr("rollin"),
+						},
+					}},
+			},
+		},
+		Accounts: map[string]v2.Account{
+			"foo": v2.Account{
+				Common: v2.Common{Providers: &v2.Providers{AWS: &v2.AWSProvider{AccountID: &id1}}},
+			},
+		},
+	}
+
+	w, err := c.Validate()
+	a.NoError(err)
+	a.Len(w, 0)
+
+	p := &Plan{}
+	p.Accounts = p.buildAccounts(c)
+	circle := p.buildCircleCIConfig(c, "0.1.0")
+	a.Len(circle.AWSProfiles, 2)
+	a.Contains(circle.AWSProfiles, "profile")
+	a.Contains(circle.AWSProfiles, "foo")
+	a.Equal(id1.String(), circle.AWSProfiles["foo"].AccountID)
+	a.Equal("rollin", circle.AWSProfiles["foo"].RoleName)
+}
+
+func Test_buildCircleCI_ProfilesDisabled(t *testing.T) {
+	a := assert.New(t)
+
+	c := &v2.Config{
+		Version: 2,
+		Defaults: v2.Defaults{
+			Common: v2.Common{
+				Project:          util.StrPtr("foo"),
+				Owner:            util.StrPtr("bar"),
+				TerraformVersion: util.StrPtr("0.1.0"),
+				Providers: &v2.Providers{
+					AWS: &v2.AWSProvider{
+						AccountID: util.JsonNumberPtr(123),
+						Region:    util.StrPtr("us-west-2"),
+						Profile:   util.StrPtr("foo"),
+						Version:   util.StrPtr("0.12.0"),
+					},
+				},
+				Backend: &v2.Backend{
+					Bucket:    util.StrPtr("bucket"),
+					Region:    util.StrPtr("us-west-2"),
+					Profile:   util.StrPtr("profile"),
+					AccountID: util.StrPtr("some account id"),
+				},
+				Tools: &v2.Tools{
+					CircleCI: &v2.CircleCI{
+						CommonCI: v1.CommonCI{
+							Enabled:        &tr,
+							AWSIAMRoleName: util.StrPtr("rollin"),
+							Providers: map[string]v1.CIProviderConfig{
+								"aws": v1.CIProviderConfig{
+									Disabled: true,
+								},
+							},
+						},
+					}},
+			},
+		},
+		Accounts: map[string]v2.Account{
+			"foo": v2.Account{
+				Common: v2.Common{Providers: &v2.Providers{AWS: &v2.AWSProvider{AccountID: &id1}}},
+			},
+		},
+	}
+
+	w, err := c.Validate()
+	a.NoError(err)
+	a.Len(w, 0)
+
+	p := &Plan{}
+	p.Accounts = p.buildAccounts(c)
+	circle := p.buildCircleCIConfig(c, "0.1.0")
+	a.Len(circle.AWSProfiles, 0)
+}
diff --git a/testdata/bless_provider/.gitignore b/testdata/bless_provider/.gitignore
index eed4e6e8..69565ae8 100644
--- a/testdata/bless_provider/.gitignore
+++ b/testdata/bless_provider/.gitignore
@@ -30,3 +30,5 @@
 
 buildevents.plan
 check-plan.output
+
+venv
diff --git a/testdata/bless_provider/scripts/common.mk b/testdata/bless_provider/scripts/common.mk
index 65de6506..6dbaa0c7 100644
--- a/testdata/bless_provider/scripts/common.mk
+++ b/testdata/bless_provider/scripts/common.mk
@@ -13,7 +13,7 @@ REFRESH_ENABLED ?= true # Should terraform refresh during plan/apply
 TF=$(wildcard *.tf)
 
 TFENV_DIR ?= $(REPO_ROOT)/.fogg/tfenv
-export PATH :=$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
+export PATH :=$(TFENV_DIR)/libexec:$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
 export TF_PLUGIN_CACHE_DIR=$(REPO_ROOT)/.terraform.d/plugin-cache
 export TF_IN_AUTOMATION=1
 terraform_command ?= $(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/terraform
diff --git a/testdata/bless_provider/scripts/component.mk b/testdata/bless_provider/scripts/component.mk
index e8174ffb..08c359e8 100644
--- a/testdata/bless_provider/scripts/component.mk
+++ b/testdata/bless_provider/scripts/component.mk
@@ -25,7 +25,7 @@ fmt: terraform ## format code in this component
 lint: lint-terraform-fmt lint-tflint ## run all linters for this component
 .PHONY: lint
 
-lint-tflint: init ## run the tflint linter for this component
+lint-tflint: ## run the tflint linter for this component
 	@printf "tflint: "
 ifeq ($(TFLINT_ENABLED),1)
 	@tflint || exit $$?;
diff --git a/testdata/github_provider/.gitignore b/testdata/github_provider/.gitignore
index eed4e6e8..69565ae8 100644
--- a/testdata/github_provider/.gitignore
+++ b/testdata/github_provider/.gitignore
@@ -30,3 +30,5 @@
 
 buildevents.plan
 check-plan.output
+
+venv
diff --git a/testdata/github_provider/scripts/common.mk b/testdata/github_provider/scripts/common.mk
index 65de6506..6dbaa0c7 100644
--- a/testdata/github_provider/scripts/common.mk
+++ b/testdata/github_provider/scripts/common.mk
@@ -13,7 +13,7 @@ REFRESH_ENABLED ?= true # Should terraform refresh during plan/apply
 TF=$(wildcard *.tf)
 
 TFENV_DIR ?= $(REPO_ROOT)/.fogg/tfenv
-export PATH :=$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
+export PATH :=$(TFENV_DIR)/libexec:$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
 export TF_PLUGIN_CACHE_DIR=$(REPO_ROOT)/.terraform.d/plugin-cache
 export TF_IN_AUTOMATION=1
 terraform_command ?= $(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/terraform
diff --git a/testdata/github_provider/scripts/component.mk b/testdata/github_provider/scripts/component.mk
index e8174ffb..08c359e8 100644
--- a/testdata/github_provider/scripts/component.mk
+++ b/testdata/github_provider/scripts/component.mk
@@ -25,7 +25,7 @@ fmt: terraform ## format code in this component
 lint: lint-terraform-fmt lint-tflint ## run all linters for this component
 .PHONY: lint
 
-lint-tflint: init ## run the tflint linter for this component
+lint-tflint: ## run the tflint linter for this component
 	@printf "tflint: "
 ifeq ($(TFLINT_ENABLED),1)
 	@tflint || exit $$?;
diff --git a/testdata/okta_provider/.gitignore b/testdata/okta_provider/.gitignore
index eed4e6e8..69565ae8 100644
--- a/testdata/okta_provider/.gitignore
+++ b/testdata/okta_provider/.gitignore
@@ -30,3 +30,5 @@
 
 buildevents.plan
 check-plan.output
+
+venv
diff --git a/testdata/okta_provider/scripts/common.mk b/testdata/okta_provider/scripts/common.mk
index 65de6506..6dbaa0c7 100644
--- a/testdata/okta_provider/scripts/common.mk
+++ b/testdata/okta_provider/scripts/common.mk
@@ -13,7 +13,7 @@ REFRESH_ENABLED ?= true # Should terraform refresh during plan/apply
 TF=$(wildcard *.tf)
 
 TFENV_DIR ?= $(REPO_ROOT)/.fogg/tfenv
-export PATH :=$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
+export PATH :=$(TFENV_DIR)/libexec:$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
 export TF_PLUGIN_CACHE_DIR=$(REPO_ROOT)/.terraform.d/plugin-cache
 export TF_IN_AUTOMATION=1
 terraform_command ?= $(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/terraform
diff --git a/testdata/okta_provider/scripts/component.mk b/testdata/okta_provider/scripts/component.mk
index e8174ffb..08c359e8 100644
--- a/testdata/okta_provider/scripts/component.mk
+++ b/testdata/okta_provider/scripts/component.mk
@@ -25,7 +25,7 @@ fmt: terraform ## format code in this component
 lint: lint-terraform-fmt lint-tflint ## run all linters for this component
 .PHONY: lint
 
-lint-tflint: init ## run the tflint linter for this component
+lint-tflint: ## run the tflint linter for this component
 	@printf "tflint: "
 ifeq ($(TFLINT_ENABLED),1)
 	@tflint || exit $$?;
diff --git a/testdata/snowflake_provider/.gitignore b/testdata/snowflake_provider/.gitignore
index eed4e6e8..69565ae8 100644
--- a/testdata/snowflake_provider/.gitignore
+++ b/testdata/snowflake_provider/.gitignore
@@ -30,3 +30,5 @@
 
 buildevents.plan
 check-plan.output
+
+venv
diff --git a/testdata/snowflake_provider/scripts/common.mk b/testdata/snowflake_provider/scripts/common.mk
index 65de6506..6dbaa0c7 100644
--- a/testdata/snowflake_provider/scripts/common.mk
+++ b/testdata/snowflake_provider/scripts/common.mk
@@ -13,7 +13,7 @@ REFRESH_ENABLED ?= true # Should terraform refresh during plan/apply
 TF=$(wildcard *.tf)
 
 TFENV_DIR ?= $(REPO_ROOT)/.fogg/tfenv
-export PATH :=$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
+export PATH :=$(TFENV_DIR)/libexec:$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
 export TF_PLUGIN_CACHE_DIR=$(REPO_ROOT)/.terraform.d/plugin-cache
 export TF_IN_AUTOMATION=1
 terraform_command ?= $(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/terraform
diff --git a/testdata/snowflake_provider/scripts/component.mk b/testdata/snowflake_provider/scripts/component.mk
index e8174ffb..08c359e8 100644
--- a/testdata/snowflake_provider/scripts/component.mk
+++ b/testdata/snowflake_provider/scripts/component.mk
@@ -25,7 +25,7 @@ fmt: terraform ## format code in this component
 lint: lint-terraform-fmt lint-tflint ## run all linters for this component
 .PHONY: lint
 
-lint-tflint: init ## run the tflint linter for this component
+lint-tflint: ## run the tflint linter for this component
 	@printf "tflint: "
 ifeq ($(TFLINT_ENABLED),1)
 	@tflint || exit $$?;
diff --git a/testdata/v1_full/.gitignore b/testdata/v1_full/.gitignore
index eed4e6e8..69565ae8 100644
--- a/testdata/v1_full/.gitignore
+++ b/testdata/v1_full/.gitignore
@@ -30,3 +30,5 @@
 
 buildevents.plan
 check-plan.output
+
+venv
diff --git a/testdata/v1_full/.travis.yml b/testdata/v1_full/.travis.yml
index 25bbbfc3..2c46468d 100644
--- a/testdata/v1_full/.travis.yml
+++ b/testdata/v1_full/.travis.yml
@@ -33,6 +33,10 @@ before_script:
   - aws --profile _idacct sts get-caller-identity
 
 
+  - aws configure set profile.czi.role_arn arn:aws:iam::1:role/travis-role
+  - aws configure set profile.czi.source_profile _idacct
+  - buildevents cmd $BUILDEVENTS_BUILD_ID $STEP_SPAN_ID sts-caller-identity.czi -- aws --profile czi sts get-caller-identity
+
   - aws configure set profile.czi-bar.role_arn arn:aws:iam::3:role/travis-role
   - aws configure set profile.czi-bar.source_profile _idacct
   - buildevents cmd $BUILDEVENTS_BUILD_ID $STEP_SPAN_ID sts-caller-identity.czi-bar -- aws --profile czi-bar sts get-caller-identity
diff --git a/testdata/v1_full/scripts/common.mk b/testdata/v1_full/scripts/common.mk
index 65de6506..6dbaa0c7 100644
--- a/testdata/v1_full/scripts/common.mk
+++ b/testdata/v1_full/scripts/common.mk
@@ -13,7 +13,7 @@ REFRESH_ENABLED ?= true # Should terraform refresh during plan/apply
 TF=$(wildcard *.tf)
 
 TFENV_DIR ?= $(REPO_ROOT)/.fogg/tfenv
-export PATH :=$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
+export PATH :=$(TFENV_DIR)/libexec:$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
 export TF_PLUGIN_CACHE_DIR=$(REPO_ROOT)/.terraform.d/plugin-cache
 export TF_IN_AUTOMATION=1
 terraform_command ?= $(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/terraform
diff --git a/testdata/v1_full/scripts/component.mk b/testdata/v1_full/scripts/component.mk
index e8174ffb..08c359e8 100644
--- a/testdata/v1_full/scripts/component.mk
+++ b/testdata/v1_full/scripts/component.mk
@@ -25,7 +25,7 @@ fmt: terraform ## format code in this component
 lint: lint-terraform-fmt lint-tflint ## run all linters for this component
 .PHONY: lint
 
-lint-tflint: init ## run the tflint linter for this component
+lint-tflint: ## run the tflint linter for this component
 	@printf "tflint: "
 ifeq ($(TFLINT_ENABLED),1)
 	@tflint || exit $$?;
diff --git a/testdata/v2_full/.circleci/config.yml b/testdata/v2_full/.circleci/config.yml
new file mode 100644
index 00000000..9418d33f
--- /dev/null
+++ b/testdata/v2_full/.circleci/config.yml
@@ -0,0 +1,68 @@
+
+version: 2
+
+working_directory: &working_directory ~/fogg
+
+jobs:
+  build:
+    docker:
+      - image: circleci/python:3.8
+    working_directory: *working_directory
+    parallelism: 1
+    steps:
+      - checkout
+      - run: python3 -m venv venv
+      - run: . venv/bin/activate
+      - run: pip install awscli --upgrade --user
+      - run: aws --version
+      - run: FOGG_VERSION="vundefined+undefined+dirty" make setup
+      - run:
+          command: |
+            .fogg/bin/fogg apply
+            if [ "$(git status --porcelain)" != "" ]; then
+              echo "git tree is dirty after running fogg apply"
+              echo "ensure you run fogg apply on your branch"
+              git status
+              exit 1
+            fi
+      - aws configure set aws_access_key_id     $IDACCT_AWS_ACCESS_KEY_ID     --profile _idacct
+      - aws configure set aws_secret_access_key $IDACCT_AWS_SECRET_ACCESS_KEY --profile _idacct
+      - aws --profile _idacct sts get-caller-identity
+      - aws configure set profile.comp1.role_arn arn:aws:iam::1234:role/foobar-role
+      - aws configure set profile.comp1.source_profile _idacct
+      - aws --profile comp1 sts get-caller-identity
+      - aws configure set profile.profile.role_arn arn:aws:iam::456:role/foobar-role
+      - aws configure set profile.profile.source_profile _idacct
+      - aws --profile profile sts get-caller-identity
+      - run:
+          command: |
+            cd terraform/accounts/bar
+            make  check
+      - run:
+          command: |
+            cd terraform/accounts/foo
+            make  check
+      - run:
+          command: |
+            cd terraform/global
+            make  check
+      - run:
+          command: |
+            cd terraform/modules/my_module
+            make  check
+      - run:
+          command: |
+            cd terraform/envs/staging/comp1
+            make  check
+      - run:
+          command: |
+            cd terraform/envs/staging/comp2
+            make  check
+      - run:
+          command: |
+            cd terraform/envs/staging/comp_helm_template
+            make  check
+      - run:
+          command: |
+            cd terraform/envs/staging/vpc
+            make  check
diff --git a/testdata/v2_full/.gitignore b/testdata/v2_full/.gitignore
index eed4e6e8..69565ae8 100644
--- a/testdata/v2_full/.gitignore
+++ b/testdata/v2_full/.gitignore
@@ -30,3 +30,5 @@
 
 buildevents.plan
 check-plan.output
+
+venv
diff --git a/testdata/v2_full/fogg.json b/testdata/v2_full/fogg.json
index bf5b4e3f..dbb5b2cd 100644
--- a/testdata/v2_full/fogg.json
+++ b/testdata/v2_full/fogg.json
@@ -26,6 +26,10 @@
         "enabled": true,
         "role_name": "bar",
         "role_path": "foo"
+      },
+      "circle_ci": {
+        "enabled": true,
+        "aws_iam_role_name": "foobar-role"
       }
     }
   },
@@ -90,4 +94,4 @@
       }
     }
   }
-}
\ No newline at end of file
+}
diff --git a/testdata/v2_full/scripts/common.mk b/testdata/v2_full/scripts/common.mk
index 65de6506..6dbaa0c7 100644
--- a/testdata/v2_full/scripts/common.mk
+++ b/testdata/v2_full/scripts/common.mk
@@ -13,7 +13,7 @@ REFRESH_ENABLED ?= true # Should terraform refresh during plan/apply
 TF=$(wildcard *.tf)
 
 TFENV_DIR ?= $(REPO_ROOT)/.fogg/tfenv
-export PATH :=$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
+export PATH :=$(TFENV_DIR)/libexec:$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
 export TF_PLUGIN_CACHE_DIR=$(REPO_ROOT)/.terraform.d/plugin-cache
 export TF_IN_AUTOMATION=1
 terraform_command ?= $(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/terraform
diff --git a/testdata/v2_full/scripts/component.mk b/testdata/v2_full/scripts/component.mk
index e8174ffb..08c359e8 100644
--- a/testdata/v2_full/scripts/component.mk
+++ b/testdata/v2_full/scripts/component.mk
@@ -25,7 +25,7 @@ fmt: terraform ## format code in this component
 lint: lint-terraform-fmt lint-tflint ## run all linters for this component
 .PHONY: lint
 
-lint-tflint: init ## run the tflint linter for this component
+lint-tflint: ## run the tflint linter for this component
 	@printf "tflint: "
 ifeq ($(TFLINT_ENABLED),1)
 	@tflint || exit $$?;
diff --git a/testdata/v2_minimal_valid/.gitignore b/testdata/v2_minimal_valid/.gitignore
index eed4e6e8..69565ae8 100644
--- a/testdata/v2_minimal_valid/.gitignore
+++ b/testdata/v2_minimal_valid/.gitignore
@@ -30,3 +30,5 @@
 
 buildevents.plan
 check-plan.output
+
+venv
diff --git a/testdata/v2_minimal_valid/scripts/common.mk b/testdata/v2_minimal_valid/scripts/common.mk
index 65de6506..6dbaa0c7 100644
--- a/testdata/v2_minimal_valid/scripts/common.mk
+++ b/testdata/v2_minimal_valid/scripts/common.mk
@@ -13,7 +13,7 @@ REFRESH_ENABLED ?= true # Should terraform refresh during plan/apply
 TF=$(wildcard *.tf)
 
 TFENV_DIR ?= $(REPO_ROOT)/.fogg/tfenv
-export PATH :=$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
+export PATH :=$(TFENV_DIR)/libexec:$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
 export TF_PLUGIN_CACHE_DIR=$(REPO_ROOT)/.terraform.d/plugin-cache
 export TF_IN_AUTOMATION=1
 terraform_command ?= $(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/terraform
diff --git a/testdata/v2_minimal_valid/scripts/component.mk b/testdata/v2_minimal_valid/scripts/component.mk
index e8174ffb..08c359e8 100644
--- a/testdata/v2_minimal_valid/scripts/component.mk
+++ b/testdata/v2_minimal_valid/scripts/component.mk
@@ -25,7 +25,7 @@ fmt: terraform ## format code in this component
 lint: lint-terraform-fmt lint-tflint ## run all linters for this component
 .PHONY: lint
 
-lint-tflint: init ## run the tflint linter for this component
+lint-tflint: ## run the tflint linter for this component
 	@printf "tflint: "
 ifeq ($(TFLINT_ENABLED),1)
 	@tflint || exit $$?;
diff --git a/testdata/v2_no_aws_provider/.gitignore b/testdata/v2_no_aws_provider/.gitignore
index eed4e6e8..69565ae8 100644
--- a/testdata/v2_no_aws_provider/.gitignore
+++ b/testdata/v2_no_aws_provider/.gitignore
@@ -30,3 +30,5 @@
 
 buildevents.plan
 check-plan.output
+
+venv
diff --git a/testdata/v2_no_aws_provider/scripts/common.mk b/testdata/v2_no_aws_provider/scripts/common.mk
index 65de6506..6dbaa0c7 100644
--- a/testdata/v2_no_aws_provider/scripts/common.mk
+++ b/testdata/v2_no_aws_provider/scripts/common.mk
@@ -13,7 +13,7 @@ REFRESH_ENABLED ?= true # Should terraform refresh during plan/apply
 TF=$(wildcard *.tf)
 
 TFENV_DIR ?= $(REPO_ROOT)/.fogg/tfenv
-export PATH :=$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
+export PATH :=$(TFENV_DIR)/libexec:$(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/:$(REPO_ROOT)/.fogg/bin:$(PATH)
 export TF_PLUGIN_CACHE_DIR=$(REPO_ROOT)/.terraform.d/plugin-cache
 export TF_IN_AUTOMATION=1
 terraform_command ?= $(TFENV_DIR)/versions/$(TERRAFORM_VERSION)/terraform
diff --git a/testdata/v2_no_aws_provider/scripts/component.mk b/testdata/v2_no_aws_provider/scripts/component.mk
index e8174ffb..08c359e8 100644
--- a/testdata/v2_no_aws_provider/scripts/component.mk
+++ b/testdata/v2_no_aws_provider/scripts/component.mk
@@ -25,7 +25,7 @@ fmt: terraform ## format code in this component
 lint: lint-terraform-fmt lint-tflint ## run all linters for this component
 .PHONY: lint
 
-lint-tflint: init ## run the tflint linter for this component
+lint-tflint: ## run the tflint linter for this component
 	@printf "tflint: "
 ifeq ($(TFLINT_ENABLED),1)
 	@tflint || exit $$?;