Skip to content

Commit 8bc9d12

Browse files
authored
feat(kubernetes): add core group with PVC + LoadBalancer Service [FIX-331] (#78)
1 parent 4362703 commit 8bc9d12

6 files changed

Lines changed: 212 additions & 3 deletions

File tree

pkg/tree/kubernetes/apps/stateful_set.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package apps
22

33
import (
4+
"github.com/infracost/go-proto/pkg/tree/kubernetes/core"
45
"github.com/infracost/go-proto/pkg/tree/kubernetes/workload"
56
"github.com/infracost/go-proto/pkg/tree/value"
67
)
@@ -10,4 +11,11 @@ import (
1011
type StatefulSet struct {
1112
workload.Workload `tree:"-"`
1213
Replicas value.Int `tree:"replicas"`
14+
15+
// VolumeClaimTemplates are spec.volumeClaimTemplates. A StatefulSet
16+
// provisions one persistent volume per template per replica, so the storage
17+
// cost is (sum of these) x Replicas — which is why they live on the
18+
// StatefulSet rather than as standalone PersistentVolumeClaims in the core
19+
// group.
20+
VolumeClaimTemplates []core.StorageRequest `tree:"volume_claim_template"`
1321
}

pkg/tree/kubernetes/core/core.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Package core models the Kubernetes core API group (the "" group, apiVersion
2+
// v1) as a service in the tree. Unlike the apps and batch groups — whose members
3+
// are all workloads sharing the workload type — the core group holds the two
4+
// cost-relevant non-workload kinds: PersistentVolumeClaim (a request for a
5+
// dynamically provisioned cloud disk) and Service (only type: LoadBalancer,
6+
// which provisions a cloud load balancer). Both are cloud spend created outside
7+
// the cluster's node pools, so nothing in the node-pool IaC accounts for them.
8+
//
9+
// Each slice is tagged with the kind, which becomes the resource Type on the
10+
// wire — mirroring the apps and batch groups.
11+
package core
12+
13+
// Core is the core/v1 API group.
14+
type Core struct {
15+
PersistentVolumeClaims []PersistentVolumeClaim `tree:"persistentvolumeclaim"`
16+
Services []Service `tree:"service"`
17+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package core
2+
3+
import (
4+
"github.com/infracost/go-proto/pkg/tree/resource"
5+
"github.com/infracost/go-proto/pkg/tree/value"
6+
)
7+
8+
// StorageRequest is the cost-relevant sizing of a persistent volume: the
9+
// requested capacity and the storage class that selects the backing disk SKU.
10+
// The storage class is the price driver (e.g. "gp3" vs "io2" on AWS,
11+
// "premium-rwo" vs "standard-rwo" on GKE differ several-fold per GB), and the
12+
// request is the exact provisioned size — so this pair is enough to price the
13+
// volume without cluster or API access.
14+
//
15+
// It is shared by a standalone PersistentVolumeClaim (embedded, so its fields
16+
// sit at the claim's top level) and by the volumeClaimTemplates of a StatefulSet
17+
// (see the apps package), where one such volume is provisioned per replica.
18+
type StorageRequest struct {
19+
// StorageClassName is spec.storageClassName. Empty means the cluster's
20+
// default StorageClass — the parser leaves it empty rather than guessing, so
21+
// the downstream pricer can fall back to a documented default.
22+
StorageClassName value.String `tree:"storage_class_name"`
23+
24+
// RequestBytes is spec.resources.requests.storage reduced to bytes — the
25+
// canonical base unit a Kubernetes quantity reduces to ("10Gi" ->
26+
// 10737418240), exact regardless of the suffix the author used. The
27+
// downstream pricer converts to GB as part of its rate.
28+
RequestBytes value.Int `tree:"request_bytes"`
29+
}
30+
31+
// PersistentVolumeClaim is a core/v1 PersistentVolumeClaim: a request that a
32+
// dynamic provisioner satisfies with a real cloud volume (EBS / Persistent Disk
33+
// / Azure disk). That volume is billed independently of the nodes, so — unlike
34+
// per-workload compute — pricing it does not double-count anything the node-pool
35+
// IaC already covers.
36+
//
37+
// The kind, address ([namespace, kind, name]) and source range live on the
38+
// embedded resource.Resource; the claim's Kubernetes labels are stored as the
39+
// base resource's Tags (reusing the tag machinery, as workloads do).
40+
type PersistentVolumeClaim struct {
41+
resource.Resource `tree:"-"`
42+
StorageRequest `tree:"-"`
43+
44+
// Annotations are the claim's Kubernetes annotations, surfaced verbatim so
45+
// downstream consumers can sniff cloud-provider signals (IRSA role ARNs,
46+
// GKE/Azure workload-identity annotations, etc.) — matching how the workload
47+
// type carries them.
48+
Annotations []resource.Tag `tree:"annotations"`
49+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package core
2+
3+
import (
4+
"github.com/infracost/go-proto/pkg/tree/resource"
5+
"github.com/infracost/go-proto/pkg/tree/value"
6+
)
7+
8+
// Service is a core/v1 Service. Only type: LoadBalancer carries cost — it makes
9+
// the cloud-controller-manager provision a real cloud load balancer (ALB/NLB,
10+
// GCP forwarding rule, Azure LB) — so the parser only surfaces those; the Type
11+
// is retained verbatim so downstream consumers can confirm it. ClusterIP /
12+
// NodePort / ExternalName Services are free and are not represented here.
13+
//
14+
// The kind, address ([namespace, kind, name]) and source range live on the
15+
// embedded resource.Resource; the Service's Kubernetes labels are stored as the
16+
// base resource's Tags.
17+
type Service struct {
18+
resource.Resource `tree:"-"`
19+
20+
// Type is spec.type — expected to be "LoadBalancer" for every Service the
21+
// parser surfaces.
22+
Type value.String `tree:"type"`
23+
24+
// Annotations are the Service's Kubernetes annotations, surfaced verbatim.
25+
// For load balancers these select the flavour that drives the price — e.g.
26+
// service.beta.kubernetes.io/aws-load-balancer-type (nlb vs the default
27+
// classic ELB) — as well as the cloud-provider signals the workload type
28+
// also carries.
29+
Annotations []resource.Tag `tree:"annotations"`
30+
31+
// Ports are the service ports (spec.ports), each becoming a listener on the
32+
// provisioned load balancer.
33+
Ports []ServicePort `tree:"ports"`
34+
}
35+
36+
// ServicePort is a single entry of a Service's spec.ports.
37+
type ServicePort struct {
38+
Port value.Int `tree:"port"`
39+
Protocol value.String `tree:"protocol"`
40+
}

pkg/tree/kubernetes/kubernetes.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
// Package kubernetes is the Kubernetes provider in the resource tree. It groups
2-
// workloads by their Kubernetes API group (the service layer): apps/v1 and
3-
// batch/v1. Workloads themselves share a single type — see the workload package.
2+
// resources by their Kubernetes API group (the service layer): apps/v1,
3+
// batch/v1 and the core (v1) group. The apps and batch groups hold workloads,
4+
// which share a single type — see the workload package. The core group holds
5+
// the cost-relevant non-workload kinds (PersistentVolumeClaim, LoadBalancer
6+
// Service) — see the core package.
47
package kubernetes
58

69
import (
710
"github.com/infracost/go-proto/pkg/tree/kubernetes/apps"
811
"github.com/infracost/go-proto/pkg/tree/kubernetes/batch"
12+
"github.com/infracost/go-proto/pkg/tree/kubernetes/core"
913
)
1014

11-
// Kubernetes is the provider node for Kubernetes workloads.
15+
// Kubernetes is the provider node for Kubernetes resources.
1216
type Kubernetes struct {
1317
Apps apps.Apps `tree:"apps"`
1418
Batch batch.Batch `tree:"batch"`
19+
Core core.Core `tree:"core"`
1520
}

pkg/tree/kubernetes_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/infracost/go-proto/pkg/tree/kubernetes"
77
"github.com/infracost/go-proto/pkg/tree/kubernetes/apps"
88
"github.com/infracost/go-proto/pkg/tree/kubernetes/batch"
9+
"github.com/infracost/go-proto/pkg/tree/kubernetes/core"
910
"github.com/infracost/go-proto/pkg/tree/kubernetes/workload"
1011
"github.com/infracost/go-proto/pkg/tree/resource"
1112
"github.com/infracost/go-proto/pkg/tree/value"
@@ -57,6 +58,18 @@ func TestKubernetesRoundTrip(t *testing.T) {
5758
},
5859
},
5960
},
61+
StatefulSets: []apps.StatefulSet{
62+
{
63+
Workload: workload.Workload{Resource: resource.Resource{ID: "db"}},
64+
Replicas: value.New[int64](3, 0, "", nil),
65+
VolumeClaimTemplates: []core.StorageRequest{
66+
{
67+
StorageClassName: value.New("gp3", 0, "", nil),
68+
RequestBytes: value.New[int64](10737418240, 0, "", nil),
69+
},
70+
},
71+
},
72+
},
6073
},
6174
Batch: batch.Batch{
6275
CronJobs: []batch.CronJob{
@@ -70,12 +83,52 @@ func TestKubernetesRoundTrip(t *testing.T) {
7083
},
7184
},
7285
},
86+
Core: core.Core{
87+
PersistentVolumeClaims: []core.PersistentVolumeClaim{
88+
{
89+
Resource: resource.Resource{
90+
ID: "data",
91+
Tags: resource.Tags{
92+
{Key: value.New("app", 0, "", nil), Value: value.New("api", 0, "", nil)},
93+
},
94+
},
95+
StorageRequest: core.StorageRequest{
96+
StorageClassName: value.New("io2", 0, "", nil),
97+
RequestBytes: value.New[int64](21474836480, 0, "", nil),
98+
},
99+
Annotations: []resource.Tag{
100+
{
101+
Key: value.New("volume.beta.kubernetes.io/storage-provisioner", 0, "", nil),
102+
Value: value.New("ebs.csi.aws.com", 0, "", nil),
103+
},
104+
},
105+
},
106+
},
107+
Services: []core.Service{
108+
{
109+
Resource: resource.Resource{ID: "api-lb"},
110+
Type: value.New("LoadBalancer", 0, "", nil),
111+
Annotations: []resource.Tag{
112+
{
113+
Key: value.New("service.beta.kubernetes.io/aws-load-balancer-type", 0, "", nil),
114+
Value: value.New("nlb", 0, "", nil),
115+
},
116+
},
117+
Ports: []core.ServicePort{
118+
{Port: value.New[int64](443, 0, "", nil), Protocol: value.New("TCP", 0, "", nil)},
119+
},
120+
},
121+
},
122+
},
73123
},
74124
}
75125

76126
origDep := original.Kubernetes.Apps.Deployments[0]
77127
origDaemon := original.Kubernetes.Apps.DaemonSets[0]
128+
origSts := original.Kubernetes.Apps.StatefulSets[0]
78129
origCron := original.Kubernetes.Batch.CronJobs[0]
130+
origPVC := original.Kubernetes.Core.PersistentVolumeClaims[0]
131+
origSvc := original.Kubernetes.Core.Services[0]
79132

80133
proto, err := original.ToProto()
81134
require.NoError(t, err)
@@ -87,6 +140,12 @@ func TestKubernetesRoundTrip(t *testing.T) {
87140
require.NotNil(t, depAttrs["containers"], "embedded base fields must be flattened")
88141
require.NotNil(t, depAttrs["annotations"], "embedded base fields must be flattened")
89142

143+
// The PersistentVolumeClaim embeds StorageRequest, so its storage fields are
144+
// flattened to the claim's top level rather than nested under an embed key.
145+
pvcAttrs := proto.Providers["kubernetes"].Services["core"].Resources[0].Attributes.Entries
146+
assert.Equal(t, origPVC.StorageClassName.Value(), pvcAttrs["storage_class_name"].GetStringValue())
147+
assert.Equal(t, origPVC.RequestBytes.Value(), pvcAttrs["request_bytes"].GetIntValue())
148+
90149
result, err := FromProto(proto)
91150
require.NoError(t, err)
92151

@@ -111,6 +170,37 @@ func TestKubernetesRoundTrip(t *testing.T) {
111170
require.Len(t, daemon.Containers, len(origDaemon.Containers))
112171
assert.Equal(t, origDaemon.Containers[0].Name.Value(), daemon.Containers[0].Name.Value())
113172

173+
// StatefulSet volumeClaimTemplates: a slice of nested StorageRequest structs.
174+
require.Len(t, result.Kubernetes.Apps.StatefulSets, 1)
175+
sts := result.Kubernetes.Apps.StatefulSets[0]
176+
assert.Equal(t, origSts.ID, sts.ID)
177+
assert.Equal(t, origSts.Replicas.Value(), sts.Replicas.Value())
178+
require.Len(t, sts.VolumeClaimTemplates, len(origSts.VolumeClaimTemplates))
179+
assert.Equal(t, origSts.VolumeClaimTemplates[0].StorageClassName.Value(), sts.VolumeClaimTemplates[0].StorageClassName.Value())
180+
assert.Equal(t, origSts.VolumeClaimTemplates[0].RequestBytes.Value(), sts.VolumeClaimTemplates[0].RequestBytes.Value())
181+
182+
// core group: PersistentVolumeClaim (embedded StorageRequest) and Service.
183+
require.Len(t, result.Kubernetes.Core.PersistentVolumeClaims, 1)
184+
pvc := result.Kubernetes.Core.PersistentVolumeClaims[0]
185+
assert.Equal(t, origPVC.ID, pvc.ID)
186+
assert.Equal(t, origPVC.StorageClassName.Value(), pvc.StorageClassName.Value())
187+
assert.Equal(t, origPVC.RequestBytes.Value(), pvc.RequestBytes.Value())
188+
require.Len(t, pvc.Tags, len(origPVC.Tags))
189+
assert.Equal(t, origPVC.Tags[0].Key.Value(), pvc.Tags[0].Key.Value())
190+
require.Len(t, pvc.Annotations, len(origPVC.Annotations))
191+
assert.Equal(t, origPVC.Annotations[0].Key.Value(), pvc.Annotations[0].Key.Value())
192+
assert.Equal(t, origPVC.Annotations[0].Value.Value(), pvc.Annotations[0].Value.Value())
193+
194+
require.Len(t, result.Kubernetes.Core.Services, 1)
195+
svc := result.Kubernetes.Core.Services[0]
196+
assert.Equal(t, origSvc.ID, svc.ID)
197+
assert.Equal(t, origSvc.Type.Value(), svc.Type.Value())
198+
require.Len(t, svc.Annotations, len(origSvc.Annotations))
199+
assert.Equal(t, origSvc.Annotations[0].Key.Value(), svc.Annotations[0].Key.Value())
200+
require.Len(t, svc.Ports, len(origSvc.Ports))
201+
assert.Equal(t, origSvc.Ports[0].Port.Value(), svc.Ports[0].Port.Value())
202+
assert.Equal(t, origSvc.Ports[0].Protocol.Value(), svc.Ports[0].Protocol.Value())
203+
114204
// Multi-level embedding: CronJob -> Job -> Workload all flatten together.
115205
require.Len(t, result.Kubernetes.Batch.CronJobs, 1)
116206
cj := result.Kubernetes.Batch.CronJobs[0]

0 commit comments

Comments
 (0)