Skip to content

Commit b9d20d8

Browse files
committed
feat(init): init --with-opencost deploys OpenCost for precise Tier-2 cost
Make `kubectl tidy init` optionally install OpenCost into the cluster so scans get precise allocated cost (spot/reserved/CUD discounts) out of the box, instead of blended derived pricing — no separate OpenCost setup. - Embed a minimal OpenCost manifest (namespace, SA, ClusterRole/Binding, Deployment, Service on :9003 at opencost.opencost.svc — which kubetidy already auto-detects for Tier 2). Prometheus endpoint is templated via a placeholder. - `init --with-opencost` applies it; `--prometheus-url` points OpenCost at your Prometheus (defaults to http://prometheus-server.monitoring.svc:80). - `init --print --with-opencost` emits the manifests for GitOps instead. - `uninstall --with-opencost` removes it (off by default, so a user's own OpenCost is never touched); deleted before the operator, idempotently. - Docs: README setup + Tier 2 section, ROADMAP (producer side of Tier 2), CHANGELOG [Unreleased].
1 parent 3d0c7f8 commit b9d20d8

10 files changed

Lines changed: 399 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ fixes/UX).
88
## [Unreleased]
99

1010
### Added
11+
- **`kubetidy init --with-opencost`** — optionally deploy OpenCost into the cluster (namespace,
12+
RBAC, deployment, service; embedded manifests) so scans get precise Tier-2 cost out of the box.
13+
OpenCost reads usage from Prometheus; point it with `--prometheus-url` (defaults to
14+
`http://prometheus-server.monitoring.svc:80`). `kubetidy uninstall --with-opencost` removes it
15+
again (off by default, so a user's own OpenCost is never touched). `init --print --with-opencost`
16+
emits the manifests for GitOps instead of applying them.
1117
- **`kubetidy cost`** — the CI cost-guardrail. Prices CPU/memory requests in manifests (no
1218
cluster) and, with `--base`/`--head`, reports the monthly $ a change adds or saves
1319
("this change adds $88/mo"); `--fail-over <budget>` fails CI on a net increase. Example

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ Tier 1 works on **any cluster** with no cost dependency — the dollar figure is
5454
blended cloud pricing (override with `--cpu-cost` / `--mem-cost`). Tier 2 kicks in when an
5555
in-cluster **OpenCost** is present (it needs Prometheus, which is why it sits above Tier 1):
5656
kubetidy auto-detects it and replaces the derived prices with real allocated cost. Point at one
57-
explicitly with `--opencost-url`.
57+
explicitly with `--opencost-url`, or let kubetidy deploy it for you with
58+
`kubectl tidy init --with-opencost` (see [setup](#one-command-setup-kubectl-tidy-init)).
5859

5960
Within Tier 1, kubetidy auto-detects the best **usage** source — Prometheus or the kubetidy
6061
operator (historical, high-confidence) over a bare metrics-server snapshot (a conservative
@@ -111,18 +112,37 @@ kubectl tidy init # install the CRD + operator (server-side appl
111112
kubectl tidy init --crd-only # just the CRD (e.g. GitOps manages the Deployment)
112113
kubectl tidy init --print # print the manifests instead of applying them
113114
kubectl tidy init --image REPO/kubetidy-operator:TAG # pin a custom operator image
115+
kubectl tidy init --with-opencost # also deploy OpenCost for precise Tier-2 cost (needs Prometheus)
114116
```
115117

116118
`init` applies the CRD first, waits for it to become Established, then deploys the operator.
117119
It is idempotent — re-run it any time to converge the cluster to the embedded manifests.
118120

121+
### Precise cost out of the box: `--with-opencost`
122+
123+
`--with-opencost` additionally deploys [OpenCost](https://www.opencost.io/) (namespace, RBAC,
124+
deployment, and service — all embedded) so scans report **precise Tier-2 cost** from your
125+
cluster's actual node pricing (spot / reserved / committed-use discounts included), instead of
126+
blended derived pricing. kubetidy auto-detects the deployed OpenCost at `opencost.opencost.svc:9003`.
127+
128+
OpenCost reads usage from Prometheus, so point it at yours:
129+
130+
```sh
131+
kubectl tidy init --with-opencost \
132+
--prometheus-url http://prometheus-server.monitoring.svc:80 # this is the default
133+
```
134+
135+
`--print --with-opencost` emits the OpenCost manifests too, for GitOps. To remove it later,
136+
`kubectl tidy uninstall --with-opencost` (off by default, so your own OpenCost is never touched).
137+
119138
To remove everything `init` created, use its inverse:
120139

121140
```sh
122141
kubectl tidy uninstall # delete the operator + all CRDs (and recorded data); prompts first
123142
kubectl tidy uninstall --dry-run # list exactly what would be removed; deletes nothing
124143
kubectl tidy uninstall --yes # skip the confirmation prompt
125144
kubectl tidy uninstall --keep-crds # remove only the operator; keep the CRDs and history
145+
kubectl tidy uninstall --with-opencost # also remove OpenCost installed via init --with-opencost
126146
kubectl tidy cleanup # alias for uninstall
127147
```
128148

ROADMAP.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,11 @@ This roadmap is intentionally public and open for discussion — please weigh in
4848
no Prometheus (the real Tier 0). The first, safe increment of the operator. See
4949
[docs/design/operator.md](docs/design/operator.md).
5050
- [x] **`kubectl tidy init`** — install the CRD + operator from manifests embedded in the
51-
binary; no manual `kubectl apply`.
51+
binary; no manual `kubectl apply`. `--with-opencost` also deploys OpenCost (the producer
52+
side of Tier 2) so precise cost works out of the box; `uninstall --with-opencost` reverses it.
5253
- [ ] Guarded `apply` with auto-rollback on SLO regression
53-
- [x] Tier 2: OpenCost integration for precise allocated cost (auto-detected, or `--opencost-url`)
54+
- [x] Tier 2: OpenCost integration for precise allocated cost — consume an existing OpenCost
55+
(auto-detected, or `--opencost-url`) **or deploy one** with `init --with-opencost`
5456
- [x] **Recommendation CRDs** — the operator writes a per-workload `Recommendation` (the LLM
5557
target; rules-engine source today). Multi-cluster aggregation still to come.
5658
- [x] **Confidence that grows with data** — recommendations are graded low/med/high and gated

internal/cli/init.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import (
1515
)
1616

1717
type initFlags struct {
18-
kubeContext string
19-
crdOnly bool
20-
printOnly bool
21-
image string
18+
kubeContext string
19+
crdOnly bool
20+
printOnly bool
21+
image string
22+
withOpenCost bool
23+
prometheusURL string
2224
}
2325

2426
// discoveryFor is a seam so tests can substitute the discovery client. In production it builds
@@ -60,6 +62,8 @@ func newInitCommand() *cobra.Command {
6062
flags.BoolVar(&f.crdOnly, "crd-only", false, "install only the UsageProfile CRD, not the operator")
6163
flags.BoolVar(&f.printOnly, "print", false, "print the manifests that would be applied, and exit")
6264
flags.StringVar(&f.image, "image", "", "operator container image to deploy (required on a real cluster; the embedded default is a local kind-only tag)")
65+
flags.BoolVar(&f.withOpenCost, "with-opencost", false, "also deploy OpenCost (needs Prometheus) so scans get precise Tier-2 cost out of the box")
66+
flags.StringVar(&f.prometheusURL, "prometheus-url", "", "Prometheus endpoint OpenCost reads from (with --with-opencost; default http://prometheus-server.monitoring.svc:80)")
6367
return cmd
6468
}
6569

@@ -76,6 +80,10 @@ func runInit(ctx context.Context, f *initFlags) error {
7680
b.WriteString("---\n")
7781
b.Write(installer.OperatorManifest())
7882
}
83+
if f.withOpenCost {
84+
b.WriteString("---\n")
85+
b.Write(installer.OpenCostManifest(f.prometheusURL))
86+
}
7987
_, err := io.WriteString(os.Stdout, b.String())
8088
return err
8189
}
@@ -91,15 +99,21 @@ func runInit(ctx context.Context, f *initFlags) error {
9199

92100
opts := installer.Options{
93101
IncludeOperator: !f.crdOnly,
102+
IncludeOpenCost: f.withOpenCost,
103+
PrometheusURL: f.prometheusURL,
94104
Image: f.image,
95105
Log: func(msg string) { _, _ = fmt.Fprintln(os.Stdout, "•", msg) },
96106
}
97107
if err := installer.Install(ctx, dyn, disco, opts); err != nil {
98108
return err
99109
}
100110

101-
_, err = io.WriteString(os.Stdout,
102-
"\n✓ kubetidy installed. The operator needs a few minutes to accumulate history;\n"+
103-
" after that, `kubectl tidy scan` runs at Tier 0 with no Prometheus.\n")
111+
msg := "\n✓ kubetidy installed. The operator needs a few minutes to accumulate history;\n" +
112+
" after that, `kubectl tidy scan` runs at Tier 0 with no Prometheus.\n"
113+
if f.withOpenCost {
114+
msg += " OpenCost is deploying in the opencost namespace; once it's ready, scans show\n" +
115+
" precise Tier-2 cost from your cluster's actual node pricing.\n"
116+
}
117+
_, err = io.WriteString(os.Stdout, msg)
104118
return err
105119
}

internal/cli/init_test.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,45 @@ func TestInitCommandFlags(t *testing.T) {
1919
if cmd.Use != "init" {
2020
t.Errorf("Use = %q, want init", cmd.Use)
2121
}
22-
for _, name := range []string{"context", "crd-only", "print", "image"} {
22+
for _, name := range []string{"context", "crd-only", "print", "image", "with-opencost", "prometheus-url"} {
2323
if cmd.Flags().Lookup(name) == nil {
2424
t.Errorf("init command missing --%s flag", name)
2525
}
2626
}
2727
}
2828

29+
func TestRunInitPrintWithOpenCost(t *testing.T) {
30+
out := captureStdout(t, func() {
31+
if err := runInit(context.Background(), &initFlags{
32+
printOnly: true,
33+
withOpenCost: true,
34+
prometheusURL: "http://prom.observability.svc:9090",
35+
}); err != nil {
36+
t.Errorf("runInit --print --with-opencost error: %v", err)
37+
}
38+
})
39+
if !strings.Contains(out, "kubetidy-operator") {
40+
t.Error("print output missing the operator")
41+
}
42+
if !strings.Contains(out, "opencost") {
43+
t.Error("print output missing OpenCost manifest")
44+
}
45+
if !strings.Contains(out, "http://prom.observability.svc:9090") {
46+
t.Error("print output missing the substituted Prometheus URL")
47+
}
48+
}
49+
50+
func TestRunInitPrintNoOpenCostByDefault(t *testing.T) {
51+
out := captureStdout(t, func() {
52+
if err := runInit(context.Background(), &initFlags{printOnly: true}); err != nil {
53+
t.Errorf("runInit --print error: %v", err)
54+
}
55+
})
56+
if strings.Contains(out, "PROMETHEUS_SERVER_ENDPOINT") {
57+
t.Error("OpenCost manifest should not be printed without --with-opencost")
58+
}
59+
}
60+
2961
func TestRunInitPrintFull(t *testing.T) {
3062
out := captureStdout(t, func() {
3163
if err := runInit(context.Background(), &initFlags{printOnly: true}); err != nil {

internal/cli/uninstall.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import (
1414
)
1515

1616
type uninstallFlags struct {
17-
kubeContext string
18-
keepCRDs bool
19-
yes bool
20-
dryRun bool
17+
kubeContext string
18+
keepCRDs bool
19+
withOpenCost bool
20+
yes bool
21+
dryRun bool
2122
}
2223

2324
// confirmReader is the input used for the interactive confirmation prompt; overridable in
@@ -43,6 +44,7 @@ func newUninstallCommand() *cobra.Command {
4344
flags := cmd.Flags()
4445
flags.StringVar(&f.kubeContext, "context", "", "kubeconfig context to use")
4546
flags.BoolVar(&f.keepCRDs, "keep-crds", false, "remove only the operator; keep the CRDs and recorded data")
47+
flags.BoolVar(&f.withOpenCost, "with-opencost", false, "also remove the OpenCost deployment kubetidy installed with init --with-opencost")
4648
flags.BoolVar(&f.dryRun, "dry-run", false, "list what would be removed without deleting anything")
4749
flags.BoolVarP(&f.yes, "yes", "y", false, "skip the confirmation prompt")
4850
return cmd
@@ -76,9 +78,10 @@ func runUninstall(ctx context.Context, f *uninstallFlags) error {
7678

7779
logf := func(msg string) { _, _ = fmt.Fprintln(os.Stdout, "•", msg) }
7880
if err := installer.Uninstall(ctx, dyn, disco, installer.UninstallOptions{
79-
KeepCRDs: f.keepCRDs,
80-
DryRun: f.dryRun,
81-
Log: logf,
81+
KeepCRDs: f.keepCRDs,
82+
IncludeOpenCost: f.withOpenCost,
83+
DryRun: f.dryRun,
84+
Log: logf,
8285
}); err != nil {
8386
return err
8487
}

internal/cli/uninstall_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ func TestUninstallCommandFlags(t *testing.T) {
1313
if cmd.Use != "uninstall" {
1414
t.Errorf("Use = %q, want uninstall", cmd.Use)
1515
}
16-
for _, name := range []string{"context", "keep-crds", "yes"} {
16+
for _, name := range []string{"context", "keep-crds", "with-opencost", "yes"} {
1717
if cmd.Flags().Lookup(name) == nil {
1818
t.Errorf("uninstall command missing --%s flag", name)
1919
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Minimal OpenCost deployment, applied by `kubectl tidy init --with-opencost`. OpenCost reads
2+
# usage from Prometheus and exposes an allocation API on :9003 at opencost.opencost.svc — which
3+
# kubetidy auto-detects for precise Tier-2 cost. __PROMETHEUS_URL__ is substituted by the
4+
# installer with the Prometheus endpoint.
5+
apiVersion: v1
6+
kind: Namespace
7+
metadata:
8+
name: opencost
9+
labels:
10+
app.kubernetes.io/managed-by: kubetidy
11+
---
12+
apiVersion: v1
13+
kind: ServiceAccount
14+
metadata:
15+
name: opencost
16+
namespace: opencost
17+
---
18+
apiVersion: rbac.authorization.k8s.io/v1
19+
kind: ClusterRole
20+
metadata:
21+
name: opencost
22+
rules:
23+
- apiGroups: [""]
24+
resources:
25+
- configmaps
26+
- nodes
27+
- pods
28+
- services
29+
- resourcequotas
30+
- replicationcontrollers
31+
- limitranges
32+
- persistentvolumeclaims
33+
- persistentvolumes
34+
- namespaces
35+
- endpoints
36+
- events
37+
verbs: ["get", "list", "watch"]
38+
- apiGroups: ["apps"]
39+
resources: ["daemonsets", "deployments", "replicasets", "statefulsets"]
40+
verbs: ["get", "list", "watch"]
41+
- apiGroups: ["batch"]
42+
resources: ["cronjobs", "jobs"]
43+
verbs: ["get", "list", "watch"]
44+
- apiGroups: ["autoscaling"]
45+
resources: ["horizontalpodautoscalers"]
46+
verbs: ["get", "list", "watch"]
47+
- apiGroups: ["storage.k8s.io"]
48+
resources: ["storageclasses"]
49+
verbs: ["get", "list", "watch"]
50+
- apiGroups: ["metrics.k8s.io"]
51+
resources: ["pods", "nodes"]
52+
verbs: ["get", "list", "watch"]
53+
---
54+
apiVersion: rbac.authorization.k8s.io/v1
55+
kind: ClusterRoleBinding
56+
metadata:
57+
name: opencost
58+
roleRef:
59+
apiGroup: rbac.authorization.k8s.io
60+
kind: ClusterRole
61+
name: opencost
62+
subjects:
63+
- kind: ServiceAccount
64+
name: opencost
65+
namespace: opencost
66+
---
67+
apiVersion: apps/v1
68+
kind: Deployment
69+
metadata:
70+
name: opencost
71+
namespace: opencost
72+
labels:
73+
app: opencost
74+
spec:
75+
replicas: 1
76+
selector:
77+
matchLabels:
78+
app: opencost
79+
template:
80+
metadata:
81+
labels:
82+
app: opencost
83+
spec:
84+
serviceAccountName: opencost
85+
containers:
86+
- name: opencost
87+
image: ghcr.io/opencost/opencost:latest
88+
imagePullPolicy: IfNotPresent
89+
ports:
90+
- name: http
91+
containerPort: 9003
92+
env:
93+
- name: PROMETHEUS_SERVER_ENDPOINT
94+
value: "__PROMETHEUS_URL__"
95+
- name: CLUSTER_ID
96+
value: "kubetidy"
97+
resources:
98+
requests:
99+
cpu: 10m
100+
memory: 55Mi
101+
limits:
102+
cpu: "1"
103+
memory: 1Gi
104+
livenessProbe:
105+
httpGet:
106+
path: /healthz
107+
port: 9003
108+
initialDelaySeconds: 30
109+
periodSeconds: 20
110+
readinessProbe:
111+
httpGet:
112+
path: /healthz
113+
port: 9003
114+
initialDelaySeconds: 10
115+
periodSeconds: 10
116+
---
117+
apiVersion: v1
118+
kind: Service
119+
metadata:
120+
name: opencost
121+
namespace: opencost
122+
labels:
123+
app: opencost
124+
spec:
125+
selector:
126+
app: opencost
127+
ports:
128+
- name: opencost
129+
port: 9003
130+
targetPort: 9003

0 commit comments

Comments
 (0)