diff --git a/go/deployment-operator/dockerfiles/harness/terraform.Dockerfile b/go/deployment-operator/dockerfiles/harness/terraform.Dockerfile index f1deaba299..a7ca494046 100644 --- a/go/deployment-operator/dockerfiles/harness/terraform.Dockerfile +++ b/go/deployment-operator/dockerfiles/harness/terraform.Dockerfile @@ -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 diff --git a/go/deployment-operator/pkg/harness/controller/controller_hooks.go b/go/deployment-operator/pkg/harness/controller/controller_hooks.go index 4f3da312b0..a458a43e3e 100644 --- a/go/deployment-operator/pkg/harness/controller/controller_hooks.go +++ b/go/deployment-operator/pkg/harness/controller/controller_hooks.go @@ -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) diff --git a/go/deployment-operator/pkg/harness/tool/terraform/infracost.go b/go/deployment-operator/pkg/harness/tool/terraform/infracost.go new file mode 100644 index 0000000000..a20314ab46 --- /dev/null +++ b/go/deployment-operator/pkg/harness/tool/terraform/infracost.go @@ -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 ' 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 +} diff --git a/go/deployment-operator/pkg/harness/tool/terraform/infracost_types.go b/go/deployment-operator/pkg/harness/tool/terraform/infracost_types.go new file mode 100644 index 0000000000..3ce1846a12 --- /dev/null +++ b/go/deployment-operator/pkg/harness/tool/terraform/infracost_types.go @@ -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" +) diff --git a/go/deployment-operator/pkg/harness/tool/terraform/terraform.go b/go/deployment-operator/pkg/harness/tool/terraform/terraform.go index 5890d7b325..3dcd4d1e47 100644 --- a/go/deployment-operator/pkg/harness/tool/terraform/terraform.go +++ b/go/deployment-operator/pkg/harness/tool/terraform/terraform.go @@ -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() } diff --git a/go/deployment-operator/pkg/harness/tool/terraform/terraform_types.go b/go/deployment-operator/pkg/harness/tool/terraform/terraform_types.go index d26fab9325..5af624ddd3 100644 --- a/go/deployment-operator/pkg/harness/tool/terraform/terraform_types.go +++ b/go/deployment-operator/pkg/harness/tool/terraform/terraform_types.go @@ -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 } diff --git a/go/deployment-operator/pkg/harness/tool/v1/tool.go b/go/deployment-operator/pkg/harness/tool/v1/tool.go index 66fa91fa3c..b4250a6b3d 100644 --- a/go/deployment-operator/pkg/harness/tool/v1/tool.go +++ b/go/deployment-operator/pkg/harness/tool/v1/tool.go @@ -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{} } diff --git a/go/deployment-operator/pkg/harness/tool/v1/types.go b/go/deployment-operator/pkg/harness/tool/v1/types.go index 192ce1775c..6330b40fdd 100644 --- a/go/deployment-operator/pkg/harness/tool/v1/types.go +++ b/go/deployment-operator/pkg/harness/tool/v1/types.go @@ -42,6 +42,9 @@ type Tool interface { // Returns true if changes are detected, false for no-op plans. // This allows the harness to skip unnecessary apply steps and not wait for approvals to free up resources. HasChanges() (bool, error) + // Infracost runs infracost breakdown on the plan and returns cost estimates + // for each resource. Returns nil if infracost is not available or fails. + Infracost() ([]*console.StackInfracostResourceAttributes, error) } // DefaultTool implements [Tool] interface.