File: README.md
Radar
The missing open-source Kubernetes UI.
Single binary. No account required. Free forever.
🌐 radarhq.io · Docs · Releases
Topology, resources, Helm, GitOps, traffic, audit, upgrade impact, and MCP context for AI agents — from your laptop or in-cluster.
Table of contents
- Why Radar?
- Installation
- Usage
- Views — Topology · Resources · Image Filesystem · Timeline · Helm · Compare · TLS · GitOps · Traffic · Cost · Audit · Upgrade impact · RBAC · MCP · Auth
- Supported Resources
- Keyboard Shortcuts
- Security
- Development · Contributing
Install and run in 30 seconds:
curl -fsSL https://get.radarhq.io | sh && kubectl radarWhy 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 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:
curl -fsSL https://get.radarhq.io | shHomebrew:
brew install skyhook-io/tap/radarThen 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):
kubectl krew install radarScoop (Windows):
scoop bucket add skyhook https://github.com/skyhook-io/scoop-bucket
scoop install radarPowerShell (Windows):
irm https://get.radarhq.io/install.ps1 | iexDirect download — GitHub Releases for macOS, Linux, or Windows.
Desktop App
Native desktop app — no terminal needed.
Homebrew (macOS):
brew install --cask skyhook-io/tap/radar-desktopDebian/Ubuntu — download the .deb from GitHub Releases, then:
sudo apt install ./radar-desktop_*.debFedora/RHEL — download the .rpm from GitHub Releases, then:
sudo rpm -i radar-desktop_*.rpmScoop (Windows):
scoop bucket add skyhook https://github.com/skyhook-io/scoop-bucket
scoop install radar-desktopWindows (direct download) — GitHub Releases.
In-Cluster Deployment
Deploy to your cluster for shared team access:
helm repo add skyhook https://skyhook-io.github.io/helm-charts
helm install radar skyhook/radar -n radar --create-namespaceSee the In-Cluster Deployment Guide for ingress, authentication, and RBAC configuration.
Gateway API HTTPRoute
Chart supports optional Gateway API HTTPRoute generation. EnablehttpRoute.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.
httpRoute:
enabled: true
parentRefs:
- name: public-gateway
hostnames: []
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: radar
port: 9280See the Helm Chart README
for full configuration details and timeout examples.
Usage
# Opens browser automatically
kubectl radar
# Quick install, PowerShell, Homebrew, and Scoop also set up the bare command
radarTo inspect an in-cluster Radar Cloud installation without changing it:
radar cloud status
radar cloud status --context my-cluster
radar cloud status --context my-cluster --namespace radar --release radarThe 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. 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) |
--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 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:
# 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 radarDefaults 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 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
Comparebutton 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
statusfields 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 —
Terminatingchip replaces stale Sync/Health badges; severity ramps with deletion age; mutating ops refuse on zombies - Cross-linked from the rest of Radar —
Managed bychip in resource drawers, GitOps routing from Topology + Timeline + Helm view,Consumed bypanel on Flux source CRs - MCP integration —
manage_gitopsexposes sync / suspend / resume / reconcile / rollback with lifecycle-aware refusal
See the GitOps guide 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
networkfeature enabled, and per-port edges additionally needdst.portandtransportnamed inattributes.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 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
diagnosetool for AI consumers) - see 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
gitRepovolume 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
externalIPsremains 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 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
SelfSubjectRulesReviewfor the current user — for fast "why can't I do X" debugging - MCP:
get_subject_permissionstool 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.
AI Integration (MCP)
Radar includes a built-in Model Context Protocol (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 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 for setup.
Supported Resources
Radar auto-discovers any CRD in your cluster. Popular tools get dedicated integrations 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 [email protected] — see SECURITY.md for the process and response timelines.
Development
See the Development Guide for building from source, architecture details, API reference, and contributing.
Quick start:
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-backendContributing
Contributions are welcome! Please read our Contributing Guide for details on the development workflow, pull request process, and coding standards.
Questions or ideas? GitHub Discussions is the place — or come say hi at radarhq.io/community.
About
Radar is built and maintained by Skyhook (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.
License
Apache 2.0 — see 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-chartsis auto-synced by thehelmjob in.github/workflows/release.ymlon every Radar release — it overwritescharts/radar/there with the contents of this directory. Do not edithelm-charts/charts/radar/by hand; changes will be clobbered on the next release.
See also: In-Cluster Deployment Guide for ingress and DNS setup.
Prerequisites
- Kubernetes 1.21+
- Helm 3.0+
Installation
Quick Start
helm repo add skyhook https://skyhook-io.github.io/helm-charts
helm repo update skyhook
helm upgrade --install radar skyhook/radar -n radar --create-namespaceAccess via port-forward:
kubectl port-forward svc/radar 9280:9280 -n radar
open http://localhost:9280With Ingress
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=PrefixWith TLS
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.comWith 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. SethttpRoute.defaultTimeout to add an explicit request timeout, or set it to0s to explicitly disable timeout. Set rules for multiple prefixes,
timeouts, filters, or custom backends; supplied rules replace that default and
pass through unchanged.
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: 9280Override 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 — it generates the full command with your
cluster's bearer token. The wizard's command follows this shape:
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-configThe 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:
# 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-tokenLeave 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.
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.
--set rbac.selfUpgrade=trueCRD 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:
--set rbac.crdGroups.all=trueThis 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:
rbac:
additionalCrdGroups:
- mycompany.ioTroubleshooting: "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:
rbac:
additionalRules:
- apiGroups: ["custom.example.com"]
resources: ["myresources"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["delete"] # Dangerous - use with cautionCapability 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
helm uninstall radar -n radar
kubectl delete namespace radarFile: 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:
- 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. - Prefer declaration over inference. Before adding a heuristic, ask "can the
user just say it?" Theapp.skyhook.io/appannotation 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. - 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 onlypart-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)Composition —
structuralRoot(topology graph) +pkg/subjectoverlay (per
cluster). Climbs each workload's ownership to its release unit (the GitOps/
Helm manager that deploys it) and stops there (§2). Thepkg/subjectoverlay
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).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)
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 bareapp 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/appannotation. 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, fromapps/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 (literallyportable:${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):
backendandfrontend, each its own Argo Application, same repo, both
labeledpart-of: shop. → Each climbs to its own Argo App and stops →
two apps, not one "shop." The sharedpart-of(tier 7) never overrides the
per-Application release boundary (tier 3). This is the anti-umbrella rule. ✓
Identity (same app across envs/clusters):
billingdeployed dev + prod via the same Argo pathapps/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. ✓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 — tworedisrows. ✓
But if both weredefault/redis(samens/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:
addapp.skyhook.io/app.corednsinkube-systemon 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/redismerge 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 +pathKeywhen co-located, but on the path stem (nopathKey)
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 likeloadtestis
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)- Authentication identifies the user (proxy headers or OIDC login)
- Reads are filtered by namespace — Radar discovers which namespaces the user can access via
SubjectAccessReviewand only returns resources from those namespaces. Cluster-scoped resources (Nodes, PersistentVolumes, StorageClasses, ClusterRoles whenrbac.viewRBACis on, cluster-scoped CRDs, etc.) have no namespace to filter on, so they are gated per-kind viaSubjectAccessReview: 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. - Writes use K8s impersonation — Radar makes the K8s API call as the authenticated user, so K8s RBAC decides whether it's allowed
- 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 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, 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: [email protected]
sets X-Forwarded-Groups: sre-team,platform-engHelm values:
auth:
mode: proxy
# Optional: customize header names (these are the defaults)
# proxy:
# userHeader: X-Forwarded-User
# groupsHeader: X-Forwarded-GroupsCLI flags:
radar --auth-mode=proxy \
--auth-user-header=X-Forwarded-User \
--auth-groups-header=X-Forwarded-GroupsSecurity: Your ingress must strip
X-Forwarded-UserandX-Forwarded-Groupsheaders 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 cookieHelm values:
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:
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/callbackIf 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:
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) 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 logoutTo 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) 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.
auth:
mode: oidc
oidc:
issuerURL: https://your-idp.example.com
clientID: radar
backchannelLogout: trueCheck 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).
sidrequired for targeted revocation. If the IdP's logout_token contains onlysub(nosid), 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) includesid.- 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):
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/callbackYou 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:
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 — 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=proxywith pinnedX-Forwarded-User/X-Forwarded-Groupsheaders. 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:viewergroups to the standard K8sadmin/edit/viewClusterRoles. Configurable viacloud.defaultRbac.*invalues.yaml. - Adds a cluster-read add-on (
cloud.defaultRbac.clusterScopedRead.{viewer,member,owner}, each default on) grantingget/list/watchon infrastructure the built-inview/edit/adminroles 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 tierfalseto 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.
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 toradar:ownerANDradar:member.radar-helm-admin— RBAC objects (Roles/Bindings, Cluster variants), validating/mutating webhooks, ApiServices. Bound toradar:ownerONLY. Granting these to a tier weaker than owner would let a member self-promote to cluster-admin in oneClusterRoleBindingwrite, 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. The authoritative reference for the Cloud-mode chart values is the comment block in 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-authConfigMap 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: [email protected], groups=[sre-team]
Auth Layer (proxy headers or OIDC token)
→ Radar extracts: user="[email protected]", groups=["sre-team"]
Radar backend
→ creates impersonated K8s client: act as [email protected] in group sre-team
K8s API server
→ checks RBAC: does any RoleBinding/ClusterRoleBinding grant "sre-team" this permission?
→ allow or denyK8s RBAC doesn't require users to have ServiceAccounts. It supports binding roles to User (a string like [email protected]) 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. |
# 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.ioFor more control, create a custom ClusterRole:
Step 1: Create a ClusterRole with the permissions you want
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):
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.ioBy user:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: alice-radar-operator
subjects:
- kind: User
name: [email protected] # Must match the username from IdP
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: radar-operator
apiGroup: rbac.authorization.k8s.ioPer-namespace (use RoleBinding instead of ClusterRoleBinding):
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.ioStep 3: Verify
After deploying with auth enabled, you can verify impersonation works:
# Check what alice can do (from a machine with cluster-admin access)
kubectl auth can-i --list [email protected] --as-group=sre-team
# Check specific permission
kubectl auth can-i get pods -n production [email protected] --as-group=sre-teamWhat 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 secretsin the storage namespace — and the K8s built-inviewrole 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):
# 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-ttlorauth.cookieTTLin 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.secretorRADAR_AUTH_SECRETenv var. If empty, a random key is generated at startup (sessions won't survive pod restarts) - For production: Use
auth.existingSecretto 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:
- Check the startup logs. Look for
[oidc] RP-Initiated Logout enabledor theprompt=loginfallback message. Both approaches should prevent silent re-authentication. - Is
post_logout_redirect_uriregistered? 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. - 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:bashkubectl 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:
# 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:
kubectl auth can-i create subjectaccessreviews \
--as=system:serviceaccount:radar:radarIf 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:
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:
kubectl auth can-i impersonate users \
--as=system:serviceaccount:radar:radarIf 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.
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:
radar --listen-address=0.0.0.0An 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.
{
"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.
{
"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
# Inside a pod, connect to a different cluster
export KUBECONFIG=/path/to/other-cluster.yaml
kubectl radarThis behavior matches kubectl and follows the Kubernetes client-go precedence rules.
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:
export KUBECONFIG=~/.kube/config:~/.kube/staging-config:~/.kube/prod-config
kubectl radarAlternatively, use --kubeconfig-dir to load all kubeconfig files from a directory:
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:
kubectl radar --namespaces ns1,ns2,ns3Radar 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 —
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 — CLI flags and basic usage
- In-Cluster Deployment — Deploy Radar inside your cluster with Helm
- Authentication & Authorization — 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-configurationannotation vs live cluster state. You see actualspec.X removed / spec.Y addedentries — noargocd app diffround-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 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 toPending 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 Nagoinstead of "Last Sync" - Topology: orange left-stripe on the root + children; stale sync/health chips suppressed
- Cluster Audit:
stuckTerminatingcheck 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) returnErrResourceTerminating(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 thekubectl.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 everyignoreDifferences 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):
- Create a get-only local account in
argocd-cm(accounts.radar: apiKey)
and grant itp, role:radar, applications, get, */*, allowinargocd-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. - Paste the token in Settings. Leave the URL empty for in-cluster
auto-discovery (theargocd-serverService, 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/argocdtoken — 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 + agetSubjectAccessReview
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/stringDatavalues
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(written0600) and is redacted
fromGET /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 intoscripts/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 anemptyDir 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— explicitargocd-serverURL; omit for in-cluster
auto-discovery. Must behttp(s), with no embedded userinfo.RADAR_ARGOCD_INSECURE_TLS—trueto 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:
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: falseScoping 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. PreferRADAR_ARGOCD_URLto 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 remotespec.destinationfall 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 genericapp.kubernetes.io/instancelabel 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 aFluxbadge in the list and an amberManaged by Flux · ns/namelink in the drawer, warning thathelm upgradewould be reverted at the next reconcile - Flux source CR drawers —
GitRepository,HelmRepository,OCIRepository,Bucketdrawers carry aConsumed bypanel listing every Kustomization + HelmRelease whosespec.sourceRefpoints 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 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.
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 installedFixtures 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 — how AI agents drive GitOps operations and read lifecycle signal
- Integrations & CRDs — full CRD support matrix for Argo CD, Flux, and everything else
- Configuration — 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
KUBECONFIGto override in-cluster detection), see the Configuration Guide.
Quick Start
helm repo add skyhook https://skyhook-io.github.io/helm-charts
helm repo update skyhook
helm upgrade --install radar skyhook/radar -n radar --create-namespaceAccess via port-forward:
kubectl port-forward svc/radar 9280:9280 -n radar
open http://localhost:9280Exposing with Ingress
Basic (No Authentication)
# values.yaml
ingress:
enabled: true
className: nginx
hosts:
- host: radar.your-domain.com
paths:
- path: /
pathType: Prefixhelm upgrade --install radar skyhook/radar \
-n radar -f values.yamlSubpath Ingress (No Strip-Prefix)
If your ingress forwards /radar/... to the Radar service as /radar/..., set basePath to the same prefix:
# values.yaml
basePath: /radar
ingress:
enabled: true
className: nginx
hosts:
- host: tools.your-domain.com
paths:
- path: /radar
pathType: PrefixThen 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:
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-logoutRadar'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 is for, and its agent dials out so there is no
per-cluster ingress to wire up.
With Basic Authentication
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 fileConfigure 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: PrefixDeploy:
bashhelm upgrade --install radar skyhook/radar \ -n radar -f values.yaml
With TLS (HTTPS)
Requires cert-manager installed in your cluster.
# 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.comDNS Setup
Get your ingress IP:
bashkubectl get ingress -n radarCreate 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 |
| 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
patchon nodes,liston pods, andcreateonpods/eviction, which are not included in the default ClusterRole. Add them viarbac.additionalRulesor use per-user authentication so each user's own RBAC governs node operations.
Enable features as needed:
# 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:
# values.yaml
debug:
image: my-registry.internal/busybox:1.36Radar 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:
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 rulesCalico 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
# 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.ioSet 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 for full setup instructions.
Quick start with proxy auth:
# values.yaml
auth:
mode: proxyThen 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:
# 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/callbackSecurity 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.
Authentication: Always enable authentication when exposing via ingress. Use built-in auth (proxy or OIDC mode) or basic auth (shown above) at minimum.
RBAC scope: The default ClusterRole grants cluster-wide read access. For namespace-restricted access, set
rbac.create: falseand create a custom Role/RoleBinding. Radar will gracefully adapt to the available permissions.Privileged features: Terminal (
podExec) and port forwarding grant significant access. Only enable these in trusted environments or when using per-user authentication.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 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
kubectl logs -n radar -l app.kubernetes.io/name=radar
kubectl describe pod -n radar -l app.kubernetes.io/name=radarIngress not working
kubectl get ingress -n radar -o yaml
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginxBasic auth prompt not appearing
Verify the secret format:
kubectl get secret radar-basic-auth -n radar -o jsonpath='{.data.auth}' | base64 -d
# Should show: username:$apr1$...Upgrading
helm repo update skyhook
helm upgrade radar skyhook/radar -n radar -f values.yamlUninstalling
helm uninstall radar -n radar
kubectl delete namespace radarFile: 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
commonCRDsslice - ** 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:
KindKnativeService NodeKind = "KnativeService"- ** Collision:** Use a prefixed kind name (e.g.,
KnativeServicenotService)
3. Backend: Topology Builder
File: pkg/topology/builder.go — buildResourcesTopology()
Add a new section that:
- Lists resources from the dynamic cache (handle errors, don't discard with
_) - Creates nodes with health derived from
status.conditions - Creates edges between your resources and to/from core K8s resources
- Adds kinds to
processedKindsto 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:
'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:
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:typescriptconst 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
apiVersionchecks:typescriptif (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:
{kind === 'nodepools' && <NodePoolRenderer data={data} />}** COLLISION GUARD (critical):** If your kind collides with a core kind, you must ALSO guard the existing core renderer:
// 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:
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
Collision guards need THREE places: renderer line,
getResourceStatus(), AND action buttons. Missing any one causes bugs.normalizeKindToPluraldoesn't handle all pluralization. Kinds ending in 's' (like "Ingress") need the+'es'path. Test with your kind names.processedKindsexclusion 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 toprocessedKinds.Dynamic cache errors from
dynamicCache.List()should be logged, not discarded with_. Silent failures are hard to debug.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.Edge type choice matters: Edge types drive the "Related Resources" grouping in the detail drawer.
EdgeExposescreates a "Services" group,EdgeManagescreates "Children",EdgeConfigurescreates "Configuration", etc. Choose semantically, not by code convenience.CSS topology icon classes must use the lowercase kind name:
.topology-icon-knativeservice, not.topology-icon-KnativeService.