Skip to content
Open
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
16 changes: 16 additions & 0 deletions go/deployment-operator/dockerfiles/harness/terraform.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,23 @@ ARG HARNESS_BASE_IMAGE_TAG=latest
ARG HARNESS_BASE_IMAGE_REPO=harness-base
ARG HARNESS_BASE_IMAGE=$HARNESS_BASE_IMAGE_REPO:$HARNESS_BASE_IMAGE_TAG

ARG INFRACOST_VERSION=0.10.44

FROM $TERRAFORM_IMAGE as terraform

# Fetch the infracost binary from the official GitHub release. We use a
# downloader stage rather than the infracost docker image because the latter
# is published as linux/amd64 only, while this image supports multi-arch.
FROM alpine:3.22 as infracost
ARG TARGETARCH
ARG INFRACOST_VERSION
RUN apk add --no-cache curl tar && \
curl -fsSL "https://github.com/infracost/infracost/releases/download/v${INFRACOST_VERSION}/infracost-linux-${TARGETARCH}.tar.gz" \
| tar -xz -C /tmp && \
mv "/tmp/infracost-linux-${TARGETARCH}" /infracost && \
chmod +x /infracost

FROM $HARNESS_BASE_IMAGE as final

COPY --from=terraform /bin/terraform /bin/terraform
COPY --from=infracost /infracost /bin/infracost
13 changes: 10 additions & 3 deletions go/deployment-operator/pkg/harness/controller/controller_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,17 @@ func (in *stackRunController) afterPlan() error {
klog.ErrorS(err, "could not run security scan")
}

// Run infracost to get cost estimates
infracostResources, err := in.tool.Infracost()
if err != nil {
klog.ErrorS(err, "could not run infracost")
}

if err = in.consoleClient.UpdateStackRun(in.stackRunID, gqlclient.StackRunAttributes{
State: state,
Violations: violations,
Status: gqlclient.StackStatusRunning,
State: state,
Violations: violations,
InfracostResources: infracostResources,
Status: gqlclient.StackStatusRunning,
}); err != nil {
if clienterrors.IsUnauthenticated(err) {
return harnesserrors.WrapUnauthenticated("could not update stack run after plan", err)
Expand Down
238 changes: 238 additions & 0 deletions go/deployment-operator/pkg/harness/tool/terraform/infracost.go
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
}

Comment on lines +117 to +131

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 terraform show subprocess does not inherit stack-run env vars

exec.CommandContext without setting cmd.Env inherits the calling process's OS environment, not the user-configured in.env passed to the infracost subprocess via harnessexec.WithEnv(in.env). For the terraform show -json call 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 adding cmd.Env = in.env for 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!

// 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"
)
12 changes: 8 additions & 4 deletions go/deployment-operator/pkg/harness/tool/terraform/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Nil guard added after unconditional dereferences

The struct literal on lines 275–276 accesses config.Run.Parallelism and config.Run.Refresh before the if config.Run != nil check on line 278. If config.Run is ever nil, the function panics at the struct literal — the guard only protects tf.env. The nil check should wrap all three config.Run accesses, or the existing accesses should be moved inside the guard.

Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,9 @@ type Terraform struct {
// refresh is a flag to refresh the state.
// Default: true
refresh *bool

// env is the list of stack run environment variables in "KEY=value" form.
// Used to detect optional integrations (e.g. infracost) and to pass them
// through to subprocesses started by the tool.
env []string
}
6 changes: 6 additions & 0 deletions go/deployment-operator/pkg/harness/tool/v1/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ func (in *DefaultTool) HasChanges() (bool, error) {
return true, nil
}

// Infracost implements [Tool] interface.
// The default implementation returns nil (no infracost support).
func (in *DefaultTool) Infracost() ([]*console.StackInfracostResourceAttributes, error) {
return nil, nil
}

func New() Tool {
return &DefaultTool{}
}
Loading
Loading