Skip to content

Commit 3d0c7f8

Browse files
authored
Merge pull request #20 from mayur-tolexo/feat/cost-guardrail
feat(cost): CI cost-guardrail — price manifest changes, flag/block PR $ deltas
2 parents 8a770fa + 87e6526 commit 3d0c7f8

10 files changed

Lines changed: 762 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ fixes/UX).
77

88
## [Unreleased]
99

10-
_Nothing yet._
10+
### Added
11+
- **`kubetidy cost`** — the CI cost-guardrail. Prices CPU/memory requests in manifests (no
12+
cluster) and, with `--base`/`--head`, reports the monthly $ a change adds or saves
13+
("this change adds $88/mo"); `--fail-over <budget>` fails CI on a net increase. Example
14+
GitHub Actions workflow in `docs/examples/cost-guardrail.yml`.
1115

1216
## [0.1.2] — 2026-06-02
1317

README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,8 @@ kubetidy ships as a single binary with two faces — use whichever you prefer:
225225
- `kubetidy <command>` (standalone)
226226

227227
Commands: **`scan`** (report), **`diff`** (reversible `kubectl patch` per recommendation),
228-
**`sweep`** (find removable junk), **`pr`** (a GitOps change set — patch files + a Markdown PR
229-
body), **`init`** (install the CRD + operator), and `version`.
228+
**`sweep`** (find removable junk), **`cost`** (CI cost-guardrail), **`pr`** (a GitOps change set
229+
— patch files + a Markdown PR body), **`init`** (install the CRD + operator), and `version`.
230230

231231
### `scan` — score, dollars, and recommendations
232232

@@ -277,6 +277,32 @@ kubectl tidy sweep -o json # machine-readable
277277
Read-only: review before deleting. kubetidy never deletes anything.
278278
```
279279

280+
### `cost` — the CI cost-guardrail
281+
282+
`cost` prices the CPU/memory requests in Kubernetes manifests — **no cluster needed** — and, given
283+
a before/after, reports the monthly `$` a change adds or saves. Drop it in CI to flag (or block)
284+
PRs that quietly grow spend.
285+
286+
```sh
287+
kubectl tidy cost ./manifests # total $/mo of a manifest set
288+
kubectl tidy cost --base /tmp/base --head ./manifests # diff: "this change adds $88/mo"
289+
kubectl tidy cost --base B --head H --fail-over 200 # exit non-zero if it adds > $200/mo
290+
kubectl tidy cost --base B --head H -o json # machine-readable (for a PR comment)
291+
```
292+
293+
```
294+
kubetidy cost · base → head
295+
296+
this change adds $88/mo ($27/mo → $116/mo)
297+
298+
WORKLOAD BEFORE AFTER DELTA
299+
Deployment/shop/checkout-api $27 $109 +$82 (changed)
300+
StatefulSet/shop/cache — $7 +$7 (added)
301+
```
302+
303+
A ready-to-copy GitHub Actions workflow (comment the delta + enforce a budget) is in
304+
[`docs/examples/cost-guardrail.yml`](docs/examples/cost-guardrail.yml).
305+
280306
### `pr` — a reviewable GitOps change set
281307

282308
`pr` turns the scan into something you can merge: one strategic-merge patch file per

ROADMAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ This roadmap is intentionally public and open for discussion — please weigh in
3434
- [x] `kubetidy pr` — GitOps change set: per-recommendation patch files + a Markdown PR body
3535
that leads with the `$/mo` delta (apply via Argo CD / Flux / `kubectl`)
3636
- [x] One-command Prometheus deploy (`make prometheus`) to unlock Tier 1 anywhere
37-
- [ ] GitHub Action / CI cost-guardrail ("this PR adds $400/mo")
37+
- [x] **CI cost-guardrail**`kubetidy cost --base --head` prices manifest changes (no cluster)
38+
and reports/blocks the $/mo a PR adds; example GitHub Action in docs/examples/.
3839
- [ ] Slack weekly digest (retention: puts kubetidy in a loop teams already run)
3940
- [ ] Opt-in anonymized score submission → "State of Cluster Waste" data report
4041

docs/examples/cost-guardrail.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Example GitHub Actions workflow: comment a PR's monthly cost delta and (optionally) fail when
2+
# it exceeds a budget. Copy this into your repo's .github/workflows/ and point --base/--head at
3+
# wherever your Kubernetes manifests live (here: a top-level ./manifests directory).
4+
#
5+
# It uses `kubetidy cost`, which prices CPU/memory requests from the manifests alone — no cluster
6+
# access or secrets needed.
7+
name: cost-guardrail
8+
9+
on:
10+
pull_request:
11+
paths: ["manifests/**"] # adjust to your manifest path
12+
13+
permissions:
14+
contents: read
15+
pull-requests: write # to comment on the PR
16+
17+
jobs:
18+
cost:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
with:
23+
fetch-depth: 0 # need the base ref to diff against
24+
25+
- name: Install kubetidy
26+
run: curl -fsSL https://raw.githubusercontent.com/mayur-tolexo/kubetidy/main/install.sh | sh
27+
28+
- name: Check out the base version of the manifests
29+
run: git worktree add /tmp/base "origin/${{ github.base_ref }}"
30+
31+
- name: Compute the cost delta
32+
id: cost
33+
run: |
34+
# Human-readable table → the PR comment; JSON → the machine-readable net delta.
35+
kubetidy cost --base /tmp/base/manifests --head ./manifests | tee cost.txt
36+
echo "net=$(kubetidy cost --base /tmp/base/manifests --head ./manifests -o json | \
37+
python3 -c 'import sys,json;print(round(json.load(sys.stdin)["NetDelta"]))')" >> "$GITHUB_OUTPUT"
38+
39+
- name: Comment on the PR
40+
uses: actions/github-script@v7
41+
with:
42+
script: |
43+
const body = "### 💸 kubetidy cost guardrail\n\n```\n" +
44+
require('fs').readFileSync('cost.txt','utf8') + "\n```";
45+
github.rest.issues.createComment({
46+
owner: context.repo.owner, repo: context.repo.repo,
47+
issue_number: context.issue.number, body,
48+
});
49+
50+
# Optional hard gate: fail the PR if it adds more than $200/mo.
51+
- name: Enforce budget
52+
run: kubetidy cost --base /tmp/base/manifests --head ./manifests --fail-over 200

internal/cli/cost.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/spf13/cobra"
12+
13+
"github.com/kubetidy/kubetidy/internal/manifest"
14+
"github.com/kubetidy/kubetidy/internal/model"
15+
)
16+
17+
type costFlags struct {
18+
base string
19+
head string
20+
cpuCoreMonth float64
21+
memGiBMonth float64
22+
failOver float64
23+
failOverSet bool
24+
output string
25+
}
26+
27+
func newCostCommand() *cobra.Command {
28+
f := &costFlags{}
29+
cmd := &cobra.Command{
30+
Use: "cost [manifests...]",
31+
Short: "Estimate the $/mo of resource requests in manifests; diff a PR's before/after",
32+
Long: "cost prices the CPU/memory requests in Kubernetes manifests (no cluster needed) and, " +
33+
"given --base and --head, reports the monthly $ a change adds or saves — the CI cost-guardrail " +
34+
"(\"this PR adds $400/mo\"). Use --fail-over to fail CI when the net increase exceeds a budget.",
35+
RunE: func(cmd *cobra.Command, args []string) error {
36+
f.failOverSet = cmd.Flags().Changed("fail-over")
37+
return runCost(cmd, f, args)
38+
},
39+
}
40+
flags := cmd.Flags()
41+
flags.StringVar(&f.base, "base", "", "base manifest file or dir (the 'before' — e.g. main branch)")
42+
flags.StringVar(&f.head, "head", "", "head manifest file or dir (the 'after' — e.g. the PR). Defaults to positional args")
43+
flags.Float64Var(&f.cpuCoreMonth, "cpu-cost", 0, "override $ per CPU core-month (0 = default)")
44+
flags.Float64Var(&f.memGiBMonth, "mem-cost", 0, "override $ per GiB-month (0 = default)")
45+
flags.Float64Var(&f.failOver, "fail-over", 0, "exit non-zero if the net monthly increase exceeds this $ amount")
46+
flags.StringVarP(&f.output, "output", "o", "table", "output format: table|json")
47+
return cmd
48+
}
49+
50+
func runCost(cmd *cobra.Command, f *costFlags, args []string) error {
51+
price := manifest.DefaultPrice(f.cpuCoreMonth, f.memGiBMonth)
52+
53+
// Head = --head, or positional args (so `kubetidy cost deploy.yaml` works).
54+
headPaths := args
55+
if f.head != "" {
56+
headPaths = append([]string{f.head}, args...)
57+
}
58+
if len(headPaths) == 0 {
59+
return fmt.Errorf("cost: provide manifests as arguments or via --head")
60+
}
61+
headWls, err := loadManifestPaths(headPaths)
62+
if err != nil {
63+
return err
64+
}
65+
headCost := manifest.CostWorkloads(headWls, price)
66+
67+
var baseCost []manifest.WorkloadCost
68+
if f.base != "" {
69+
baseWls, err := loadManifestPaths([]string{f.base})
70+
if err != nil {
71+
return err
72+
}
73+
baseCost = manifest.CostWorkloads(baseWls, price)
74+
}
75+
76+
report := manifest.Compare(baseCost, headCost)
77+
if err := renderCost(cmd.OutOrStdout(), report, f.base != "", f.output); err != nil {
78+
return err
79+
}
80+
81+
// CI guardrail: fail when the net increase exceeds the budget.
82+
if f.failOverSet && report.NetDelta > f.failOver {
83+
return fmt.Errorf("cost guardrail: net increase %s/mo exceeds budget %s/mo",
84+
signedDollars(report.NetDelta), signedDollars(f.failOver))
85+
}
86+
return nil
87+
}
88+
89+
// loadManifestPaths reads every .yaml/.yml/.json under the given files/dirs and parses workloads.
90+
func loadManifestPaths(paths []string) ([]model.Workload, error) {
91+
var all []model.Workload
92+
for _, p := range paths {
93+
info, err := os.Stat(p)
94+
if err != nil {
95+
return nil, fmt.Errorf("cost: %w", err)
96+
}
97+
var files []string
98+
if info.IsDir() {
99+
entries, err := os.ReadDir(p)
100+
if err != nil {
101+
return nil, fmt.Errorf("cost: reading dir %s: %w", p, err)
102+
}
103+
for _, e := range entries {
104+
if !e.IsDir() && isManifestFile(e.Name()) {
105+
files = append(files, filepath.Join(p, e.Name()))
106+
}
107+
}
108+
} else {
109+
files = []string{p}
110+
}
111+
for _, file := range files {
112+
b, err := os.ReadFile(file)
113+
if err != nil {
114+
return nil, fmt.Errorf("cost: reading %s: %w", file, err)
115+
}
116+
wls, err := manifest.ParseWorkloadsBytes(b)
117+
if err != nil {
118+
return nil, fmt.Errorf("cost: parsing %s: %w", file, err)
119+
}
120+
all = append(all, wls...)
121+
}
122+
}
123+
return all, nil
124+
}
125+
126+
func isManifestFile(name string) bool {
127+
switch strings.ToLower(filepath.Ext(name)) {
128+
case ".yaml", ".yml", ".json":
129+
return true
130+
default:
131+
return false
132+
}
133+
}
134+
135+
func renderCost(w io.Writer, rep manifest.CostReport, diff bool, output string) error {
136+
if output == "json" {
137+
enc := json.NewEncoder(w)
138+
enc.SetIndent("", " ")
139+
return enc.Encode(rep)
140+
}
141+
if output != "" && output != "table" {
142+
return fmt.Errorf("unknown output format %q (want table|json)", output)
143+
}
144+
145+
var b strings.Builder
146+
if !diff {
147+
// Single set: just the total monthly cost.
148+
fmt.Fprintf(&b, "kubetidy cost\n\n Total: %s/mo (resource requests)\n\n", signedDollars(rep.AfterTotal))
149+
for _, c := range rep.Changes {
150+
fmt.Fprintf(&b, " %-44s %s/mo\n", c.Ref, signedDollars(c.After))
151+
}
152+
_, err := io.WriteString(w, b.String())
153+
return err
154+
}
155+
156+
verb := "adds"
157+
if rep.NetDelta < 0 {
158+
verb = "saves"
159+
}
160+
headline := fmt.Sprintf("this change %s %s/mo", verb, signedDollars(abs64f(rep.NetDelta)))
161+
if rep.NetDelta == 0 {
162+
headline = "no monthly cost change"
163+
}
164+
fmt.Fprintf(&b, "kubetidy cost · base → head\n\n %s (%s/mo → %s/mo)\n\n",
165+
headline, signedDollars(rep.BeforeTotal), signedDollars(rep.AfterTotal))
166+
167+
fmt.Fprintf(&b, " %-40s %10s %10s %10s\n", "WORKLOAD", "BEFORE", "AFTER", "DELTA")
168+
for _, c := range rep.Changes {
169+
if c.Status == manifest.Unchanged {
170+
continue
171+
}
172+
fmt.Fprintf(&b, " %-40s %10s %10s %10s (%s)\n",
173+
truncate(c.Ref, 40), dollarsOrDash(c.Before, c.Status == manifest.Added),
174+
dollarsOrDash(c.After, c.Status == manifest.Removed), signedDelta(c.Delta), c.Status)
175+
}
176+
_, err := io.WriteString(w, b.String())
177+
return err
178+
}
179+
180+
func dollarsOrDash(v float64, dash bool) string {
181+
if dash {
182+
return "—"
183+
}
184+
return signedDollars(v)
185+
}
186+
187+
func signedDelta(v float64) string {
188+
if v > 0 {
189+
return "+" + signedDollars(v)
190+
}
191+
if v < 0 {
192+
return "-" + signedDollars(abs64f(v))
193+
}
194+
return "$0"
195+
}
196+
197+
func abs64f(v float64) float64 {
198+
if v < 0 {
199+
return -v
200+
}
201+
return v
202+
}
203+
204+
func truncate(s string, n int) string {
205+
if len(s) <= n {
206+
return s
207+
}
208+
return s[:n-1] + "…"
209+
}

0 commit comments

Comments
 (0)