Skip to content
Draft
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,6 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

// DEV: local proto with provider raw_options (remove once proto is tagged/published)
replace github.com/infracost/proto => ../proto
70 changes: 70 additions & 0 deletions internal/cluster/cluster.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Package cluster resolves the underlying-cluster topology that Kubernetes
// workloads deploy onto, so the kubernetes provider plugin can price them.
//
// Kubernetes manifests carry no cluster info; the cluster's node pools live in
// separate infrastructure-as-code (e.g. an EKS module in another repo). This
// package derives a cluster Config from that IaC; the scanner serializes it to
// JSON and passes it to the kubernetes provider plugin via the generic
// per-plugin options channel (TreeInput.raw_options). The same resolver is
// intended for reuse by Infracost Cloud, which has access to every repo and can
// join app repos to their cluster repos.
package cluster

import "encoding/json"

// KubernetesProviderName is the provider plugin name (GetPluginInfo.Name) whose
// raw_options the resolved cluster Config is sent to.
const KubernetesProviderName = "infracost/kubernetes"

// Config is the cluster topology. Its JSON shape is the contract with the
// kubernetes provider plugin (mirrors providers/pkg/kubernetes.ClusterConfig);
// keep the two in sync, or promote to a shared schema module.
type Config struct {
CloudProvider string `json:"cloud_provider"`
Region string `json:"region"`
ComputePools []ComputePool `json:"compute_pools"`
StorageBackends []StorageBackend `json:"storage_backends,omitempty"`
LoadBalancerDefaults *LoadBalancerDefaults `json:"load_balancer_defaults,omitempty"`
}

type ComputePool struct {
Name string `json:"name"`
// InstanceTypes are the interchangeable instance families a (usually Spot)
// node group may launch. The provider prices all of them and uses the
// cheapest, since a capacity/price-optimized autoscaler rarely lands on the
// first/newest one.
InstanceTypes []string `json:"instance_types"`
Spot bool `json:"spot"`
NodeCount int64 `json:"node_count,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
// Taints the node group applies. Mirrors the provider schema so a
// supplied/derived spec can drive taint-aware pod->pool matching. Not yet
// populated by FromTerraformTree (the parsed tree drops node-group
// labels/taints — see docs/kubernetes-plugin.md).
Taints []Taint `json:"taints,omitempty"`
}

// Taint is a node taint (key/value/effect).
type Taint struct {
Key string `json:"key"`
Value string `json:"value"`
Effect string `json:"effect"`
}

type StorageBackend struct {
Name string `json:"name"`
StorageType string `json:"storage_type"`
}

type LoadBalancerDefaults struct {
Type string `json:"type"`
}

// JSON renders the config as the JSON the provider expects.
func (c *Config) JSON() (string, error) {
b, err := json.Marshal(c)
if err != nil {
return "", err
}
return string(b), nil
}
55 changes: 55 additions & 0 deletions internal/cluster/resolve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package cluster

import (
"context"
"os"
"path/filepath"

"github.com/infracost/proto/gen/go/infracost/parser/options"

Check failure on line 8 in internal/cluster/resolve.go

View workflow job for this annotation

GitHub Actions / Unit Tests

github.com/infracost/proto@v1.147.0: replacement directory ../proto does not exist
pb "github.com/infracost/proto/gen/go/infracost/plugin"
"google.golang.org/grpc"
)

// TerraformParser is the subset of a parser plugin the resolver needs.
// *plugins.ParserPlugin satisfies it (it embeds the ParserServiceClient).
type TerraformParser interface {
Parse(ctx context.Context, in *pb.ParseRequest, opts ...grpc.CallOption) (*pb.ParseResponse, error)
}

// ResolveFromDir parses the cluster IaC at dir with the given Terraform parser
// plugin and derives the cluster topology. Returns (nil, nil) when dir has no
// recognizable cluster.
//
// The repo root is set to dir's enclosing git repository (not dir itself) so
// that local modules referenced as siblings — e.g. a root at infra/prod that
// uses ../modules/base — are loaded; the parser rejects modules outside the
// repo root.
func ResolveFromDir(ctx context.Context, dir string, parser TerraformParser) (*Config, error) {
resp, err := parser.Parse(ctx, &pb.ParseRequest{
Path: dir,
GenericOptions: &options.GenericOptions{
RepoDirectory: repoRoot(dir),
WorkingDirectory: dir,
},
})
if err != nil {
return nil, err
}
return FromTerraformTree(resp.GetTree())
}

// repoRoot walks up from dir to the nearest ancestor containing a .git entry,
// returning that directory. Falls back to dir when no repository is found.
func repoRoot(dir string) string {
d := dir
for {
if _, err := os.Stat(filepath.Join(d, ".git")); err == nil {
return d
}
parent := filepath.Dir(d)
if parent == d {
return dir
}
d = parent
}
}
39 changes: 39 additions & 0 deletions internal/cluster/resolve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package cluster

import (
"context"
"testing"

gptree "github.com/infracost/go-proto/pkg/tree"
"github.com/infracost/go-proto/pkg/tree/aws/eks"
pb "github.com/infracost/proto/gen/go/infracost/plugin"
"google.golang.org/grpc"
)

type fakeParser struct {
resp *pb.ParseResponse
err error
}

func (f fakeParser) Parse(_ context.Context, _ *pb.ParseRequest, _ ...grpc.CallOption) (*pb.ParseResponse, error) {
return f.resp, f.err
}

func TestResolveFromDir(t *testing.T) {
var w gptree.Tree
w.AWS.EKS.NodeGroups = []eks.NodeGroup{
nodeGroup("default_workers", "us-east-2", 3, true, "r7i.large"),
}
pt, err := w.ToProto()
if err != nil {
t.Fatalf("ToProto: %v", err)
}

cfg, err := ResolveFromDir(context.Background(), "./infra", fakeParser{resp: &pb.ParseResponse{Tree: pt}})
if err != nil {
t.Fatalf("ResolveFromDir: %v", err)
}
if cfg == nil || len(cfg.ComputePools) != 1 || len(cfg.ComputePools[0].InstanceTypes) != 1 || cfg.ComputePools[0].InstanceTypes[0] != "r7i.large" {
t.Fatalf("unexpected config: %+v", cfg)
}
}
112 changes: 112 additions & 0 deletions internal/cluster/terraform.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package cluster

import (
"encoding/json"
"strings"

gptree "github.com/infracost/go-proto/pkg/tree"
"github.com/infracost/go-proto/pkg/tree/aws/eks"
prototree "github.com/infracost/proto/gen/go/infracost/tree"
)

// FromTerraformTree derives a cluster Config from a parsed Terraform tree (the
// cluster's IaC), reading EKS node groups. Each node group becomes a compute
// pool, including its Kubernetes labels + taints (surfaced by the terraform
// parser in raw attributes) so the provider can match a pod to the pool it
// would actually schedule onto.
//
// Returns (nil, nil) when the tree defines no recognizable cluster, so callers
// can fall back to an explicitly-provided cluster spec.
func FromTerraformTree(protoTree *prototree.Tree) (*Config, error) {
if protoTree == nil {
return nil, nil
}
w, err := gptree.FromProto(protoTree)
if err != nil {
return nil, err
}

cfg := &Config{CloudProvider: "aws"}

for i := range w.AWS.EKS.NodeGroups {
ng := &w.AWS.EKS.NodeGroups[i]

// A node group may list several interchangeable instance families
// (Spot diversification); carry them all so the provider can price the
// cheapest.
var instanceTypes []string
for _, it := range ng.InstanceTypes.Items() {
if v := it.Value(); v != "" {
instanceTypes = append(instanceTypes, v)
}
}
if len(instanceTypes) == 0 {
continue
}

if cfg.Region == "" {
cfg.Region = ng.GetBase().Region
}

ras := ng.GetBase().Definition.RawStringAttributes
cfg.ComputePools = append(cfg.ComputePools, ComputePool{
Name: ng.Name.Value(),
InstanceTypes: instanceTypes,
NodeCount: ng.InstanceCount.Value(),
Spot: ng.PurchaseOption.Value() == eks.PurchaseOptionSpot,
Labels: parseLabels(ras["k8s_labels"]),
Taints: parseTaints(ras["k8s_taints"]),
})
}

if len(cfg.ComputePools) == 0 {
return nil, nil
}
return cfg, nil
}

func parseLabels(raw string) map[string]string {
if raw == "" {
return nil
}
var m map[string]string
if json.Unmarshal([]byte(raw), &m) != nil {
return nil
}
return m
}

func parseTaints(raw string) []Taint {
if raw == "" {
return nil
}
var entries []map[string]string
if json.Unmarshal([]byte(raw), &entries) != nil {
return nil
}
out := make([]Taint, 0, len(entries))
for _, e := range entries {
out = append(out, Taint{
Key: e["key"],
Value: e["value"],
Effect: normalizeTaintEffect(e["effect"]),
})
}
return out
}

// normalizeTaintEffect maps the Terraform/AWS taint effect spelling
// (NO_SCHEDULE) to the Kubernetes spelling (NoSchedule) the provider matches
// pod tolerations against.
func normalizeTaintEffect(effect string) string {
switch strings.ToUpper(strings.ReplaceAll(effect, "_", "")) {
case "NOSCHEDULE":
return "NoSchedule"
case "NOEXECUTE":
return "NoExecute"
case "PREFERNOSCHEDULE":
return "PreferNoSchedule"
default:
return effect
}
}
89 changes: 89 additions & 0 deletions internal/cluster/terraform_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package cluster

import (
"testing"

gptree "github.com/infracost/go-proto/pkg/tree"
"github.com/infracost/go-proto/pkg/tree/aws/eks"
"github.com/infracost/go-proto/pkg/tree/resource"
"github.com/infracost/go-proto/pkg/tree/value"
)

// nodeGroup builds an eks.NodeGroup mirroring the shape produced by parsing a
// terraform-aws-modules/eks managed node group.
func nodeGroup(name, region string, count int64, spot bool, instanceTypes ...string) eks.NodeGroup {
items := make([]value.Value[string], 0, len(instanceTypes))
for _, it := range instanceTypes {
items = append(items, value.New(it, 0, "", nil))
}
purchase := eks.PurchaseOptionOnDemand
if spot {
purchase = eks.PurchaseOptionSpot
}
return eks.NodeGroup{
Resource: resource.Resource{Region: region},
Name: value.New(name, 0, "", nil),
ClusterName: value.New("prod", 0, "", nil),
InstanceCount: value.New(count, 0, "", nil),
InstanceTypes: *value.NewList(items, 0, "", nil),
PurchaseOption: value.New(purchase, 0, "", nil),
}
}

func TestFromTerraformTree_EKSNodeGroups(t *testing.T) {
// Mirrors coast's prod cluster in ./infra: SPOT worker pools on r7i.large,
// a t3.large data-export pool, region us-east-2.
var w gptree.Tree
w.AWS.EKS.NodeGroups = []eks.NodeGroup{
nodeGroup("default_workers", "us-east-2", 3, true, "r7i.large", "r7a.large", "r6i.large"),
nodeGroup("data_export", "us-east-2", 1, true, "t3.large", "t3a.large"),
}

pt, err := w.ToProto()
if err != nil {
t.Fatalf("ToProto: %v", err)
}

cfg, err := FromTerraformTree(pt)
if err != nil {
t.Fatalf("FromTerraformTree: %v", err)
}
if cfg == nil {
t.Fatal("expected a cluster config, got nil")
}
if cfg.CloudProvider != "aws" || cfg.Region != "us-east-2" {
t.Fatalf("unexpected cloud/region: %s/%s", cfg.CloudProvider, cfg.Region)
}
if len(cfg.ComputePools) != 2 {
t.Fatalf("expected 2 pools, got %d", len(cfg.ComputePools))
}

p0 := cfg.ComputePools[0]
if p0.Name != "default_workers" || !p0.Spot || p0.NodeCount != 3 {
t.Fatalf("unexpected first pool: %+v", p0)
}
if len(p0.InstanceTypes) != 3 || p0.InstanceTypes[0] != "r7i.large" || p0.InstanceTypes[2] != "r6i.large" {
t.Fatalf("expected all 3 instance families carried, got %v", p0.InstanceTypes)
}

// The derived config must serialize to the JSON the provider consumes.
js, err := cfg.JSON()
if err != nil || js == "" {
t.Fatalf("JSON(): %q err=%v", js, err)
}
}

func TestFromTerraformTree_NoCluster(t *testing.T) {
var w gptree.Tree
pt, err := w.ToProto()
if err != nil {
t.Fatalf("ToProto: %v", err)
}
cfg, err := FromTerraformTree(pt)
if err != nil {
t.Fatalf("FromTerraformTree: %v", err)
}
if cfg != nil {
t.Fatalf("expected nil config for a tree with no node groups, got %+v", cfg)
}
}
Loading
Loading