From ac6faff234c4eb43a93e10a188fd4e1ec98efd4e Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Mon, 17 Aug 2026 10:57:23 +0100 Subject: [PATCH] fix(autodetect): stop dropping nested non-Terraform projects The final filter in filterProjects dropped any non-Terraform project that had a project ancestor. Nesting is the normal, correct layout for several formats - a Kustomize app directory holds base/ and overlays/ subdirectories that are themselves kustomization directories - so this discarded projects that should have been detected. The effect is worst in a GitOps repository that keeps a controller manifest in its root, pointing at the manifests held elsewhere in the tree. That root directory is a Kubernetes project in its own right, so every project in the repository has a project ancestor and all of them are dropped, collapsing the repository to its root - which is read non-recursively and yields nothing. The filter's documented purpose is narrower than its condition: a CloudFormation template inside a Terraform project is usually deployed by that Terraform via aws_cloudformation_stack, so emitting it standalone represents one deployment twice. Check for that case specifically. Where a parent genuinely owns its nested directories, the parent's IdentifyEnvironments answer already claims them, which suppresses them precisely rather than by proximity. This also restores nesting for ARM and Cisco Stacks projects, which the blanket condition dropped for the same reason. IsInsideProject had no other callers and is replaced by the narrower IsInsideTerraformProject. --- autodetect/node.go | 14 +++-- autodetect/search.go | 18 +++++- generate_nesting_with_plugins_test.go | 87 +++++++++++++++++++++++++++ plugin_setup_test.go | 1 + setup_test.go | 53 ++++++++++++++++ 5 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 generate_nesting_with_plugins_test.go diff --git a/autodetect/node.go b/autodetect/node.go index 8131a24..96086d7 100644 --- a/autodetect/node.go +++ b/autodetect/node.go @@ -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 } diff --git a/autodetect/search.go b/autodetect/search.go index 225875f..2655c73 100644 --- a/autodetect/search.go +++ b/autodetect/search.go @@ -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) diff --git a/generate_nesting_with_plugins_test.go b/generate_nesting_with_plugins_test.go new file mode 100644 index 0000000..2625514 --- /dev/null +++ b/generate_nesting_with_plugins_test.go @@ -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", + }, + }) +} diff --git a/plugin_setup_test.go b/plugin_setup_test.go index b7a63cd..5c0d6ca 100644 --- a/plugin_setup_test.go +++ b/plugin_setup_test.go @@ -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 diff --git a/setup_test.go b/setup_test.go index 359198b..2510c19 100644 --- a/setup_test.go +++ b/setup_test.go @@ -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,