Skip to content
Draft
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
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
39 changes: 39 additions & 0 deletions pkg/plugins/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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 != "" {
Expand Down
162 changes: 162 additions & 0 deletions pkg/plugins/parser/manager.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading