## File: README.md

# Radar

[](https://www.producthunt.com/products/radar-7?embed=true&utm_source=badge-top-post-badge&utm_medium=badge&utm_campaign=badge-radar-42edb7b0-e388-4fa8-9ba5-4876c2c0d638)

**The missing open-source Kubernetes UI.**
Single binary. No account required. Free forever.

🌐 **[radarhq.io](https://radarhq.io)** · [Docs](https://radarhq.io/docs) · [Releases](https://github.com/skyhook-io/radar/releases)

Topology, resources, Helm, GitOps, traffic, audit, upgrade impact, and MCP context for AI agents — from your laptop or in-cluster.

[](https://github.com/skyhook-io/radar/actions/workflows/ci.yml)
[](https://github.com/skyhook-io/radar/actions/workflows/codeql.yml)
[](https://github.com/skyhook-io/radar/releases/latest)
[](https://github.com/skyhook-io/radar/releases)
[](https://artifacthub.io/packages/helm/skyhook/radar)
[](https://radarhq.io/community/chat)
[](LICENSE)
[](https://go.dev/)


**Table of contents**

- [Why Radar?](#why-radar)
- [Installation](#installation)
- [Usage](#usage)
- [Views](#views) — Topology · Resources · Image Filesystem · Timeline · Helm · Compare · TLS · GitOps · Traffic · Cost · Audit · Upgrade impact · RBAC · MCP · Auth
- [Supported Resources](#supported-resources)
- [Keyboard Shortcuts](#keyboard-shortcuts)
- [Security](#security)
- [Development](#development) · [Contributing](#contributing)





**Install and run in 30 seconds:**
```bash
curl -fsSL https://get.radarhq.io | sh && kubectl radar
```
[More installation options ↓](#installation)

## Why Radar?

- **Zero install on your cluster** — runs on your laptop, talks to the K8s API directly
- **Single binary** — no dependencies, no agents, no CRDs
- **Fast on big clusters** — tested on tens of thousands of pods, with responsive views and live updates under real cluster churn
- **Private by design** — your cluster data stays on your machine. No account, no agents, no cloud sync, no cluster telemetry
- **Airgapped-friendly** — runs as a single binary against the Kubernetes API and works in locked-down environments with outbound egress blocked
- **Real-time** — watches your cluster via informers, pushes updates to the browser via SSE
- **Works everywhere** — GKE, EKS, AKS, minikube, kind, k3s, or any conformant cluster
- **AI-ready** — built-in [MCP server](docs/mcp.md) lets AI agents query your cluster through Radar
- **In-cluster option** — deploy with Helm for shared team access with RBAC-scoped permissions

> "Have Radar deployed at work. As far as Kubernetes dashboards go, this is one of the best." — u/TheRealNetroxen

---

## Installation

**Quick Install:**
```bash
curl -fsSL https://get.radarhq.io | sh
```

**Homebrew:**
```bash
brew install skyhook-io/tap/radar
```

Then run: `kubectl radar`. Quick install, PowerShell, Homebrew, and Scoop also set up the `radar` shorthand. Krew and direct downloads use `kubectl radar` unless you add your own `radar` symlink.


**More install options** — Desktop App (macOS/Linux/Windows), Krew, Scoop, In-Cluster Helm

#### CLI

**Krew (kubectl plugin manager):**
```bash
kubectl krew install radar
```

**Scoop (Windows):**
```powershell
scoop bucket add skyhook https://github.com/skyhook-io/scoop-bucket
scoop install radar
```

**PowerShell (Windows):**
```powershell
irm https://get.radarhq.io/install.ps1 | iex
```

**Direct download** — [GitHub Releases](https://github.com/skyhook-io/radar/releases) for macOS, Linux, or Windows.

#### Desktop App

Native desktop app — no terminal needed.

**Homebrew (macOS):**
```bash
brew install --cask skyhook-io/tap/radar-desktop
```

**Debian/Ubuntu** — download the `.deb` from [GitHub Releases](https://github.com/skyhook-io/radar/releases), then:
```bash
sudo apt install ./radar-desktop_*.deb
```

**Fedora/RHEL** — download the `.rpm` from [GitHub Releases](https://github.com/skyhook-io/radar/releases), then:
```bash
sudo rpm -i radar-desktop_*.rpm
```

**Scoop (Windows):**
```powershell
scoop bucket add skyhook https://github.com/skyhook-io/scoop-bucket
scoop install radar-desktop
```

**Windows (direct download)** — [GitHub Releases](https://github.com/skyhook-io/radar/releases).

#### In-Cluster Deployment

Deploy to your cluster for shared team access:

```bash
helm repo add skyhook https://skyhook-io.github.io/helm-charts
helm install radar skyhook/radar -n radar --create-namespace
```

See the [In-Cluster Deployment Guide](docs/in-cluster.md) for ingress, authentication, and RBAC configuration.

### Gateway API HTTPRoute

Chart supports optional Gateway API `HTTPRoute` generation. Enable
`httpRoute.enabled`, set `parentRefs`, and use `httpRoute.rules` for multiple
path prefixes, per-route timeouts, filters, or custom backends. `hostnames`
defaults to an empty list, and `apiVersion` can be overridden for older Gateway
API installations. The generated default route has no chart-imposed timeout,
so the Gateway deployment default applies. Set `httpRoute.defaultTimeout` for
an explicit timeout; custom rules can define their own `timeouts`.

`ingress` and `httpRoute` are mutually exclusive: enabling both stops the
install with an error, so enable only one. When `httpRoute` is enabled you must
set at least one `parentRefs` entry (the Gateway the route attaches to);
otherwise the install fails instead of creating an HTTPRoute that is attached to
no Gateway.

```yaml
httpRoute:
  enabled: true
  parentRefs:
    - name: public-gateway
  hostnames: []
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: radar
          port: 9280
```

See the [Helm Chart README](deploy/helm/radar/README.md#with-gateway-api-httproute)
for full configuration details and timeout examples.



---

## Usage

```bash
# Opens browser automatically
kubectl radar

# Quick install, PowerShell, Homebrew, and Scoop also set up the bare command
radar
```

To inspect an in-cluster Radar Cloud installation without changing it:

```bash
radar cloud status
radar cloud status --context my-cluster
radar cloud status --context my-cluster --namespace radar --release radar
```

The command reports installation ownership, chart and image, agent readiness,
and Cloud configuration without printing the connection token. Passing both
`--namespace` and `--release` selects an exact installation. Live tunnel status
is reported by Radar Cloud using the token in the referenced Kubernetes Secret.
If the Secret or Hub is unavailable, local installation diagnostics still run.
Interactive terminals use restrained status colors; set `NO_COLOR` (or pipe the
output) for plain text. URLs, tokens, and suggested commands remain unstyled.

**CLI Flags**

| Flag | Default | Description |
|------|---------|-------------|
| `--kubeconfig` | `~/.kube/config` | Path to kubeconfig file |
| `--kubeconfig-dir` | | Comma-separated directories containing kubeconfig files |
| `--namespace` | (all) | Initial namespace filter (supports multi-select in the UI; also used as RBAC fallback for namespace-scoped users) |
| `--namespaces` | (all) | Initial namespace filters as a comma-separated list, e.g. `--namespaces ns1,ns2,ns3`. Use this when your identity can list resources in specific namespaces but cannot list namespaces cluster-wide. |
| `--namespace-scope` | `false` | Pin namespaced informer caches to a **single** namespace for large clusters (scoping to multiple namespaces is not supported yet). Requires `--namespace`, a kubeconfig context namespace, or a saved local single-namespace pick. Local mode can rebuild the cache when switching namespaces; auth/cloud mode locks the shared cache to the startup namespace. |
| `--port` | `9280` | Server port |
| `--listen-address` | `127.0.0.1` | HTTP listen address. Use `127.0.0.1` or `localhost` for local-only access; use `0.0.0.0` explicitly for containers, VMs, WSL, or remote/shared access, together with authentication and network controls. |
| `--base-path` | | Serve Radar under a URL prefix such as `/radar`. Use when an ingress forwards a subpath without stripping it — everything, including `/api/health`, moves under the prefix. Not supported with `--cloud-url`. |
| `--no-browser` | `false` | Don't auto-open browser |
| `--browser` | | Browser to use when opening the UI, e.g. `firefox`, `google-chrome`, or `Google Chrome` on macOS |
| `--timeline-storage` | `memory` | Timeline storage backend: `memory` or `sqlite` |
| `--timeline-db` | `~/.radar/timeline.db` | Path to SQLite database (when using sqlite storage) |
| `--timeline-max-size` | `1Gi` | Maximum SQLite DB + WAL size before pruning oldest events (e.g. `800Mi`, `8Gi`; `0` disables) |
| `--history-limit` | `10000` | Maximum events to retain in timeline |
| `--disable-exec` | `false` | Disable terminal and debug shell |
| `--disable-helm-write` | `false` | Disable Helm write operations |
| `--disable-local-terminal` | `false` | Disable local terminal feature |
| `--debug-image` | `busybox:latest` | Image for ephemeral debug containers and node debug pods. If built-in restricted PodSecurity rejects the default pod debug container, Radar retries with a restricted-compatible Linux security context using the target/pod non-root UID, or UID `65532` by default; point at a compatible mirror for air-gapped / private-registry clusters. |
| `--list-page-size` | `0` (off) | Paginate the initial LIST of high-cardinality kinds (Pods, ReplicaSets) at this size. Helps very large clusters that fail to sync; only used when WatchList streaming is unavailable. Try `2000`. |
| `--context-switch-timeout` | `30s` | Maximum time a kubeconfig context switch may take. Widen on high-latency control planes — see [Tuning for slow clusters](#tuning-for-slow-or-high-latency-clusters). Env: `RADAR_CONTEXT_SWITCH_TIMEOUT`. |
| `--first-paint-backstop` | `5m` | Hard upper bound on the initial critical-cache sync wait before Radar falls through to a partial-data render. Env: `RADAR_FIRST_PAINT_BACKSTOP`. |
| `--namespace-list-timeout` | `5s` | Timeout for the cluster-wide namespace LIST used to decide if the user is RBAC-namespace-restricted. A timeout on a slow control plane is misreported in the UI as "Limited list — RBAC". Env: `RADAR_NAMESPACE_LIST_TIMEOUT`. |
| `--max-scope-candidates` | `20` | Cap on the namespace-fallback probe fanout (used by accounts that can list namespaces cluster-wide but not list a specific kind cluster-wide). Raise above `20` for clusters with more than 20 namespaces. Env: `RADAR_MAX_SCOPE_CANDIDATES`. |
| `--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). |
| `--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 |
| `--version` | | Show version and exit |

See [Configuration Guide](docs/configuration.md) for details on cluster connection precedence, multiple kubeconfig files, and context switching.

### Tuning for slow or high-latency clusters

The default deadlines (30 s context switch, 5 m first-paint backstop, 5 s
namespace LIST, 20 scope candidates) are tuned for healthy clusters reached
over fast, low-latency connections. They are too tight for clusters reached
over SSH tunnels, geographically distant control planes, or accounts subject
to API-server throttling, where they surface as one of three symptoms:

- "Context switch timed out" toasts when the cache eventually does sync
- "Limited list — RBAC doesn't allow listing all namespaces" even though the
  account has cluster-wide list permission (the LIST timed out, not RBAC)
- Kinds silently marked denied because the namespace they live in fell past
  the 20-entry candidate cap

Widen the four flags via CLI or via the matching environment variables
(`RADAR_CONTEXT_SWITCH_TIMEOUT`, `RADAR_FIRST_PAINT_BACKSTOP`,
`RADAR_NAMESPACE_LIST_TIMEOUT`, `RADAR_MAX_SCOPE_CANDIDATES`) — env vars
keep secrets out of `ps` and let in-cluster deployments source the values
from a ConfigMap:

```bash
# CLI
kubectl radar \
  --context-switch-timeout=120s \
  --first-paint-backstop=10m \
  --namespace-list-timeout=30s \
  --max-scope-candidates=200

# Environment (e.g. in a Deployment manifest)
RADAR_CONTEXT_SWITCH_TIMEOUT=120s \
RADAR_FIRST_PAINT_BACKSTOP=10m \
RADAR_NAMESPACE_LIST_TIMEOUT=30s \
RADAR_MAX_SCOPE_CANDIDATES=200 \
  kubectl radar
```

Defaults are preserved when neither the flag nor the env var is set, so
existing deployments are unaffected.

---

## Views

### Topology

Interactive graph showing how your Kubernetes resources are connected in real-time.



- Two modes: **Resources** (full hierarchy) and **Traffic** (network flow path)
- Group by namespace, app label, or view ungrouped
- Filter by resource kind — click any node for full details
- Auto-layout powered by ELK.js, live updates via SSE

### Resources

Table-based resource browser with smart columns per resource kind.



- Browse all resource types including CRDs
- Search by name, filter by status or problems (CrashLoopBackOff, ImagePullBackOff, etc.)
- Add custom columns from any label or annotation — sortable, filterable, and resizable
- Click any resource for YAML manifest, related resources, logs, and events

### Image Filesystem Viewer

Inspect container image filesystems directly from the Pod view — no need to pull images locally or exec into containers.



- Click any container image in a Pod to browse its complete filesystem
- Tree view with file sizes, permissions, and symlink targets
- Search files by name across the entire image
- Download individual files for inspection
- Works with public images (Docker Hub, Quay, GHCR) and private registries (GCR, ECR, ACR) using your cluster's ImagePullSecrets
- Disk-based layer caching for fast repeated access

### Timeline

Unified timeline of Kubernetes events and resource changes.



- Filter by event type (all or warnings only)
- Resource change diffs showing what changed (replicas, images, etc.)
- Real-time updates as new events occur

### Helm

Manage Helm releases deployed in your cluster — inspect values and rendered manifests, diff revisions, identify failed upgrades and rollback-after-failure patterns, diagnose failed hooks, upgrade, rollback, and uninstall. Radar tracks available chart upgrades (from your configured repos or your own OCI registries) and lets you pick a specific target version. See [Helm Support](docs/helm.md) for the detailed behavior and limits.



- View all releases across namespaces with status, chart version, app version, resource health, storage namespace, and Flux ownership
- Inspect values, compare revisions across values/manifests/notes/resources, and view release history
- Surface failed upgrades, stuck pending operations, rollback history, and inferred atomic-style rollbacks
- Correlate failed/running hooks with remaining Job, Pod, Event, and redacted log evidence
- Upgrade, rollback, or uninstall releases directly from the UI

### Compare Resources

Diff any two Kubernetes resources of the same kind side-by-side — like comparing a staging Deployment to its production sibling, or two pods that should be identical but aren't.



- **Two entry points**: a `Compare` button in the resource detail drawer, or compare mode in the resource table (toggle, pick two rows, hit Compare)
- **Side-by-side or unified** view, with one-click swap of A ↔ B
- **Diff-only mode** collapses unchanged regions so you only see what differs
- **Spec-only mode** drops `status` fields to focus on intent rather than observed state
- Server-assigned noise (`managedFields`, `resourceVersion`, `kubectl.kubernetes.io/last-applied-configuration`) is stripped automatically so the diff stays signal — flip **Raw metadata** on if you actually want to see it
- Same-namespace candidates are surfaced first in the picker — usually the resource you want to compare against
- Shareable URLs: `/compare?kind=&apiGroup=&a=ns/name&b=ns/name`



### TLS Certificate Management

View TLS certificate details and expiry dates across all namespaces — catch expiring certificates before they cause outages.

- Parses TLS secrets to show certificate subject, issuer, and validity period
- Dashboard-level certificate expiry overview
- Available from the resource detail view for any TLS-type Secret

### GitOps

Monitor, diagnose, and manage FluxCD and ArgoCD resources from a dedicated GitOps workspace.



- Fleet view + per-app detail page (Topology / Changes / Activity tabs) for **ArgoCD** (`Application`, `ApplicationSet`, `AppProject`) and **FluxCD** (`GitRepository`, `OCIRepository`, `HelmRepository`, `Bucket`, `Kustomization`, `HelmRelease`, `Alert`)
- **Diagnosis pipeline** — field-level drift, recent events per resource, stuck-drift-loop detection, parsed operation-failures, structured one-click remediation
- **Lifecycle awareness** — `Terminating` chip replaces stale Sync/Health badges; severity ramps with deletion age; mutating ops refuse on zombies
- **Cross-linked from the rest of Radar** — `Managed by` chip in resource drawers, GitOps routing from Topology + Timeline + Helm view, `Consumed by` panel on Flux source CRs
- **MCP integration** — `manage_gitops` exposes sync / suspend / resume / reconcile / rollback with lifecycle-aware refusal

See the [GitOps guide](docs/gitops.md) for the full feature matrix, RBAC requirements, demo cluster, and single-cluster scope notes.

### Traffic

Visualize live network traffic between services using Hubble, Caretta, Istio, or Beyla.



- Auto-detects Hubble (Cilium), Istio, Caretta, or Grafana Beyla as traffic data sources
- Beyla (standalone or via Grafana Alloy) provides eBPF L4 + HTTP visibility with no service mesh, read from Prometheus
- Beyla needs its `network` feature enabled, and per-port edges additionally need `dst.port` and `transport` named in `attributes.select` — both are off by default, and Radar says so in the Traffic view rather than showing partial edges silently
- Animated flow graph showing requests per second between services
- Filter by namespace, protocol, or status code
- Setup wizard to install a traffic source if none is detected

### Capacity (Karpenter)

Read-only diagnosis for Karpenter-managed fleets — why is my pod pending, which NodePool could take it, why aren't my nodes joining, what is disruption doing to my fleet? Appears automatically when Karpenter NodePools are detected (RBAC-gated).

- **Overview** — fleet KPIs with claim lifecycle detail, a cluster scheduling-capacity bar (requests vs allocatable, in-flight beyond the edge, pending demand as an honest not-to-scale count), prioritized operational signals, and the NodePool inventory
- **NodePool detail** — the capacity ledger (configured limit, provisioned, headroom, allocatable, scheduled requests, unallocated, actual usage), claim lifecycle, fleet composition, and workload attribution
- **Demand** — pending pods grouped by scheduling signature, each group evaluated against every NodePool's declared constraints with per-predicate evidence; filterable by state, pool, and workload
- **Activity** — provisioning / disruption / interruption episodes classified from Karpenter's exact event vocabulary, with per-evidence confidence
- Every quantity carries per-value certainty (`= ≥ ≤ ?`) — unavailable is never rendered as zero, partial is never rendered as exact
- Issues, Pending-pod drawers, and the Home posture card deep-link into the right diagnosis

See [docs/capacity.md](docs/capacity.md) for the full reference.

### Cost Insights

Track Kubernetes spending with OpenCost integration — no additional configuration needed.

- Cluster hourly and projected monthly cost, top namespaces by spend
- Cost trend charts with 6h/24h/7d range selector
- Namespace and workload-level cost breakdowns with efficiency scoring
- Node costs with instance type and region pricing
- Appears automatically when OpenCost metrics are detected in Prometheus

### Cluster Audit

Proactive best-practices scanner with 31 checks across security, reliability, and efficiency — inspired by Polaris, Kubescape, Trivy, and NSA/CISA guidelines. Runs instantly against cached data with zero cluster-side installation.

- Security: privileged containers, privilege escalation, dangerous/insecure capabilities, host namespaces, container runtime socket mounts, sensitive host paths, secrets in ConfigMaps, auto-mounted service account tokens
- Reliability: missing probes, image tag `latest`, single-replica deployments, missing PDB/topology spread, pod HA risk (all replicas on same node), orphan services/ingresses, deprecated API versions
- Efficiency: missing CPU/memory requests and limits, orphan ConfigMaps/Secrets
- Check-grouped remediation queue with search and category, severity, and framework filters; expand a check to see affected resources
- Each finding includes description and remediation guidance, with inline hide actions for a check or category
- Configurable: ignored namespaces (with wildcard patterns), disabled checks, persisted across sessions
- Framework labels: NSA/CISA, CIS benchmarks
- MCP tool (`get_cluster_audit`) for AI-assisted cluster analysis

### Network Path Diagnose

Hop-ordered diagnosis for Service, Ingress, HTTPRoute, GRPCRoute, and Gateway - answering "if traffic is sent toward this resource, does it reach a healthy process, and if not which hop breaks first?"

- Composes the detections Radar already runs (missing backend Service, port mismatches, no-ready-endpoints, route not Accepted by parent Gateway, readiness probe targeting the wrong port) into a path shape ordered along the traffic flow
- Upstreams (Ingresses / Routes pointing at a Service) are judged independently - one broken Ingress doesn't condemn the other delivery paths
- First critical hop is named explicitly so the operator can localize the break without reading the whole list; each finding ships a kubectl reproducer
- **Optional one-shot reachability test** runs DNS / TCP / TLS / HTTP probes against the declared path - direct TCP when Radar is in-cluster, K8s API server proxy when running from a laptop - so the same button works regardless of where Radar runs. Probes never override the static verdict; they add evidence.
- NetworkPolicies that select the subject's pods are statically evaluated for their caller-independent ingress rules: a "would block" **WARNING prediction** when no rule admits the path's port, a source-restricted advisory, or an outbound egress note. It's a prediction, never a verdict - the CNI is the only enforcement authority, so the live in-cluster probe confirms or downgrades it
- Static trace is pure functions over the in-memory informer cache. Active probing from a laptop uses the cluster's normal RBAC (`get services/proxy`, `get pods/proxy`); in-cluster mode goes directly to the data path.
- Exposed via the **Reachability** tab in the resource detail view (and via the network branch of the MCP `diagnose` tool for AI consumers) - see [docs/reachability.md](docs/reachability.md)
### Kubernetes Upgrade Impact

Open **Checks → Upgrade impact** before upgrading the control plane. Radar compares the current cluster with a target Kubernetes minor and orders evidenced compatibility, health, admission, drain, runtime, and configuration checks by required action. Release-specific checks appear only when their Kubernetes minor lies in the selected upgrade path; the current catalog contains 18 checks through Kubernetes 1.36.

- Finds blockers such as skipped minor versions, APIs removed in the target release, unsupported kubelet or kube-proxy skew, overlapping PodDisruptionBudgets, and the `gitRepo` volume driver disabled in Kubernetes 1.36
- Flags likely operational impact such as FlexVolume exposure and renamed control-plane metrics as warnings, while intent-dependent configuration such as deprecated Service `externalIPs` remains review
- Inspects live resources, aggregated API availability, Helm release manifests, kubectl last-applied configuration, API server usage metrics, and PrometheusRule expressions
- Distinguishes **Passed**, **Review**, **Warning**, **Blocked**, **Incomplete**, and **Not applicable** instead of flattening advisory findings, likely impact, and missing evidence into one state
- Scans every namespace the current identity can read; the header namespace picker remains a browsing filter and does not narrow upgrade analysis
- Shows the bundled catalog boundary and the evidence scope for sampled or unavailable data

See the [Kubernetes upgrade impact guide](https://radarhq.io/docs/features/upgrade-impact) for the check catalog, coverage semantics, and RBAC notes.

### Access Control (RBAC visibility)

Inspect what any ServiceAccount can actually do — without three `kubectl describe` calls.

- **ServiceAccount detail**: direct bindings, effective permissions (per-binding and deduplicated flat view), inherited grants via implicit groups (`system:authenticated`, `system:serviceaccounts`), and "Used by Pods" closing the loop
- **Pod detail**: "Permissions" section showing the most-permissive rules the Pod's SA grants, plus a blast-radius alert when the SA has wildcards, cluster-admin, escalation verbs, or cluster-wide `create pods`
- **Workload detail** (Deployment / StatefulSet / DaemonSet): same Permissions section framed at the workload level — every Pod the workload spawns inherits these grants
- **Namespace detail**: RBAC summary with RoleBindings configured here + ClusterRoleBindings whose subjects reference this namespace
- **Role / ClusterRole detail**: who is bound to this role, with subject summaries inline
- **RoleBinding detail**: inline preview of the rules the binding grants + warnings when subjects include wide groups (`system:authenticated`, `system:unauthenticated`, `system:masters`)
- **"My Permissions" panel**: namespace-scoped live `SelfSubjectRulesReview` for the current user — for fast "why can't I do X" debugging
- **MCP**: `get_subject_permissions` tool exposes the same data to AI agents for "is this SA over-privileged?" / "blast radius if compromised?" queries

Read-only visibility ships first; the considered follow-ups (RBAC audit checks, verb × resource matrix, subject explorer, graph view, in-UI edits, "can-i" queries) are tracked in [#1090](https://github.com/skyhook-io/radar/issues/1090).

### AI Integration (MCP)

Radar includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets AI agents — Claude, Cursor, Copilot, and others — query your cluster through Radar.

Instead of raw `kubectl` output (verbose YAML that burns through LLM context windows), your AI gets pre-processed, token-optimized data: topology graphs, health assessments, deduplicated events, and filtered logs. Read tools are strictly read-only; write tools (restart, scale, sync, and the like) carry explicit destructive-action hints and run under your cluster's RBAC, so the apiserver enforces what each identity is allowed to do.

Enabled by default. Disable with `--no-mcp`. See the **[MCP Guide](docs/mcp.md)** for setup instructions.

### Authentication

For shared in-cluster deployments, Radar supports optional user authentication with per-user Kubernetes RBAC.

- **Proxy mode** — works with oauth2-proxy, Pomerium, Cloudflare Access, or any auth proxy that sets forwarded headers
- **OIDC mode** — built-in login via Google, Okta, Dex, Keycloak, or any OIDC provider
- Per-user namespace scoping and write authorization via K8s impersonation
- UI adapts automatically — buttons only appear if the user has RBAC permission

No auth by default (local use). See the **[Authentication Guide](docs/authentication.md)** for setup.

---

## Supported Resources

Radar auto-discovers any CRD in your cluster. Popular tools get [dedicated integrations](docs/integrations.md) with topology edges, detail views, and AI summaries.

**Default chart RBAC** covers the built-in Kubernetes kinds listed below — Workloads, Networking (including NetworkPolicies and PodDisruptionBudgets), Configuration, Storage (PersistentVolumes, PersistentVolumeClaims, StorageClasses), HorizontalPodAutoscalers, ServiceAccounts, LimitRanges, ResourceQuotas, Nodes, Namespaces, and Events. RBAC objects (Roles, ClusterRoles, RoleBindings, ClusterRoleBindings) are opt-in via `rbac.viewRBAC=true`. **CRD-based integrations** (Gateway API, VerticalPodAutoscaler, Calico, ArgoCD, FluxCD, cert-manager, etc.) need both the CRD installed in your cluster *and* read access granted — most groups are default-on under `rbac.crdGroups.<name>` (e.g. `gatewayApi`, `verticalPodAutoscaler`, `calico`); check `values.yaml` or add custom rules via `rbac.additionalRules`.

Upgrade impact also gets list-only access to CSIStorageCapacities, FlowSchemas, PriorityLevelConfigurations, and PodSecurityPolicies on clusters where those kinds are served. These reads inspect source-manifest evidence and do not add the kinds to Radar's resource browser.

| Category | Resources |
|----------|-----------|
| **Workloads** | Deployments, DaemonSets, StatefulSets, ReplicaSets, Pods, Jobs, CronJobs |
| **Networking** | Services, Ingresses, NetworkPolicies, Endpoints, EndpointSlices, PodDisruptionBudgets |
| **Configuration** | ConfigMaps, Secrets (names only, values hidden), LimitRanges, ResourceQuotas |
| **Storage** | PersistentVolumeClaims, PersistentVolumes, StorageClasses |
| **Autoscaling** | HorizontalPodAutoscalers, VerticalPodAutoscalers |
| **Cluster** | Nodes, Namespaces, ServiceAccounts, Events |
| **GitOps (FluxCD)** | GitRepository, OCIRepository, HelmRepository, Kustomization, HelmRelease, Alert |
| **GitOps (ArgoCD)** | Application, ApplicationSet, AppProject |
| **Argo Rollouts** | Rollout |
| **Argo Workflows** | Workflow, WorkflowTemplate |
| **cert-manager** | Certificate, CertificateRequest, Order, Challenge, Issuer, ClusterIssuer |
| **Gateway API** | Gateway, GatewayClass, HTTPRoute, GRPCRoute, TCPRoute, TLSRoute |
| **Istio** | VirtualService, DestinationRule, Gateway, ServiceEntry, PeerAuthentication, AuthorizationPolicy |
| **Traefik** | IngressRoute, IngressRouteTCP, IngressRouteUDP, Middleware, MiddlewareTCP, TraefikService, ServersTransport, ServersTransportTCP, TLSOption, TLSStore |
| **Contour** | HTTPProxy |
| **Knative Serving** | Service, Configuration, Revision, Route, DomainMapping |
| **Knative Eventing** | Broker, Trigger, EventType, Channel, InMemoryChannel, Subscription |
| **Knative Sources** | PingSource, ApiServerSource, ContainerSource, SinkBinding |
| **Knative Flows** | Sequence, Parallel |
| **Knative Networking** | Ingress, Certificate, ServerlessService |
| **Karpenter** | NodePool, NodeClaim (+ provider-specific NodeClasses via auto-discovery) |
| **KEDA** | ScaledObject, ScaledJob, TriggerAuthentication, ClusterTriggerAuthentication |
| **Prometheus Operator** | ServiceMonitor, PodMonitor, PrometheusRule, Alertmanager |
| **Security (Trivy)** | VulnerabilityReport, ConfigAuditReport, ExposedSecretReport, ClusterComplianceReport, SbomReport, RbacAssessmentReport, InfraAssessmentReport |
| **Velero** | Backup, Restore, Schedule, BackupStorageLocation, VolumeSnapshotLocation |
| **External Secrets** | ExternalSecret, ClusterExternalSecret, SecretStore, ClusterSecretStore |
| **CloudNativePG** | Cluster, Backup, ScheduledBackup, Pooler |
| **Crossplane** | Managed Resources (any provider), Composite Resources, Claims, Provider, ProviderConfig, Function, Configuration, Composition, CompositionRevision, XRD |
| **Kyverno** | Policy, ClusterPolicy, PolicyReport, ClusterPolicyReport |
| **Sealed Secrets** | SealedSecret |
| **Dynamic Resource Allocation** | ResourceClaim, ResourceClaimTemplate, DeviceClass, ResourceSlice (resource.k8s.io, K8s 1.32+) |
| **NVIDIA GPU Operator** | ClusterPolicy, NVIDIADriver |
| **Calico** | NetworkPolicy, GlobalNetworkPolicy, StagedNetworkPolicy, StagedGlobalNetworkPolicy, StagedKubernetesNetworkPolicy, IPPool, HostEndpoint, Tier |
| **Cost (OpenCost)** | Namespace/workload/node cost breakdown via Prometheus (no CRDs) |
| **CRDs** | Any Custom Resource Definition in your cluster (auto-discovered) |

---

## Keyboard Shortcuts

| Shortcut | Action |
|----------|--------|
| `g` then a letter | Switch view — `g h` Home, `g r` Resources, `g i` Issues, `g t` Topology, `g a` Applications, `g l` Timeline, `g f` Traffic, `g m` Helm, `g o` GitOps, `g u` Checks, `g c` Cost |
| `t` | Toggle dark/light theme |
| `?` | Show keyboard shortcuts |
| `⌘K` | Open command palette |
| `/` | Focus search (context-aware) |
| `f` | Fit topology to screen |
| `+` / `-` / `0` | Zoom in / out / reset (topology) |
| `j` / `k` | Navigate rows (resources, helm) |
| `g g` / `G` | Jump to first / last row |
| `Enter` / `d` | Open selected resource detail |
| `y` | Open YAML view |
| `l` | Open logs (pods/workloads) |
| `[` / `]` | Previous / next resource kind |
| `Escape` | Close panel/modal/search |

**Topology:** Pan (drag), Zoom (scroll), Select (click), Multi-select (Shift+click)

---

## Security

Radar reads your cluster through your own credentials and keeps cluster data local. It does not upload manifests, logs, events, metrics, or resource data to Skyhook, and it does not require an account, agent, or cloud backend. Found a vulnerability? Please report it privately to **security@skyhook.io** — see [SECURITY.md](SECURITY.md) for the process and response timelines.

---

## Development

See the **[Development Guide](DEVELOPMENT.md)** for building from source, architecture details, API reference, and contributing.

Quick start:
```bash
git clone https://github.com/skyhook-io/radar.git
cd radar
make deps

# Terminal 1: Frontend with hot reload (port 9273)
make watch-frontend

# Terminal 2: Backend with hot reload (port 9280)
make watch-backend
```

---

## Contributing

Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details on the development workflow, pull request process, and coding standards.

Questions or ideas? [GitHub Discussions](https://github.com/skyhook-io/radar/discussions) is the place — or come say hi at [radarhq.io/community](https://radarhq.io/community).

---

## About

Radar is built and maintained by [Skyhook](https://skyhook.io) (YC W23) and is open source under Apache-2.0. The OSS version is fully featured and the recommended way to run Radar.

For teams that want hosted multi-cluster Radar with SSO and shared dashboards, we also offer [Radar Cloud](https://radarhq.io).

---

## License

Apache 2.0 — see [LICENSE](LICENSE)

---

---

## File: deploy/helm/radar/README.md

# Radar Helm Chart

Deploy Radar to your Kubernetes cluster for web-based cluster visualization and management.

> **Maintainers**: this directory is the canonical source for the chart. The published copy at [`skyhook-io/helm-charts`](https://github.com/skyhook-io/helm-charts) is auto-synced by the `helm` job in [`.github/workflows/release.yml`](../../../.github/workflows/release.yml) on every Radar release — it overwrites `charts/radar/` there with the contents of this directory. Do not edit `helm-charts/charts/radar/` by hand; changes will be clobbered on the next release.

> **See also:** [In-Cluster Deployment Guide](../../../docs/in-cluster.md) for ingress and DNS setup.

## Prerequisites

- Kubernetes 1.21+
- Helm 3.0+

## Installation

### Quick Start

```bash
helm repo add skyhook https://skyhook-io.github.io/helm-charts
helm repo update skyhook
helm upgrade --install radar skyhook/radar -n radar --create-namespace
```

Access via port-forward:
```bash
kubectl port-forward svc/radar 9280:9280 -n radar
open http://localhost:9280
```

### With Ingress

```bash
helm upgrade --install radar skyhook/radar \
  -n radar --create-namespace \
  --set ingress.enabled=true \
  --set ingress.className=nginx \
  --set ingress.hosts[0].host=radar.example.com \
  --set ingress.hosts[0].paths[0].path=/ \
  --set ingress.hosts[0].paths[0].pathType=Prefix
```

### With TLS

```bash
helm upgrade --install radar skyhook/radar \
  -n radar --create-namespace \
  --set ingress.enabled=true \
  --set ingress.className=nginx \
  --set ingress.hosts[0].host=radar.example.com \
  --set ingress.hosts[0].paths[0].path=/ \
  --set ingress.hosts[0].paths[0].pathType=Prefix \
  --set ingress.tls[0].secretName=radar-tls \
  --set ingress.tls[0].hosts[0]=radar.example.com
```

### With Gateway API HTTPRoute

HTTPRoute is disabled by default. Empty `httpRoute.rules` creates a `/`
`PathPrefix` route to Radar's Service. The generated rule has no timeout by
default, so the Gateway deployment's default timeout applies. Set
`httpRoute.defaultTimeout` to add an explicit request timeout, or set it to
`0s` to explicitly disable timeout. Set `rules` for multiple prefixes,
timeouts, filters, or custom backends; supplied rules replace that default and
pass through unchanged.

```yaml
httpRoute:
  enabled: true
  parentRefs:
    - name: public-gateway
  hostnames: []
  # Optional timeout for chart-generated default rule.
  # defaultTimeout: 30s
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /api
      timeouts:
        request: 30s
        backendRequest: 20s
      backendRefs:
        - name: radar
          port: 9280
    - matches:
        - path:
            type: PathPrefix
            value: /admin
      timeouts:
        request: 2m
      backendRefs:
        - name: radar
          port: 9280
```

Override `httpRoute.apiVersion` when cluster Gateway API support requires a
different version, for example `gateway.networking.k8s.io/v1beta1`. Custom
rules can define their own `timeouts` independently.

`httpRoute` and `ingress` are mutually exclusive - enabling both fails the
render. `httpRoute.enabled` requires at least one `parentRefs` entry (the
Gateway to attach to), otherwise the render fails rather than emit a route
bound to no Gateway.

### Connecting to Radar Cloud

To connect Radar to Radar Cloud (hosted SaaS), follow the install wizard at
[radarhq.io](https://radarhq.io) — it generates the full command with your
cluster's bearer token. The wizard's command follows this shape:

```bash
kubectl create namespace radar --dry-run=client -o yaml | kubectl apply -f -
kubectl create secret generic radar-cloud-config -n radar \
  --from-literal=token=$TOKEN \
  --dry-run=client -o yaml | kubectl apply -f -
helm upgrade --install radar skyhook/radar -n radar \
  --set cloud.enabled=true \
  --set cloud.url=wss://api.radarhq.io/agent \
  --set cloud.clusterName=$CLUSTER_NAME \
  --set cloud.existingSecret=radar-cloud-config
```

The `radar-cloud-config` Secret is managed independently of Helm, so token
rotation is one `kubectl apply` — no `helm upgrade` required. The same
applies to GitOps users: manage the Secret with SealedSecrets / SOPS /
External Secrets and reference it via `cloud.existingSecret`; Helm never
touches its contents.

### Connecting to Argo CD (GitOps deep diff)

Radar's GitOps pages show a Git-rendered desired-vs-live diff when connected to
Argo CD's API. A self-hosted / in-cluster Radar can be given the token
declaratively so it survives pod restarts and needs no interactive Settings paste:

```bash
# A read-only Argo CD account token is enough:
#   argocd account generate-token --account radar
kubectl create secret generic radar-argocd-token -n radar \
  --from-literal=token=$ARGOCD_TOKEN \
  --dry-run=client -o yaml | kubectl apply -f -
helm upgrade --install radar skyhook/radar -n radar \
  --set argocd.existingSecret=radar-argocd-token
```

Leave `argocd.url` unset to auto-discover the in-cluster `argocd-server`, or set
it to pin an explicit endpoint. Providing a token makes the integration
**environment-managed**: read-only in the Settings UI, never written to disk.
Prefer `argocd.existingSecret` over the inline `argocd.token` so the token never
lands in the Helm release state. Rotation requires a pod restart. See
[docs/gitops.md](../../../docs/gitops.md#provisioning-the-token-per-deployment-shape).

## Configuration

| Parameter | Description | Default |
|-----------|-------------|---------|
| `replicaCount` | Number of replicas | `1` |
| `image.repository` | Image repository | `ghcr.io/skyhook-io/radar` |
| `image.tag` | Image tag | Chart appVersion |
| `service.type` | Service type | `ClusterIP` |
| `service.port` | Service port | `9280` |
| `basePath` | URL prefix Radar serves under, e.g. `/radar` for no-strip-prefix subpath ingress | `""` |
| `debug.image` | Image for ephemeral debug containers and node debug pods. In built-in restricted PodSecurity namespaces, pod debug containers may retry as the target/pod non-root UID, or UID `65532` by default; point at a compatible mirror for air-gapped / private-registry clusters. | `""` (busybox:latest) |
| `listPageSize` | Paginate the initial LIST of high-cardinality kinds (Pods, ReplicaSets) on very large clusters; `0` = off, try `2000`. Only used when the apiserver lacks WatchList streaming. | `0` |
| `ingress.enabled` | Enable ingress | `false` |
| `ingress.className` | Ingress class name | `""` |
| `httpRoute.enabled` | Enable Gateway API HTTPRoute | `false` |
| `httpRoute.apiVersion` | HTTPRoute API version override | `gateway.networking.k8s.io/v1` |
| `httpRoute.hostnames` | HTTPRoute hostnames | `[]` |
| `httpRoute.defaultTimeout` | Optional request timeout for generated default rule; empty uses Gateway deployment default | `""` |
| `httpRoute.rules` | HTTPRoute rules, passed through unchanged | `[]` |
| `timeline.storage` | Timeline storage (memory/sqlite) | `memory` |
| `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` |
| `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 | `{}` |
| `argocd.existingSecret` | Name of a Secret holding the Argo CD API token (recommended — keeps it out of the release) | `""` |
| `argocd.existingSecretKey` | Key within `argocd.existingSecret` holding the token | `token` |
| `argocd.token` | Inline Argo CD API token (dev only — lands in the release state) | `""` |
| `argocd.url` | Explicit `argocd-server` URL; blank auto-discovers in-cluster | `""` |
| `argocd.insecureTls` | Skip TLS verification for a self-signed `argocd-server` | `false` |
| `resources.limits.memory` | Memory limit | `512Mi` |
| `resources.requests.memory` | Memory request | `128Mi` |

See `values.yaml` for all configuration options.

### Timeline storage: memory vs sqlite

Radar's timeline records every cluster change so you can scrub backwards through "what happened, when." Two backends:

- **`memory`** (default): events live in-process. Lost on pod restart. Lower memory footprint per retention window than SQLite (no indexes, no WAL). Pick this if you only need recent activity (last few hours), don't care about losing history when a pod cycles, or want the simplest setup.
- **`sqlite`**: events persist to a PVC across restarts. Pick this if you want a multi-day audit trail, need to inspect changes that happened while you weren't looking, or run Radar in-cluster long-term. Adds operational concerns: the PVC will fill if retention is unbounded; restarting on a multi-GB DB is slower (more rows to load).

**Sizing**: timeline volume depends on cluster size and controller churn. Tune `timeline.retention`, `timeline.maxSize`, and `persistence.size` together. Set `timeline.retention=0` to disable age cleanup; keep `timeline.maxSize` enabled for in-cluster deployments so Radar prunes oldest events before the PVC fills.

`/api/diagnostics` surfaces `timeline.retentionAge`, `timeline.maxStorageBytes`, `timeline.lastCleanupAt`, `timeline.lastCleanupDeletedRows`, `timeline.lastCleanupError`, and `timeline.storageBytes` so you can confirm cleanup is keeping up without tailing logs.

## RBAC

The chart creates a ClusterRole with read-only access to common Kubernetes resources.

### Default Permissions (Core K8s Resources)

Always granted (required for basic functionality):

| API Group | Resources |
|-----------|-----------|
| Core (`""`) | pods, services, configmaps, events, namespaces, nodes, pvcs, serviceaccounts, endpoints |
| `apps` | deployments, daemonsets, statefulsets, replicasets |
| `networking.k8s.io` | ingresses, networkpolicies |
| `batch` | jobs, cronjobs |
| `autoscaling` | horizontalpodautoscalers |
| `apiextensions.k8s.io` | customresourcedefinitions (for CRD discovery) |

### Privileged Permissions (Opt-in)

Disabled by default for security:

| Feature | Value | Description |
|---------|-------|-------------|
| Secrets | `rbac.secrets: true` | View secrets in resource list |
| Terminal | `rbac.podExec: true` | Shell access to pods |
| Port Forward | `rbac.portForward: true` | Port forwarding to pods |
| Logs | `rbac.podLogs: true` | View pod logs (**enabled by default**) |
| Helm Write | `rbac.helm: true` | Install/upgrade/rollback/uninstall Helm releases. Under auth or cloud-mode, also emits a split helm add-on ClusterRole — `radar-helm` (member-safe: CRDs, storage, namespaces) and `radar-helm-admin` (owner-only: RBAC, webhooks, ApiServices) |
| RBAC view | `rbac.viewRBAC: true` | Show ClusterRoles, ClusterRoleBindings, Roles, RoleBindings in the resource browser. Off by default — cache-served reads bypass per-user RBAC, so this exposes the cluster's authorization graph to every authenticated Radar user. Auto-enabled under auth or cloud mode (every read is re-checked per user there). |
| Webhooks view | `rbac.viewWebhooks: true` | Show MutatingWebhookConfigurations and ValidatingWebhookConfigurations in the resource browser. Off by default — the configurations reveal which admission controls are enforced (Gatekeeper / Kyverno policies, image scanners, DLP) and where the gaps are, which is recon value for a low-trust viewer. Auto-enabled under auth or cloud mode. |

### In-app Agent Upgrades (opt-in, for Radar Cloud users)

`rbac.selfUpgrade: true` lets a Radar Cloud organization owner trigger one-click upgrades from the web UI — no terminal or cloud credentials needed. It does not upgrade anything automatically. Disabled by default; only needed when connecting to Radar Cloud (the install wizard sets this automatically).

It creates a namespace-scoped Role (not a ClusterRole) with `get` + `patch` on this Deployment only, enforced via `resourceNames`. The endpoint requires an explicitly attributed Cloud `owner` role and validates the requested Radar tag. It preserves the repository already configured on the live container, including private mirrors.

```bash
--set rbac.selfUpgrade=true
```

### CRD Access

Radar discovers CRDs in your cluster. All common CRD groups are enabled by default. Granting RBAC for CRDs that don't exist has no effect.

**Wildcard option:** Grant read access to ALL CRDs with one setting:
```bash
--set rbac.crdGroups.all=true
```
This overrides individual settings below. Simpler but broader — some orgs may not allow this.

| Option | API Groups |
|--------|------------|
| `apiRegistration` | `apiregistration.k8s.io` |
| `argo` | `argoproj.io` |
| `awx` | `awx.ansible.com` |
| `calico` | `projectcalico.org`, `crd.projectcalico.org` |
| `certManager` | `cert-manager.io`, `acme.cert-manager.io` |
| `cilium` | `cilium.io` |
| `cloudnativePg` | `postgresql.cnpg.io`, `barmancloud.cnpg.io` |
| `clusterApi` | `cluster.x-k8s.io`, `infrastructure.cluster.x-k8s.io`, `controlplane.cluster.x-k8s.io`, `bootstrap.cluster.x-k8s.io`, `addons.cluster.x-k8s.io` |
| `contour` | `projectcontour.io` |
| `crossplane` | `crossplane.io`, `pkg.crossplane.io`, `apiextensions.crossplane.io`, `helm.crossplane.io`, `kubernetes.crossplane.io`. For Upbound provider groups (e.g. `s3.aws.upbound.io`, `compute.gcp.upbound.io`) use `additionalCrdGroups` — K8s RBAC has no apiGroup wildcards. |
| `descheduler` | `descheduler.alpha.kubernetes.io` |
| `envoyGateway` | `gateway.envoyproxy.io` |
| `externalDns` | `externaldns.k8s.io` |
| `externalSecrets` | `external-secrets.io` |
| `flux` | `*.toolkit.fluxcd.io` |
| `gatewayApi` | `gateway.networking.k8s.io` |
| `gcpMonitoring` | `monitoring.googleapis.com` |
| `grafana` | `monitoring.grafana.com`, `tempo.grafana.com`, `loki.grafana.com`, `grafana.integreatly.org` |
| `istio` | `networking.istio.io`, `security.istio.io` |
| `karpenter` | `karpenter.sh`, `karpenter.k8s.aws`, `karpenter.azure.com`, `karpenter.k8s.gcp`, `eks.amazonaws.com` |
| `keda` | `keda.sh` |
| `knative` | `serving.knative.dev`, `eventing.knative.dev`, `sources.knative.dev`, `messaging.knative.dev`, `flows.knative.dev`, `networking.internal.knative.dev` |
| `kubeshark` | `kubeshark.io` |
| `kured` | `kured.io` |
| `kyverno` | `kyverno.io`, `policies.kyverno.io`, `wgpolicyk8s.io`, `reports.kyverno.io`, `openreports.io` |
| `mariadb` | `mariadb.mmontes.io` |
| `networkPolicyApi` | `policy.networking.k8s.io` |
| `nginx` | `nginx.org` |
| `openshift` | `observability.openshift.io` |
| `opentelemetry` | `opentelemetry.io` |
| `prometheus` | `monitoring.coreos.com` |
| `reflector` | `reflector.v1.k8s.emberstack.com` |
| `reloader` | `reloader.stakater.com` |
| `sealedSecrets` | `bitnami.com` |
| `strimzi` | `strimzi.io`, `kafka.strimzi.io` |
| `tekton` | `tekton.dev` |
| `traefik` | `traefik.io`, `traefik.containo.us` |
| `trivy` | `aquasecurity.github.io` |
| `velero` | `velero.io` |
| `verticalPodAutoscaler` | `autoscaling.k8s.io` |

**Disable groups:** `--set rbac.crdGroups.calico=false`

**Add unlisted CRDs:**
```yaml
rbac:
  additionalCrdGroups:
    - mycompany.io
```

### Troubleshooting: "Failed to list resource" Warnings

If you see these warnings, Radar discovered a CRD but doesn't have RBAC access. This is **not an error** — add the API group to `additionalCrdGroups` if you need it.

### Advanced: Custom Rules

For fine-grained control, use `additionalRules` to add arbitrary RBAC rules:
```yaml
rbac:
  additionalRules:
    - apiGroups: ["custom.example.com"]
      resources: ["myresources"]
      verbs: ["get", "list", "watch"]
    - apiGroups: [""]
      resources: ["pods"]
      verbs: ["delete"]  # Dangerous - use with caution
```

### Capability Detection

Radar uses its ServiceAccount permissions to access the Kubernetes API. The UI automatically detects which features are available based on RBAC and hides unavailable features (e.g., the terminal button won't appear if `podExec` is disabled).

## Uninstalling

```bash
helm uninstall radar -n radar
kubectl delete namespace radar
```

---

## File: docs/app-grouping.md

# Application grouping — the mental model

A reader-first map of how Radar decides "these workloads are one app" and "these
apps across clusters are the same logical app." This is the *model*, in English;
the code lives in the files named at the end.

---

## 0. The complexity budget — read this before adding anything

This system tends toward complexity (the inputs are a mess), so it has a **hard
design constraint.** Honor it on every change:

1. **The spine stays three sentences.** An app is a *release unit*; *identity* is
   "same app elsewhere," distinct from composition; *declared origins fold across
   clusters, names don't.* If a change makes one of these three harder to state,
   it's probably wrong. Everything else is edge-case handling around this spine —
   it must never *become* the model.
2. **Prefer declaration over inference.** Before adding a heuristic, ask "can the
   user just *say it*?" The `app.skyhook.io/app` annotation exists precisely so
   that when inference gets hairy, we hand the user a one-word escape hatch instead
   of getting cleverer. Reach for that first.
3. **The explainability test.** Every heuristic must be stateable in **one plain
   sentence a non-k8s-expert nods at** (it goes in a tooltip). If it can't be —
   simplify it, or replace it with a declaration. A heuristic you can't explain is
   a heuristic the operator can't trust.

Heuristics are fine; opacity is not. The current **watch-item is env-token
discovery** (§12) — the one piece that fails the one-sentence test today and the
first thing to put on the chopping block if the model needs to lose weight.

---

## 1. Why this is hard

A "deployable application" is **not a Kubernetes object.** There is no `App` kind.
What a human calls "the billing app" is scattered across Deployments, a Worker, a
CronJob, a Service, maybe an Ingress — possibly across several namespaces, and
(in a fleet) across several clusters. Grouping is the act of **reconstructing the
human's notion of an app from the rubble of K8s objects.** Every bit of
complexity below exists because we get only indirect, inconsistent hints.

---

## 2. Two questions, two levels — keep them separate

Almost all confusion comes from conflating two genuinely different problems:

| | Question | Example |
|---|----------|---------|
| **Composition** | Which workloads are **deployed/released as one unit** — that unit *is* the app, in one cluster | the workloads in one Helm release / one Argo Application / one Flux Kustomization |
| **Identity** | Which app-instances are the **same logical app**, across envs/clusters | billing@dev + billing@staging + billing@prod = **billing**, in three environments |

Composition is "what ships and versions together." Identity is "is this the same
app as that one over there." Different layers solve them. When you're lost, first
ask: *am I thinking about composition or identity?*

### What an "app" is — the unit of independent release

An app is **the thing that ships together and is versioned together** — *not* a
logical umbrella over services that happen to be related. This is the single most
important judgment in the whole system, and it is deliberate:

> Radar finds an app by climbing each workload's ownership to the **GitOps/Helm
> manager that deploys it** (an Argo Application, a Flux Kustomization/HelmRelease,
> a Helm release) and **stops there.** It does **not** climb up to the shared git
> repo, or to a parent app-of-apps, even though it could.

That "stop" is the whole point. If `backend` and `frontend` are deployed as **two
separate Argo Applications**, they are **two apps** — even when they live in the
same repo — because each is released and versioned on its own. Climbing to the
shared repo/parent would collapse every separately-released service into one giant
"shop" blob, which has no operational value. We refuse to do that. (Workloads with
**no manager** resolve instead to their **topmost ownerRef** — a Deployment, or an
operator CR like a CNPG `Cluster`.)

The weaker labels (`part-of`, `name`) are a fallback for loose/raw workloads, not
an umbrella over independently-released ones. **One honest caveat to the absolute
claim:** apps are assembled by union-find over *atoms* — each workload contributes
its structural root **and** its single strongest overlay label (§4). In the normal
case a GitOps-managed workload carries its manager's label, so that label *is* the
high-tier atom and the boundary holds. But the union also runs on the overlay
atom, so if two workloads with **distinct manager roots** happen to expose only a
shared weak label (e.g. a manager that doesn't stamp its own label, leaving only
`part-of`), that shared label can still union the two. Precedence makes a stronger
signal win *as the key*, but the union-find can cross a structural boundary via a
shared weak overlay. And note: a shared **satellite** (Service/Ingress/ConfigMap)
never merges two apps — only the workload atoms do.

**The honest edge case:** one Helm *umbrella* chart that bundles many services is
a single release unit, so Radar calls it one app — even if you think of the
services as standalone. Where "released together" and "feels standalone" diverge,
Radar follows the *release unit.* (If that proves too coarse, the refinement is to
split an umbrella by independently-versioned image — a future call, not today's
behavior.)

---

## 3. The pipeline — three layers

A workload flows left-to-right; each layer answers part of the question.

```
  workload ──▶ [1] structuralRoot + ──▶ [2] applications_identity ──▶ [3] hub + fleet fold
                  pkg/subject            app-instances → logical        logical apps →
              workload → release unit    app + env (identity,           one fleet row
              (composition)              per cluster)                   (identity, x-cluster)
```

1. **Composition — `structuralRoot` (topology graph) + `pkg/subject` overlay (per
   cluster).** Climbs each workload's ownership to its **release unit** (the GitOps/
   Helm manager that deploys it) and stops there (§2). The `pkg/subject` overlay
   then *consolidates* roots the graph can't connect — hub-spoke Argo (controller
   in another cluster), native-Helm release annotations — by reading the workload's
   labels via a **precedence cascade** (§4). Output: every workload tagged with an
   **overlay key** (which release unit / app it belongs to) and a **tier** (how
   strong that signal is).

2. **`applications_identity.go` (per cluster, OSS) — identity, within a cluster.**
   Takes the per-cluster app rows and decides which are **env-variants of one
   logical app** (`billing-dev`, `billing-staging` → "billing" spanning dev +
   staging). It attaches an **identity** to each row:
   - `key` — the logical app name (the display name of the group)
   - `env` — this instance's environment (dev | staging | prod | …)
   - `source` — *how* we concluded it (the provenance tier; see §5)
   - `portable` — *may this fold across clusters?* (see §6)

3. **Hub + fleet fold (cross cluster) — identity, across clusters.** The hub gathers
   every cluster's rows and merges them in **two stages** (see §6): first it merges
   any rows sharing the same overlay key (`app.Key`) into one fleet row — a
   *structural* join that can span clusters even for non-declared apps; then the
   fleet fold (`foldAppGroups`, a shared k8s-ui util that Radar Cloud renders)
   collapses the remaining differently-keyed rows that share a **portable**
   identity. Non-portable, differently-keyed rows stay per-cluster.

---

## 4. The composition cascade (the 9 tiers)

When the topology graph can't connect two roots, `pkg/subject` consolidates them
from the workload's labels, walking signals in strict precedence — the **first**
(lowest-numbered) that matches wins. The ranking is **"how strongly does this
signal indicate the release unit"**: a GitOps/Helm manager *is* a release unit (it
deploys a set of workloads together), so it outranks any membership label.

| Tier | Signal | Kind |
|------|--------|------|
| 1 | Flux HelmRelease (`helm.toolkit.fluxcd.io/*`) | **Release unit** (declared GitOps) |
| 2 | Flux Kustomization (`kustomize.toolkit.fluxcd.io/*`) | **Release unit** (declared GitOps) |
| 3 | Argo tracking-id (`argocd.argoproj.io/tracking-id`) | **Release unit** (declared GitOps) |
| 4 | Argo instance (`argocd.argoproj.io/instance`) | **Release unit** (declared GitOps) |
| 5 | Helm release (`meta.helm.sh/release-name`) | **Release unit** (Helm) |
| 6 | `app.kubernetes.io/instance` | Membership label |
| 7 | `app.kubernetes.io/part-of` | Membership label |
| 8 | `app.kubernetes.io/name` | **Name** |
| 9 | bare `app` / name heuristic | **Name** — *disabled in Applications today* |

Confidence: **tiers 1–4 are high** (GitOps controllers), **tiers 5–8 are medium**
(`meta.helm.sh/release-name`, `instance`, `part-of`, `name`), the rest low. So a
Helm-release (tier 5) is a release *unit* but only *medium* confidence, not high.
**Tier 9 (bare `app`) is off by default** — the Applications path calls the
resolver with `allowBareApp=false`, and the overlay is dropped entirely when bare
`app` would be the winner. So in practice the active cascade is tiers 1–8.

**Two consequences of the ranking:** a workload released by its own Argo App
(tier 3) wins on tier over a shared `part-of` (tier 7), so `part-of` doesn't *name*
its app (§2's rule, with the union-find caveat there). And the most important split
for the *cross-cluster* layer is **declared GitOps origin (1–4) vs. a name (6–8)**
(see §6).

---

## 5. The identity tiers (the provenance `source`)

Layer 2 re-expresses the same declared-vs-name split as a `source` on the wire, so
the cross-cluster layer can make a safe decision from a machine-readable field
(not a human evidence string). Three families:

- **Explicit** — `app.skyhook.io/app` annotation. The user *deliberately* declares
  "this is app X." Authoritative; overrides everything. *The escape hatch (§8).*
- **Declared origins** — `argo-path`, `argo-appset`, `flux-source`. A shared
  *upstream*: a GitOps source path with an env overlay, an ApplicationSet that fans
  one app across envs, a Flux Kustomization path.
- **Names** — `label` (`app.kubernetes.io/name`), `name-stem` (inferred from the
  workload name + a shared image repo), `namespace`.

---

## 6. Cross-cluster — there are TWO joins, not one

This is the part most people (including an early draft of this doc) get wrong.
Two rows from two clusters can end up as one fleet row via **two different
mechanisms**, and only the second is "declared-only":

**Join A — the hub merges by overlay key (`app.Key`), first.** The hub stores
rows under `rows[app.Key]` — the per-cluster overlay key. If two clusters produce
the **same overlay key**, they become **one fleet row with a cell per cluster**,
*before identity or portability is even considered.* For a declared app (Argo)
the key is cluster-specific (the Argo identity), so this rarely fires. But for a
**raw app with no GitOps signal, the key is `<ns>/<kind>/<name>`** — so two
clusters each running `default/Deployment/redis` **merge into one `redis` row.**
This join is **structural, not declared-only** — it's a name+namespace match, and
the frontend cannot un-merge what the hub already merged. *(See the open question
at the end — this is arguably an over-merge that should be cluster-scoped for
non-declared rows.)*

**Join B — the fleet fold by portable identity, second.** For rows that did *not*
key-merge (different `app.Key` per cluster — the normal case for declared apps
named per-env, e.g. `billing-dev` vs `billing-prod`), `foldAppGroups` collapses
those that share a **portable identity**. *This* is the declared-only join:

- A **declared origin is collision-free** — same Argo path / ApplicationSet / Flux
  source / explicit annotation ⇒ the same app ⇒ `portable = true` ⇒ fold.
- A **name collides** (two teams' `redis`) ⇒ `portable = false` ⇒ **not** folded by
  Join B; the row stays scoped to its cluster (`localScope`).

So the precise rule is: **`portable = true` only for explicit + declared origins,
and that governs Join B. Join A (the hub key-merge) is a separate, structural
match that declared-only does not currently constrain.** Join B is why grouping
looks "less aggressive" than naive name-matching for *differently-keyed* apps —
that's the point; naive matching merged unrelated apps and reported their health
as one. Join A is the gap where a name-based cross-cluster merge can still slip
through.

---

## 7. Two subtleties that trip people up

**`pathKey` — display the name, fold by name **and** path.** A co-located
declared-path identity (`argo-path`/`flux-source`) *shows* its name stem as `key`
— so that within one cluster a declared instance (`billing-staging`, from
`apps/billing/overlays/staging`) folds with a raw-but-corroborated sibling
(`billing` in dev, no path) — and carries the path stem in `pathKey`. The fleet
fold (Join B) keys portable rows on **`key` + `pathKey`** (literally
`portable:${key}:${pathKey}`): same name *and* same path → fold; same name but
*different* path (`teamA/billing` vs `teamB/billing`) → stay apart. Name for
display, name+path for the join.
*(Caveat: this is the co-located shape. A **hub-spoke** Argo claim instead puts the
path stem directly in `key` and leaves `pathKey` empty — so it's already
collision-safe by construction, but its `key` looks different from a co-located
row's display-name key. Two identical apps that are co-located in one cluster and
hub-spoke in another therefore won't fold together — a known seam.)*

**Add-ons are the one exception to "no name folds."** Platform inventory (coredns,
kube-proxy, cert-manager…) — classified as add-ons, not *your* apps — may fold
across clusters **by name when a shared helm chart or dominant image corroborates**
it's the same software. The harm of a wrong add-on merge is low (it's inventory,
not app health), so the bar is corroboration, not a declared origin. Your apps
never get this latitude.

---

## 8. The user's escape hatch — "how do I force grouping?"

When an app has only a name (no GitOps origin), it stays per-cluster — *and the
operator needs to know why and how to fix it.* The answer:

> Set **`app.skyhook.io/app: <name>`** on the app's workloads, the **same value in
> every cluster**, and Radar folds them. (Or deploy via Argo/Flux with the
> environment in the source path.)

Because the user sets it deliberately, it's a true cross-cluster declaration
(zero collision) — `source = explicit`, `portable = true`, highest precedence.
The in-product tooltip on each row says exactly this when a row is per-cluster.

---

## 9. The hub-spoke twist (centralized Argo)

In hub-spoke Argo the Application *object* lives on a **controller** cluster while
its **workloads** run on a **member** cluster — so the member's own rows never see
the Application, and only the fleet hub sees both. The hub reads each Application's
declared identity + its `spec.destination`, resolves the destination to a member
cluster, and **stamps** the declared identity onto that cluster's matching
workload rows. After that, the normal portable fold (§6) takes over.

---

## 10. Worked examples

**Composition (what is one app):**

0. **`backend` and `frontend`, each its own Argo Application, same repo, both
   labeled `part-of: shop`.** → Each climbs to its *own* Argo App and stops →
   **two apps**, not one "shop." The shared `part-of` (tier 7) never overrides the
   per-Application release boundary (tier 3). This is the anti-umbrella rule. ✓

**Identity (same app across envs/clusters):**

1. **`billing` deployed dev + prod via the same Argo path** `apps/billing/overlays/<env>`.
   → Tier 3 (Argo) → `source=argo-path`, `key=billing`, `pathKey=apps/billing`,
   `portable=true`. Both clusters' rows fold into one **billing · dev prod** row. ✓

2. **Two teams' `redis`, no GitOps, in *different* namespaces** (`team-a/redis`,
   `team-b/redis`), different clusters.
   → different overlay keys → **no Join-A merge**; tier 8 (name) → `portable=false`
   → **no Join-B fold**. They **stay per-cluster** — two `redis` rows. ✓
   *But* if both were `default/redis` (same `ns/kind/name`), they'd share an
   overlay key and **Join A would merge them at the hub** regardless of
   declared-only (the §6 caveat). To fold reliably *if* they're really one app:
   add `app.skyhook.io/app`.

3. **`coredns` in `kube-system` on both clusters.**
   → add-on, same name + same chart → folds across clusters as one add-on row,
   *because* the chart corroborates it's the same software (§7). ✓

---

## 11. Where the code lives

| Concern | File |
|---------|------|
| **Composition: the boundary** — `structuralRoot`, `rootOf`, `groupApplications`, `inputAtoms`, the union-find over atoms | `internal/server/applications.go` |
| Composition: the overlay signal cascade (the 9 tiers, `ResolveOverlay`) | `pkg/subject/overlay.go` |
| Identity within a cluster: env grouping + `source`/`portable`/`pathKey` | `internal/server/applications_identity.go` |
| Hub: Join A (key-merge), forward identity, declared-only promotion, hub-spoke claim stamping, add-on fold | `radar-hub: internal/server/fleet_applications.go` → `ComputeFleetApplications` |
| Cross-cluster Join B (fold by `key`+`pathKey` when `portable`) | `packages/k8s-ui/src/utils/applications.ts` → `foldAppGroups` |
| The "why grouped / how to fold" tooltip | `packages/k8s-ui/src/components/applications/AppTooltips.tsx` |

**One-line summary:** *compose workloads into apps by the strongest deploy/release
signal (stopping at the manager, never the shared repo); across clusters, the hub
first merges identical overlay keys (structural — watch for raw-name over-merge),
then folds the rest only on collision-free declared origins.*

---

## 12. Known gaps / open questions

- **Join A undercuts declared-only for raw apps (§6).** The hub merges rows by
  overlay key *before* portability. Raw apps key on `<ns>/<kind>/<name>`, so two
  clusters' `default/redis` merge into one fleet row even though "names shouldn't
  fold." Declared-only governs Join B but not Join A. *Open: should the hub scope
  non-declared overlay keys by cluster, so only declared identities cross?*
- **Co-located vs hub-spoke key shape (§7).** A path-declared app keys on its
  display name + `pathKey` when co-located, but on the path stem (no `pathKey`)
  when stamped from a hub-spoke claim — so the same app in both topologies won't
  fold. Rare, but a real seam.
- **Umbrella Helm charts (§2).** A chart bundling many independently-versioned
  services is one release unit → one app. If that's too coarse, the refinement is
  to split by independently-versioned image — not today's behavior.
- **⚠️ Complexity watch-item: env-token discovery.** To put a non-trio app on the
  env ladder, the identity resolver *discovers* whether a token like `loadtest` is
  an environment by structural inference — "it recurs across ≥3 repo-corroborated
  name-stem groups spanning ≥2 namespaces." That buys zero-config custom-env
  detection, but it's the **one heuristic that fails the §0 one-sentence test** —
  no operator can predict it. It's the first candidate to simplify if the model
  grows: fall back to the trio (dev/staging/prod) + explicit env labels
  (`app.kubernetes.io/environment`) + GitOps overlay paths, and make custom envs a
  label. Flagged, not yet cut (the zero-config value is real).

---

## File: docs/authentication.md

# Authentication & Authorization

Radar supports optional user authentication with per-user authorization powered by Kubernetes RBAC. When enabled, each user sees only the namespaces they have access to, and write operations (restart, scale, exec, Helm, etc.) are executed with the user's identity — so K8s RBAC controls what each user can do.

> **No auth by default.** When running locally or without `--auth-mode`, everything works exactly as before — no login, no restrictions.

## How It Works

```
User → [Auth Layer] → Radar Backend → K8s API (as user, via impersonation)
```

1. **Authentication** identifies the user (proxy headers or OIDC login)
2. **Reads** are filtered by namespace — Radar discovers which namespaces the user can access via `SubjectAccessReview` and only returns resources from those namespaces. Cluster-scoped resources (Nodes, PersistentVolumes, StorageClasses, ClusterRoles when `rbac.viewRBAC` is on, cluster-scoped CRDs, etc.) have no namespace to filter on, so they are gated per-kind via `SubjectAccessReview`: a user sees them only if their own RBAC permits listing that kind. Cluster-wide pod visibility does **not** imply Node/PV visibility — each cluster-scoped read goes through its own check.
3. **Writes** use K8s impersonation — Radar makes the K8s API call as the authenticated user, so K8s RBAC decides whether it's allowed
4. **UI adapts** — capability checks run per-user, so buttons (exec, restart, scale, Helm) only appear if the user has permission

Radar doesn't have its own role/permission system. It delegates everything to K8s RBAC, which means permissions are managed with standard K8s tooling (`kubectl`, Terraform, GitOps, etc.).

### Read granularity: namespace-level

For **namespaced** resources, reads are authorized at **namespace granularity**: if your RBAC lets you access a namespace, Radar shows the namespaced resources in it. Radar does **not** additionally check per-resource-kind RBAC for most namespaced kinds — so a user who can read Pods in a namespace also sees ConfigMaps, Services, etc. in that namespace through Radar, even if their RBAC wouldn't allow `kubectl get configmaps` directly.

This is a deliberate tradeoff that follows from how Radar reads the cluster. Rather than calling the Kubernetes API on every request, Radar watches each resource kind once under its own ServiceAccount and serves reads from an in-memory cache shared by all users — that's what makes it fast on large clusters and able to render topology and real-time views. Because the cache is populated by Radar's identity, not yours, per-user access has to be re-derived by asking Kubernetes "is this user allowed?" via `SubjectAccessReview`. Doing that at namespace granularity is one such check per namespace; doing it per-resource-kind would be a check for *every kind in every namespace* on *every* read — many times the API-server load, and worst exactly on the large, multi-namespace clusters where the shared cache matters most. So namespace membership is the per-user filter, and per-kind checks are reserved for the cases below where the exposure is highest.

Two kinds are gated more tightly, per-resource-kind, because the shared cache can hold data the user's own RBAC wouldn't grant:

- **Secrets** (and Secret-derived data such as TLS certificate metadata) — shown only if the user can list Secrets in that namespace.
- **Cluster-scoped resources** (Nodes, PersistentVolumes, ClusterRoles, cluster-scoped CRDs, etc.) — shown only if the user's RBAC permits listing that kind.

**If namespace-level isn't tight enough for you**, scope the boundary at the cache instead of at read time: run a Radar instance per trust boundary and give each one a **namespace-scoped ServiceAccount** (a `Role`/`RoleBinding`, no `ClusterRole`) limited to that boundary's namespaces. Radar detects the restricted permissions at startup and only watches and caches what its ServiceAccount can list — so the instance simply never holds another team's data, and there's nothing to over-expose. Point each team at their own instance (an ingress or auth proxy can route them). See [In-Cluster Deployment → namespace-scoped RBAC](in-cluster.md) for the `rbac.create: false` + custom `Role` setup.

## Auth Modes

| Mode | Flag | When to Use |
|------|------|-------------|
| `none` | `--auth-mode=none` | Local use, single-user, no restrictions needed (default) |
| `proxy` | `--auth-mode=proxy` | You already have an auth proxy (oauth2-proxy, Pomerium, Cloudflare Access, etc.) in front of your ingress |
| `oidc` | `--auth-mode=oidc` | You want Radar to handle login directly — no separate auth proxy needed |

**How to choose:** If your organization already routes internal tools through an auth proxy or SSO gateway, use **proxy** mode — it's zero config on the Radar side. If you want a standalone deployment with built-in login, use **OIDC** mode.

### Proxy Mode

Use this when you already have (or plan to deploy) an auth proxy like [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/), Pomerium, Authelia, or similar. The proxy authenticates users and forwards their identity to Radar via HTTP headers.

**Flow:**
```
Browser → Auth Proxy → Radar
           sets X-Forwarded-User: alice@company.com
           sets X-Forwarded-Groups: sre-team,platform-eng
```

**Helm values:**
```yaml
auth:
  mode: proxy
  # Optional: customize header names (these are the defaults)
  # proxy:
  #   userHeader: X-Forwarded-User
  #   groupsHeader: X-Forwarded-Groups
```

**CLI flags:**
```bash
radar --auth-mode=proxy \
      --auth-user-header=X-Forwarded-User \
      --auth-groups-header=X-Forwarded-Groups
```

> **Security:** Your ingress must strip `X-Forwarded-User` and `X-Forwarded-Groups` headers from external requests to prevent spoofing. The auth proxy should be the **only** path to Radar. Radar logs a warning at startup as a reminder.

**Logout behavior:**

The user menu always shows a **Logout** button. Clicking it clears Radar's session cookie. On its own, that isn't enough to switch users: your proxy re-injects the identity header on the next request and signs the same user back in.

To make logout actually switch users, point Radar at your proxy's sign-out URL with `--auth-proxy-logout-url` (or `auth.proxy.logoutURL` in Helm) — e.g. oauth2-proxy's `/oauth2/sign_out`. Radar then redirects the browser there after clearing its own cookie, so the upstream session is torn down too. When unset, the menu shows a note that the proxy may re-authenticate automatically.

> **Note:** HTTP Basic Auth has no reliable logout mechanism — browsers cache credentials and resend them. If you need user-switching, prefer a proxy with a real sign-out endpoint (oauth2-proxy, Authelia) over plain Basic Auth.

### OIDC Mode

Use this when you want Radar to handle login directly — no separate auth proxy needed. Radar redirects to your identity provider (Google, Okta, Dex, Keycloak, etc.), validates the token, and creates a session cookie.

**Flow:**
```
Browser → Radar → redirects to IdP → user logs in → callback → session cookie
```

**Helm values:**
```yaml
auth:
  mode: oidc
  secret: ""  # HMAC key for session cookies (auto-generated if empty, but sessions won't survive pod restarts)
  oidc:
    issuerURL: https://accounts.google.com    # Your OIDC provider
    clientID: your-client-id
    clientSecret: your-client-secret
    redirectURL: https://radar.example.com/auth/callback
    groupsClaim: groups                        # JWT claim containing group membership
    # scopes: ["openid", "profile", "email", "groups"]  # Default — uncomment to override (e.g., drop "groups" for Google)
```

**Scopes:** by default Radar requests `openid profile email groups` at the authorization endpoint. The `groups` scope is required by Dex, Keycloak, and most IdPs to actually include the groups claim in the ID token. If your IdP rejects unknown scopes (Google in particular doesn't define `groups`), override via `auth.oidc.scopes` / `--auth-oidc-scopes` to drop it or substitute the provider-specific equivalent.

**Split public/internal provider URLs:** Kubernetes deployments sometimes need the browser to use the canonical issuer URL while the Radar pod talks to the IdP through service DNS. Keep `issuerURL` set to the canonical browser-facing issuer; Radar still validates the token `iss` claim against that value. Set `internalIssuerURL` when the internal endpoint has the same path layout, and Radar will fetch discovery internally while deriving server-side token, userinfo, and JWKS URLs from the internal base:

```yaml
auth:
  mode: oidc
  oidc:
    issuerURL: http://authentik.example.com/application/o/radar/
    internalIssuerURL: http://authentik-server.authentik.svc.cluster.local/application/o/radar/
    clientID: radar
    redirectURL: https://radar.example.com/auth/callback
```

If the endpoints cannot be derived from the two issuer bases, override them explicitly. `authorizationURL` is browser-facing; `tokenURL`, `userInfoURL`, and `jwksURL` are server-side URLs used by the Radar process:

```yaml
auth:
  mode: oidc
  oidc:
    issuerURL: http://authentik.example.com/application/o/radar/
    authorizationURL: http://authentik.example.com/application/o/radar/authorize/
    tokenURL: http://authentik-server.authentik.svc.cluster.local/application/o/radar/token/
    userInfoURL: http://authentik-server.authentik.svc.cluster.local/application/o/radar/userinfo/
    jwksURL: http://authentik-server.authentik.svc.cluster.local/application/o/radar/jwks/
```

**Logout behavior:**

When a user clicks logout, Radar clears the local session cookie and — if the identity provider supports it — redirects the browser to the provider's logout endpoint ([RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)) to terminate the SSO session as well. This prevents the common issue where the user appears to log out but is silently re-authenticated on the next visit.

Radar discovers the provider's `end_session_endpoint` automatically from the OIDC discovery document at startup. If the provider supports it (Okta, Keycloak, Azure AD), Radar redirects the browser there to terminate the SSO session. If the provider doesn't advertise `end_session_endpoint` (e.g., Google), Radar uses `prompt=login` on the next authorization request to force the IdP to show a login screen instead of silently re-authenticating. Check the startup logs for confirmation:

```
[oidc] RP-Initiated Logout enabled (end_session_endpoint discovered)
# or
[oidc] IdP does not advertise end_session_endpoint — will use prompt=login on next auth after logout
```

To redirect users back to Radar after IdP logout, set `--auth-oidc-post-logout-redirect-url` (or `auth.oidc.postLogoutRedirectURL` in Helm). This URL **must be registered** with your identity provider as a valid post-logout redirect URI.

**Back-Channel Logout (IdP-initiated session revocation):**

When an admin disables a user at the IdP level (e.g., disables an Okta account), Radar has no way to know — the existing session cookie remains valid until it expires. Back-Channel Logout ([spec](https://openid.net/specs/openid-connect-backchannel-1_0.html)) solves this: the IdP POSTs a signed `logout_token` to Radar's `/auth/backchannel-logout` endpoint, and Radar immediately revokes the matching session.

To enable, set `--auth-oidc-backchannel-logout` (or `auth.oidc.backchannelLogout: true` in Helm), then register `https://radar.example.com/auth/backchannel-logout` as the Back-Channel Logout URI in your IdP.

If Radar is served under a base path, the route sits at `{basePath}/auth/backchannel-logout` — register that. An unprefixed URI is not routed to Radar, and the IdP's POST is silently lost: disabling a user leaves their Radar session live until the cookie expires, which is the exact failure this feature exists to prevent.

```yaml
auth:
  mode: oidc
  oidc:
    issuerURL: https://your-idp.example.com
    clientID: radar
    backchannelLogout: true
```

Check the startup logs to confirm IdP support:
```
[oidc] Backchannel Logout enabled (sid-based revocation)
# or
[oidc] WARNING: --auth-oidc-backchannel-logout is set, but IdP does not advertise backchannel_logout_supported.
```

**Limitations:**
- **Single-replica only.** Revocations are stored in memory and not shared across pods. If Radar runs multiple replicas behind a load balancer, a revocation hitting one pod won't affect sessions on other pods.
- **Lost on restart.** If Radar restarts, all pending revocations are lost. The session will expire naturally at cookie TTL (4h default).
- **`sid` required for targeted revocation.** If the IdP's logout_token contains only `sub` (no `sid`), Radar cannot target the specific session — it logs a warning and the session expires at cookie TTL. Most major IdPs (Okta, Keycloak, Auth0, Azure AD) include `sid`.
- **Google does not support back-channel logout.** For Google, session exposure is bounded by cookie TTL only.

**Using K8s Secrets for sensitive values:**

For production, use K8s Secrets instead of storing credentials in Helm values (which are visible in Helm release history):

```yaml
auth:
  mode: oidc
  # Session signing key from a K8s Secret
  existingSecret: radar-auth-secret        # K8s Secret containing the HMAC key
  existingSecretKey: auth-secret            # Key within the Secret (default)
  oidc:
    issuerURL: https://accounts.google.com
    clientID: your-client-id
    # OIDC client secret from a K8s Secret
    existingSecret: radar-oidc-credentials  # K8s Secret containing the client secret
    clientSecretKey: client-secret           # Key within the Secret (default)
    redirectURL: https://radar.example.com/auth/callback
```

You can also mix approaches — use `existingSecret` for the client secret but pass `clientID` as a plain value (it's not sensitive).

**TLS configuration for self-signed certificates:**

If your OIDC provider uses a self-signed or internal CA certificate (e.g., on-prem Keycloak), use one of:

```yaml
auth:
  oidc:
    caCert: /etc/radar/oidc-ca.crt          # Path to CA certificate file (secure)
    # insecureSkipVerify: true              # Skip TLS verification (dev/test only)
```

When using `caCert` in Kubernetes, mount the CA certificate into the pod via a ConfigMap or Secret volume.

### Radar Cloud mode

If you see `RADAR_CLOUD_MODE` or `cloud.*` values in the chart, they control a specialized deployment mode used by [Radar Cloud](https://radarhq.io) — a hosted SaaS that lets a single Cloud frontend manage many in-cluster Radar instances over an outbound tunnel. You don't need to use it to run Radar standalone; leave `cloud.enabled: false` (the default).

Under cloud-mode (`RADAR_CLOUD_MODE=true`, set automatically by the chart when `cloud.enabled=true`), Radar:

- Forces `--auth-mode=proxy` with pinned `X-Forwarded-User` / `X-Forwarded-Groups` headers. Radar accepts those headers only on requests marked in-process by its authenticated Cloud tunnel; the ordinary pod TCP listener cannot assert Cloud identity.
- Ships three default ClusterRoleBindings mapping Cloud's `radar:owner` / `radar:member` / `radar:viewer` groups to the standard K8s `admin` / `edit` / `view` ClusterRoles. Configurable via `cloud.defaultRbac.*` in `values.yaml`.
- Adds a **cluster-read add-on** (`cloud.defaultRbac.clusterScopedRead.{viewer,member,owner}`, each default on) granting `get/list/watch` on infrastructure the built-in `view`/`edit`/`admin` roles exclude — Nodes, PersistentVolumes, StorageClasses, IngressClasses, PriorityClasses, RuntimeClasses, CRDs, and admission webhook configurations — plus list-only Upgrade impact source evidence for CSIStorageCapacities, legacy PodSecurityPolicies, and API flow-control configuration. When the matching collection is enabled, it also grants APIServices, PrometheusRules, Karpenter kinds, and node metrics. It does not grant Secrets, RBAC objects, API-server metrics, or kubelet proxy access. It's an independent axis per tier: set a tier `false` to make it namespaced-only (e.g. `clusterScopedRead.viewer: false`). Owner node cordon/drain is a separate cluster-scoped *write*, off by default (`cloud.defaultRbac.nodeOps`).
- Restricts the ordinary pod/ClusterIP TCP listener to `/api/health`; the full handler is served only over yamux streams from the outbound Cloud tunnel. Cloud mode also omits `/debug/pprof/*` and narrows auth exemptions to health only.

<a id="cloud-mode-helm-bindings"></a>
**Optional Upgrade impact metrics evidence.** The default Cloud bindings deliberately omit the broader API server `/metrics` endpoint and kubelet `nodes/proxy` access. Upgrade impact reports checks that depend on those sources as **Incomplete**. To enable deprecated-API request evidence for a tier, bind a separate ClusterRole granting `get` on `nonResourceURLs: ["/metrics"]` to its canonical `radar:viewer`, `radar:member`, or `radar:owner` group. Keep this opt-in separate from the default cluster-read role so operators can make the broader disclosure decision explicitly.

**Helm-specific bindings (when `rbac.helm=true`).** Helm's pre-flight existence check needs cluster-scoped reads/writes that the K8s built-in `admin`/`edit`/`view` ClusterRoles don't grant. The chart emits two add-on ClusterRoles, split by trust tier:

- `radar-helm` — CRDs, StorageClasses, RuntimeClasses, PriorityClasses, PodDisruptionBudgets, Namespaces. Bound to `radar:owner` AND `radar:member`.
- `radar-helm-admin` — RBAC objects (Roles/Bindings, Cluster variants), validating/mutating webhooks, ApiServices. Bound to `radar:owner` ONLY. Granting these to a tier weaker than owner would let a member self-promote to cluster-admin in one `ClusterRoleBinding` write, collapsing the owner/member distinction.

A `radar:member` attempting to install a chart that bundles its own RBAC will get a typed `rbac_preflight` 403 with an actionable "ask an owner" message. Day-to-day app charts and operator-CRD installs still work for members.

Customer-facing documentation for Radar Cloud lives on [radarhq.io](https://radarhq.io). The authoritative reference for the Cloud-mode chart values is the comment block in [`deploy/helm/radar/values.yaml`](../deploy/helm/radar/values.yaml) under `cloud:`.

## Setting Up User Permissions

Radar delegates authorization entirely to K8s RBAC via impersonation. It doesn't have its own role system — permissions are managed with standard K8s tooling (kubectl, Terraform, Helm, GitOps, etc.).

**If your cluster already has RBAC bindings** (most production clusters do), they work automatically. Radar impersonates the authenticated user, and K8s evaluates their existing bindings. You only need to create new bindings if users don't already have the access they need.

### How It Works with Cloud Providers

Cloud-managed clusters typically map their IAM to K8s RBAC automatically:

- **GKE**: Google Groups for RBAC maps Google Workspace groups to K8s groups. IAM roles (`roles/container.viewer`, etc.) grant K8s access. The username is the user's Google email.
- **EKS**: The `aws-auth` ConfigMap maps IAM roles/users to K8s users and groups. The username is typically the IAM role ARN or a mapped alias.
- **AKS**: Azure AD integration maps Azure AD groups directly to K8s groups. The username is the Azure AD user principal name.

In all cases, the username and groups that Radar receives from your auth layer (proxy headers or OIDC token) must match what K8s RBAC expects. Usually this means using the same email/UPN your cloud provider uses.

### Understanding the Chain

```
Identity Provider (Google, Okta, etc.)
  → returns: username=alice@company.com, groups=[sre-team]

Auth Layer (proxy headers or OIDC token)
  → Radar extracts: user="alice@company.com", groups=["sre-team"]

Radar backend
  → creates impersonated K8s client: act as alice@company.com in group sre-team

K8s API server
  → checks RBAC: does any RoleBinding/ClusterRoleBinding grant "sre-team" this permission?
  → allow or deny
```

K8s RBAC doesn't require users to have ServiceAccounts. It supports binding roles to `User` (a string like `alice@company.com`) and `Group` (a string like `sre-team`). These strings just need to match what the identity provider returns.

### Using Built-in K8s Roles

K8s ships with built-in ClusterRoles that work well with Radar. You don't need to create custom roles unless you want fine-grained control:

| Built-in Role | What Users Can Do in Radar |
|---------------|---------------------------|
| `view` | See resources, topology, events, logs. No restart, scale, or Helm writes. |
| `edit` | Everything in `view` + restart, scale, edit resources. Does NOT include exec or port-forward (those require `pods/exec` and `pods/portforward` subresources — use the custom `radar-operator` role below). |
| `admin` | Everything in `edit` + manage RBAC within namespaces. Same exec/port-forward caveat as `edit`. |
| `cluster-admin` | Full access to everything across all namespaces, including exec and port-forward. |

```yaml
# Example: give the dev-team group read-only access to all namespaces
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: dev-team-view
subjects:
  - kind: Group
    name: dev-team
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: view
  apiGroup: rbac.authorization.k8s.io
```

For more control, create a custom ClusterRole:

### Step 1: Create a ClusterRole with the permissions you want

```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: radar-operator
rules:
  # View workloads (most users)
  - apiGroups: ["", "apps", "batch"]
    resources: ["pods", "deployments", "statefulsets", "daemonsets", "services",
                "configmaps", "jobs", "cronjobs", "replicasets", "events"]
    verbs: ["get", "list", "watch"]

  # Restart and scale workloads
  - apiGroups: ["apps"]
    resources: ["deployments", "statefulsets", "daemonsets"]
    verbs: ["patch", "update"]

  # View logs
  - apiGroups: [""]
    resources: ["pods/log"]
    verbs: ["get"]

  # Exec into pods (optional — omit for read-only users)
  - apiGroups: [""]
    resources: ["pods/exec"]
    verbs: ["create"]
```

### Step 2: Bind it to your users or groups

**By group** (recommended — matches group from your identity provider):
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: sre-team-radar-operator
subjects:
  - kind: Group
    name: sre-team                    # Must match the group string from IdP
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: radar-operator
  apiGroup: rbac.authorization.k8s.io
```

**By user:**
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: alice-radar-operator
subjects:
  - kind: User
    name: alice@company.com           # Must match the username from IdP
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: radar-operator
  apiGroup: rbac.authorization.k8s.io
```

**Per-namespace** (use RoleBinding instead of ClusterRoleBinding):
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: dev-team-radar-operator
  namespace: staging                  # Only grants access in this namespace
subjects:
  - kind: Group
    name: dev-team
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: radar-operator
  apiGroup: rbac.authorization.k8s.io
```

### Step 3: Verify

After deploying with auth enabled, you can verify impersonation works:

```bash
# Check what alice can do (from a machine with cluster-admin access)
kubectl auth can-i --list --as=alice@company.com --as-group=sre-team

# Check specific permission
kubectl auth can-i get pods -n production --as=alice@company.com --as-group=sre-team
```

## What Users See

When auth is enabled:
- A **username** appears in the Radar header with a logout option
- The **namespace selector** only shows namespaces the user can access
- **Topology, resources, events, dashboard** are filtered to accessible namespaces
- **Cluster-scoped resources** (Nodes, PersistentVolumes, StorageClasses) are gated per-kind via `SubjectAccessReview` — a user sees them only if their own RBAC permits listing that kind, independent of namespace access
- **Helm releases** are read with the user's own identity, like everything else. Helm stores releases as Secrets, so a user sees a release only where their RBAC grants `list secrets` in the storage namespace — and the K8s built-in `view` role deliberately does not. Radar resolves the namespaces the user can actually read release storage in and lists only there, returning 403 when there are none. Write operations (install, upgrade, rollback, uninstall) are impersonated and need the matching RBAC.
- **Write buttons** (restart, scale, exec, Helm install, etc.) only appear if the user has permission
- Write operations return **403** from K8s if RBAC denies them (shown as an error toast)
- The **/api/auth/me** endpoint returns the current user info and whether auth is enabled

## ServiceAccount RBAC

When auth is enabled, Radar's ServiceAccount needs two additional permissions (added automatically by the Helm chart):

```yaml
# Impersonate users and groups
- apiGroups: [""]
  resources: ["users", "groups"]
  verbs: ["impersonate"]

# Check permissions for specific users
- apiGroups: ["authorization.k8s.io"]
  resources: ["subjectaccessreviews"]
  verbs: ["create"]
```

The ServiceAccount's existing read permissions (list pods, watch deployments, etc.) continue to power the shared cache. Impersonation is only used for write operations and permission checks.

## Session Cookies

Radar uses stateless HMAC-SHA256 signed cookies for sessions. The cookie contains the username and groups — no server-side session storage.

- **Cookie TTL**: 4 hours by default (sliding), configurable with `--auth-cookie-ttl` or `auth.cookieTTL` in Helm values. Sessions auto-extend while you're active; idle sessions expire after the configured TTL. Active users won't notice — Radar's frontend polling keeps the session alive automatically.
- **Proxy mode**: When the cookie expires, the middleware transparently re-creates the session from proxy headers on the next request, so the shorter default TTL has no UX impact.
- **Session secret**: Set `auth.secret` or `RADAR_AUTH_SECRET` env var. If empty, a random key is generated at startup (sessions won't survive pod restarts)
- **For production**: Use `auth.existingSecret` to reference a K8s Secret, so sessions survive restarts

## Configuration Reference

| Parameter | CLI Flag | Helm Value | Default |
|-----------|----------|------------|---------|
| Auth mode | `--auth-mode` | `auth.mode` | `none` |
| Session secret | `--auth-secret` | `auth.secret` | auto-generated |
| Cookie TTL | `--auth-cookie-ttl` | `auth.cookieTTL` | `4h` (sliding) |
| User header (proxy) | `--auth-user-header` | `auth.proxy.userHeader` | `X-Forwarded-User` |
| Groups header (proxy) | `--auth-groups-header` | `auth.proxy.groupsHeader` | `X-Forwarded-Groups` |
| Proxy logout URL (proxy) | `--auth-proxy-logout-url` | `auth.proxy.logoutURL` | — |
| OIDC issuer | `--auth-oidc-issuer` | `auth.oidc.issuerURL` | — |
| OIDC internal issuer | `--auth-oidc-internal-issuer` | `auth.oidc.internalIssuerURL` | — |
| OIDC authorization URL | `--auth-oidc-authorization-url` | `auth.oidc.authorizationURL` | — |
| OIDC token URL | `--auth-oidc-token-url` | `auth.oidc.tokenURL` | — |
| OIDC userinfo URL | `--auth-oidc-userinfo-url` | `auth.oidc.userInfoURL` | — |
| OIDC JWKS URL | `--auth-oidc-jwks-url` | `auth.oidc.jwksURL` | — |
| OIDC client ID | `--auth-oidc-client-id` | `auth.oidc.clientID` | — |
| OIDC client secret | `--auth-oidc-client-secret` | `auth.oidc.clientSecret` | — |
| OIDC client secret (K8s Secret) | — | `auth.oidc.existingSecret` | — |
| OIDC client secret key | — | `auth.oidc.clientSecretKey` | `client-secret` |
| OIDC redirect URL | `--auth-oidc-redirect-url` | `auth.oidc.redirectURL` | — |
| OIDC groups claim | `--auth-oidc-groups-claim` | `auth.oidc.groupsClaim` | `groups` |
| OIDC scopes | `--auth-oidc-scopes` | `auth.oidc.scopes` | `openid,profile,email,groups` |
| OIDC post-logout redirect | `--auth-oidc-post-logout-redirect-url` | `auth.oidc.postLogoutRedirectURL` | — |
| OIDC username prefix | `--auth-oidc-username-prefix` | `auth.oidc.usernamePrefix` | — |
| OIDC groups prefix | `--auth-oidc-groups-prefix` | `auth.oidc.groupsPrefix` | — |
| OIDC CA certificate | `--auth-oidc-ca-cert` | `auth.oidc.caCert` | — |
| OIDC skip TLS verify | `--auth-oidc-insecure-skip-verify` | `auth.oidc.insecureSkipVerify` | `false` |
| OIDC backchannel logout | `--auth-oidc-backchannel-logout` | `auth.oidc.backchannelLogout` | `false` |

## Troubleshooting

### User is logged back in immediately after logout (OIDC)

This happens when the identity provider's SSO session is not terminated during logout. Check:

1. **Check the startup logs.** Look for `[oidc] RP-Initiated Logout enabled` or the `prompt=login` fallback message. Both approaches should prevent silent re-authentication.
2. **Is `post_logout_redirect_uri` registered?** If you set `--auth-oidc-post-logout-redirect-url`, it must be registered as a valid post-logout redirect URI with your identity provider. If not registered, the IdP may show an error instead of redirecting.
3. **Browser extensions or aggressive caching?** Some browser extensions may interfere with the redirect to the IdP's logout endpoint.

### Users get 401 on every request

- **Proxy mode**: Check that the auth proxy is setting `X-Forwarded-User`. Inspect with:
  ```bash
  kubectl logs -n radar -l app.kubernetes.io/name=radar | grep "auth"
  ```
- **OIDC mode**: Verify the issuer URL, client ID, and redirect URL are correct. Check Radar logs for OIDC errors.

### Users authenticate but see empty dashboard / no resources

The user will see a "No Namespace Access" message if they have no K8s RBAC bindings. Radar uses `SubjectAccessReview` to discover which namespaces each user can access — with no bindings, the result is zero namespaces.

**Verify the user's access:**
```bash
# Check if the user can list pods in a specific namespace
kubectl auth can-i list pods -n <namespace> \
  --as= --as-group=<group>

# Check cluster-wide access
kubectl auth can-i list pods --all-namespaces \
  --as= --as-group=<group>
```

**Fix:** Create a RoleBinding or ClusterRoleBinding for the user/group (see examples above).

### All users see empty data even with correct RBAC

Radar's ServiceAccount may be missing the `subjectaccessreviews` permission needed to check user access. When this fails, Radar denies all namespace access (fail-closed). Verify:
```bash
kubectl auth can-i create subjectaccessreviews \
  --as=system:serviceaccount:radar:radar
```

If this returns `no`, the Helm chart's RBAC was not applied. Re-install with `rbac.create: true` (the default) or add the rule manually.

### Write operations return 403

The user's K8s RBAC doesn't include the required verb. Check with:
```bash
kubectl auth can-i patch deployments -n <namespace> \
  --as= --as-group=<group>
```

### Impersonation errors in Radar logs

Radar's ServiceAccount is missing impersonation permissions. Verify:
```bash
kubectl auth can-i impersonate users \
  --as=system:serviceaccount:radar:radar
```

If using `rbac.create: false` in Helm, make sure your custom ClusterRole includes the impersonation rules.

---

## File: docs/configuration.md

# Configuration

This document covers Radar's cluster connection behavior. For CLI flags and basic usage, see the [README](../README.md#usage).

## HTTP Listener

Radar listens on `127.0.0.1:9280` by default, so an unauthenticated local
instance is reachable only from the same network namespace. `localhost` is
accepted as an equivalent spelling.

To reach Radar through a VM, WSL, dev container, jump host, or another machine,
opt into a shared listener explicitly:

```bash
radar --listen-address=0.0.0.0
```

An all-interface listener can be reached by non-browser clients; CORS is not an
authentication boundary. Enable Radar authentication and restrict network
access whenever using `0.0.0.0`.

The Docker image and Helm chart set `0.0.0.0` explicitly because their HTTP
listener must be reachable through a published container port or Kubernetes
Service. Desktop Radar and temporary `radar diagnose` servers remain
loopback-only.

## Persistent Configuration

Radar stores configuration in two files under `~/.radar/`:

### Config File (`~/.radar/config.json`)

Persistent defaults for CLI flags. CLI flags always override these values. Managed via the Settings dialog in the UI or `PUT /api/config`.

```json
{
  "kubeconfig": "",
  "kubeconfigDirs": [],
  "namespace": "",
  "namespaces": [],
  "port": 9280,
  "noBrowser": false,
  "browser": "",
  "timelineStorage": "memory",
  "timelineDbPath": "~/.radar/timeline.db",
  "timelineMaxSize": "0",
  "historyLimit": 10000,
  "prometheusUrl": "",
  "prometheusHeaders": {},
  "mcp": true,
  "debugImage": ""
}
```

All fields are optional — omitted fields use built-in defaults.

| Field | Description |
|-------|-------------|
| `kubeconfig` | Path to kubeconfig file (same as `--kubeconfig`) |
| `kubeconfigDirs` | Directories containing kubeconfig files (same as `--kubeconfig-dir`) |
| `namespace` | Initial namespace filter |
| `namespaces` | Initial namespace filters as a list (same as `--namespaces ns1,ns2,ns3`) |
| `port` | Server port (default 9280) |
| `noBrowser` | Don't auto-open browser |
| `browser` | Browser for automatic launch (same as `--browser`; on macOS, app names like `Google Chrome` are supported) |
| `timelineStorage` | `memory` or `sqlite` |
| `timelineDbPath` | Path to SQLite database |
| `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. |
| `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`. |
| `argoCdInsecureTls` | Skip TLS verification for argocd-server (self-signed default installs). Scoped to the Argo CD client only. |
| `prometheusHeadersFromEnv` | Header values read from environment variables at startup — e.g. `{"Authorization": "PROMETHEUS_TOKEN"}`. Equivalent CLI: `--prometheus-header-from-env Key=ENV_VAR` (repeatable). Use this with Kubernetes Secret-backed env vars in Helm deployments. |
| `mcp` | Enable/disable MCP server for AI tools (default: enabled) |
| `debugImage` | Image for ephemeral debug containers and node debug pods (same as `--debug-image`). Empty = `busybox:latest`; point at a mirror for air-gapped / private-registry clusters. |

### Settings File (`~/.radar/settings.json`)

User preferences for the UI. Managed via the Settings dialog or `PUT /api/settings`.

```json
{
  "theme": "system",
  "pinnedKinds": [
    { "name": "Deployments", "kind": "Deployment", "group": "" }
  ]
}
```

| Field | Values | Description |
|-------|--------|-------------|
| `theme` | `light`, `dark`, `system` | UI theme preference |
| `pinnedKinds` | Array of `{name, kind, group}` | Resource kinds pinned to the sidebar |

## Cluster Connection Precedence

Radar connects to Kubernetes clusters using the same configuration sources as `kubectl`:

| Priority | Source | Description |
|----------|--------|-------------|
| 1 | `--kubeconfig` flag | Explicit path to kubeconfig file |
| 2 | `KUBECONFIG` env var / `--kubeconfig-dir` flag | Either can provide kubeconfig(s); mutually exclusive alternatives |
| 3 | In-cluster config | Automatic when running inside a Kubernetes pod (`KUBERNETES_SERVICE_HOST` is set) |
| 4 | `~/.kube/config` | Default kubeconfig location |

## KUBECONFIG vs In-Cluster Detection

When Radar runs inside a Kubernetes pod, Kubernetes automatically sets the `KUBERNETES_SERVICE_HOST` environment variable. This normally triggers in-cluster configuration using the pod's service account credentials.

However, **explicit kubeconfig takes precedence**. If you set `KUBECONFIG` or pass `--kubeconfig`, Radar uses that instead of in-cluster config. This allows you to:

- Run Radar inside a pod but connect to a different cluster
- Use specific credentials instead of the pod's service account
- Test with a custom kubeconfig while developing inside a cluster

**Example: Override in-cluster config**
```bash
# Inside a pod, connect to a different cluster
export KUBECONFIG=/path/to/other-cluster.yaml
kubectl radar
```

This behavior matches `kubectl` and follows the [Kubernetes client-go precedence rules](https://github.com/kubernetes/kubernetes/issues/43662).

## Multiple Kubeconfig Files

`KUBECONFIG` can contain multiple file paths (colon-separated on Linux/macOS, semicolon-separated on Windows). Radar merges these files following Kubernetes conventions:

```bash
export KUBECONFIG=~/.kube/config:~/.kube/staging-config:~/.kube/prod-config
kubectl radar
```

Alternatively, use `--kubeconfig-dir` to load all kubeconfig files from a directory:

```bash
kubectl radar --kubeconfig-dir ~/.kube/configs/
```

## Context Switching

Radar supports switching between Kubernetes contexts at runtime through the UI. Click the context selector in the header to switch between available contexts.

When running in-cluster (using the pod's service account), context switching is disabled.

## Namespace Picker

The header has a namespace picker on the right. Pick a single namespace to focus the view, or **All namespaces** to see everything you have access to. Cluster-scoped resources (Nodes, Namespaces, PVs, StorageClasses) appear regardless of the pick if your RBAC permits them — they have no namespace to filter on. Namespace-restricted users without their own cluster-scoped RBAC won't see cluster-scoped sections at all.

The pick is a per-user view filter — it doesn't change anything for other users sharing the same Radar instance. Locally, your pick is remembered per kubeconfig context across restarts. In shared (auth-enabled) deployments the pick lives for the session.

Until you make a pick, local sessions default to the namespace set on the kubeconfig context (kubectl parity — the same namespace `kubectl` would use, including one set via `kubectl config set-context` or `kubens`). An explicit `--namespace` / `--namespaces` flag outranks the kubeconfig value, and contexts without either default to **All namespaces**. Once you pick namespaces or explicitly choose **All namespaces**, that choice sticks for the context and the kubeconfig value is no longer consulted.

If your account can list resources inside several namespaces but cannot list namespaces cluster-wide, start Radar with an explicit list:

```bash
kubectl radar --namespaces ns1,ns2,ns3
```

Radar probes each listed namespace for access and watches every namespace where access is granted — resource views then cover all of them, not just the first. The list is also each user's initial picker selection: locally via the launch URL, and in shared (auth-enabled) deployments as a per-session default seeded on first read. Clearing the picker back to **All namespaces** sticks for the rest of the session. The picker can switch between those namespaces or keep several selected at once.

This covers built-in resource types and custom resources alike: CRDs (GitOps, Gateway API, etc.) are probed per-kind across the same list and watched in every granted namespace. The list is capped by `--max-scope-candidates` (default 20) — startup fails with a clear error rather than silently probing a subset.

When Radar starts with `--namespace-scope`, the picker controls the process-wide cache scope instead of just a view filter. Namespaced informer caches are pinned to one namespace while cluster-scoped resources remain cluster-wide. Local/no-auth sessions can switch the scoped namespace, which rebuilds the cache in place. Auth-enabled and Radar Cloud sessions lock the picker to the startup namespace so one user cannot reshape the shared backend cache for everyone.

**Single namespace only.** `--namespace-scope` pins the cache to exactly one namespace; scoping to several namespaces at once is not supported yet. Passing more than one (e.g. `--namespace=a,b`) fails at startup with a clear error rather than silently caching nothing. When scoped, the namespace picker becomes single-select, and a switch re-points the whole cache to the new namespace rather than adding to it.

## Radar Cloud

Radar is free and fully functional without an account. A Cloud button in the
header offers to connect the cluster to [Radar Cloud](https://app.radarhq.io) —
optional, and nothing else depends on it.

| Variable | Effect |
|---|---|
| `RADAR_CLOUD_FUNNEL=off` | Removes the Cloud button entirely. `on` forces it on. |
| `RADAR_HUB_URL` | Point Cloud connection at a self-hosted Radar Hub instead of the hosted service. |
| `RADAR_HUB_APP_URL` | Self-hosted Hub's web origin, when it differs from `RADAR_HUB_URL`. |

To connect a cluster from the command line, use `radar cloud install`
(`--hub-url` for a self-hosted Hub).

### What Radar sends

Until you connect a cluster to Cloud, Radar makes exactly two outbound
requests, both to Skyhook, neither containing cluster data:

- **Update check** — periodically, to `releases.skyhook.io`, with the Radar
  version, OS/arch, install method, and whether it's running locally or
  in-cluster. Skipped entirely on development builds.
- **Cloud dialog copy** — only when you *open* the Cloud dialog, to fetch the
  current terms shown in it. No identifiers are sent. `RADAR_CLOUD_FUNNEL=off`
  stops this request from ever happening.

A standalone Radar sends your cluster's data nowhere: it talks to your
Kubernetes API directly and keeps everything it reads on your machine.

Connecting a cluster to Cloud is what changes that, and it is the point of
connecting — the cluster's agent opens an outbound tunnel to the Hub so the
team can reach the same views without each person holding kubeconfig access.
Deciding whether to connect is a separate question from the two requests
above, which happen either way.

## Related Documentation

- [README](../README.md#usage) — CLI flags and basic usage
- [In-Cluster Deployment](in-cluster.md) — Deploy Radar inside your cluster with Helm
- [Authentication & Authorization](authentication.md) — Proxy and OIDC auth for shared deployments

---

## File: docs/gitops.md

# GitOps (Argo CD & Flux)

Radar's GitOps workspace gives Argo CD and Flux first-class treatment. Instead of treating Applications and Kustomizations as generic CRDs, you get a typed fleet view, a per-app detail page that diagnoses *why* something is misbehaving, and the controls you'd otherwise reach for `argocd` / `flux` CLI to run.

The hard part of GitOps tooling isn't sync — it's diagnosis. Radar surfaces drift, recent events, controller-failure attribution, and lifecycle state inline so you don't have to context-switch between `kubectl get`, `argocd app diff`, controller logs, and a YAML viewer to understand a stuck reconcile.



## Supported CRDs

| Tool | Kinds |
|------|-------|
| **FluxCD** | `GitRepository`, `OCIRepository`, `HelmRepository`, `Bucket`, `Kustomization`, `HelmRelease`, `Alert` |
| **ArgoCD** | `Application`, `ApplicationSet`, `AppProject` |

## Fleet view

Open the **GitOps** tab in the sidebar. Argo + Flux rows mix in the same table or tile view with resolved source URLs (`github.com/owner/repo`) for both ecosystems — not the CRD-internal source name.

- **Filters**: Sync, Health, Project, Namespace, Labels, Automation (auto-sync / manual / suspended), Lifecycle (active / terminating)
- **Modes**: Applications / Sources / Projects / Alerts
- **Default sort**: smart-tiered by urgency — Failed > Terminating > Degraded > Missing > OutOfSync > Suspended > Progressing > Synced

## Per-app detail page

Click any row to open a detail page with three top-level tabs.



### Topology tab

Graph or table sub-modes. The graph shows the application root and every managed resource, with ownership edges (Service → Deployment → ReplicaSet → Pod chains). Filter by kind, sync, health, role, namespace, or search. Group large pod sets to keep the graph scannable; click a group to expand.

GitOps CRs that are themselves managed (a child Application from an app-of-apps parent, a Kustomization referenced by another Kustomization, ApplicationSet children) render with a small `Argo` or `Flux` tool badge and a `→` chevron — click to open that CR's own GitOps detail page. The child page's breadcrumb shows the lineage `GitOps / parent-ns/parent / child-ns/child`; the parent segment is clickable to navigate back.

### Changes tab

Per managed resource, in one row:

- **Sync status** chip (`Synced`, `OutOfSync`, etc.) with the health chip beside it
- **Field-level drift** computed inline from each resource's `kubectl.kubernetes.io/last-applied-configuration` annotation vs live cluster state. You see actual `spec.X removed / spec.Y added` entries — no `argocd app diff` round-trip needed. Live objects are pulled via a dedicated direct-GET path so the dynamic informer cache doesn't have to retain the annotation cluster-wide
- **Recent events** (5 most recent, namespace-RBAC-filtered) so `ImagePullBackOff`, `FailedScheduling`, webhook denials, PVC pending, etc. show up next to the resource that caused them
- **Open** button — drops into the standard resource drawer

OutOfSync alerts in the Issues band at the top of the page are clickable — they jump to the affected row and highlight it for ~4 seconds.

### Activity tab

Operation history with deploy timestamps, the git revision deployed, who initiated the sync (human user or automation), an outcome chip per row, and per-revision **Rollback** buttons for Argo. In-progress operations pin to the top of the timeline.

## Diagnosis pipeline

The Issues band at the top of the detail page surfaces six classes of problems:

- **Operation failures** (Argo) — the parser recognizes 11 patterns (annotation-too-large, label-too-long, hook failure, admission webhook denial, RBAC, conflict, immutable field, schema migration, connectivity, etc.) and rewrites each into a plain-English cause
- **Stuck-drift loop** — when sync succeeded but the app is *still* OutOfSync with auto-sync on and a recent reconcile, something is mutating resources after each apply. The Issue calls out likely culprits (mutating webhook, sibling controller, schema migration)
- **Manual drift without auto-sync** — drift exists but auto-sync is disabled. The Issue tells you "nothing will reconcile until you click Sync" so you stop waiting
- **Argo Application conditions** — `ComparisonError` (verify repo creds), `OrphanedResourceWarning`, `InvalidSpecError`, etc. extracted into typed-severity Issues
- **Per-resource health** — Degraded / Missing children get a critical Issue each, deduped against any operation failure that already named the same resource (no triplicate rendering)
- **Pending deletion** (lifecycle) — see [Lifecycle awareness](#lifecycle-awareness) below

**Structured remediation** — when the diagnosis pipeline recognizes a fixable failure (e.g. Argo operation error "namespace X not found"), the Issue carries a primary-blue action button that performs the fix in one click. Duplicate per-resource Missing issues + SyncError condition rows are then suppressed so the user sees one clear "create the namespace and retry" path instead of three.

While an operation is running, the page polls every 2s; otherwise on-demand.

## Lifecycle awareness

When a GitOps resource is being deleted (`metadata.deletionTimestamp` set), its Sync and Health values are leftovers from the last reconcile *before* deletion was triggered. Showing them as if they're current produces contradictory state ("Syncing · Progressing · Terminating") that misleads operators into trying to fix routine sync problems on a resource the cluster is actively tearing down.

Radar treats Terminating as a distinct lifecycle phase that dominates other status:

- **Detail header**: orange `[Terminating]` chip replaces Sync/Health badges. Source / Revision / Last reconcile / Sync mode metadata swaps to `Pending deletion · Finalizers`, with the original fields behind a "Show pre-deletion metadata" toggle
- **Action buttons**: Sync, Reconcile, Suspend / Resume, Rollback, Sync-with-source disable with a tooltip explaining why. Refresh and Terminate stay enabled — they're read-only / cleanup-only verbs
- **Lifecycle banner**: a dedicated orange banner above the Issues band; pre-deletion failures collapse behind a `Pre-deletion issues (N)` disclosure
- **Severity ramp**: info <5min, warning 5-30min, alert >30min. Past 30min the Issue's Cause line names the controller responsible for the finalizer and reports its pod state ("helm-controller is not running in flux-system")
- **Fleet view**: `—` in Sync/Health columns, orange row stripe, `[TERMINATING]` chip in the leftmost slot, `Pending Nago` instead of "Last Sync"
- **Topology**: orange left-stripe on the root + children; stale sync/health chips suppressed
- **Cluster Audit**: `stuckTerminating` check across all typed K8s resources with the same 5-minute/30-minute thresholds, presented as Medium/High posture priority
- **Mutating ops** (`Sync`, `Reconcile`, `Rollback`, `SetAutoSync`, `SyncWithSource`) return `ErrResourceTerminating` (HTTP 409) on zombies, including over MCP

## Operations

### Argo CD

| Operation | What it does |
|---|---|
| **Sync…** | Opens a dialog with prune / dry-run / apply-only / force / replace / server-side apply / sync-options. Force-only routes via `syncStrategy.hook.force` so PreSync / PostSync hooks still run; ApplyOnly uses `syncStrategy.apply` |
| **Refresh** | Re-fetches the source repo |
| **Hard refresh** | Sets `RefreshType=hard` to bypass repo-cache |
| **Terminate** | Cancels an in-flight operation |
| **Suspend / Enable auto-sync** | Toggles automated sync; remembers the prior `prune` / `selfHeal` settings on suspend so resume restores them |
| **Rollback** | Pick a prior history entry by ID. Force / DryRun flags honored |
| **Selective sync** | From the Topology tab, mark individual resources and sync only those |

### Flux

| Operation | What it does |
|---|---|
| **Reconcile** | Annotates `reconcile.fluxcd.io/requestedAt` to trigger a single reconcile |
| **Sync with source** (Kustomization / HelmRelease) | Reconciles the source first, then the resource itself |
| **Suspend / Resume** | Toggles `spec.suspend` |

### Keyboard shortcuts (per-app detail page)

| Key | Action |
|---|---|
| `s` | Sync (opens the options dialog for Argo) / Reconcile (Flux) |
| `r` | Refresh (Argo) |
| `Shift+R` | Hard refresh (Argo) |
| `t` | Terminate running sync (Argo, only when an op is in flight) |

## Argo CD API integration (deep diff)

Radar's inline field-level drift approximates desired state from the
`kubectl.kubernetes.io/last-applied-configuration` annotation — absent under
server-side apply and Helm-installed resources. Connecting Radar to the
argocd-server API upgrades the Changes tab with the **canonical Git-rendered
diff**: desired (rendered from Git, with Argo's own normalizations and every
`ignoreDifferences` rule applied — including jq rules Radar can't evaluate)
vs live, per managed resource, as a full YAML line diff.

Setup (Settings → Argo CD, or `PUT /api/integrations/argocd`):

1. Create a get-only local account in `argocd-cm` (`accounts.radar: apiKey`)
   and grant it `p, role:radar, applications, get, */*, allow` in
   `argocd-rbac-cm`, then mint a token
   (`argocd account generate-token --account radar`). A project-scoped token
   works too. Don't use an admin/personal token.
2. Paste the token in Settings. Leave the URL empty for in-cluster
   auto-discovery (the `argocd-server` Service, port-forwarded automatically
   when Radar runs outside the cluster), or set it explicitly for external
   endpoints. Self-signed installs need the insecure-TLS toggle.
   Alternatively "Use Argo CD CLI session" adopts your local
   `~/.config/argocd` token — an explicit action, never silent.

Behavior and guarantees:

- The token is transport, not authorization: every diff request passes
  Radar's own per-user RBAC (Application access + a `get` SubjectAccessReview
  on the target resource's kind) before Argo is consulted. Users can never
  see manifests through Radar's token that Radar's RBAC would deny.
- **Secret manifests are structurally redacted** — `data`/`stringData` values
  are masked with per-key changed/unchanged markers on both sides of the
  diff. There is no un-redact option.
- The token lives in `~/.radar/config.json` (written `0600`) and is redacted
  from `GET /api/config`; saving settings never erases it.
- A token is bound to the server it was issued for: changing the Argo CD URL
  requires re-entering the token, and a token saved in auto-discovery mode
  (empty URL) is bound to the kubeconfig context it was saved under — after a
  context switch or a Radar restart it must be re-confirmed in Settings, so it
  is never sent to a different cluster's argocd-server.
- Argo CD *core* installs have no argocd-server — Radar degrades to the
  annotation-based drift view. Same when the server is unreachable or the
  token expires; the Changes tab keeps working, only the deep diff goes away.

The gitops-demo cluster mints a ready-to-use token into
`scripts/gitops-demo/.radar-argocd-token` (see the demo section below).

### Provisioning the token per deployment shape

The Settings paste above is right for a laptop, but not for a headless or hosted
Radar — pasting in the UI stores the token under `/home/nonroot`, which is an
`emptyDir` in the default chart and is lost on pod restart. Pick by how Radar runs:

| Deployment | How to provision | Notes |
|------------|------------------|-------|
| **Local / desktop** | Settings → Argo CD (paste, or "Use Argo CD CLI session") | Interactive; persists to `~/.radar/config.json`. |
| **In-cluster (self-hosted)** | Helm `argocd.existingSecret` (or `argocd.token`) → `RADAR_ARGOCD_TOKEN` | Declarative, survives restarts, read-only in the UI. |
| **Radar Cloud** | The customer's in-cluster Radar carries the token (as above); the token never reaches the hub. | Same env path; the hub proxies to the binary, which holds the credential. |

When a token is provided via the environment, the integration becomes
**environment-managed**: the Settings card renders read-only and `PUT
/api/integrations/argocd` returns `409` — the deployment is the source of truth.
The env token is held in memory only and is never written to `~/.radar/config.json`.

Environment variables (read once at startup):

- `RADAR_ARGOCD_TOKEN` — the API token. `RADAR_ARGOCD_TOKEN_FILE` (a mounted-secret
  path) takes precedence and is preferred — a file isn't exposed via
  `/proc//environ`.
- `RADAR_ARGOCD_URL` — explicit `argocd-server` URL; omit for in-cluster
  auto-discovery. Must be `http(s)`, with no embedded userinfo.
- `RADAR_ARGOCD_INSECURE_TLS` — `true` to skip TLS verification (self-signed
  server). Leave unset otherwise; it exposes the token on the path to the server.

Helm (`deploy/helm/radar`), preferring a pre-created Secret so the token never
lands in the release state:

```yaml
argocd:
  existingSecret: radar-argocd-token   # Secret with a `token` key (recommended)
  # token: argo_xxx                    # or inline (dev only — lands in release values)
  # url: https://argocd.example.com    # optional; blank auto-discovers in-cluster
  # insecureTls: false
```

Scoping and lifecycle:

- **Scope the Secret per cluster.** The token authenticates to *one* `argocd-server`;
  auto-discovery sends it to whichever server this cluster exposes. Broadcasting one
  Secret across clusters with a shared GitOps manifest would send the token to each
  cluster's server. Prefer `RADAR_ARGOCD_URL` to pin the endpoint explicitly.
- **Rotation requires a pod restart** — env vars and Secret-backed env are read at
  startup and don't hot-reload. Roll the Deployment to pick up a new token.
- **Invalid env config fails closed.** If the env vars are set but don't resolve to
  a usable token (unreadable/empty file, invalid URL, non-boolean TLS), Radar does
  NOT fall back to a previously UI-set on-disk token — it disables the deep diff
  (annotation-drift fallback) and surfaces the reason on the Settings card. Fix the
  deployment and restart. Removing the env vars entirely returns control to any
  on-disk config; on a persistence-enabled deployment, clear both to fully remove a
  credential.
- Deep-diff cross-cluster destinations are refused: a Radar SAR can only authorize
  a read against its own cluster, so Applications targeting a remote
  `spec.destination` fall back to annotation drift.

## Cross-linking from the rest of Radar

The GitOps tab isn't the only place Argo/Flux ownership matters. Surfaces across Radar know about GitOps and route into the right detail page when they should:

- **K8s resource drawer** — every resource that carries Argo's tracking-id annotation or Flux's owner labels gets a `Managed by <app>` chip in the drawer header. Click → owning Application/Kustomization/HelmRelease detail page. The generic `app.kubernetes.io/instance` label is intentionally *not* used as a signal — it's stamped by virtually every Helm chart and would false-positive plain Helm installs
- **Topology** — clicking an Argo / Flux CR node opens its GitOps detail page directly, not the generic drawer
- **Timeline** — lane labels for Argo / Flux CRs route to detail
- **Helm view** — releases installed by Flux's helm-controller (detected via a HelmRelease CR lookup keyed by `<storageNamespace>/<releaseName>`, since Flux's labels live on the *managed* resources, not the release Secret) carry a `Flux` badge in the list and an amber `Managed by Flux · ns/name` link in the drawer, warning that `helm upgrade` would be reverted at the next reconcile
- **Flux source CR drawers** — `GitRepository`, `HelmRepository`, `OCIRepository`, `Bucket` drawers carry a `Consumed by` panel listing every Kustomization + HelmRelease whose `spec.sourceRef` points at the source. Answers "if I edit this, what gets affected on the next reconcile?" without guessing

## MCP integration

`manage_gitops` MCP tool exposes the same actions to AI agents with per-action input validation:

- Argo: `sync`, `suspend`, `resume`
- Flux: `reconcile`, `suspend`, `resume`

`get_resource` Summary carries lifecycle signal (`terminating`, `finalizers`) so AI agents can distinguish a zombie from a live resource and won't suggest `Sync` on something pending deletion.

See [MCP server](mcp.md) for the full tool list and security model.

## Demo cluster

`make gitops-demo` bootstraps a `kind` cluster pre-loaded with Argo CD + Flux + a curated set of fixtures covering every UI state the GitOps tab needs to render. Pinned to Argo CD `v2.13.2` + Flux `v2.4.0`.

```bash
make gitops-demo              # bootstrap + apply fixtures
make gitops-demo-drift        # induce stable drift on guestbook-drift
make gitops-demo-down         # teardown
make gitops-demo-status       # inventory what's installed
```

Fixtures cover healthy + drifted + broken-sync + broken-path + manual-sync + suspended + rollback history + ApplicationSet → 3 children + Flux Kustomization with `dependsOn` chain + HelmRelease (managed-resources tree) + Flux zombie (Terminating lifecycle) + broken Kustomization (missing namespace) + broken HelmRelease (no-such-version, retry counter). See `scripts/gitops-demo/README.md` for the full matrix.

## RBAC

Reading status only needs the default ClusterRole's read access on `argoproj.io` / `*.toolkit.fluxcd.io` (enabled by default in the Helm chart's `rbac.crdGroups.argo` / `.flux`).

Triggering Sync / Reconcile / Suspend / Rollback needs `patch` on the parent CRDs. The chart enables the right verbs when `rbac.helm: true` is set, or you can scope it more tightly via `rbac.additionalRules`.

The lifecycle controller-health probe (Home dashboard `GitOps Controllers` card, finalizer-owner attribution in lifecycle Issues) reads pods in `argocd` and `flux-system`. Operators with no access there still get the chip and the basic Issue; the controller-status enrichment is omitted gracefully.

## Single-cluster scope

Radar shows GitOps connections only when the controller and managed resources live in the same cluster. ArgoCD's hub-spoke pattern (controller in one cluster, workloads in another) means Application → resource edges won't render when you're connected to the ArgoCD hub. Flux typically deploys to its own cluster, so connections usually work.

## See also

- [MCP server](mcp.md) — how AI agents drive GitOps operations and read lifecycle signal
- [Integrations & CRDs](integrations.md) — full CRD support matrix for Argo CD, Flux, and everything else
- [Configuration](configuration.md) — cluster connection, multi-context kubeconfig handling

---

## File: docs/in-cluster.md

# In-Cluster Deployment

Deploy Radar to your Kubernetes cluster for shared team access.

> **Note:** This guide covers deploying Radar as a pod in your cluster. If you're running Radar locally but need to understand cluster connection behavior (e.g., using `KUBECONFIG` to override in-cluster detection), see the [Configuration Guide](configuration.md).

## Quick Start

```bash
helm repo add skyhook https://skyhook-io.github.io/helm-charts
helm repo update skyhook
helm upgrade --install radar skyhook/radar -n radar --create-namespace
```

Access via port-forward:
```bash
kubectl port-forward svc/radar 9280:9280 -n radar
open http://localhost:9280
```

## Exposing with Ingress

### Basic (No Authentication)

```yaml
# values.yaml
ingress:
  enabled: true
  className: nginx
  hosts:
    - host: radar.your-domain.com
      paths:
        - path: /
          pathType: Prefix
```

```bash
helm upgrade --install radar skyhook/radar \
  -n radar -f values.yaml
```

### Subpath Ingress (No Strip-Prefix)

If your ingress forwards `/radar/...` to the Radar service as `/radar/...`, set `basePath` to the same prefix:

```yaml
# values.yaml
basePath: /radar

ingress:
  enabled: true
  className: nginx
  hosts:
    - host: tools.your-domain.com
      paths:
        - path: /radar
          pathType: Prefix
```

Then open `https://tools.your-domain.com/radar/`. Do not set `basePath` when your ingress already rewrites `/radar` to `/`.

Path segments accept letters, digits, `-`, `_`, `.` and `~`. Radar serves the app
**only** under the prefix — requests to unprefixed paths get a 404, so update any
external health checks or scrapers that hit `/api/health` or `/metrics` directly
(the chart's own probes follow `basePath` automatically). `basePath` is not
supported together with Radar Cloud (`--cloud-url`), which owns the URL path
itself.

**Every URL you hand to an external system must include the prefix.** With OIDC
that means the values you register with your identity provider:

```yaml
basePath: /radar
auth:
  oidc:
    redirectURL: https://tools.your-domain.com/radar/auth/callback           # not /auth/callback
    postLogoutRedirectURL: https://tools.your-domain.com/radar/              # not /
    # With backchannelLogout, the URI registered at the IdP is likewise
    # https://tools.your-domain.com/radar/auth/backchannel-logout
```

Radar's callback route lives at `{basePath}/auth/callback`, so a redirect URL
without the prefix sends the IdP to a path the ingress doesn't route to Radar and
login ends in a 404.

**Give each instance its own hostname.** Two Radars behind subpaths on one
hostname (`/radar-a`, `/radar-b`) are the same browser origin, so they share
browser state: the session cookie is set at `Path=/`, and `localStorage` — which
holds the theme, log-viewer preferences and similar per-instance settings — is
scoped per origin with no path-scoped equivalent in the platform at all. Logging
into one can end the other's session, and preferences set in one show up in the
other. A hostname each keeps them properly separate.

Separately from that: a Radar per cluster gives you a view per cluster. Each
watches only its own cluster and carries its own upgrades, ingress and auth
config, with no cross-cluster search or combined issue list across them. If you
want several clusters in one view, that is what
[Radar Cloud](https://radarhq.io) is for, and its agent dials out so there is no
per-cluster ingress to wire up.

### With Basic Authentication

1. **Create the auth secret:**
   ```bash
   # Install htpasswd if needed: brew install httpd (macOS) or apt install apache2-utils (Linux)

   # Generate credentials (replace 'admin' and 'your-password')
   htpasswd -nb admin 'your-password' > auth

   # Create the secret
   kubectl create secret generic radar-basic-auth \
     --from-file=auth \
     -n radar

   rm auth  # Clean up local file
   ```

2. **Configure ingress:**
   ```yaml
   # values.yaml
   ingress:
     enabled: true
     className: nginx
     annotations:
       nginx.ingress.kubernetes.io/auth-type: basic
       nginx.ingress.kubernetes.io/auth-secret: radar-basic-auth
       nginx.ingress.kubernetes.io/auth-realm: "Radar"
     hosts:
       - host: radar.your-domain.com
         paths:
           - path: /
             pathType: Prefix
   ```

3. **Deploy:**
   ```bash
   helm upgrade --install radar skyhook/radar \
     -n radar -f values.yaml
   ```

### With TLS (HTTPS)

Requires [cert-manager](https://cert-manager.io/) installed in your cluster.

```yaml
# values.yaml
ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
    - host: radar.your-domain.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: radar-tls
      hosts:
        - radar.your-domain.com
```

## DNS Setup

1. **Get your ingress IP:**
   ```bash
   kubectl get ingress -n radar
   ```

2. **Create a DNS A record** pointing your domain to the ingress IP.

**Multi-cluster naming convention:**
```
radar.<cluster-name>.<domain>
```
Example: `radar.prod-us-east1.example.com`

## RBAC

Radar uses its ServiceAccount to access the Kubernetes API. The Helm chart creates a ClusterRole with **read-only access** to common resources by default:

- Pods, Services, ConfigMaps, Events, Namespaces, Nodes, ServiceAccounts, Endpoints
- Deployments, DaemonSets, StatefulSets, ReplicaSets
- Ingresses, NetworkPolicies, Jobs, CronJobs, HPAs, PVCs
- Pod logs (enabled by default)

### Opt-in Permissions

Some features require additional permissions. Most are disabled by default for security:

| Feature | Value | Default | Description |
|---------|-------|---------|-------------|
| Secrets | `rbac.secrets: true` | `false` | Show secrets in resource list |
| Terminal | `rbac.podExec: true` | `false` | Shell access to pods |
| Port Forward | `rbac.portForward: true` | `false` | Port forwarding to pods/services |
| Logs | `rbac.podLogs: true` | `true` | View pod logs |
| Helm Write | `rbac.helm: true` | `false` | Install/upgrade/rollback/uninstall Helm releases (grants broad write access; auto-enables secrets). When auth or cloud is on, also emits a split helm add-on: `radar-helm` (CRDs/storage/PDBs/namespaces, bound to owner+member) and `radar-helm-admin` (RBAC/webhooks/APIServices, owner-only) — see [authentication.md](authentication.md#cloud-mode-helm-bindings) |
| RBAC view | `rbac.viewRBAC: true` | `false` | Show ClusterRoles, ClusterRoleBindings, Roles, RoleBindings in the resource browser. Off by default: cache-served reads bypass per-user RBAC, so granting this exposes the cluster's authorization graph to every authenticated Radar user |
| Traffic TLS | `rbac.traffic: true` | `true` | Read Hubble relay TLS certs for Cilium traffic observation |

> **Node management** (cordon, uncordon, drain) is available via the MCP server and API. These operations require `patch` on nodes, `list` on pods, and `create` on `pods/eviction`, which are not included in the default ClusterRole. Add them via `rbac.additionalRules` or use [per-user authentication](authentication.md) so each user's own RBAC governs node operations.

Enable features as needed:

```yaml
# values.yaml
rbac:
  secrets: false      # Keep disabled unless needed
  podExec: true       # Enable terminal feature
  podLogs: true       # Enable log viewer (default)
  portForward: true   # Enable port forwarding
  helm: false         # Enable Helm write operations (broad permissions)
```

The terminal's **Debug** action launches a throwaway container (ephemeral container on a pod, or a privileged pod on a node) using `busybox:latest` by default. If the built-in restricted Pod Security Standard rejects the default pod debug container, Radar retries with a restricted-compatible Linux security context using the target/pod non-root UID, or UID `65532` by default, so custom images used in restricted namespaces must work as a non-root user. In air-gapped or private-registry clusters where the default image can't be pulled, point it at a reachable mirror:

```yaml
# values.yaml
debug:
  image: my-registry.internal/busybox:1.36
```

Radar doesn't attach image-pull secrets to debug containers or pods — ephemeral containers inherit the target pod's, and node debug pods rely on the `default` namespace's ServiceAccount / node registry config — so the image must be pullable without Radar supplying credentials.

### CRD Permissions

Radar reads CRDs from many popular tools. Each CRD group can be toggled individually:

```yaml
rbac:
  crdGroups:
    all: false          # Wildcard — grant read access to ALL API groups
    # Individual groups (all default to true):
    argo: true          # argoproj.io
    calico: true        # projectcalico.org, crd.projectcalico.org
    certManager: true   # cert-manager.io
    flux: true          # *.toolkit.fluxcd.io
    istio: true         # networking.istio.io, security.istio.io
    karpenter: true     # karpenter.sh, karpenter.k8s.aws, karpenter.azure.com, karpenter.k8s.gcp
    keda: true          # keda.sh
    knative: true       # serving, eventing, sources, messaging, flows, networking.internal (.knative.dev)
    prometheus: true    # monitoring.coreos.com
    traefik: true       # traefik.io
    velero: true        # velero.io
    # ... and 25+ more (see values.yaml for full list)
  additionalCrdGroups: []   # Add custom API groups
  additionalRules: []       # Arbitrary extra ClusterRole rules
```

Calico access is read-only (`get`, `list`, and `watch`) for both API groups and is enabled by default. Set `rbac.crdGroups.calico=false` to disable it. If chart-managed Calico access is disabled, grant only the resources you need through `rbac.additionalRules`; both groups may be required while a cluster exposes modern and legacy Calico APIs.

### Graceful RBAC Degradation

You see what you have access to — Radar doesn't require cluster-admin. Whatever your ServiceAccount (or the impersonated user, when auth is enabled) can list, Radar shows. Resource types you can't list show an actionable denied-state instead of a misleading "0 / None found": for a core cluster-scoped kind (Node, PV, StorageClass, and the like) your identity can't read, Radar shows a copyable `ClusterRole` + `ClusterRoleBinding` request to hand to a cluster admin (reason `rbac_denied`), and distinguishes that from a kind Radar's own ServiceAccount can't read, where a user-level grant wouldn't help (reason `unavailable`, no snippet). The "Your access on this cluster" dialog lists the core cluster-scoped kinds being hidden alongside your effective rules. Namespaces you can't access don't appear.

A namespace-scoped ServiceAccount (RoleBinding without a ClusterRole) is fully supported — Radar detects this at startup and works within the permitted namespace.

**RBAC granularity (auth enabled):**

- Namespaced resources (Pods, Deployments, Services, …) are filtered by namespace: read access is granted in any namespace where the user has list-pods or list-deployments. **Secrets are gated independently** — a user sees Secret objects (including Helm release storage) only in namespaces where their own RBAC grants `list secrets`, even when they have broader access in that namespace. RBAC objects and webhook configurations are likewise re-checked per user. Other cached resource types are visible within a namespace once the user has workload-read access there.
- Cluster-scoped resources (Nodes, PVs, StorageClasses, ClusterRoles, cluster-scoped CRDs, …) are gated per-kind via SubjectAccessReview. Cluster-wide pod visibility does NOT imply Node visibility — every cluster-scoped read goes through its own RBAC check, with results cached per user.

The same RBAC boundary applies to MCP — read tools intersect with each user's allowed namespaces, write tools impersonate the user against the apiserver, and cluster-scoped reads run the same per-kind SAR. The pod ServiceAccount's permissions are the upper bound for both REST and MCP; per-user RBAC narrows that to what each user can see.

**Example: Namespace-scoped deployment**

```yaml
# Custom Role granting access to a single namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: radar-viewer
  namespace: my-team
rules:
  - apiGroups: ["", "apps", "batch", "networking.k8s.io"]
    resources: ["pods", "services", "deployments", "daemonsets", "statefulsets",
                "replicasets", "jobs", "cronjobs", "configmaps", "events",
                "ingresses", "persistentvolumeclaims", "resourcequotas"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["pods/log"]
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: radar-viewer
  namespace: my-team
subjects:
  - kind: ServiceAccount
    name: radar
    namespace: radar
roleRef:
  kind: Role
  name: radar-viewer
  apiGroup: rbac.authorization.k8s.io
```

Set `rbac.create: false` in the Helm values and apply the custom Role/RoleBinding above. Radar will detect the namespace-scoped permissions and work within `my-team` only.

## Authentication

For shared team access, enable authentication so each user gets per-user permissions via Kubernetes RBAC. See the **[Authentication & Authorization Guide](authentication.md)** for full setup instructions.

**Quick start with proxy auth:**
```yaml
# values.yaml
auth:
  mode: proxy
```

Then deploy an auth proxy (e.g., oauth2-proxy) in front of Radar. Users authenticate through the proxy, and Radar uses K8s impersonation so each user's actions are governed by their own K8s RBAC bindings.

**Quick start with OIDC:**
```yaml
# values.yaml
auth:
  mode: oidc
  oidc:
    issuerURL: https://accounts.google.com
    clientID: your-client-id
    clientSecret: your-client-secret
    redirectURL: https://radar.example.com/auth/callback
```

## Security Considerations

When deploying Radar in-cluster:

The Helm chart explicitly sets `--listen-address=0.0.0.0` so its ClusterIP
Service can reach the pod. Unlike the native CLI's loopback-only default, this
makes Radar reachable on the pod network; the following controls are therefore
part of the deployment boundary.

1. **Authentication**: Always enable authentication when exposing via ingress. Use [built-in auth](authentication.md) (proxy or OIDC mode) or basic auth (shown above) at minimum.

2. **RBAC scope**: The default ClusterRole grants cluster-wide read access. For namespace-restricted access, set `rbac.create: false` and create a custom Role/RoleBinding. Radar will gracefully adapt to the available permissions.

3. **Privileged features**: Terminal (`podExec`) and port forwarding grant significant access. Only enable these in trusted environments or when using [per-user authentication](authentication.md).

4. **Network access**: Consider using NetworkPolicies to restrict which pods can reach Radar.

## Timeline Storage: memory vs sqlite

Radar's timeline records every cluster change. Two backends:

- **`memory`** (default): events live in-process, lost on pod restart. Lowest footprint; pick this if you only need recent activity (last few hours).
- **`sqlite`**: events persist to a PVC across restarts. Multi-day audit trail; pick this for long-running in-cluster deployments where you care about history surviving pod cycles.

Timeline volume depends on cluster size and controller churn. Tune `timeline.retention` (Go duration; `0` disables age cleanup), `timeline.maxSize`, and `persistence.size` together. Keep `timeline.maxSize` below the PVC size so Radar prunes oldest events before the volume fills.

Cleanup runs hourly + once at startup. Confirm it's keeping up via `/api/diagnostics` — the `timeline.maxStorageBytes`, `timeline.lastCleanupAt`, `timeline.lastCleanupDeletedRows`, `timeline.lastCleanupError`, and `timeline.storageBytes` fields surface the state without requiring `kubectl logs`.

## Configuration Reference

See [Helm Chart README](../deploy/helm/radar/README.md) for all available values.

| Parameter | Description | Default |
|-----------|-------------|---------|
| `image.repository` | Container image | `ghcr.io/skyhook-io/radar` |
| `image.tag` | Image tag | Chart appVersion |
| `ingress.enabled` | Enable ingress | `false` |
| `ingress.className` | Ingress class | `""` |
| `service.port` | Service port | `9280` |
| `basePath` | URL prefix Radar serves under, e.g. `/radar` for no-strip-prefix subpath ingress | `""` |
| `mcp.enabled` | Enable MCP server for AI tools | `true` |
| `debug.image` | Image for ephemeral debug containers and node debug pods. In built-in restricted PodSecurity namespaces, pod debug containers may retry as the target/pod non-root UID, or UID `65532` by default; point at a compatible mirror for air-gapped / private-registry clusters. | `""` (busybox:latest) |
| `listPageSize` | Paginate the initial LIST of high-cardinality kinds (Pods, ReplicaSets) on very large clusters that fail to sync; `0` = off, try `2000`. Only used when the apiserver lacks WatchList streaming. | `0` |
| `timeline.storage` | Event storage (memory/sqlite) | `memory` |
| `timeline.dbPath` | SQLite database path | `/data/timeline.db` |
| `timeline.historyLimit` | Max events to retain (memory only) | `10000` |
| `timeline.retention` | SQLite retention (Go duration; `0` disables) | `168h` |
| `timeline.maxSize` | SQLite max DB + WAL size before oldest events are pruned (`0` disables) | `800Mi` |
| `traffic.prometheusUrl` | Manual Prometheus/VictoriaMetrics URL | `""` (auto-discover) |
| `traffic.prometheusHeadersFromEnv` | Prometheus headers sourced from environment variables, for secret-backed auth headers | `{}` |
| `persistence.enabled` | Enable PVC for SQLite storage | `false` |
| `persistence.size` | PVC size | `1Gi` |
| `rbac.podLogs` | Enable log viewer | `true` |
| `rbac.podExec` | Enable terminal feature | `false` |
| `rbac.portForward` | Enable port forwarding | `false` |
| `rbac.secrets` | Show secrets in resource list | `false` |
| `rbac.helm` | Enable Helm write operations | `false` |
| `rbac.viewRBAC` | Show RBAC objects in resource browser | `false` |
| `rbac.traffic` | Read Hubble TLS certs | `true` |
| `rbac.crdGroups.all` | Wildcard CRD read access | `false` |

**Response compression:** Radar gzip-compresses HTTP responses by default (streaming endpoints like SSE are excluded). The level defaults to `1` (best speed), since on large clusters peak response size coincides with peak CPU. Set the `RADAR_COMPRESS_LEVEL` environment variable (via the chart's pod `env`) to `0` to disable, or `2`-`9` to trade CPU for smaller bodies on bandwidth-bound deployments.

## Troubleshooting

### Pod not starting

```bash
kubectl logs -n radar -l app.kubernetes.io/name=radar
kubectl describe pod -n radar -l app.kubernetes.io/name=radar
```

### Ingress not working

```bash
kubectl get ingress -n radar -o yaml
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx
```

### Basic auth prompt not appearing

Verify the secret format:
```bash
kubectl get secret radar-basic-auth -n radar -o jsonpath='{.data.auth}' | base64 -d
# Should show: username:$apr1$...
```

## Upgrading

```bash
helm repo update skyhook
helm upgrade radar skyhook/radar -n radar -f values.yaml
```

## Uninstalling

```bash
helm uninstall radar -n radar
kubectl delete namespace radar
```

---

## File: docs/INTEGRATION_GUIDE.md

# CRD Integration Guide

Step-by-step checklist for adding first-class support for a new CRD (Custom Resource Definition) to Radar. Follow this when adding integrations like Knative, Istio, Karpenter, etc.

Study an existing integration that's similar to yours before starting. Good references:
- **Simple (no collisions):** Karpenter, KEDA, Velero
- **With topology:** FluxCD, ArgoCD
- **With kind collisions:** Istio (Gateway), Knative (Service, Ingress, Certificate)

---

## Pre-Implementation: Collision Check

**Before writing any code**, check if your CRD kind names collide with core K8s kinds or other CRD integrations.

Common collisions:
| Kind | Core K8s | Other CRDs |
|------|----------|------------|
| Service | `v1` | Knative `serving.knative.dev` |
| Ingress | `networking.k8s.io` | Knative `networking.internal.knative.dev` |
| Gateway | `gateway.networking.k8s.io` | Istio `networking.istio.io` |
| Certificate | cert-manager `cert-manager.io` | Knative `networking.internal.knative.dev` |
| Configuration | — | Knative `serving.knative.dev` |
| Route | — | Knative `serving.knative.dev`, OpenShift |
| Broker | — | Knative `eventing.knative.dev` |
| Channel | — | Knative `messaging.knative.dev` |
| Backup | Velero `velero.io` | CloudNativePG `cnpg.io` |
| Cluster | — | CAPI `cluster.x-k8s.io`, CloudNativePG `postgresql.cnpg.io` |
| Machine | — | CAPI `cluster.x-k8s.io` |
| MachineSet | — | CAPI `cluster.x-k8s.io` |

If your kind collides, you need group-qualified handling throughout (marked with  below).

---

## Checklist

### 1. Backend: CRD Warmup

**File:** `internal/k8s/dynamic_cache.go` — `WarmupCommonCRDs()`

Add your CRD kinds to the warmup list so they're cached immediately on startup.

- **No collision:** Add kind name to `commonCRDs` slice
- ** Collision:** Add group-qualified warmup after the generic loop:
  ```go
  if gvr, ok := discovery.GetGVRWithGroup("Service", "serving.knative.dev"); ok {
      gvrs = append(gvrs, gvr)
      log.Printf("Warming up CRD: Service (serving.knative.dev)")
  }
  ```

### 2. Backend: Topology Types

**File:** `pkg/topology/types.go`

Add `NodeKind` constants for each resource that will appear in topology:
```go
KindKnativeService NodeKind = "KnativeService"
```

- ** Collision:** Use a prefixed kind name (e.g., `KnativeService` not `Service`)

### 3. Backend: Topology Builder

**File:** `pkg/topology/builder.go` — `buildResourcesTopology()`

Add a new section that:
1. **Lists resources** from the dynamic cache (handle errors, don't discard with `_`)
2. **Creates nodes** with health derived from `status.conditions`
3. **Creates edges** between your resources and to/from core K8s resources
4. **Adds kinds to `processedKinds`** to prevent duplicate generic CRD nodes

Edge type semantics (choose carefully):
| Edge Type | Meaning | Example |
|-----------|---------|---------|
| `EdgeManages` | Owner relationship | Deployment → ReplicaSet |
| `EdgeExposes` | Network exposure (UI: "Services" group) | Service → Pod, Route → Revision |
| `EdgeConfigures` | Configuration | ConfigMap → Deployment, DestinationRule → Service |
| `EdgeUses` | Scaling relationship | HPA → Deployment |
| `EdgeProtects` | Policy protection | PDB → Deployment |

**Performance tip:** If you need the same resource list for both node creation and edge creation, store it in a slice during phase 1 and reuse in phase 2. Don't re-fetch.

**File:** `pkg/topology/relationships.go`

Add your kinds to `buildNodeID` and `normalizeKind` maps.
- ** Collision:** Use a unique ID prefix (e.g., `knativeservice/`, `istiogateway/`)

### 4. Frontend: Group Name Mapping

**File:** `packages/k8s-ui/src/utils/api-resources.ts`

Map API group to display group name:
```typescript
'serving.knative.dev': 'Knative',
'eventing.knative.dev': 'Knative',
```

### 5. Frontend: Resource Utils

**Create:** `packages/k8s-ui/src/components/resources/resource-utils-{integration}.ts`

Status extraction functions. Most CRDs use the standard `status.conditions` pattern:
```typescript
export function getMyResourceStatus(data: any): { text: string; color: string } {
  const conditions = data?.status?.conditions || []
  const ready = conditions.find((c: any) => c.type === 'Ready')
  // ...
}
```

### 6. Frontend: Table Columns

**File:** `packages/k8s-ui/src/components/resources/ResourcesView.tsx` — `KNOWN_COLUMNS`

Add column definitions. The key is the **lowercase plural** of the kind.

- ** Collision:** Add entry to `GROUP_QUALIFIED_COLUMN_KEYS`:
  ```typescript
  const GROUP_QUALIFIED_COLUMN_KEYS: Record<string, Record<string, string>> = {
    services: { 'serving.knative.dev': 'knativeservices' },
    ingresses: { 'networking.internal.knative.dev': 'knativeingresses' },
  }
  ```

Also verify `normalizeKindToPlural()` handles your kind correctly (watch out for kinds ending in 's', 'sh', 'ch', 'x', 'z').

### 7. Frontend: Cell Renderers

**Create:** `packages/k8s-ui/src/components/resources/renderers/{integration}-cells.tsx`

Cell components that render rich table cells (status badges, links, etc.).

**File:** `packages/k8s-ui/src/components/resources/ResourcesView.tsx` — `CellContent` component

Add cases for your kinds.
- ** Collision:** Use `apiVersion` checks:
  ```typescript
  if (kind === 'services' && resource.apiVersion?.includes('serving.knative.dev')) {
    return <KnativeServiceCell ... />
  }
  ```

### 8. Frontend: Detail Renderers

**Create:** `packages/k8s-ui/src/components/resources/renderers/{ResourceName}Renderer.tsx`

Follow existing patterns: `AlertBanner` for problems, `Section` components, `PropertyList`, `ConditionsSection`.

**File:** `packages/k8s-ui/src/components/resources/renderers/index.ts` — export new renderers

**File:** `packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx`

Three wiring points (all must be updated):

#### a. `KNOWN_KINDS` set
Add your kinds so the dispatch shows the custom renderer instead of generic YAML.

#### b. Render lines
Add conditional render for each kind:
```tsx
{kind === 'nodepools' && <NodePoolRenderer data={data} />}
```

** COLLISION GUARD (critical):** If your kind collides with a core kind, you must ALSO guard the existing core renderer:
```tsx
// Guard core renderer — exclude when it's actually a Knative Service
{kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceRenderer ... />}

// Add Knative renderer with positive check
{(kind === 'services' && data?.apiVersion?.includes('serving.knative.dev')) && <KnativeServiceRenderer ... />}
```

This applies to the renderer AND to any action buttons (e.g., Port Forward button should not show for KNative Services).

#### c. `getResourceStatus()` function
Add your status function. ** COLLISION GUARD:** Insert apiVersion check INSIDE the existing kind's block, BEFORE the core status function:
```typescript
if (k === 'services') {
  if (data.apiVersion?.includes('serving.knative.dev')) {
    return getKnativeServiceStatus(data)  // Must come first!
  }
  return getServiceStatus(data)  // Core fallback
}
```

### 9. Frontend: Topology UI

Update these files to support new topology node kinds:

| File | What to Add |
|------|-------------|
| `packages/k8s-ui/src/types/core.ts` | Kind to `CoreNodeKind` type union + `displayKind` map |
| `web/src/App.tsx` | Kind to `ALL_NODE_KINDS` array |
| `packages/k8s-ui/src/utils/resource-icons.ts` | Icon mapping |
| `packages/k8s-ui/src/utils/badge-colors.ts` | Badge CSS class |
| `packages/k8s-ui/src/components/topology/TopologyFilterSidebar.tsx` | Filter sidebar entry |
| `packages/k8s-ui/src/components/topology/K8sResourceNode.tsx` | Node dimensions |
| `packages/k8s-ui/src/components/topology/layout.ts` | `kindPriority` entry |
| `packages/k8s-ui/src/utils/resource-hierarchy.ts` | `appLabelEligibleKinds` (if groupable by app label) |
| `packages/k8s-ui/src/components/topology/topology.css` | `.topology-icon-{kind}` CSS class with color |

### 10. Documentation

| File | What to Update |
|------|----------------|
| `docs/integrations.md` | Full integration section (follow existing pattern) |
| `README.md` | Add to "Supported Resources" table |
| `CLAUDE.md` | Add to renderers list and topology builder section |

---

## Verification Checklist

After implementation, verify:

- [ ] `npm run tsc` — no TypeScript errors
- [ ] `go test ./...` — no Go test failures
- [ ] `make build` — full build succeeds
- [ ] Resource table shows custom columns (not generic)
- [ ] Detail drawer shows custom renderer (not generic YAML)
- [ ] ** If collisions:** Core kind still renders correctly (e.g., core K8s Service still shows Ports/Selector, not KNative sections)
- [ ] ** If collisions:** `getResourceStatus()` returns correct status for both core and CRD kinds
- [ ] ** If collisions:** Action buttons (Port Forward, etc.) only show for appropriate kinds
- [ ] Topology shows nodes with correct icons and edges
- [ ] No regressions on existing resource types

---

## Common Gotchas

1. **Collision guards need THREE places:** renderer line, `getResourceStatus()`, AND action buttons. Missing any one causes bugs.

2. **`normalizeKindToPlural`** doesn't handle all pluralization. Kinds ending in 's' (like "Ingress") need the `+'es'` path. Test with your kind names.

3. **`processedKinds` exclusion** in builder.go prevents your resources from appearing twice (once as custom topology nodes, once as generic CRD nodes). If you add topology nodes, add the kind to `processedKinds`.

4. **Dynamic cache errors** from `dynamicCache.List()` should be logged, not discarded with `_`. Silent failures are hard to debug.

5. **Two-phase topology pattern:** Store resources in slices during node creation (phase 1), reuse during edge creation (phase 2). Don't call `dynamicCache.List()` twice for the same kind.

6. **Edge type choice matters:** Edge types drive the "Related Resources" grouping in the detail drawer. `EdgeExposes` creates a "Services" group, `EdgeManages` creates "Children", `EdgeConfigures` creates "Configuration", etc. Choose semantically, not by code convenience.

7. **CSS topology icon classes** must use the lowercase kind name: `.topology-icon-knativeservice`, not `.topology-icon-KnativeService`.