Skip to content
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
14 changes: 9 additions & 5 deletions autodetect/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,20 @@ func (n *Node) IsCloudFormation() bool {
return n.ProjectType == projecttype.CloudFormation
}

func (n *Node) IsInsideProject() bool {
// IsInsideTerraformProject reports whether any ancestor of n is a Terraform or
// Terragrunt project. It is deliberately narrower than "is inside any project":
// nested projects are the normal layout for several formats (a Kustomize app
// directory holds base/ and overlays/ subdirectories that are themselves
// projects), so proximity to another project is not on its own a reason to drop
// one.
func (n *Node) IsInsideTerraformProject() bool {
if n == nil {
return false
}
n = n.Parent
for n != nil {
if n.IsProject() {
for p := n.Parent; p != nil; p = p.Parent {
if p.IsTerraform() || p.IsTerragrunt() {
return true
}
n = n.Parent
}
return false
}
Expand Down
18 changes: 16 additions & 2 deletions autodetect/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,24 @@ func filterProjects(tree *Node, projectNodes []*Node, rootDir string, config *Co
projectNodes = filtered
}

// remove cfn projects that lie within a tf/tg project
// Remove CloudFormation projects that lie within a Terraform/Terragrunt
// project: such a template is nearly always deployed by the Terraform that
// encloses it (via aws_cloudformation_stack), so emitting it standalone
// represents one deployment twice.
//
// This deliberately checks CloudFormation-inside-Terraform specifically
// rather than "any non-Terraform project inside any project". Nesting is the
// normal, correct layout for other formats - a Kustomize app is a directory
// holding base/ and overlays/ that are themselves kustomization directories -
// and suppressing a nested project because a parent directory also identifies
// is what dropped every Kubernetes project in a GitOps repository whose root
// happened to hold a single manifest. Where a parent genuinely owns its
// nested directories, the parent's IdentifyEnvironments answer claims them
// (see claimedDirs in expandProjects), which suppresses them precisely instead
// of by proximity.
filtered = make([]*Node, 0, len(projectNodes))
for _, project := range projectNodes {
if (!project.IsTerragrunt() && !project.IsTerraform()) && project.IsInsideProject() {
if project.IsCloudFormation() && project.IsInsideTerraformProject() {
continue
}
filtered = append(filtered, project)
Expand Down
87 changes: 87 additions & 0 deletions generate_nesting_with_plugins_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package config_test

import (
"testing"

"github.com/infracost/config"
)

// Test_Generate_NestedKubernetesProjects_WithPlugins checks that projects nested
// beneath a directory that is itself a project are still detected.
//
// A GitOps repository commonly keeps a controller manifest in its root - an
// object pointing at the manifests held elsewhere in the tree - which makes the
// root a Kubernetes project in its own right. Every real project in the
// repository then sits beneath it. Nesting like this is the normal layout for
// Kubernetes (a Kustomize app directory holds base/ and overlays/ that are
// themselves kustomization directories), so a project must not be dropped merely
// for having a project ancestor: doing so collapses the whole repository to its
// root, which is read non-recursively and yields nothing.
//
// Where a parent genuinely owns its nested directories, the parent's
// IdentifyEnvironments answer claims them and they are suppressed precisely -
// which is why app/base and app/overlays/dev do not appear here as projects of
// their own, while app itself expands into its dev environment.
func Test_Generate_NestedKubernetesProjects_WithPlugins(t *testing.T) {
root := NewFilesystem(t)
root.AddGitOpsControllerManifest("root-app.yaml")

app := root.AddDirectory("app")
base := app.AddDirectory("base")
base.AddKustomization("deployment.yaml")
base.AddKubernetesDeployment("deployment.yaml")
dev := app.AddDirectory("overlays").AddDirectory("dev")
dev.AddKustomization("../../base")

wrappers := root.AddDirectory("wrappers")
wrappers.AddGitOpsControllerManifest("frontend.yaml")

testConfigGenerationWithPlugins(t, root.Path(), []*config.Project{
{
Name: "main",
Path: ".",
Type: "kubernetes",
},
{
Name: "app-dev",
Path: "app/overlays/dev",
EnvName: "dev",
Type: "kubernetes",
DependencyPaths: []string{"app/base/**"},
},
{
Name: "wrappers",
Path: "wrappers",
Type: "kubernetes",
},
})
}

// Test_Generate_CloudFormationInsideTerraform_WithPlugins pins the one nesting
// case that is still suppressed: a CloudFormation template inside a Terraform
// project is nearly always deployed by that Terraform (via
// aws_cloudformation_stack), so emitting it standalone would represent a single
// deployment twice. The same template outside a Terraform project is a project.
func Test_Generate_CloudFormationInsideTerraform_WithPlugins(t *testing.T) {
root := NewFilesystem(t)

infra := root.AddDirectory("infra")
infra.AddTerraformFileWithProviderBlock("main.tf")
infra.AddCFNYAML()

standalone := root.AddDirectory("standalone")
standalone.AddCFNYAML()

testConfigGenerationWithPlugins(t, root.Path(), []*config.Project{
{
Name: "infra",
Path: "infra",
Type: "terraform",
},
{
Name: "standalone-template",
Path: "standalone/template.yml",
Type: "cloudformation",
},
})
}
1 change: 1 addition & 0 deletions plugin_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ var requiredPlugins = []string{
"infracost-parser-terragrunt",
"infracost-parser-cloudformation",
"infracost-parser-ciscostacks",
"infracost-parser-kubernetes",
}

// pluginDir is the directory the test plugins are extracted into. It is
Expand Down
53 changes: 53 additions & 0 deletions setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,59 @@ func (d *Directory) AddCFNJSON() {
}`)
}

// AddKubernetesDeployment writes a manifest holding a single costed workload,
// which makes the directory a raw-manifest Kubernetes project.
func (d *Directory) AddKubernetesDeployment(name string) {
d.AddFile(name).Content(`apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
template:
spec:
containers:
- name: web
image: web:1
resources:
requests:
cpu: 500m
memory: 512Mi
`)
}

// AddKustomization writes a kustomization file referencing the given resources,
// which makes the directory a Kustomize project. A reference to a sibling
// directory's base is what marks this directory as an overlay of it.
func (d *Directory) AddKustomization(resources ...string) {
content := "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n"
for _, resource := range resources {
content += fmt.Sprintf(" - %s\n", resource)
}
d.AddFile("kustomization.yaml").Content(content)
}

// AddGitOpsControllerManifest writes a manifest for a GitOps controller object
// that points at manifests held elsewhere rather than describing a workload of
// its own (here an Argo CD Application). It carries apiVersion and kind like any
// manifest, so a directory holding only these is still a Kubernetes project -
// one that happens to have nothing to cost.
func (d *Directory) AddGitOpsControllerManifest(name string) {
d.AddFile(name).Content(`apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root-app
namespace: argocd
spec:
source:
path: apps
repoURL: https://github.com/example/manifests.git
targetRevision: HEAD
directory:
recurse: true
`)
}

func (d *Directory) AddFile(name string) *File {
f := &File{
t: d.t,
Expand Down
Loading