diff --git a/go.mod b/go.mod index ee07f20..b9b416c 100644 --- a/go.mod +++ b/go.mod @@ -93,3 +93,5 @@ require ( gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/infracost/proto => ../proto diff --git a/go.sum b/go.sum index f1c0475..cbcb046 100644 --- a/go.sum +++ b/go.sum @@ -116,8 +116,6 @@ github.com/infracost/config v0.5.0 h1:sB+AJ7dkJKsfdiCpPLIDKYp/ND64cPT1G8z+p+rRF4 github.com/infracost/config v0.5.0/go.mod h1:XyCBuDQowPYNF6n95+Woz61snyaf7K94DVbNiHcg+1A= github.com/infracost/go-proto v1.13.0 h1:XiwWCPyCSHj3u4a8h558clCV8yhiSZlaz1082IPmWdM= github.com/infracost/go-proto v1.13.0/go.mod h1:2+Ca9361C01Kfi1QWdf8Rab5Eul4L+6gNxnxb3TXrGs= -github.com/infracost/proto v1.34.0 h1:zkp3C8YCozE6hZ8bP00dk4jQE2c0/QZDZjkII08k9/g= -github.com/infracost/proto v1.34.0/go.mod h1:Z8vPWBWblwJlw+/ksO+BtsXwf9NiOcSTWx0WRWNbfUA= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/pkg/plugins/config.go b/pkg/plugins/config.go index 80107d0..eaa2874 100644 --- a/pkg/plugins/config.go +++ b/pkg/plugins/config.go @@ -61,6 +61,8 @@ func (c *Config) Process() { func (c *Config) EnsureParser() error { if c.Parser.Plugin != "" { + parser.PluginDir = filepath.Dir(c.Parser.Plugin) + c.ensurePerIaCPlugins() return nil } @@ -69,9 +71,46 @@ func (c *Config) EnsureParser() error { return err } c.Parser.Plugin = path + parser.PluginDir = filepath.Dir(path) + c.ensurePerIaCPlugins() return nil } +// ensurePerIaCPlugins downloads any available per-IaC parser plugins into the +// same directory as the mono-parser. Failures are non-fatal — the mono-parser +// fallback still works. +func (c *Config) ensurePerIaCPlugins() { + if parser.PluginDir == "" { + return + } + + perIaCPlugins := []string{ + "infracost-parser-plugin-terraform", + "infracost-parser-plugin-cloudformation", + } + + for _, name := range perIaCPlugins { + path, err := c.Ensure(name, "") + if err != nil { + logging.Debugf("per-IaC plugin %s not available: %v", name, err) + continue + } + + dest := filepath.Join(parser.PluginDir, pluginBinaryName(name)) + if path == dest { + continue + } + + // Symlink into the plugin directory so the manager finds it. + if _, err := os.Stat(dest); err == nil { + continue + } + if err := os.Symlink(path, dest); err != nil { + logging.Debugf("failed to symlink per-IaC plugin %s: %v", name, err) + } + } +} + func (c *Config) EnsureProvider(provider proto.Provider) error { override, version := c.providerOverride(provider) if override != "" { diff --git a/pkg/plugins/parser/manager.go b/pkg/plugins/parser/manager.go new file mode 100644 index 0000000..767f27b --- /dev/null +++ b/pkg/plugins/parser/manager.go @@ -0,0 +1,162 @@ +package parser + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/hashicorp/go-hclog" + "github.com/infracost/cli/pkg/logging" + proto "github.com/infracost/proto/gen/go/infracost/parser/api" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// PerIaCPlugin represents a discovered per-IaC parser plugin. +type PerIaCPlugin struct { + Name string + Path string + Metadata *proto.DescribeResponse + client proto.ParserServiceClient + stop func() +} + +// PluginManager discovers per-IaC parser plugins and provides dual-mode +// routing: per-IaC plugins first, then fallback to the mono-parser. +type PluginManager struct { + perIaCPlugins []*PerIaCPlugin + level hclog.Level +} + +// NewPluginManager creates a manager and discovers per-IaC plugins +// in the given directory. Only binaries matching infracost-parser-plugin-* +// are loaded. Plugins that fail to load or don't support Describe are skipped. +func NewPluginManager(pluginDir string, level hclog.Level) *PluginManager { + m := &PluginManager{level: level} + m.discoverPlugins(pluginDir) + return m +} + +func (m *PluginManager) discoverPlugins(pluginDir string) { + if pluginDir == "" { + return + } + + entries, err := os.ReadDir(pluginDir) + if err != nil { + logging.Debugf("plugin manager: cannot read plugin directory %s: %v", pluginDir, err) + return + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !isPerIaCPluginBinary(name) { + continue + } + + pluginPath := filepath.Join(pluginDir, name) + p, err := m.loadPerIaCPlugin(pluginPath) + if err != nil { + logging.Debugf("plugin manager: skipping %s: %v", name, err) + continue + } + m.perIaCPlugins = append(m.perIaCPlugins, p) + } + + sort.Slice(m.perIaCPlugins, func(i, j int) bool { + return m.perIaCPlugins[i].Metadata.Priority < m.perIaCPlugins[j].Metadata.Priority + }) + + if len(m.perIaCPlugins) > 0 { + names := make([]string, len(m.perIaCPlugins)) + for i, p := range m.perIaCPlugins { + names[i] = p.Name + } + logging.Debugf("plugin manager: discovered %d per-IaC plugins: %s", len(m.perIaCPlugins), strings.Join(names, ", ")) + } +} + +func isPerIaCPluginBinary(name string) bool { + base := strings.TrimSuffix(name, ".exe") + return strings.HasPrefix(base, "infracost-parser-plugin-") && + !strings.HasSuffix(base, "-debug") +} + +func (m *PluginManager) loadPerIaCPlugin(path string) (*PerIaCPlugin, error) { + client, stop, err := Connect(path, m.level) + if err != nil { + return nil, fmt.Errorf("connect: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + desc, err := client.Describe(ctx, &proto.DescribeRequest{}) + if err != nil { + stop() + if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented { + return nil, fmt.Errorf("plugin does not implement Describe RPC") + } + return nil, fmt.Errorf("describe: %w", err) + } + + return &PerIaCPlugin{ + Name: desc.Name, + Path: path, + Metadata: desc, + client: client, + stop: stop, + }, nil +} + +// Detect asks all per-IaC plugins whether they can handle the given path, +// in priority order. Returns the claiming plugin and project type, or nil +// if no per-IaC plugin claims the path. +func (m *PluginManager) Detect(ctx context.Context, path string) (*PerIaCPlugin, string, error) { + for _, p := range m.perIaCPlugins { + resp, err := p.client.Detect(ctx, &proto.DetectRequest{Path: path}) + if err != nil { + if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented { + continue + } + logging.Debugf("plugin manager: detect error from %s: %v", p.Name, err) + continue + } + if resp.Detected { + return p, resp.ProjectType, nil + } + } + return nil, "", nil +} + +// Client returns the gRPC client for a per-IaC plugin. +func (p *PerIaCPlugin) Client() proto.ParserServiceClient { + return p.client +} + +// HasPlugins returns true if any per-IaC plugins were discovered. +func (m *PluginManager) HasPlugins() bool { + return len(m.perIaCPlugins) > 0 +} + +// Plugins returns the list of discovered per-IaC plugins. +func (m *PluginManager) Plugins() []*PerIaCPlugin { + return m.perIaCPlugins +} + +// Close shuts down all per-IaC plugin connections. +func (m *PluginManager) Close() { + for _, p := range m.perIaCPlugins { + if p.stop != nil { + p.stop() + } + } + m.perIaCPlugins = nil +} diff --git a/pkg/plugins/parser/manager_test.go b/pkg/plugins/parser/manager_test.go new file mode 100644 index 0000000..39eca4d --- /dev/null +++ b/pkg/plugins/parser/manager_test.go @@ -0,0 +1,346 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/hashicorp/go-hclog" +) + +func TestIsPerIaCPluginBinary(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {"infracost-parser-plugin-terraform", true}, + {"infracost-parser-plugin-cloudformation", true}, + {"infracost-parser-plugin-terraform.exe", true}, + {"infracost-parser-plugin-cloudformation.exe", true}, + {"infracost-parser-plugin", false}, + {"infracost-parser-plugin.exe", false}, + {"infracost-parser-plugin-terraform-debug", false}, + {"infracost-parser-plugin-terraform-debug.exe", false}, + {"random-binary", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isPerIaCPluginBinary(tt.name) + if got != tt.want { + t.Errorf("isPerIaCPluginBinary(%q) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func TestNewPluginManager_EmptyDir(t *testing.T) { + dir := t.TempDir() + mgr := NewPluginManager(dir, hclog.Off) + defer mgr.Close() + + if mgr.HasPlugins() { + t.Fatal("expected no plugins in empty directory") + } + if len(mgr.Plugins()) != 0 { + t.Fatalf("expected 0 plugins, got %d", len(mgr.Plugins())) + } +} + +func TestNewPluginManager_EmptyString(t *testing.T) { + mgr := NewPluginManager("", hclog.Off) + defer mgr.Close() + + if mgr.HasPlugins() { + t.Fatal("expected no plugins with empty plugin dir") + } +} + +func TestNewPluginManager_NonexistentDir(t *testing.T) { + mgr := NewPluginManager("/nonexistent/plugin/dir", hclog.Off) + defer mgr.Close() + + if mgr.HasPlugins() { + t.Fatal("expected no plugins with nonexistent dir") + } +} + +func TestNewPluginManager_NoMatchingBinaries(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "random-binary") + if err := os.WriteFile(f, []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatal(err) + } + + mgr := NewPluginManager(dir, hclog.Off) + defer mgr.Close() + + if mgr.HasPlugins() { + t.Fatal("expected no plugins when no matching binaries exist") + } +} + +func TestDetect_NoPlugins(t *testing.T) { + dir := t.TempDir() + mgr := NewPluginManager(dir, hclog.Off) + defer mgr.Close() + + plugin, projectType, err := mgr.Detect(context.Background(), "/some/path") + if err != nil { + t.Fatal(err) + } + if plugin != nil { + t.Fatal("expected nil plugin when no plugins are discovered") + } + if projectType != "" { + t.Fatalf("expected empty project type, got %q", projectType) + } +} + +func TestPluginManager_Close_Idempotent(t *testing.T) { + dir := t.TempDir() + mgr := NewPluginManager(dir, hclog.Off) + mgr.Close() + mgr.Close() + if mgr.HasPlugins() { + t.Fatal("expected no plugins after close") + } +} + +// Integration tests that use real compiled per-IaC plugin binaries. +// These require the parser binaries to be built first: +// cd /path/to/parser && make build-plugins + +func parserBinDir() string { + binDir := os.Getenv("INFRACOST_TEST_PARSER_BIN_DIR") + if binDir != "" { + return binDir + } + // Default to the relative path from the CLI repo root. + return filepath.Join("..", "..", "..", "..", "parser", "bin") +} + +func hasPerIaCPlugins(t *testing.T) string { + t.Helper() + dir := parserBinDir() + tfPlugin := filepath.Join(dir, "infracost-parser-plugin-terraform") + cfnPlugin := filepath.Join(dir, "infracost-parser-plugin-cloudformation") + + if _, err := os.Stat(tfPlugin); err != nil { + t.Skipf("terraform per-IaC plugin not found at %s (run: cd parser && make build-plugins)", tfPlugin) + } + if _, err := os.Stat(cfnPlugin); err != nil { + t.Skipf("cloudformation per-IaC plugin not found at %s (run: cd parser && make build-plugins)", cfnPlugin) + } + abs, _ := filepath.Abs(dir) + return abs +} + +func TestIntegration_PluginManager_DiscoverPlugins(t *testing.T) { + dir := hasPerIaCPlugins(t) + + mgr := NewPluginManager(dir, hclog.Off) + defer mgr.Close() + + if !mgr.HasPlugins() { + t.Fatal("expected to discover per-IaC plugins") + } + + plugins := mgr.Plugins() + if len(plugins) < 2 { + t.Fatalf("expected at least 2 plugins, got %d", len(plugins)) + } + + names := make(map[string]bool) + for _, p := range plugins { + names[p.Name] = true + } + if !names["terraform"] { + t.Error("expected terraform plugin to be discovered") + } + if !names["cloudformation"] { + t.Error("expected cloudformation plugin to be discovered") + } +} + +func TestIntegration_PluginManager_PriorityOrder(t *testing.T) { + dir := hasPerIaCPlugins(t) + + mgr := NewPluginManager(dir, hclog.Off) + defer mgr.Close() + + plugins := mgr.Plugins() + for i := 1; i < len(plugins); i++ { + if plugins[i].Metadata.Priority < plugins[i-1].Metadata.Priority { + t.Errorf("plugins not sorted by priority: %s (priority %d) before %s (priority %d)", + plugins[i-1].Name, plugins[i-1].Metadata.Priority, + plugins[i].Name, plugins[i].Metadata.Priority) + } + } + + if plugins[0].Name != "terraform" { + t.Errorf("expected terraform plugin first (lowest priority number), got %s", plugins[0].Name) + } +} + +func TestIntegration_Detect_TerraformDirectory(t *testing.T) { + dir := hasPerIaCPlugins(t) + + tfDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tfDir, "main.tf"), []byte(`resource "aws_instance" "a" {}`), 0644); err != nil { + t.Fatal(err) + } + + mgr := NewPluginManager(dir, hclog.Off) + defer mgr.Close() + + plugin, projectType, err := mgr.Detect(context.Background(), tfDir) + if err != nil { + t.Fatal(err) + } + if plugin == nil { + t.Fatal("expected terraform plugin to claim directory") + } + if plugin.Name != "terraform" { + t.Errorf("expected terraform plugin, got %s", plugin.Name) + } + if projectType != "terraform" { + t.Errorf("expected project type 'terraform', got %q", projectType) + } +} + +func TestIntegration_Detect_TerragruntDirectory(t *testing.T) { + dir := hasPerIaCPlugins(t) + + tgDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tgDir, "terragrunt.hcl"), []byte(`include "root" {}`), 0644); err != nil { + t.Fatal(err) + } + + mgr := NewPluginManager(dir, hclog.Off) + defer mgr.Close() + + plugin, projectType, err := mgr.Detect(context.Background(), tgDir) + if err != nil { + t.Fatal(err) + } + if plugin == nil { + t.Fatal("expected terraform plugin to claim terragrunt directory") + } + if projectType != "terragrunt" { + t.Errorf("expected project type 'terragrunt', got %q", projectType) + } +} + +func TestIntegration_Detect_CloudFormationJSON(t *testing.T) { + dir := hasPerIaCPlugins(t) + + cfnDir := t.TempDir() + cfnJSON := `{ + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + "MyBucket": { "Type": "AWS::S3::Bucket" } + } + }` + f := filepath.Join(cfnDir, "template.json") + if err := os.WriteFile(f, []byte(cfnJSON), 0644); err != nil { + t.Fatal(err) + } + + mgr := NewPluginManager(dir, hclog.Off) + defer mgr.Close() + + plugin, projectType, err := mgr.Detect(context.Background(), f) + if err != nil { + t.Fatal(err) + } + if plugin == nil { + t.Fatal("expected cloudformation plugin to claim JSON template") + } + if plugin.Name != "cloudformation" { + t.Errorf("expected cloudformation plugin, got %s", plugin.Name) + } + if projectType != "cloudformation" { + t.Errorf("expected project type 'cloudformation', got %q", projectType) + } +} + +func TestIntegration_Detect_CloudFormationYAML(t *testing.T) { + dir := hasPerIaCPlugins(t) + + cfnDir := t.TempDir() + cfnYAML := `AWSTemplateFormatVersion: "2010-09-09" +Resources: + MyBucket: + Type: AWS::S3::Bucket +` + f := filepath.Join(cfnDir, "stack.yaml") + if err := os.WriteFile(f, []byte(cfnYAML), 0644); err != nil { + t.Fatal(err) + } + + mgr := NewPluginManager(dir, hclog.Off) + defer mgr.Close() + + plugin, projectType, err := mgr.Detect(context.Background(), f) + if err != nil { + t.Fatal(err) + } + if plugin == nil { + t.Fatal("expected cloudformation plugin to claim YAML template") + } + if projectType != "cloudformation" { + t.Errorf("expected project type 'cloudformation', got %q", projectType) + } +} + +func TestIntegration_Detect_UnknownFile(t *testing.T) { + dir := hasPerIaCPlugins(t) + + tmpDir := t.TempDir() + f := filepath.Join(tmpDir, "data.csv") + if err := os.WriteFile(f, []byte("a,b,c\n1,2,3\n"), 0644); err != nil { + t.Fatal(err) + } + + mgr := NewPluginManager(dir, hclog.Off) + defer mgr.Close() + + plugin, _, err := mgr.Detect(context.Background(), f) + if err != nil { + t.Fatal(err) + } + if plugin != nil { + t.Errorf("expected no plugin to claim .csv file, but %s did", plugin.Name) + } +} + +func TestIntegration_Detect_TFJSONPriorityOverCFN(t *testing.T) { + dir := hasPerIaCPlugins(t) + + tmpDir := t.TempDir() + tfJSON := `{"resource": {"aws_instance": {"a": {}}}}` + f := filepath.Join(tmpDir, "main.tf.json") + if err := os.WriteFile(f, []byte(tfJSON), 0644); err != nil { + t.Fatal(err) + } + + mgr := NewPluginManager(dir, hclog.Off) + defer mgr.Close() + + plugin, projectType, err := mgr.Detect(context.Background(), f) + if err != nil { + t.Fatal(err) + } + if plugin == nil { + t.Fatal("expected a plugin to claim .tf.json file") + } + if plugin.Name != "terraform" { + t.Errorf("expected terraform plugin (higher priority) to claim .tf.json, got %s", plugin.Name) + } + if projectType != "terraform" { + t.Errorf("expected project type 'terraform', got %q", projectType) + } +} diff --git a/pkg/plugins/parser/mocks/mocks.go b/pkg/plugins/parser/mocks/mocks.go index 9fe4445..35bab1d 100644 --- a/pkg/plugins/parser/mocks/mocks.go +++ b/pkg/plugins/parser/mocks/mocks.go @@ -39,6 +39,74 @@ func (_m *MockParserServiceClient) EXPECT() *MockParserServiceClient_Expecter { return &MockParserServiceClient_Expecter{mock: &_m.Mock} } +// Describe provides a mock function for the type MockParserServiceClient +func (_mock *MockParserServiceClient) Describe(ctx context.Context, in *api.DescribeRequest, opts ...grpc.CallOption) (*api.DescribeResponse, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, in, opts) + } else { + tmpRet = _mock.Called(ctx, in) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for Describe") + } + + var r0 *api.DescribeResponse + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *api.DescribeRequest, ...grpc.CallOption) (*api.DescribeResponse, error)); ok { + return returnFunc(ctx, in, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *api.DescribeRequest, ...grpc.CallOption) *api.DescribeResponse); ok { + r0 = returnFunc(ctx, in, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*api.DescribeResponse) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *api.DescribeRequest, ...grpc.CallOption) error); ok { + r1 = returnFunc(ctx, in, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Detect provides a mock function for the type MockParserServiceClient +func (_mock *MockParserServiceClient) Detect(ctx context.Context, in *api.DetectRequest, opts ...grpc.CallOption) (*api.DetectResponse, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, in, opts) + } else { + tmpRet = _mock.Called(ctx, in) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for Detect") + } + + var r0 *api.DetectResponse + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *api.DetectRequest, ...grpc.CallOption) (*api.DetectResponse, error)); ok { + return returnFunc(ctx, in, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *api.DetectRequest, ...grpc.CallOption) *api.DetectResponse); ok { + r0 = returnFunc(ctx, in, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*api.DetectResponse) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *api.DetectRequest, ...grpc.CallOption) error); ok { + r1 = returnFunc(ctx, in, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + // Initialize provides a mock function for the type MockParserServiceClient func (_mock *MockParserServiceClient) Initialize(ctx context.Context, in *api.InitializeRequest, opts ...grpc.CallOption) (*api.InitializeResponse, error) { var tmpRet mock.Arguments diff --git a/pkg/plugins/parser/parse.go b/pkg/plugins/parser/parse.go index 860219c..a213c22 100644 --- a/pkg/plugins/parser/parse.go +++ b/pkg/plugins/parser/parse.go @@ -18,6 +18,10 @@ import ( "github.com/infracost/proto/gen/go/infracost/parser/terraform" ) +// PluginDir is the directory to scan for per-IaC parser plugins. +// Set by the CLI before parsing begins. If empty, only the mono-parser is used. +var PluginDir string + func (c *Config) Parse(ctx context.Context, path string, cfg *repoconfig.Config, project *repoconfig.Project, level hclog.Level, options *options.GenericOptions) (*api.ParseResponse, error) { var cache protocache.Cache[*api.ParseResponse] @@ -42,22 +46,75 @@ func (c *Config) Parse(ctx context.Context, path string, cfg *repoconfig.Config, } func (c *Config) parseWithoutCache(ctx context.Context, path string, cfg *repoconfig.Config, project *repoconfig.Project, level hclog.Level, options *options.GenericOptions) (*api.ParseResponse, error) { - // If the path points to a directory, assume Terraform. + // Step 1: Try per-IaC plugins if any are available. + if resp, handled, err := c.tryPerIaCPlugins(ctx, path, cfg, project, level, options); handled { + return resp, err + } + + // Step 2: Fallback to existing mono-parser routing. + return c.legacyRoute(ctx, path, cfg, project, level, options) +} + +// tryPerIaCPlugins attempts to detect and parse using per-IaC plugins. +// Returns (response, true, err) if a plugin handled the path, or (nil, false, nil) to fall back. +func (c *Config) tryPerIaCPlugins(ctx context.Context, path string, cfg *repoconfig.Config, project *repoconfig.Project, level hclog.Level, opts *options.GenericOptions) (*api.ParseResponse, bool, error) { + if PluginDir == "" { + return nil, false, nil + } + + mgr := NewPluginManager(PluginDir, level) + defer mgr.Close() + + if !mgr.HasPlugins() { + return nil, false, nil + } + + plugin, projectType, err := mgr.Detect(ctx, path) + if err != nil { + logging.Debugf("per-IaC plugin detection error: %v, falling back to mono-parser", err) + return nil, false, nil + } + if plugin == nil { + return nil, false, nil + } + + logging.Debugf("per-IaC plugin %q claimed path %s as %s", plugin.Name, path, projectType) + + client := plugin.Client() + if _, err := client.Initialize(ctx, new(api.InitializeRequest)); err != nil { + return nil, false, fmt.Errorf("failed to initialize per-IaC plugin %s: %w", plugin.Name, err) + } + + // Route to appropriate parse method based on what the plugin detected. + switch projectType { + case "terraform": + resp, err := c.parseTerraformWith(ctx, client, path, cfg, project, opts) + return resp, true, err + case "terragrunt": + resp, err := c.parseTerraformWith(ctx, client, path, cfg, project, opts) + return resp, true, err + case "cloudformation": + resp, err := c.parseCloudFormationWith(ctx, client, path, project, opts) + return resp, true, err + default: + logging.Debugf("per-IaC plugin %q returned unknown project type %q, falling back", plugin.Name, projectType) + return nil, false, nil + } +} + +// legacyRoute is the existing hardcoded routing logic (mono-parser fallback). +func (c *Config) legacyRoute(ctx context.Context, path string, cfg *repoconfig.Config, project *repoconfig.Project, level hclog.Level, options *options.GenericOptions) (*api.ParseResponse, error) { if info, err := os.Stat(path); err == nil && info.IsDir() { return c.parseTerraform(ctx, path, cfg, project, level, options) } - // If it's a file (or Stat failed), decide by extension. name := filepath.Base(path) ext := strings.ToLower(filepath.Ext(name)) - // Terraform: .tf files or .tf.json if ext == ".tf" || strings.HasSuffix(strings.ToLower(name), ".tf.json") { return c.parseTerraform(ctx, filepath.Dir(path), cfg, project, level, options) - } - // CloudFormation common template extensions: .json, .yaml, .yml, .template switch ext { case ".json", ".yaml", ".yml", ".template": return c.parseCloudFormation(ctx, path, project, level, options) @@ -66,6 +123,80 @@ func (c *Config) parseWithoutCache(ctx context.Context, path string, cfg *repoco return nil, fmt.Errorf("unsupported file type: %s, only Terraform and CloudFormation are supported", ext) } +// parseTerraformWith uses a specific client (per-IaC plugin) instead of loading the mono-parser. +func (c *Config) parseTerraformWith(ctx context.Context, client api.ParserServiceClient, path string, cfg *repoconfig.Config, project *repoconfig.Project, opts *options.GenericOptions) (*api.ParseResponse, error) { + dir := path + if info, err := os.Stat(path); err == nil && !info.IsDir() { + dir = filepath.Dir(path) + } + + var regexSourceMap map[string]string + if len(cfg.Terraform.SourceMap) > 0 { + regexSourceMap = make(map[string]string, len(cfg.Terraform.SourceMap)) + for _, source := range cfg.Terraform.SourceMap { + regexSourceMap[source.Match] = source.Replace + } + } + + var cloudConfig *terraform.TerraformCloudConfiguration + if project.Terraform.Cloud.Org != "" { + cloudConfig = &terraform.TerraformCloudConfiguration{ + Organization: project.Terraform.Cloud.Org, + Hostname: project.Terraform.Cloud.Host, + Workspace: project.Terraform.Cloud.Workspace, + } + } + + return client.Parse(ctx, &api.ParseRequest{ + RepoDirectory: opts.RepoDirectory, + WorkingDirectory: opts.WorkingDirectory, + Target: &api.ParseRequestTarget{ + Value: &api.ParseRequestTarget_Terraform{ + Terraform: &terraform.Target{ + Directory: dir, + Options: &terraform.Options{ + Generic: opts, + RegexSourceMap: regexSourceMap, + Env: project.Env, + Workspace: project.Terraform.Workspace, + TfVarsFiles: project.Terraform.VarFiles, + TerraformCloudConfiguration: cloudConfig, + }, + }, + }, + }, + }) +} + +// parseCloudFormationWith uses a specific client (per-IaC plugin) instead of loading the mono-parser. +func (c *Config) parseCloudFormationWith(ctx context.Context, client api.ParserServiceClient, path string, project *repoconfig.Project, opts *options.GenericOptions) (*api.ParseResponse, error) { + var awsContext *cloudformation.AwsContext + if project.AWS.AccountID != "" || project.AWS.Region != "" || project.AWS.StackID != "" || project.AWS.StackName != "" { + awsContext = &cloudformation.AwsContext{ + AccountId: project.AWS.AccountID, + Region: project.AWS.Region, + StackId: project.AWS.StackID, + StackName: project.AWS.StackName, + } + } + + return client.Parse(ctx, &api.ParseRequest{ + RepoDirectory: opts.RepoDirectory, + WorkingDirectory: opts.WorkingDirectory, + Target: &api.ParseRequestTarget{ + Value: &api.ParseRequestTarget_Cloudformation{ + Cloudformation: &cloudformation.Target{ + TemplatePath: path, + Options: &cloudformation.Options{ + Generic: opts, + AwsContext: awsContext, + }, + }, + }, + }, + }) +} + func (c *Config) parseTerraform(ctx context.Context, path string, cfg *repoconfig.Config, project *repoconfig.Project, level hclog.Level, options *options.GenericOptions) (*api.ParseResponse, error) { client, stop, err := c.Load(level) if stop != nil { diff --git a/pkg/plugins/parser/parse_test.go b/pkg/plugins/parser/parse_test.go new file mode 100644 index 0000000..c01b182 --- /dev/null +++ b/pkg/plugins/parser/parse_test.go @@ -0,0 +1,233 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/hashicorp/go-hclog" + repoconfig "github.com/infracost/config" + "github.com/infracost/proto/gen/go/infracost/parser/options" +) + +// End-to-end tests that exercise the full dual-mode flow: +// tryPerIaCPlugins → legacyRoute, using real compiled plugin binaries. +// +// These require both the mono-parser and per-IaC plugins to be built: +// cd parser && make build-plugins + +func setupE2EConfig(t *testing.T) (*Config, string) { + t.Helper() + + binDir := parserBinDir() + monoPlugin := filepath.Join(binDir, "infracost-parser-plugin") + if _, err := os.Stat(monoPlugin); err != nil { + t.Skipf("mono-parser plugin not found at %s (run: cd parser && make build-plugins)", monoPlugin) + } + + absDir, _ := filepath.Abs(binDir) + monoPlugin = filepath.Join(absDir, "infracost-parser-plugin") + + cfg := &Config{ + Plugin: monoPlugin, + } + cfg.Process() + + // Set PluginDir so per-IaC plugins are discovered. + PluginDir = absDir + + return cfg, absDir +} + +func defaultRepoConfig() *repoconfig.Config { + return &repoconfig.Config{} +} + +func defaultProject() *repoconfig.Project { + return &repoconfig.Project{} +} + +func defaultOptions() *options.GenericOptions { + return &options.GenericOptions{ + RepoDirectory: os.TempDir(), + WorkingDirectory: os.TempDir(), + } +} + +func TestE2E_ParseTerraformDir_ViaPerIaCPlugin(t *testing.T) { + cfg, _ := setupE2EConfig(t) + + tfDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tfDir, "main.tf"), []byte(` +resource "aws_instance" "example" { + ami = "ami-12345678" + instance_type = "t2.micro" +} +`), 0644); err != nil { + t.Fatal(err) + } + + opts := defaultOptions() + opts.RepoDirectory = tfDir + opts.WorkingDirectory = tfDir + + resp, err := cfg.parseWithoutCache(context.Background(), tfDir, defaultRepoConfig(), defaultProject(), hclog.Off, opts) + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } + if resp.Result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestE2E_ParseCloudFormationJSON_ViaPerIaCPlugin(t *testing.T) { + cfg, _ := setupE2EConfig(t) + + cfnDir := t.TempDir() + cfnJSON := `{ + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "my-test-bucket" + } + } + } + }` + f := filepath.Join(cfnDir, "template.json") + if err := os.WriteFile(f, []byte(cfnJSON), 0644); err != nil { + t.Fatal(err) + } + + opts := defaultOptions() + opts.RepoDirectory = cfnDir + opts.WorkingDirectory = cfnDir + + resp, err := cfg.parseWithoutCache(context.Background(), f, defaultRepoConfig(), defaultProject(), hclog.Off, opts) + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } + if resp.Result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestE2E_ParseCloudFormationYAML_ViaPerIaCPlugin(t *testing.T) { + cfg, _ := setupE2EConfig(t) + + cfnDir := t.TempDir() + cfnYAML := `AWSTemplateFormatVersion: "2010-09-09" +Resources: + MyBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: my-test-bucket +` + f := filepath.Join(cfnDir, "stack.yaml") + if err := os.WriteFile(f, []byte(cfnYAML), 0644); err != nil { + t.Fatal(err) + } + + opts := defaultOptions() + opts.RepoDirectory = cfnDir + opts.WorkingDirectory = cfnDir + + resp, err := cfg.parseWithoutCache(context.Background(), f, defaultRepoConfig(), defaultProject(), hclog.Off, opts) + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } + if resp.Result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestE2E_FallbackToMonoParser_WhenNoPerIaCPlugins(t *testing.T) { + binDir := parserBinDir() + monoPlugin := filepath.Join(binDir, "infracost-parser-plugin") + if _, err := os.Stat(monoPlugin); err != nil { + t.Skipf("mono-parser plugin not found at %s", monoPlugin) + } + + absPlugin, _ := filepath.Abs(monoPlugin) + cfg := &Config{ + Plugin: absPlugin, + } + cfg.Process() + + // Point PluginDir at an empty directory so no per-IaC plugins are found. + PluginDir = t.TempDir() + + tfDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tfDir, "main.tf"), []byte(` +resource "aws_s3_bucket" "b" { + bucket = "my-bucket" +} +`), 0644); err != nil { + t.Fatal(err) + } + + opts := defaultOptions() + opts.RepoDirectory = tfDir + opts.WorkingDirectory = tfDir + + resp, err := cfg.parseWithoutCache(context.Background(), tfDir, defaultRepoConfig(), defaultProject(), hclog.Off, opts) + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected non-nil response from mono-parser fallback") + } + if resp.Result == nil { + t.Fatal("expected non-nil result from mono-parser fallback") + } +} + +func TestE2E_FallbackToMonoParser_EmptyPluginDir(t *testing.T) { + binDir := parserBinDir() + monoPlugin := filepath.Join(binDir, "infracost-parser-plugin") + if _, err := os.Stat(monoPlugin); err != nil { + t.Skipf("mono-parser plugin not found at %s", monoPlugin) + } + + absPlugin, _ := filepath.Abs(monoPlugin) + cfg := &Config{ + Plugin: absPlugin, + } + cfg.Process() + + // Empty string means per-IaC is completely disabled. + PluginDir = "" + + tfDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tfDir, "main.tf"), []byte(` +resource "aws_instance" "x" { + ami = "ami-abc" + instance_type = "t3.small" +} +`), 0644); err != nil { + t.Fatal(err) + } + + opts := defaultOptions() + opts.RepoDirectory = tfDir + opts.WorkingDirectory = tfDir + + resp, err := cfg.parseWithoutCache(context.Background(), tfDir, defaultRepoConfig(), defaultProject(), hclog.Off, opts) + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected non-nil response from mono-parser fallback") + } +}