Skip to content

Commit c34f0b4

Browse files
committed
migrate infracost to console repo
1 parent ccf7604 commit c34f0b4

8 files changed

Lines changed: 351 additions & 7 deletions

File tree

go/deployment-operator/dockerfiles/harness/terraform.Dockerfile

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,23 @@ ARG HARNESS_BASE_IMAGE_TAG=latest
55
ARG HARNESS_BASE_IMAGE_REPO=harness-base
66
ARG HARNESS_BASE_IMAGE=$HARNESS_BASE_IMAGE_REPO:$HARNESS_BASE_IMAGE_TAG
77

8+
ARG INFRACOST_VERSION=0.10.44
9+
810
FROM $TERRAFORM_IMAGE as terraform
11+
12+
# Fetch the infracost binary from the official GitHub release. We use a
13+
# downloader stage rather than the infracost docker image because the latter
14+
# is published as linux/amd64 only, while this image supports multi-arch.
15+
FROM alpine:3.22 as infracost
16+
ARG TARGETARCH
17+
ARG INFRACOST_VERSION
18+
RUN apk add --no-cache curl tar && \
19+
curl -fsSL "https://github.com/infracost/infracost/releases/download/v${INFRACOST_VERSION}/infracost-linux-${TARGETARCH}.tar.gz" \
20+
| tar -xz -C /tmp && \
21+
mv "/tmp/infracost-linux-${TARGETARCH}" /infracost && \
22+
chmod +x /infracost
23+
924
FROM $HARNESS_BASE_IMAGE as final
1025

1126
COPY --from=terraform /bin/terraform /bin/terraform
27+
COPY --from=infracost /infracost /bin/infracost

go/deployment-operator/pkg/harness/controller/controller_hooks.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,17 @@ func (in *stackRunController) afterPlan() error {
232232
klog.ErrorS(err, "could not run security scan")
233233
}
234234

235+
// Run infracost to get cost estimates
236+
infracostResources, err := in.tool.Infracost()
237+
if err != nil {
238+
klog.ErrorS(err, "could not run infracost")
239+
}
240+
235241
if err = in.consoleClient.UpdateStackRun(in.stackRunID, gqlclient.StackRunAttributes{
236-
State: state,
237-
Violations: violations,
238-
Status: gqlclient.StackStatusRunning,
242+
State: state,
243+
Violations: violations,
244+
InfracostResources: infracostResources,
245+
Status: gqlclient.StackStatusRunning,
239246
}); err != nil {
240247
if clienterrors.IsUnauthenticated(err) {
241248
return harnesserrors.WrapUnauthenticated("could not update stack run after plan", err)
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package terraform
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"strconv"
12+
"strings"
13+
14+
console "github.com/pluralsh/console/go/client"
15+
"github.com/samber/lo"
16+
"k8s.io/klog/v2"
17+
18+
harnessexec "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec"
19+
"github.com/pluralsh/console/go/deployment-operator/pkg/log"
20+
)
21+
22+
const infracostAPIKeyEnv = "INFRACOST_API_KEY"
23+
24+
// Infracost implements [v1.Tool] interface.
25+
// It runs infracost breakdown on the terraform plan and returns cost estimates.
26+
// Infracost is only executed when the stack run provides an INFRACOST_API_KEY
27+
// environment variable, which acts as both the toggle and the credential.
28+
func (in *Terraform) Infracost() ([]*console.StackInfracostResourceAttributes, error) {
29+
if !in.infracostEnabled() {
30+
klog.V(log.LogLevelDebug).Info("INFRACOST_API_KEY not set on stack run, skipping cost estimation")
31+
return nil, nil
32+
}
33+
34+
if !in.infracostAvailable() {
35+
klog.V(log.LogLevelDebug).Info("infracost binary not found in PATH, skipping cost estimation")
36+
return nil, nil
37+
}
38+
39+
report, err := in.runInfracost()
40+
if err != nil {
41+
return nil, fmt.Errorf("failed to run infracost: %w", err)
42+
}
43+
44+
resources := in.convertInfracostReport(report)
45+
klog.V(log.LogLevelDebug).InfoS("infracost breakdown completed", "resourceCount", len(resources))
46+
47+
return resources, nil
48+
}
49+
50+
// infracostEnabled returns true if the stack run provided an INFRACOST_API_KEY
51+
// environment variable with a non-empty value.
52+
func (in *Terraform) infracostEnabled() bool {
53+
prefix := infracostAPIKeyEnv + "="
54+
for _, e := range in.env {
55+
if strings.HasPrefix(e, prefix) && len(e) > len(prefix) {
56+
return true
57+
}
58+
}
59+
return false
60+
}
61+
62+
// infracostAvailable checks if the infracost binary is available in PATH.
63+
func (in *Terraform) infracostAvailable() bool {
64+
_, err := exec.LookPath("infracost")
65+
return err == nil
66+
}
67+
68+
// runInfracost executes infracost breakdown and returns the parsed report.
69+
// Infracost does not accept binary terraform plan files, so we first convert
70+
// the plan to JSON using 'terraform show -json', write it to a temp file,
71+
// and then pass that to infracost.
72+
func (in *Terraform) runInfracost() (*InfracostReport, error) {
73+
tmpFile, err := in.terraformPlanToJSONFile()
74+
if err != nil {
75+
return nil, err
76+
}
77+
defer os.Remove(tmpFile)
78+
79+
// Run infracost breakdown with the JSON plan file. Pass the stack run env
80+
// vars through so that INFRACOST_API_KEY (and any other infracost config)
81+
// is available to the subprocess.
82+
output, err := harnessexec.NewExecutable(
83+
"infracost",
84+
harnessexec.WithArgs([]string{"breakdown", "--path", tmpFile, "--format", "json"}),
85+
harnessexec.WithDir(in.dir),
86+
harnessexec.WithEnv(in.env),
87+
).RunWithOutput(context.Background())
88+
if err != nil {
89+
return nil, fmt.Errorf("failed executing infracost breakdown: %s: %w", string(output), err)
90+
}
91+
92+
var report InfracostReport
93+
if err := json.Unmarshal(output, &report); err != nil {
94+
return nil, fmt.Errorf("failed unmarshaling infracost JSON: %w", err)
95+
}
96+
97+
klog.V(log.LogLevelTrace).InfoS("infracost report parsed successfully", "projects", len(report.Projects))
98+
return &report, nil
99+
}
100+
101+
// terraformPlanToJSONFile runs 'terraform show -json <planFile>' and streams
102+
// stdout directly into a temp file, returning the temp file path. The caller
103+
// is responsible for removing the file. Streaming avoids buffering the entire
104+
// plan JSON (which can be large) in memory.
105+
func (in *Terraform) terraformPlanToJSONFile() (string, error) {
106+
tmpFile, err := os.CreateTemp("", "plan-*.json")
107+
if err != nil {
108+
return "", fmt.Errorf("failed creating temp file for plan JSON: %w", err)
109+
}
110+
111+
cmd := exec.CommandContext(context.Background(), "terraform", "show", "-json", in.planFileName)
112+
cmd.Dir = in.dir
113+
cmd.Stdout = tmpFile
114+
var stderr bytes.Buffer
115+
cmd.Stderr = &stderr
116+
117+
klog.V(log.LogLevelExtended).InfoS("executing", "command", "terraform show -json "+in.planFileName)
118+
119+
runErr := cmd.Run()
120+
if closeErr := tmpFile.Close(); closeErr != nil && runErr == nil {
121+
runErr = closeErr
122+
}
123+
if runErr != nil {
124+
_ = os.Remove(tmpFile.Name())
125+
return "", fmt.Errorf("failed converting plan to JSON: %s: %w", stderr.String(), runErr)
126+
}
127+
128+
klog.V(log.LogLevelTrace).InfoS("converted terraform plan to JSON", "tempFile", filepath.Base(tmpFile.Name()))
129+
return tmpFile.Name(), nil
130+
}
131+
132+
// convertInfracostReport converts an InfracostReport to console StackInfracostResourceAttributes.
133+
func (in *Terraform) convertInfracostReport(report *InfracostReport) []*console.StackInfracostResourceAttributes {
134+
if report == nil {
135+
return nil
136+
}
137+
138+
result := make([]*console.StackInfracostResourceAttributes, 0)
139+
140+
for _, project := range report.Projects {
141+
projectName := project.Name
142+
143+
// Process breakdown resources
144+
if project.Breakdown != nil {
145+
result = append(result, in.convertBreakdownResources(
146+
project.Breakdown.Resources,
147+
InfracostResourceScopeBreakdown,
148+
projectName,
149+
)...)
150+
}
151+
152+
// Process diff resources
153+
if project.Diff != nil {
154+
result = append(result, in.convertBreakdownResources(
155+
project.Diff.Resources,
156+
InfracostResourceScopeDiff,
157+
projectName,
158+
)...)
159+
}
160+
161+
// Process past breakdown resources
162+
if project.PastBreakdown != nil {
163+
result = append(result, in.convertBreakdownResources(
164+
project.PastBreakdown.Resources,
165+
InfracostResourceScopePastBreakdown,
166+
projectName,
167+
)...)
168+
}
169+
}
170+
171+
return result
172+
}
173+
174+
// convertBreakdownResources converts a list of InfracostResource to console attributes.
175+
func (in *Terraform) convertBreakdownResources(
176+
resources []InfracostResource,
177+
scope InfracostResourceScope,
178+
projectName string,
179+
) []*console.StackInfracostResourceAttributes {
180+
result := make([]*console.StackInfracostResourceAttributes, 0, len(resources))
181+
182+
for _, resource := range resources {
183+
attr := in.convertResource(resource, scope, projectName)
184+
if attr != nil {
185+
result = append(result, attr)
186+
}
187+
188+
// Also process subresources recursively
189+
if len(resource.SubResources) > 0 {
190+
result = append(result, in.convertBreakdownResources(
191+
resource.SubResources,
192+
scope,
193+
projectName,
194+
)...)
195+
}
196+
}
197+
198+
return result
199+
}
200+
201+
// convertResource converts a single InfracostResource to console StackInfracostResourceAttributes.
202+
func (in *Terraform) convertResource(
203+
resource InfracostResource,
204+
scope InfracostResourceScope,
205+
projectName string,
206+
) *console.StackInfracostResourceAttributes {
207+
hourlyCost := parseStringToFloat(resource.HourlyCost)
208+
monthlyCost := parseStringToFloat(resource.MonthlyCost)
209+
210+
// Skip resources with no cost (free tier or unsupported)
211+
if hourlyCost == nil && monthlyCost == nil {
212+
return nil
213+
}
214+
215+
return &console.StackInfracostResourceAttributes{
216+
ResourceScope: string(scope),
217+
ProjectName: lo.ToPtr(projectName),
218+
Name: resource.Name,
219+
ResourceType: lo.ToPtr(resource.ResourceType),
220+
HourlyCost: hourlyCost,
221+
MonthlyCost: monthlyCost,
222+
}
223+
}
224+
225+
// parseStringToFloat converts a string cost value to a float pointer.
226+
// Returns nil if the string is nil, empty, or cannot be parsed.
227+
func parseStringToFloat(s *string) *float64 {
228+
if s == nil || *s == "" {
229+
return nil
230+
}
231+
232+
val, err := strconv.ParseFloat(*s, 64)
233+
if err != nil {
234+
return nil
235+
}
236+
237+
return &val
238+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package terraform
2+
3+
// InfracostReport represents the top-level structure of infracost JSON output.
4+
type InfracostReport struct {
5+
Version string `json:"version"`
6+
Currency string `json:"currency"`
7+
Projects []InfracostProject `json:"projects"`
8+
TotalHourlyCost *string `json:"totalHourlyCost"`
9+
TotalMonthlyCost *string `json:"totalMonthlyCost"`
10+
}
11+
12+
// InfracostProject represents a single project in the infracost output.
13+
type InfracostProject struct {
14+
Name string `json:"name"`
15+
Metadata InfracostMetadata `json:"metadata"`
16+
Breakdown *InfracostBreakdown `json:"breakdown"`
17+
Diff *InfracostBreakdown `json:"diff"`
18+
PastBreakdown *InfracostBreakdown `json:"pastBreakdown"`
19+
}
20+
21+
// InfracostMetadata contains metadata about the project.
22+
type InfracostMetadata struct {
23+
Path string `json:"path"`
24+
Type string `json:"type"`
25+
Workspace string `json:"workspace"`
26+
}
27+
28+
// InfracostBreakdown contains cost breakdown information.
29+
type InfracostBreakdown struct {
30+
Resources []InfracostResource `json:"resources"`
31+
TotalHourlyCost *string `json:"totalHourlyCost"`
32+
TotalMonthlyCost *string `json:"totalMonthlyCost"`
33+
}
34+
35+
// InfracostResource represents a single resource in the cost breakdown.
36+
type InfracostResource struct {
37+
Name string `json:"name"`
38+
ResourceType string `json:"resourceType"`
39+
Tags map[string]string `json:"tags"`
40+
Metadata map[string]interface{} `json:"metadata"`
41+
HourlyCost *string `json:"hourlyCost"`
42+
MonthlyCost *string `json:"monthlyCost"`
43+
CostComponents []InfracostCostComponent `json:"costComponents"`
44+
SubResources []InfracostResource `json:"subresources"`
45+
}
46+
47+
// InfracostCostComponent represents a cost component of a resource.
48+
type InfracostCostComponent struct {
49+
Name string `json:"name"`
50+
Unit string `json:"unit"`
51+
HourlyQuantity *string `json:"hourlyQuantity"`
52+
MonthlyQuantity *string `json:"monthlyQuantity"`
53+
Price string `json:"price"`
54+
HourlyCost *string `json:"hourlyCost"`
55+
MonthlyCost *string `json:"monthlyCost"`
56+
}
57+
58+
// InfracostResourceScope represents the scope of an infracost resource.
59+
type InfracostResourceScope string
60+
61+
const (
62+
InfracostResourceScopeBreakdown InfracostResourceScope = "breakdown"
63+
InfracostResourceScopePastBreakdown InfracostResourceScope = "past_breakdown"
64+
InfracostResourceScopeDiff InfracostResourceScope = "diff"
65+
)

go/deployment-operator/pkg/harness/tool/terraform/terraform.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,12 +267,16 @@ func (in *Terraform) init() v1.Tool {
267267

268268
// New creates a Terraform structure that implements v1.Tool interface.
269269
func New(config v1.Config) v1.Tool {
270-
return (&Terraform{
270+
tf := &Terraform{
271271
DefaultTool: v1.DefaultTool{Scanner: config.Scanner},
272272
workDir: config.WorkDir,
273273
dir: config.ExecDir,
274274
variables: config.Variables,
275-
parallelism: config.Run.Parallelism,
276-
refresh: config.Run.Refresh,
277-
}).init()
275+
}
276+
if config.Run != nil {
277+
tf.parallelism = config.Run.Parallelism
278+
tf.refresh = config.Run.Refresh
279+
tf.env = config.Run.Env()
280+
}
281+
return tf.init()
278282
}

go/deployment-operator/pkg/harness/tool/terraform/terraform_types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,9 @@ type Terraform struct {
3333
// refresh is a flag to refresh the state.
3434
// Default: true
3535
refresh *bool
36+
37+
// env is the list of stack run environment variables in "KEY=value" form.
38+
// Used to detect optional integrations (e.g. infracost) and to pass them
39+
// through to subprocesses started by the tool.
40+
env []string
3641
}

go/deployment-operator/pkg/harness/tool/v1/tool.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ func (in *DefaultTool) HasChanges() (bool, error) {
4141
return true, nil
4242
}
4343

44+
// Infracost implements [Tool] interface.
45+
// The default implementation returns nil (no infracost support).
46+
func (in *DefaultTool) Infracost() ([]*console.StackInfracostResourceAttributes, error) {
47+
return nil, nil
48+
}
49+
4450
func New() Tool {
4551
return &DefaultTool{}
4652
}

0 commit comments

Comments
 (0)