Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ require (
github.com/google/uuid v1.6.0
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-plugin v1.8.0
github.com/infracost/config v0.11.0
github.com/infracost/config v0.11.1-0.20260714133450-cdea9fbca70d
github.com/infracost/go-proto v1.25.1
github.com/infracost/proto v1.156.0
github.com/infracost/proto v1.158.1-0.20260713113858-cd2bda46a784
github.com/liamg/tml v0.7.1
github.com/maruel/panicparse/v2 v2.5.0
github.com/mattn/go-runewidth v0.0.16
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,12 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/infracost/config v0.11.0 h1:CYzIOu9bmbZVeMO6jqKn18VG55uTB/2nlgLFSsZDEX8=
github.com/infracost/config v0.11.0/go.mod h1:IfWRgSkjAuSRowYwzGEw/fYTCDEGc/XU10U3sIj/YWs=
github.com/infracost/config v0.11.1-0.20260714133450-cdea9fbca70d h1:zOvIQfVBL5wmc8T73hFbxWjRHSqNesg0saNszKRcaeU=
github.com/infracost/config v0.11.1-0.20260714133450-cdea9fbca70d/go.mod h1:x7QfnnYbZZUoLrCae5O6RN1eA1CfSq9kzTuvaPjKAt4=
github.com/infracost/go-proto v1.25.1 h1:34bfSUVRFsbTBASShBSQ+9IGvUmEg8C89X73Hem/Asw=
github.com/infracost/go-proto v1.25.1/go.mod h1:9m9zdHPl6mME24y3J0CdTBWXr+d03mfgXuy+9JzA/wU=
github.com/infracost/proto v1.156.0 h1:Kmlapuqqn0POosT0L2VgoW5GSUDU4ll9EfeQeKfkuCM=
github.com/infracost/proto v1.156.0/go.mod h1:Z8vPWBWblwJlw+/ksO+BtsXwf9NiOcSTWx0WRWNbfUA=
github.com/infracost/proto v1.158.1-0.20260713113858-cd2bda46a784 h1:B83UG7tMbCU2DbSgA7AYCYfYrlYje5EWvsFWOZL31CM=
github.com/infracost/proto v1.158.1-0.20260713113858-cd2bda46a784/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
147 changes: 58 additions & 89 deletions pkg/scanner/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,22 +115,19 @@ func ScanProject(ctx context.Context, opts *ScanProjectOptions) (*ProjectResult,
}

genericOptions := buildGenericOptions(opts)
rawOptions, rawOptionsFormat, err := buildIaCOptions(opts, projectType)
rawOptions, err := buildIaCOptions(opts, projectType, genericOptions)
if err != nil {
return nil, fmt.Errorf("failed to build parser plugin options: %w", err)
}

// Try the parser-results cache first. The fingerprint folds in
// RawOptions + format so e.g. a different workspace or tfvars file
// invalidates correctly. Skipping the gRPC + HCL parse + module load
// is the entire point of this cache; the typical hot path on a 10k-
// project repo where one project changed is N-1 hits + 1 miss.
// Try the parser-results cache first. The fingerprint folds in RawOptions so e.g. a different
// workspace or tfvars file invalidates correctly. Skipping the gRPC + HCL parse + module load
// is the entire point of this cache; the typical hot path on a 10k-project repo where one
// project changed is N-1 hits + 1 miss.
pluginName := parserPlugin.Info.GetName()
pluginVersion := parserPlugin.Info.GetVersion()

fingerprintExtra := append([]byte(rawOptionsFormat), 0)
fingerprintExtra = append(fingerprintExtra, rawOptions...)
fingerprint, fpErr := fingerprintProject(absoluteProjectPath, fingerprintExtra)
fingerprint, fpErr := fingerprintProject(absoluteProjectPath, rawOptions)
if fpErr != nil {
logging.Debugf("parser fingerprint failed for %q: %s", absoluteProjectPath, fpErr)
}
Expand All @@ -142,10 +139,10 @@ func ScanProject(ctx context.Context, opts *ScanProjectOptions) (*ProjectResult,

if response == nil {
response, err = parserPlugin.Parse(ctx, &pluginpb.ParseRequest{
Path: absoluteProjectPath,
GenericOptions: genericOptions,
RawOptions: rawOptions,
RawOptionsFormat: rawOptionsFormat,
Path: absoluteProjectPath,
GenericOptions: genericOptions,
RawOptions: rawOptions,
// raw_options_format was removed from the proto - raw_options is always JSON now.
})
if err != nil {
return nil, fmt.Errorf("parser plugin error: %w (run with --debug or set INFRACOST_CLI_LOG_LEVEL=debug for more details)", err)
Expand Down Expand Up @@ -292,103 +289,75 @@ func finalProjectType(projectType repoconfig.ProjectType, absoluteProjectPath st
}

func buildGenericOptions(opts *ScanProjectOptions) *options.GenericOptions {
return &options.GenericOptions{
genericOptions := &options.GenericOptions{
ProjectName: opts.Project.Name,
EnvironmentName: opts.Project.EnvName,
RepoDirectory: opts.RootDir,
TemporaryDirectory: os.TempDir(),
CacheDirectory: opts.CacheDir,
WorkingDirectory: opts.RootDir,
// Workspace and the terraform cloud config are caller-sourced runtime options passed via
// GenericOptions (not the blob), because they are also read outside the plugin.
Workspace: opts.Project.Terraform.Workspace,
}

if c := opts.Project.Terraform.Cloud; c.Org != "" || c.Workspace != "" || c.Host != "" {
genericOptions.TerraformCloudConfiguration = &options.TerraformCloudConfiguration{
Organization: c.Org,
Workspace: c.Workspace,
Hostname: c.Host,
}
}

return genericOptions
}

func buildIaCOptions(opts *ScanProjectOptions, projectType repoconfig.ProjectType) ([]byte, string, error) {
// buildIaCOptions returns the raw_options blob to send to the parser plugin for a project. The
// persistable parse options live in the config plugins.<name> blob (config generates them, and
// folds hand-written / older configs into them on load), so they are forwarded verbatim. The
// caller-sourced runtime options are written into the typed GenericOptions fields, which the latest
// plugins read in preference to the blob - so they are never duplicated into the blob.
func buildIaCOptions(opts *ScanProjectOptions, projectType repoconfig.ProjectType, genericOptions *options.GenericOptions) ([]byte, error) {
switch projectType {
case repoconfig.ProjectTypeTerraform, repoconfig.ProjectTypeTerragrunt, repoconfig.ProjectTypeCiscoStacks:
data, err := json.Marshal(buildTerraformPluginOptions(opts, namingPolicyAttributeRequirements(opts.FinopsPolicies)))
return data, "application/json", err
if genericOptions != nil {
genericOptions.Env = opts.Project.Env
genericOptions.RequiredAttributes = protoAttributeRequirements(namingPolicyAttributeRequirements(opts.FinopsPolicies))
}
return pluginBlobJSON(opts.Project, string(projectType))
case repoconfig.ProjectTypeCloudFormation:
data, err := json.Marshal(buildCloudFormationPluginOptions(opts.Project))
return data, "application/json", err
// cdk_* projects resolve to cloudformation (see finalProjectType) and the config keys their
// blob under cloudformation too.
return pluginBlobJSON(opts.Project, string(repoconfig.ProjectTypeCloudFormation))
default:
return nil, "", nil
return nil, nil
}
}

type terraformPluginOptions struct {
RegexSourceMap map[string]string `json:"regexSourceMap,omitempty"`
Env map[string]string `json:"env,omitempty"`
Vars map[string]any `json:"vars,omitempty"`
Workspace string `json:"workspace,omitempty"`
TfVarsFiles []string `json:"tfVarsFiles,omitempty"`
TerraformCloudConfiguration *terraformCloudConfiguration `json:"terraformCloudConfiguration,omitempty"`
RequiredAttributes map[string][]string `json:"requiredAttributes,omitempty"`
}

type terraformCloudConfiguration struct {
Organization string `json:"organization"`
Workspace string `json:"workspace"`
Hostname string `json:"hostname"`
}

type cloudFormationPluginOptions struct {
InputParameters map[string]any `json:"inputParameters"`
AWSContext *awsContext `json:"awsContext"`
}

type awsContext struct {
Region string `json:"region"`
AccountID string `json:"accountId"`
StackID string `json:"stackId"`
StackName string `json:"stackName"`
}

func buildTerraformPluginOptions(opts *ScanProjectOptions, requiredAttributes []*parserapi.AttributeRequirement) *terraformPluginOptions {
regexSourceMap := make(map[string]string, len(opts.RepoConfig.Terraform.SourceMap))
for _, source := range opts.RepoConfig.Terraform.SourceMap {
regexSourceMap[source.Match] = source.Replace
}

var cloudConfig *terraformCloudConfiguration
if opts.Project.Terraform.Cloud.Org != "" || opts.Project.Terraform.Cloud.Workspace != "" || opts.Project.Terraform.Cloud.Host != "" {
cloudConfig = &terraformCloudConfiguration{
Organization: opts.Project.Terraform.Cloud.Org,
Workspace: opts.Project.Terraform.Cloud.Workspace,
Hostname: opts.Project.Terraform.Cloud.Host,
}
}

tfRequiredAttributes := make(map[string][]string, len(requiredAttributes))
for _, attr := range requiredAttributes {
tfRequiredAttributes[attr.ResourceType] = attr.Attributes
}

return &terraformPluginOptions{
RegexSourceMap: regexSourceMap,
Env: opts.Project.Env,
Vars: opts.Project.Terraform.Vars,
Workspace: opts.Project.Terraform.Workspace,
TfVarsFiles: opts.Project.Terraform.VarFiles,
TerraformCloudConfiguration: cloudConfig,
RequiredAttributes: tfRequiredAttributes,
// pluginBlobJSON marshals the project's persisted plugins.<key> blob (keyed by the consuming plugin)
// as JSON, returning nil when there is no blob for that key.
func pluginBlobJSON(project *repoconfig.Project, key string) ([]byte, error) {
blob := project.Plugins[key]
if len(blob) == 0 {
return nil, nil
}
return json.Marshal(blob)
}

func buildCloudFormationPluginOptions(project *repoconfig.Project) *cloudFormationPluginOptions {
var awsCtx *awsContext
if project.AWS.Region != "" || project.AWS.AccountID != "" || project.AWS.StackID != "" || project.AWS.StackName != "" {
awsCtx = &awsContext{
Region: project.AWS.Region,
AccountID: project.AWS.AccountID,
StackID: project.AWS.StackID,
StackName: project.AWS.StackName,
}
// protoAttributeRequirements converts the naming-policy attribute requirements into the typed
// GenericOptions form.
func protoAttributeRequirements(reqs []*parserapi.AttributeRequirement) []*options.AttributeRequirement {
if len(reqs) == 0 {
return nil
}

return &cloudFormationPluginOptions{
InputParameters: nil,
AWSContext: awsCtx,
out := make([]*options.AttributeRequirement, 0, len(reqs))
for _, r := range reqs {
if r == nil {
continue
}
out = append(out, &options.AttributeRequirement{ResourceType: r.ResourceType, Attributes: r.Attributes})
}
return out
}

type namingValidationSettings struct {
Expand Down
Loading