Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ output) for plain text. URLs, tokens, and suggested commands remain unstyled.
| `--prometheus-url` | (auto-discover) | Manual Prometheus/VictoriaMetrics URL (skips auto-discovery) |
| `--prometheus-header` | | HTTP header sent with every Prometheus request, format `Key=Value` (repeatable). Required for auth-protected backends. |
| `--prometheus-header-from-env` | | HTTP header sent with every Prometheus request, sourced from an environment variable, format `Key=ENV_VAR` (repeatable). |
| `--opencost-currency` | (auto-detect, then USD) | Override the ISO 4217 currency label for OpenCost values. Radar labels values but does not convert them. |
| `--auth-mode` | `none` | Authentication mode: `none`, `proxy`, or `oidc` ([details](docs/authentication.md)) |
| `--no-mcp` | `false` | Disable MCP server for AI tool integration |
| `--mcp-catalog-stdio` | `false` | Start only the MCP catalog over stdio for registry introspection |
Expand Down Expand Up @@ -428,7 +429,9 @@ See [docs/capacity.md](docs/capacity.md) for the full reference.

### Cost Insights

Track Kubernetes spending with OpenCost integration — no additional configuration needed.
Track Kubernetes spending with OpenCost integration. Radar reads `currencyCode` from a running
OpenCost pricing configuration when available and otherwise uses USD. Override the label in
Settings → Cost, config, CLI, or Helm. Radar does not convert values between currencies.

- Cluster hourly and projected monthly cost, top namespaces by spend
- Cost trend charts with 6h/24h/7d range selector
Expand Down
11 changes: 11 additions & 0 deletions cmd/desktop/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func main() {
timelineRetention := flag.Duration("timeline-retention", fileCfg.TimelineRetentionOr(7*24*time.Hour), "How long to retain timeline events when --timeline-storage=sqlite (e.g. 168h, 720h). 0 disables age-based cleanup.")
timelineMaxSize := flag.String("timeline-max-size", fileCfg.TimelineMaxSizeOr("1Gi"), "Maximum SQLite timeline storage size before pruning oldest events (e.g. 800Mi, 8Gi). 0 disables size-based pruning.")
prometheusURL := flag.String("prometheus-url", fileCfg.PrometheusURL, "Manual Prometheus/VictoriaMetrics URL (skips auto-discovery)")
openCostCurrency := flag.String("opencost-currency", fileCfg.OpenCostCurrency, "Override the ISO 4217 currency label for OpenCost values (empty: auto-detect, then USD)")
flag.Parse()

if *showVersion {
Expand Down Expand Up @@ -107,19 +108,27 @@ func main() {
}
namespaceFlagSet := false
namespacesFlagSet := false
openCostCurrencyFlagSet := false
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "namespace":
namespaceFlagSet = true
case "namespaces":
namespacesFlagSet = true
case "opencost-currency":
openCostCurrencyFlagSet = true
}
})
timelineMaxSizeBytes, err := config.ParseByteSize(*timelineMaxSize)
if err != nil {
log.Printf("ERROR: invalid --timeline-max-size %q: %v", *timelineMaxSize, err)
os.Exit(1)
}
normalizedOpenCostCurrency, err := config.NormalizeOpenCostCurrency(*openCostCurrency)
if err != nil {
log.Printf("ERROR: invalid --opencost-currency %q: %v", *openCostCurrency, err)
os.Exit(1)
}
resolvedPrometheusHeaders, err := app.ResolvePrometheusHeaders(fileCfg.PrometheusHeaders, fileCfg.PrometheusHeadersFromEnv)
if err != nil {
log.Printf("ERROR: invalid Prometheus header configuration: %v", err)
Expand Down Expand Up @@ -163,6 +172,8 @@ func main() {
TimelineRetention: *timelineRetention,
TimelineMaxSizeBytes: timelineMaxSizeBytes,
PrometheusURL: *prometheusURL,
OpenCostCurrency: normalizedOpenCostCurrency,
OpenCostFlagSet: openCostCurrencyFlagSet,
PrometheusHeaders: resolvedPrometheusHeaders,
PrometheusHeadersFromEnv: fileCfg.PrometheusHeadersFromEnv,
Version: version,
Expand Down
10 changes: 10 additions & 0 deletions cmd/explorer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func main() {
aiHistory := flag.Bool("ai-history", fileCfg.AIHistoryOr(true), "Persist AI investigations (transcripts + verdicts) to ~/.radar/ai-runs.db so they survive restarts")
// Traffic/metrics options
prometheusURL := flag.String("prometheus-url", fileCfg.PrometheusURL, "Manual Prometheus/VictoriaMetrics URL (skips auto-discovery)")
openCostCurrency := flag.String("opencost-currency", fileCfg.OpenCostCurrency, "Override the ISO 4217 currency label for OpenCost values (empty: auto-detect, then USD)")
// --prometheus-header Key=Value, repeatable. Defaults populated from
// config file; any --prometheus-header flag replaces the file value rather
// than merging — matches kubectl semantics (file is the default, CLI wins).
Expand Down Expand Up @@ -270,9 +271,14 @@ func main() {
if err != nil {
log.Fatalf("Invalid --timeline-max-size %q: %v", *timelineMaxSize, err)
}
normalizedOpenCostCurrency, err := config.NormalizeOpenCostCurrency(*openCostCurrency)
if err != nil {
log.Fatalf("Invalid --opencost-currency %q: %v", *openCostCurrency, err)
}
noMCPFlagSet := false
namespaceFlagSet := false
namespacesFlagSet := false
openCostCurrencyFlagSet := false
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "no-mcp":
Expand All @@ -281,6 +287,8 @@ func main() {
namespaceFlagSet = true
case "namespaces":
namespacesFlagSet = true
case "opencost-currency":
openCostCurrencyFlagSet = true
}
})
if *mcpCatalogOnly && noMCPFlagSet && *noMCP {
Expand Down Expand Up @@ -349,6 +357,8 @@ func main() {
TimelineRetention: *timelineRetention,
TimelineMaxSizeBytes: timelineMaxSizeBytes,
PrometheusURL: *prometheusURL,
OpenCostCurrency: normalizedOpenCostCurrency,
OpenCostFlagSet: openCostCurrencyFlagSet,
PrometheusHeaders: resolvedPrometheusHeaders,
PrometheusHeadersFromEnv: promHeadersFromEnv.value(),
BeylaJobSelector: *beylaJobSelector,
Expand Down
1 change: 1 addition & 0 deletions deploy/helm/radar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ lands in the Helm release state. Rotation requires a pod restart. See
| `timeline.retention` | SQLite retention (Go duration; `0` disables) | `168h` |
| `timeline.maxSize` | SQLite max DB + WAL size before oldest events are pruned (`0` disables) | `800Mi` |
| `persistence.enabled` | Enable PVC for SQLite | `false` |
| `cost.currency` | Optional ISO 4217 override for OpenCost values; empty auto-detects, then uses USD | `""` |
| `traffic.prometheusUrl` | Manual Prometheus/VictoriaMetrics URL (skips auto-discovery) | `""` |
| `traffic.prometheusHeaders` | HTTP headers sent with every Prometheus request (auth-protected backends) | `{}` |
| `traffic.prometheusHeadersFromEnv` | Prometheus headers sourced from environment variables, for secret-backed auth headers | `{}` |
Expand Down
3 changes: 3 additions & 0 deletions deploy/helm/radar/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ spec:
{{- if .Values.traffic.prometheusUrl }}
- --prometheus-url={{ .Values.traffic.prometheusUrl }}
{{- end }}
{{- if .Values.cost.currency }}
- --opencost-currency={{ .Values.cost.currency }}
{{- end }}
{{- range $k, $v := .Values.traffic.prometheusHeaders }}
- {{ printf "--prometheus-header=%s=%s" $k $v | quote }}
{{- end }}
Expand Down
11 changes: 11 additions & 0 deletions deploy/helm/radar/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,17 @@
"enabled": { "type": "boolean" }
}
},
"cost": {
"type": "object",
"additionalProperties": false,
"properties": {
"currency": {
"type": "string",
"pattern": "^$|^[A-Za-z]{3}$",
"description": "Optional ISO 4217 override for OpenCost values; empty auto-detects from a running OpenCost pricing config, then uses USD."
}
}
},
"traffic": {
"type": "object",
"additionalProperties": true,
Expand Down
6 changes: 6 additions & 0 deletions deploy/helm/radar/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,12 @@ mcp:
# Set to false to disable the MCP server (useful when deployed behind authentication)
enabled: true

# OpenCost display configuration
cost:
# Optional ISO 4217 override for OpenCost values. Empty detects currencyCode
# from a running OpenCost pricing config when available, then uses USD.
currency: ""

# Traffic source configuration
traffic:
# Manual Prometheus/VictoriaMetrics URL (bypasses auto-discovery)
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Persistent defaults for CLI flags. CLI flags always override these values. Manag
"timelineMaxSize": "0",
"historyLimit": 10000,
"prometheusUrl": "",
"opencostCurrency": "",
"prometheusHeaders": {},
"mcp": true,
"debugImage": ""
Expand All @@ -68,6 +69,7 @@ All fields are optional — omitted fields use built-in defaults.
| `timelineMaxSize` | Max SQLite DB + WAL size before pruning oldest events (`0` disables) |
| `historyLimit` | Max timeline events to retain |
| `prometheusUrl` | Manual Prometheus/VictoriaMetrics URL — skips auto-discovery. Useful when Prometheus is not in the same cluster or uses a non-standard service name. |
| `opencostCurrency` | Optional ISO 4217 override for values produced by OpenCost. Empty detects `currencyCode` from a running OpenCost pricing configuration when Radar auto-discovers cluster Prometheus, then falls back to `USD`. Radar labels values but does not convert them. Equivalent CLI: `--opencost-currency`; an explicit CLI value remains authoritative while Radar runs and after restart. |
| `prometheusHeaders` | HTTP headers sent with every Prometheus request. Required for auth-protected backends — e.g. `{"X-Scope-OrgID": "my-org"}`. Equivalent CLI: `--prometheus-header Key=Value` (repeatable). Stored in plain text in `config.json` — protect the file accordingly. |
| `argoCdUrl` | Manual argocd-server URL for the Argo CD API integration — skips auto-discovery. |
| `argoCdToken` | Argo CD API token (get-only account recommended). Stored in plain text — the file is written `0600`; the token is redacted from `GET /api/config`. |
Expand Down
4 changes: 3 additions & 1 deletion docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -1166,7 +1166,9 @@ PolicyReport findings are policy posture, not live operational failure, so they

[OpenCost](https://www.opencost.io/) is a CNCF tool for Kubernetes cost monitoring, exposing cloud provider pricing and workload resource allocation as Prometheus metrics.

Radar discovers if OpenCost metrics are available in the already-discovered Prometheus. If OpenCost is installed and scraping into Prometheus, cost data appears automatically with no additional configuration. The integration is passive and read-only.
Radar discovers if OpenCost metrics are available in the already-discovered Prometheus. If OpenCost is installed and scraping into Prometheus, cost data appears automatically. The integration is passive and read-only.

OpenCost's Prometheus metrics contain numeric values but no currency metadata. When Radar auto-discovers Prometheus in the connected cluster, it looks for `currencyCode` in the pricing ConfigMap referenced by a running OpenCost deployment. If that evidence is unavailable or ambiguous, Radar uses USD. Radar skips cluster inference for a manually configured Prometheus URL because it may serve another cluster. Override the label in Settings → Cost or `opencostCurrency` (CLI: `--opencost-currency`; Helm: `cost.currency`). CLI and Helm overrides remain authoritative while Radar runs and after restart. Radar labels the values but does not convert them.

### What Radar Shows

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ require (
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/text v0.41.0
google.golang.org/grpc v1.83.0
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
helm.sh/helm/v3 v3.21.3
Expand Down Expand Up @@ -157,7 +158,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
Expand Down
6 changes: 6 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ type AppConfig struct {
TimelineRetention time.Duration
TimelineMaxSizeBytes int64
PrometheusURL string
OpenCostCurrency string
OpenCostFlagSet bool
PrometheusHeaders map[string]string
PrometheusHeadersFromEnv map[string]string
BeylaJobSelector string
Expand Down Expand Up @@ -275,6 +277,7 @@ func CreateServer(cfg AppConfig) *server.Server {
TimelineMaxSize: fmt.Sprintf("%d", cfg.TimelineMaxSizeBytes),
HistoryLimit: cfg.HistoryLimit,
PrometheusURL: cfg.PrometheusURL,
OpenCostCurrency: cfg.OpenCostCurrency,
PrometheusHeaders: cfg.PrometheusHeaders,
PrometheusHeadersFromEnv: cfg.PrometheusHeadersFromEnv,
DebugImage: cfg.DebugImage,
Expand All @@ -292,6 +295,8 @@ func CreateServer(cfg AppConfig) *server.Server {
StaticFS: static.FS,
StaticRoot: "dist",
EffectiveConfig: effectiveCfg,
OpenCostCurrency: cfg.OpenCostCurrency,
OpenCostManaged: cfg.OpenCostFlagSet,
DiagConfig: &server.DiagConfig{
Port: cfg.Port,
DevMode: cfg.DevMode,
Expand All @@ -300,6 +305,7 @@ func CreateServer(cfg AppConfig) *server.Server {
HistoryLimit: cfg.HistoryLimit,
DebugEvents: cfg.DebugEvents,
MCPEnabled: cfg.MCPEnabled,
OpenCostCurrency: cfg.OpenCostCurrency,
HasPrometheusURL: cfg.PrometheusURL != "",
HasPrometheusHeaders: len(cfg.PrometheusHeaders) > 0,
},
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type Config struct {
TimelineMaxSize string `json:"timelineMaxSize,omitempty"` // Byte size (e.g. "800Mi", "8Gi"); "0" disables
HistoryLimit int `json:"historyLimit,omitempty"`
PrometheusURL string `json:"prometheusUrl,omitempty"`
OpenCostCurrency string `json:"opencostCurrency,omitempty"`
// PrometheusHeaders are sent with every request to the Prometheus API.
// Required for auth-protected backends (Bearer tokens, X-Scope-OrgID, etc.).
// Stored in plain text in ~/.radar/config.json — protect the file accordingly.
Expand Down
23 changes: 23 additions & 0 deletions internal/config/opencost.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package config

import (
"fmt"
"strings"

"golang.org/x/text/currency"
)

func NormalizeOpenCostCurrency(raw string) (string, error) {
code := strings.ToUpper(strings.TrimSpace(raw))
if code == "" {
return "", nil
}
if code == "XXX" || code == "XTS" {
return "", fmt.Errorf("must be a monetary ISO 4217 currency code")
}
unit, err := currency.ParseISO(code)
if err != nil {
return "", fmt.Errorf("must be a recognized ISO 4217 currency code: %w", err)
}
return unit.String(), nil
}
31 changes: 31 additions & 0 deletions internal/config/opencost_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package config

import "testing"

func TestNormalizeOpenCostCurrency(t *testing.T) {
tests := []struct {
name string
raw string
want string
wantErr bool
}{
{name: "auto", want: ""},
{name: "trim and uppercase", raw: " gbp ", want: "GBP"},
{name: "zero decimal currency", raw: "jpy", want: "JPY"},
{name: "unknown", raw: "ZZZ", wantErr: true},
{name: "malformed", raw: "EURO", wantErr: true},
{name: "no currency", raw: "XXX", wantErr: true},
{name: "testing code", raw: "XTS", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NormalizeOpenCostCurrency(tt.raw)
if (err != nil) != tt.wantErr {
t.Fatalf("NormalizeOpenCostCurrency(%q) error = %v, wantErr %v", tt.raw, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("NormalizeOpenCostCurrency(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
Loading
Loading