Skip to content

Commit 247f26c

Browse files
committed
feat: filter tag policies by IaC type [FIX-488]
Tagging policies need to target specific IaC types, since tags behave differently in Kubernetes to other IaC types and further types may diverge in future. Adds pkg/project as the canonical home for IaC project types. These values are currently declared in infracost/config, which already depends on go-proto, so putting them at the bottom of the graph lets config alias them rather than declare its own and keeps config, the CLI and the runner agreeing on the spelling. It also adds the kubernetes type, which config was missing and the kubernetes parser plugin hardcodes as a literal. EvaluateAgainstResources now matches TagPolicy.iac_type_filter against ProjectInfo.type. The project's type is collapsed onto the set a user can select before matching, so a cloudformation policy still covers CDK projects and a terraform policy still covers Cisco Stacks and untyped projects. Terragrunt stays distinct, being a tool users target deliberately. NOTE: go.mod pins infracost/proto to a commit on its FIX-488 branch. This must be re-pointed at the release tag before merge.
1 parent 77d9049 commit 247f26c

6 files changed

Lines changed: 174 additions & 2 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.25.2
44

55
require (
66
github.com/hashicorp/hcl/v2 v2.24.0
7-
github.com/infracost/proto v1.150.0
7+
github.com/infracost/proto v1.163.1-0.20260804063818-1685ac50472d
88
github.com/stretchr/testify v1.11.1
99
github.com/zclconf/go-cty v1.17.0
1010
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQx
2626
github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=
2727
github.com/infracost/proto v1.150.0 h1:vLB8ozP4u+WAlEPeevsC2I35r1/7rh7DNuhy4zm3gpw=
2828
github.com/infracost/proto v1.150.0/go.mod h1:Z8vPWBWblwJlw+/ksO+BtsXwf9NiOcSTWx0WRWNbfUA=
29+
github.com/infracost/proto v1.163.1-0.20260804063818-1685ac50472d h1:q07iBffPPUg2dqzI01GOh+rFNhou7aO+pURWy9bzzKA=
30+
github.com/infracost/proto v1.163.1-0.20260804063818-1685ac50472d/go.mod h1:Z8vPWBWblwJlw+/ksO+BtsXwf9NiOcSTWx0WRWNbfUA=
2931
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
3032
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
3133
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

pkg/event/tagging.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/agnivade/levenshtein"
1010
"github.com/infracost/go-proto/pkg/address"
11+
"github.com/infracost/go-proto/pkg/project"
1112
"github.com/infracost/proto/gen/go/infracost/parser/event"
1213
"github.com/infracost/proto/gen/go/infracost/provider"
1314
)
@@ -83,11 +84,18 @@ type TagPolicies []*event.TagPolicy
8384

8485
func (t TagPolicies) EvaluateAgainstResources(resources []*provider.Resource, projectInfo *provider.ProjectInfo) []TaggingPolicyResult {
8586

87+
// Policies are filtered by the family a user can select, not the raw type,
88+
// so a cloudformation policy still covers a CDK project. Filter values are
89+
// validated against that set when the policy is saved, so only the
90+
// project's own type needs collapsing here.
91+
iacType := string(project.NormalizeForFilter(project.Type(projectInfo.GetType())))
92+
8693
// filter out policies for other repos/branches etc.
8794
var filteredPolicies []*event.TagPolicy
8895
for _, policy := range t {
8996
if StringFilterFromProto(policy.GetProjectFilter()).Matches(projectInfo.Name) &&
90-
StringFilterFromProto(policy.GetBranchFilter()).Matches(projectInfo.BranchName) {
97+
StringFilterFromProto(policy.GetBranchFilter()).Matches(projectInfo.BranchName) &&
98+
StringFilterFromProto(policy.GetIacTypeFilter()).Matches(iacType) {
9199
filteredPolicies = append(filteredPolicies, policy)
92100
}
93101
}

pkg/event/tagging_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,56 @@ func TestEvaluateAgainstResources_ProjectFilter(t *testing.T) {
408408
})
409409
}
410410

411+
func TestEvaluateAgainstResources_IacTypeFilter(t *testing.T) {
412+
resources := []*provider.Resource{
413+
mkResource("aws_instance.web", "aws_instance", []*provider.Tag{mkTag("Env", "prod")}),
414+
}
415+
requirements := []*eventpb.TagPolicyRequirement{
416+
{Key: "Env", Type: eventpb.TagPolicyRequirement_ANY, Mandatory: true},
417+
}
418+
419+
tests := []struct {
420+
name string
421+
filter *eventpb.StringFilter
422+
projectType string
423+
wantApplies bool
424+
}{
425+
{"no filter applies to everything", nil, "kubernetes", true},
426+
{"included type applies", &eventpb.StringFilter{Include: []string{"kubernetes"}}, "kubernetes", true},
427+
{"type outside include list is skipped", &eventpb.StringFilter{Include: []string{"kubernetes"}}, "terraform", false},
428+
{"excluded type is skipped", &eventpb.StringFilter{Exclude: []string{"kubernetes"}}, "kubernetes", false},
429+
{"type outside exclude list applies", &eventpb.StringFilter{Exclude: []string{"kubernetes"}}, "terraform", true},
430+
{"multiple included types", &eventpb.StringFilter{Include: []string{"terraform", "cloudformation"}}, "cloudformation", true},
431+
432+
// The project's type is collapsed onto the selectable set before
433+
// matching, so CDK projects are covered by a cloudformation policy.
434+
{"cdk project matches cloudformation", &eventpb.StringFilter{Include: []string{"cloudformation"}}, "cdk_python", true},
435+
{"cisco stacks matches terraform", &eventpb.StringFilter{Include: []string{"terraform"}}, "cisco_stacks", true},
436+
{"untyped project matches terraform", &eventpb.StringFilter{Include: []string{"terraform"}}, "", true},
437+
{"terragrunt is not covered by terraform", &eventpb.StringFilter{Include: []string{"terraform"}}, "terragrunt", false},
438+
}
439+
440+
for _, tt := range tests {
441+
t.Run(tt.name, func(t *testing.T) {
442+
policies := TagPolicies{{
443+
Id: "iac-type-filter",
444+
Name: "IaC Type Filter Policy",
445+
IacTypeFilter: tt.filter,
446+
Requirements: requirements,
447+
}}
448+
449+
projectInfo := &provider.ProjectInfo{Name: "proj", BranchName: "main", Type: tt.projectType}
450+
results := policies.EvaluateAgainstResources(resources, projectInfo)
451+
452+
if tt.wantApplies {
453+
assert.Len(t, results, 1)
454+
} else {
455+
assert.Empty(t, results)
456+
}
457+
})
458+
}
459+
}
460+
411461
func TestEvaluateAgainstResources_MultipleRequirements(t *testing.T) {
412462
policy := &eventpb.TagPolicy{
413463
Id: "multi-req",

pkg/project/project.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Package project holds the canonical definition of an IaC project type: the
2+
// value a parser plugin reports via GetParserConfig's config_file_project_type,
3+
// which the config file records and ProjectInfo.type carries over the wire.
4+
//
5+
// This package is the single source of truth for those values. The config
6+
// package aliases them rather than declaring its own, so config, the CLI and
7+
// the runner all agree on the spelling. It deliberately has no imports, so it
8+
// can sit at the bottom of the dependency graph.
9+
package project
10+
11+
// Type is the project type reported by a parser plugin. It is an open string
12+
// rather than an enum: a plugin may return a custom type via
13+
// config_file_project_type, defaulting to its own name when unset.
14+
type Type string
15+
16+
const (
17+
// Unknown is a project with no recorded type. Treated as Terraform, which
18+
// is what the config file assumes for an untyped project.
19+
Unknown Type = ""
20+
Terraform Type = "terraform"
21+
Terragrunt Type = "terragrunt"
22+
CloudFormation Type = "cloudformation"
23+
CDKTypeScript Type = "cdk_typescript"
24+
CDKJavaScript Type = "cdk_javascript"
25+
CDKPython Type = "cdk_python"
26+
CiscoStacks Type = "cisco_stacks"
27+
Kubernetes Type = "kubernetes"
28+
)
29+
30+
// Filterable is the set of types a governance policy can be filtered by, in
31+
// display order. It is deliberately coarser than the full set above:
32+
// NormalizeForFilter folds the niche and derived types onto the family a user
33+
// would recognise, so policies are written against a short, stable list.
34+
var Filterable = []Type{Terraform, Terragrunt, CloudFormation, Kubernetes}
35+
36+
// NormalizeForFilter collapses a project type onto the Filterable set, so a
37+
// policy targeting "cloudformation" also covers the CDK variants that the
38+
// cloudformation plugin parses, and one targeting "terraform" covers the
39+
// Terraform-family types that are not worth surfacing separately.
40+
//
41+
// Terragrunt is kept distinct despite sharing Terraform's options schema: it is
42+
// a tool users target deliberately. Cisco Stacks is not, and is already
43+
// reported as Terraform elsewhere.
44+
//
45+
// Anything unrecognised is returned unchanged: a new plugin's type must not
46+
// silently fall into an existing family before it is added above. Callers
47+
// filtering on the result will simply not match it, which is the safe default.
48+
func NormalizeForFilter(t Type) Type {
49+
switch t {
50+
case Unknown, Terraform, CiscoStacks:
51+
return Terraform
52+
case CloudFormation, CDKTypeScript, CDKJavaScript, CDKPython:
53+
return CloudFormation
54+
case "terraform-plan":
55+
// Not a declared project type: the terraform-plan plugin sets no
56+
// config_file_project_type, so the caller falls back to the plugin name.
57+
return Terraform
58+
default:
59+
return t
60+
}
61+
}

pkg/project/project_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package project
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestNormalizeForFilter(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
input Type
13+
expected Type
14+
}{
15+
{"terraform is unchanged", Terraform, Terraform},
16+
{"terragrunt stays distinct", Terragrunt, Terragrunt},
17+
{"cloudformation is unchanged", CloudFormation, CloudFormation},
18+
{"kubernetes is unchanged", Kubernetes, Kubernetes},
19+
20+
{"untyped project folds to terraform", Unknown, Terraform},
21+
{"cisco stacks folds to terraform", CiscoStacks, Terraform},
22+
{"terraform-plan folds to terraform", "terraform-plan", Terraform},
23+
24+
{"cdk typescript folds to cloudformation", CDKTypeScript, CloudFormation},
25+
{"cdk javascript folds to cloudformation", CDKJavaScript, CloudFormation},
26+
{"cdk python folds to cloudformation", CDKPython, CloudFormation},
27+
28+
{"unrecognised type is left alone", "pulumi", "pulumi"},
29+
}
30+
31+
for _, tt := range tests {
32+
t.Run(tt.name, func(t *testing.T) {
33+
assert.Equal(t, tt.expected, NormalizeForFilter(tt.input))
34+
})
35+
}
36+
}
37+
38+
// Every type must normalize onto the set users can actually pick from,
39+
// otherwise a policy could never match projects of that type.
40+
func TestNormalizeForFilter_LandsOnFilterableSet(t *testing.T) {
41+
all := []Type{
42+
Unknown, Terraform, Terragrunt, CloudFormation,
43+
CDKTypeScript, CDKJavaScript, CDKPython, CiscoStacks, Kubernetes,
44+
}
45+
46+
for _, projectType := range all {
47+
t.Run(string(projectType), func(t *testing.T) {
48+
assert.Contains(t, Filterable, NormalizeForFilter(projectType))
49+
})
50+
}
51+
}

0 commit comments

Comments
 (0)