-
Notifications
You must be signed in to change notification settings - Fork 11
feat: migrate infracost to console repo #3626
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,238 @@ | ||
| package terraform | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "strconv" | ||
| "strings" | ||
|
|
||
| console "github.com/pluralsh/console/go/client" | ||
| "github.com/samber/lo" | ||
| "k8s.io/klog/v2" | ||
|
|
||
| harnessexec "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" | ||
| "github.com/pluralsh/console/go/deployment-operator/pkg/log" | ||
| ) | ||
|
|
||
| const infracostAPIKeyEnv = "INFRACOST_API_KEY" | ||
|
|
||
| // Infracost implements [v1.Tool] interface. | ||
| // It runs infracost breakdown on the terraform plan and returns cost estimates. | ||
| // Infracost is only executed when the stack run provides an INFRACOST_API_KEY | ||
| // environment variable, which acts as both the toggle and the credential. | ||
| func (in *Terraform) Infracost() ([]*console.StackInfracostResourceAttributes, error) { | ||
| if !in.infracostEnabled() { | ||
| klog.V(log.LogLevelDebug).Info("INFRACOST_API_KEY not set on stack run, skipping cost estimation") | ||
| return nil, nil | ||
| } | ||
|
|
||
| if !in.infracostAvailable() { | ||
| klog.V(log.LogLevelDebug).Info("infracost binary not found in PATH, skipping cost estimation") | ||
| return nil, nil | ||
| } | ||
|
|
||
| report, err := in.runInfracost() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to run infracost: %w", err) | ||
| } | ||
|
|
||
| resources := in.convertInfracostReport(report) | ||
| klog.V(log.LogLevelDebug).InfoS("infracost breakdown completed", "resourceCount", len(resources)) | ||
|
|
||
| return resources, nil | ||
| } | ||
|
|
||
| // infracostEnabled returns true if the stack run provided an INFRACOST_API_KEY | ||
| // environment variable with a non-empty value. | ||
| func (in *Terraform) infracostEnabled() bool { | ||
| prefix := infracostAPIKeyEnv + "=" | ||
| for _, e := range in.env { | ||
| if strings.HasPrefix(e, prefix) && len(e) > len(prefix) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // infracostAvailable checks if the infracost binary is available in PATH. | ||
| func (in *Terraform) infracostAvailable() bool { | ||
| _, err := exec.LookPath("infracost") | ||
| return err == nil | ||
| } | ||
|
|
||
| // runInfracost executes infracost breakdown and returns the parsed report. | ||
| // Infracost does not accept binary terraform plan files, so we first convert | ||
| // the plan to JSON using 'terraform show -json', write it to a temp file, | ||
| // and then pass that to infracost. | ||
| func (in *Terraform) runInfracost() (*InfracostReport, error) { | ||
| tmpFile, err := in.terraformPlanToJSONFile() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| defer os.Remove(tmpFile) | ||
|
|
||
| // Run infracost breakdown with the JSON plan file. Pass the stack run env | ||
| // vars through so that INFRACOST_API_KEY (and any other infracost config) | ||
| // is available to the subprocess. | ||
| output, err := harnessexec.NewExecutable( | ||
| "infracost", | ||
| harnessexec.WithArgs([]string{"breakdown", "--path", tmpFile, "--format", "json"}), | ||
| harnessexec.WithDir(in.dir), | ||
| harnessexec.WithEnv(in.env), | ||
| ).RunWithOutput(context.Background()) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed executing infracost breakdown: %s: %w", string(output), err) | ||
| } | ||
|
|
||
| var report InfracostReport | ||
| if err := json.Unmarshal(output, &report); err != nil { | ||
| return nil, fmt.Errorf("failed unmarshaling infracost JSON: %w", err) | ||
| } | ||
|
|
||
| klog.V(log.LogLevelTrace).InfoS("infracost report parsed successfully", "projects", len(report.Projects)) | ||
| return &report, nil | ||
| } | ||
|
|
||
| // terraformPlanToJSONFile runs 'terraform show -json <planFile>' and streams | ||
| // stdout directly into a temp file, returning the temp file path. The caller | ||
| // is responsible for removing the file. Streaming avoids buffering the entire | ||
| // plan JSON (which can be large) in memory. | ||
| func (in *Terraform) terraformPlanToJSONFile() (string, error) { | ||
| tmpFile, err := os.CreateTemp("", "plan-*.json") | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed creating temp file for plan JSON: %w", err) | ||
| } | ||
|
|
||
| cmd := exec.CommandContext(context.Background(), "terraform", "show", "-json", in.planFileName) | ||
| cmd.Dir = in.dir | ||
| cmd.Stdout = tmpFile | ||
| var stderr bytes.Buffer | ||
| cmd.Stderr = &stderr | ||
|
|
||
| klog.V(log.LogLevelExtended).InfoS("executing", "command", "terraform show -json "+in.planFileName) | ||
|
|
||
| runErr := cmd.Run() | ||
| if closeErr := tmpFile.Close(); closeErr != nil && runErr == nil { | ||
| runErr = closeErr | ||
| } | ||
| if runErr != nil { | ||
| _ = os.Remove(tmpFile.Name()) | ||
| return "", fmt.Errorf("failed converting plan to JSON: %s: %w", stderr.String(), runErr) | ||
| } | ||
|
|
||
| klog.V(log.LogLevelTrace).InfoS("converted terraform plan to JSON", "tempFile", filepath.Base(tmpFile.Name())) | ||
| return tmpFile.Name(), nil | ||
| } | ||
|
|
||
| // convertInfracostReport converts an InfracostReport to console StackInfracostResourceAttributes. | ||
| func (in *Terraform) convertInfracostReport(report *InfracostReport) []*console.StackInfracostResourceAttributes { | ||
| if report == nil { | ||
| return nil | ||
| } | ||
|
|
||
| result := make([]*console.StackInfracostResourceAttributes, 0) | ||
|
|
||
| for _, project := range report.Projects { | ||
| projectName := project.Name | ||
|
|
||
| // Process breakdown resources | ||
| if project.Breakdown != nil { | ||
| result = append(result, in.convertBreakdownResources( | ||
| project.Breakdown.Resources, | ||
| InfracostResourceScopeBreakdown, | ||
| projectName, | ||
| )...) | ||
| } | ||
|
|
||
| // Process diff resources | ||
| if project.Diff != nil { | ||
| result = append(result, in.convertBreakdownResources( | ||
| project.Diff.Resources, | ||
| InfracostResourceScopeDiff, | ||
| projectName, | ||
| )...) | ||
| } | ||
|
|
||
| // Process past breakdown resources | ||
| if project.PastBreakdown != nil { | ||
| result = append(result, in.convertBreakdownResources( | ||
| project.PastBreakdown.Resources, | ||
| InfracostResourceScopePastBreakdown, | ||
| projectName, | ||
| )...) | ||
| } | ||
| } | ||
|
|
||
| return result | ||
| } | ||
|
|
||
| // convertBreakdownResources converts a list of InfracostResource to console attributes. | ||
| func (in *Terraform) convertBreakdownResources( | ||
| resources []InfracostResource, | ||
| scope InfracostResourceScope, | ||
| projectName string, | ||
| ) []*console.StackInfracostResourceAttributes { | ||
| result := make([]*console.StackInfracostResourceAttributes, 0, len(resources)) | ||
|
|
||
| for _, resource := range resources { | ||
| attr := in.convertResource(resource, scope, projectName) | ||
| if attr != nil { | ||
| result = append(result, attr) | ||
| } | ||
|
|
||
| // Also process subresources recursively | ||
| if len(resource.SubResources) > 0 { | ||
| result = append(result, in.convertBreakdownResources( | ||
| resource.SubResources, | ||
| scope, | ||
| projectName, | ||
| )...) | ||
| } | ||
| } | ||
|
|
||
| return result | ||
| } | ||
|
|
||
| // convertResource converts a single InfracostResource to console StackInfracostResourceAttributes. | ||
| func (in *Terraform) convertResource( | ||
| resource InfracostResource, | ||
| scope InfracostResourceScope, | ||
| projectName string, | ||
| ) *console.StackInfracostResourceAttributes { | ||
| hourlyCost := parseStringToFloat(resource.HourlyCost) | ||
| monthlyCost := parseStringToFloat(resource.MonthlyCost) | ||
|
|
||
| // Skip resources with no cost (free tier or unsupported) | ||
| if hourlyCost == nil && monthlyCost == nil { | ||
| return nil | ||
| } | ||
|
|
||
| return &console.StackInfracostResourceAttributes{ | ||
| ResourceScope: string(scope), | ||
| ProjectName: lo.ToPtr(projectName), | ||
| Name: resource.Name, | ||
| ResourceType: lo.ToPtr(resource.ResourceType), | ||
| HourlyCost: hourlyCost, | ||
| MonthlyCost: monthlyCost, | ||
| } | ||
| } | ||
|
|
||
| // parseStringToFloat converts a string cost value to a float pointer. | ||
| // Returns nil if the string is nil, empty, or cannot be parsed. | ||
| func parseStringToFloat(s *string) *float64 { | ||
| if s == nil || *s == "" { | ||
| return nil | ||
| } | ||
|
|
||
| val, err := strconv.ParseFloat(*s, 64) | ||
| if err != nil { | ||
| return nil | ||
| } | ||
|
|
||
| return &val | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| package terraform | ||
|
|
||
| // InfracostReport represents the top-level structure of infracost JSON output. | ||
| type InfracostReport struct { | ||
| Version string `json:"version"` | ||
| Currency string `json:"currency"` | ||
| Projects []InfracostProject `json:"projects"` | ||
| TotalHourlyCost *string `json:"totalHourlyCost"` | ||
| TotalMonthlyCost *string `json:"totalMonthlyCost"` | ||
| } | ||
|
|
||
| // InfracostProject represents a single project in the infracost output. | ||
| type InfracostProject struct { | ||
| Name string `json:"name"` | ||
| Metadata InfracostMetadata `json:"metadata"` | ||
| Breakdown *InfracostBreakdown `json:"breakdown"` | ||
| Diff *InfracostBreakdown `json:"diff"` | ||
| PastBreakdown *InfracostBreakdown `json:"pastBreakdown"` | ||
| } | ||
|
|
||
| // InfracostMetadata contains metadata about the project. | ||
| type InfracostMetadata struct { | ||
| Path string `json:"path"` | ||
| Type string `json:"type"` | ||
| Workspace string `json:"workspace"` | ||
| } | ||
|
|
||
| // InfracostBreakdown contains cost breakdown information. | ||
| type InfracostBreakdown struct { | ||
| Resources []InfracostResource `json:"resources"` | ||
| TotalHourlyCost *string `json:"totalHourlyCost"` | ||
| TotalMonthlyCost *string `json:"totalMonthlyCost"` | ||
| } | ||
|
|
||
| // InfracostResource represents a single resource in the cost breakdown. | ||
| type InfracostResource struct { | ||
| Name string `json:"name"` | ||
| ResourceType string `json:"resourceType"` | ||
| Tags map[string]string `json:"tags"` | ||
| Metadata map[string]interface{} `json:"metadata"` | ||
| HourlyCost *string `json:"hourlyCost"` | ||
| MonthlyCost *string `json:"monthlyCost"` | ||
| CostComponents []InfracostCostComponent `json:"costComponents"` | ||
| SubResources []InfracostResource `json:"subresources"` | ||
| } | ||
|
|
||
| // InfracostCostComponent represents a cost component of a resource. | ||
| type InfracostCostComponent struct { | ||
| Name string `json:"name"` | ||
| Unit string `json:"unit"` | ||
| HourlyQuantity *string `json:"hourlyQuantity"` | ||
| MonthlyQuantity *string `json:"monthlyQuantity"` | ||
| Price string `json:"price"` | ||
| HourlyCost *string `json:"hourlyCost"` | ||
| MonthlyCost *string `json:"monthlyCost"` | ||
| } | ||
|
|
||
| // InfracostResourceScope represents the scope of an infracost resource. | ||
| type InfracostResourceScope string | ||
|
|
||
| const ( | ||
| InfracostResourceScopeBreakdown InfracostResourceScope = "breakdown" | ||
| InfracostResourceScopePastBreakdown InfracostResourceScope = "past_breakdown" | ||
| InfracostResourceScopeDiff InfracostResourceScope = "diff" | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -267,12 +267,16 @@ func (in *Terraform) init() v1.Tool { | |
|
|
||
| // New creates a Terraform structure that implements v1.Tool interface. | ||
| func New(config v1.Config) v1.Tool { | ||
| return (&Terraform{ | ||
| tf := &Terraform{ | ||
| DefaultTool: v1.DefaultTool{Scanner: config.Scanner}, | ||
| workDir: config.WorkDir, | ||
| dir: config.ExecDir, | ||
| variables: config.Variables, | ||
| parallelism: config.Run.Parallelism, | ||
| refresh: config.Run.Refresh, | ||
| }).init() | ||
| } | ||
| if config.Run != nil { | ||
| tf.parallelism = config.Run.Parallelism | ||
| tf.refresh = config.Run.Refresh | ||
| tf.env = config.Run.Env() | ||
| } | ||
| return tf.init() | ||
| } | ||
|
Comment on lines
+270
to
282
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The struct literal on lines 275–276 accesses |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
terraform showsubprocess does not inherit stack-run env varsexec.CommandContextwithout settingcmd.Envinherits the calling process's OS environment, not the user-configuredin.envpassed to the infracost subprocess viaharnessexec.WithEnv(in.env). For theterraform show -jsoncall this is likely benign (plan conversion only reads a local file), but the inconsistency could bite if a custom provider or wrapper reads a credential from the stack-run env. Consider addingcmd.Env = in.envfor parity.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!