radar Documentation

The missing open source Kubernetes UI. Topology, event timeline, and service traffic — plus resource browsing and Helm management.

RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy radar on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name radar -p 9280:9280 --restart always skyhook-io/radar:latest

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

Install and run in 30 seconds:

bash
curl -fsSL https://get.radarhq.io | sh && kubectl radar

More installation options ↓

Why Radar?

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

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


Installation

Quick Install:

bash
curl -fsSL https://get.radarhq.io | sh

Homebrew:

bash
brew install skyhook-io/tap/radar

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

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

CLI

Krew (kubectl plugin manager):

bash
kubectl krew install radar

Scoop (Windows):

powershell
scoop bucket add skyhook https://github.com/skyhook-io/scoop-bucket
scoop install radar

PowerShell (Windows):

powershell
irm https://get.radarhq.io/install.ps1 | iex

Direct download — GitHub Releases for macOS, Linux, or Windows.

Desktop App

Native desktop app — no terminal needed.

Homebrew (macOS):

bash
brew install --cask skyhook-io/tap/radar-desktop

Debian/Ubuntu — download the .deb from GitHub Releases, then:

bash
sudo apt install ./radar-desktop_*.deb

Fedora/RHEL — download the .rpm from GitHub Releases, then:

bash
sudo rpm -i radar-desktop_*.rpm

Scoop (Windows):

powershell
scoop bucket add skyhook https://github.com/skyhook-io/scoop-bucket
scoop install radar-desktop

Windows (direct download) — GitHub Releases.

In-Cluster Deployment

Deploy to your cluster for shared team access:

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

See the In-Cluster Deployment Guide for ingress, authentication, and RBAC configuration.

Gateway API HTTPRoute

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

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

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

See the Helm Chart README
for full configuration details and timeout examples.


Usage

bash
# Opens browser automatically
kubectl radar

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

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

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

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

CLI Flags

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

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

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

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


Views

Topology

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

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

Resources

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

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

Image Filesystem Viewer

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

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

Timeline

Unified timeline of Kubernetes events and resource changes.

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

Helm

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

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

Compare Resources

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

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

TLS Certificate Management

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

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

GitOps

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

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

See the GitOps guide for the full feature matrix, RBAC requirements, demo cluster, and single-cluster scope notes.

Traffic

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

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

Capacity (Karpenter)

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

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

See docs/capacity.md for the full reference.

Cost Insights

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

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

Cluster Audit

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

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

Network Path Diagnose

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

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

Kubernetes Upgrade Impact

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

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

See the Kubernetes upgrade impact guide for the check catalog, coverage semantics, and RBAC notes.

Access Control (RBAC visibility)

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

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

Read-only visibility ships first; the considered follow-ups (RBAC audit checks, verb × resource matrix, subject explorer, graph view, in-UI edits, "can-i" queries) are tracked in #1090.

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:

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

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

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

Contributing

Contributions are welcome! Please read our Contributing Guide 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-charts is auto-synced by the helm job in .github/workflows/release.yml on every Radar release — it overwrites charts/radar/ there with the contents of this directory. Do not edit helm-charts/charts/radar/ by hand; changes will be clobbered on the next release.

See also: In-Cluster Deployment Guide for ingress and DNS setup.

Prerequisites

  • Kubernetes 1.21+
  • Helm 3.0+

Installation

Quick Start

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

Access via port-forward:

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

With Ingress

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

With TLS

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

With Gateway API HTTPRoute

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

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

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

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

Connecting to Radar Cloud

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

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

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

Connecting to Argo CD (GitOps deep diff)

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

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

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

Configuration

Parameter Description Default
replicaCount Number of replicas 1
image.repository Image repository ghcr.io/skyhook-io/radar
image.tag Image tag Chart appVersion
service.type Service type ClusterIP
service.port Service port 9280
basePath URL prefix Radar serves under, e.g. /radar for no-strip-prefix subpath ingress ""
debug.image Image for ephemeral debug containers and node debug pods. In built-in restricted PodSecurity namespaces, pod debug containers may retry as the target/pod non-root UID, or UID 65532 by default; point at a compatible mirror for air-gapped / private-registry clusters. "" (busybox:latest)
listPageSize Paginate the initial LIST of high-cardinality kinds (Pods, ReplicaSets) on very large clusters; 0 = off, try 2000. Only used when the apiserver lacks WatchList streaming. 0
ingress.enabled Enable ingress false
ingress.className Ingress class name ""
httpRoute.enabled Enable Gateway API HTTPRoute false
httpRoute.apiVersion HTTPRoute API version override gateway.networking.k8s.io/v1
httpRoute.hostnames HTTPRoute hostnames []
httpRoute.defaultTimeout Optional request timeout for generated default rule; empty uses Gateway deployment default ""
httpRoute.rules HTTPRoute rules, passed through unchanged []
timeline.storage Timeline storage (memory/sqlite) memory
timeline.retention SQLite retention (Go duration; 0 disables) 168h
timeline.maxSize SQLite max DB + WAL size before oldest events are pruned (0 disables) 800Mi
persistence.enabled Enable PVC for SQLite false
traffic.prometheusUrl Manual Prometheus/VictoriaMetrics URL (skips auto-discovery) ""
traffic.prometheusHeaders HTTP headers sent with every Prometheus request (auth-protected backends) {}
traffic.prometheusHeadersFromEnv Prometheus headers sourced from environment variables, for secret-backed auth headers {}
argocd.existingSecret Name of a Secret holding the Argo CD API token (recommended — keeps it out of the release) ""
argocd.existingSecretKey Key within argocd.existingSecret holding the token token
argocd.token Inline Argo CD API token (dev only — lands in the release state) ""
argocd.url Explicit argocd-server URL; blank auto-discovers in-cluster ""
argocd.insecureTls Skip TLS verification for a self-signed argocd-server false
resources.limits.memory Memory limit 512Mi
resources.requests.memory Memory request 128Mi

See values.yaml for all configuration options.

Timeline storage: memory vs sqlite

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

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

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

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

RBAC

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

Default Permissions (Core K8s Resources)

Always granted (required for basic functionality):

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

Privileged Permissions (Opt-in)

Disabled by default for security:

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

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

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

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

bash
--set rbac.selfUpgrade=true

CRD Access

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

Wildcard option: Grant read access to ALL CRDs with one setting:

bash
--set rbac.crdGroups.all=true

This overrides individual settings below. Simpler but broader — some orgs may not allow this.

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

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

Add unlisted CRDs:

yaml
rbac:
  additionalCrdGroups:
    - mycompany.io

Troubleshooting: "Failed to list resource" Warnings

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

Advanced: Custom Rules

For fine-grained control, use additionalRules to add arbitrary RBAC rules:

yaml
rbac:
  additionalRules:
    - apiGroups: ["custom.example.com"]
      resources: ["myresources"]
      verbs: ["get", "list", "watch"]
    - apiGroups: [""]
      resources: ["pods"]
      verbs: ["delete"]  # Dangerous - use with caution

Capability Detection

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

Uninstalling

bash
helm uninstall radar -n radar
kubectl delete namespace radar

File: docs/app-grouping.md

Application grouping — the mental model

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


0. The complexity budget — read this before adding anything

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

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

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


1. Why this is hard

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


2. Two questions, two levels — keep them separate

Almost all confusion comes from conflating two genuinely different problems:

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

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

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

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

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

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

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

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


3. The pipeline — three layers

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

text
workload ──▶ [1] structuralRoot + ──▶ [2] applications_identity ──▶ [3] hub + fleet fold
                  pkg/subject            app-instances → logical        logical apps →
              workload → release unit    app + env (identity,           one fleet row
              (composition)              per cluster)                   (identity, x-cluster)
  1. Composition — structuralRoot (topology graph) + pkg/subject overlay (per
    cluster).
    Climbs each workload's ownership to its release unit (the GitOps/
    Helm manager that deploys it) and stops there (§2). The pkg/subject overlay
    then consolidates roots the graph can't connect — hub-spoke Argo (controller
    in another cluster), native-Helm release annotations — by reading the workload's
    labels via a precedence cascade (§4). Output: every workload tagged with an
    overlay key (which release unit / app it belongs to) and a tier (how
    strong that signal is).

  2. applications_identity.go (per cluster, OSS) — identity, within a cluster.
    Takes the per-cluster app rows and decides which are env-variants of one
    logical app
    (billing-dev, billing-staging → "billing" spanning dev +
    staging). It attaches an identity to each row:

    • key — the logical app name (the display name of the group)
    • env — this instance's environment (dev | staging | prod | …)
    • source — how we concluded it (the provenance tier; see §5)
    • portable — may this fold across clusters? (see §6)
  3. Hub + fleet fold (cross cluster) — identity, across clusters. The hub gathers
    every cluster's rows and merges them in two stages (see §6): first it merges
    any rows sharing the same overlay key (app.Key) into one fleet row — a
    structural join that can span clusters even for non-declared apps; then the
    fleet fold (foldAppGroups, a shared k8s-ui util that Radar Cloud renders)
    collapses the remaining differently-keyed rows that share a portable
    identity. Non-portable, differently-keyed rows stay per-cluster.


4. The composition cascade (the 9 tiers)

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

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

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

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


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

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

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

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

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

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

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

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

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


7. Two subtleties that trip people up

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

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


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

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

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

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


9. The hub-spoke twist (centralized Argo)

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


10. Worked examples

Composition (what is one app):

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

Identity (same app across envs/clusters):

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

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

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


11. Where the code lives

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

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


12. Known gaps / open questions

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

File: docs/authentication.md

Authentication & Authorization

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

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

How It Works

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

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

Read granularity: namespace-level

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

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

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

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

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

text
Browser → Auth Proxy → Radar
           sets X-Forwarded-User: [email protected]
           sets X-Forwarded-Groups: sre-team,platform-eng

Helm values:

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

CLI flags:

bash
radar --auth-mode=proxy \
      --auth-user-header=X-Forwarded-User \
      --auth-groups-header=X-Forwarded-Groups

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

Logout behavior:

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

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

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

OIDC Mode

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

Flow:

text
Browser → Radar → redirects to IdP → user logs in → callback → session cookie

Helm values:

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

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

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

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

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

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

Logout behavior:

When a user clicks logout, Radar clears the local session cookie and — if the identity provider supports it — redirects the browser to the provider's logout endpoint (RP-Initiated Logout) 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:

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

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

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

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

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

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

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

Check the startup logs to confirm IdP support:

text
[oidc] Backchannel Logout enabled (sid-based revocation)
# or
[oidc] WARNING: --auth-oidc-backchannel-logout is set, but IdP does not advertise backchannel_logout_supported.

Limitations:

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

Using K8s Secrets for sensitive values:

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

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

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

TLS configuration for self-signed certificates:

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

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

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

Radar Cloud mode

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

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

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


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

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

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

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

Customer-facing documentation for Radar Cloud lives on radarhq.io. 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-auth ConfigMap maps IAM roles/users to K8s users and groups. The username is typically the IAM role ARN or a mapped alias.
  • AKS: Azure AD integration maps Azure AD groups directly to K8s groups. The username is the Azure AD user principal name.

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

Understanding the Chain

text
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 deny

K8s 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.
yaml
# Example: give the dev-team group read-only access to all namespaces
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: dev-team-view
subjects:
  - kind: Group
    name: dev-team
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: view
  apiGroup: rbac.authorization.k8s.io

For more control, create a custom ClusterRole:

Step 1: Create a ClusterRole with the permissions you want

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

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

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

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

Step 2: Bind it to your users or groups

By group (recommended — matches group from your identity provider):

yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: sre-team-radar-operator
subjects:
  - kind: Group
    name: sre-team                    # Must match the group string from IdP
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: radar-operator
  apiGroup: rbac.authorization.k8s.io

By user:

yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: alice-radar-operator
subjects:
  - kind: User
    name: [email protected]           # Must match the username from IdP
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: radar-operator
  apiGroup: rbac.authorization.k8s.io

Per-namespace (use RoleBinding instead of ClusterRoleBinding):

yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: dev-team-radar-operator
  namespace: staging                  # Only grants access in this namespace
subjects:
  - kind: Group
    name: dev-team
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: radar-operator
  apiGroup: rbac.authorization.k8s.io

Step 3: Verify

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

bash
# Check what alice can do (from a machine with cluster-admin access)
kubectl auth can-i --list [email protected] --as-group=sre-team

# Check specific permission
kubectl auth can-i get pods -n production [email protected] --as-group=sre-team

What Users See

When auth is enabled:

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

ServiceAccount RBAC

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

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

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

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

Session Cookies

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

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

Configuration Reference

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

Troubleshooting

User is logged back in immediately after logout (OIDC)

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

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

Users get 401 on every request

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

Users authenticate but see empty dashboard / no resources

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

Verify the user's access:

bash
# Check if the user can list pods in a specific namespace
kubectl auth can-i list pods -n <namespace> \
  --as= --as-group=<group>

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

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

All users see empty data even with correct RBAC

Radar's ServiceAccount may be missing the subjectaccessreviews permission needed to check user access. When this fails, Radar denies all namespace access (fail-closed). Verify:

bash
kubectl auth can-i create subjectaccessreviews \
  --as=system:serviceaccount:radar:radar

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

Write operations return 403

The user's K8s RBAC doesn't include the required verb. Check with:

bash
kubectl auth can-i patch deployments -n <namespace> \
  --as= --as-group=<group>

Impersonation errors in Radar logs

Radar's ServiceAccount is missing impersonation permissions. Verify:

bash
kubectl auth can-i impersonate users \
  --as=system:serviceaccount:radar:radar

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


File: docs/configuration.md

Configuration

This document covers Radar's cluster connection behavior. For CLI flags and basic usage, see the README.

HTTP Listener

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

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

bash
radar --listen-address=0.0.0.0

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

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

Persistent Configuration

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

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

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

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

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

Field Description
kubeconfig Path to kubeconfig file (same as --kubeconfig)
kubeconfigDirs Directories containing kubeconfig files (same as --kubeconfig-dir)
namespace Initial namespace filter
namespaces Initial namespace filters as a list (same as --namespaces ns1,ns2,ns3)
port Server port (default 9280)
noBrowser Don't auto-open browser
browser Browser for automatic launch (same as --browser; on macOS, app names like Google Chrome are supported)
timelineStorage memory or sqlite
timelineDbPath Path to SQLite database
timelineMaxSize Max SQLite DB + WAL size before pruning oldest events (0 disables)
historyLimit Max timeline events to retain
prometheusUrl Manual Prometheus/VictoriaMetrics URL — skips auto-discovery. Useful when Prometheus is not in the same cluster or uses a non-standard service name.
prometheusHeaders HTTP headers sent with every Prometheus request. Required for auth-protected backends — e.g. {"X-Scope-OrgID": "my-org"}. Equivalent CLI: --prometheus-header Key=Value (repeatable). Stored in plain text in config.json — protect the file accordingly.
argoCdUrl Manual argocd-server URL for the Argo CD API integration — skips auto-discovery.
argoCdToken Argo CD API token (get-only account recommended). Stored in plain text — the file is written 0600; the token is redacted from GET /api/config.
argoCdInsecureTls Skip TLS verification for argocd-server (self-signed default installs). Scoped to the Argo CD client only.
prometheusHeadersFromEnv Header values read from environment variables at startup — e.g. {"Authorization": "PROMETHEUS_TOKEN"}. Equivalent CLI: --prometheus-header-from-env Key=ENV_VAR (repeatable). Use this with Kubernetes Secret-backed env vars in Helm deployments.
mcp Enable/disable MCP server for AI tools (default: enabled)
debugImage Image for ephemeral debug containers and node debug pods (same as --debug-image). Empty = busybox:latest; point at a mirror for air-gapped / private-registry clusters.

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

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

json
{
  "theme": "system",
  "pinnedKinds": [
    { "name": "Deployments", "kind": "Deployment", "group": "" }
  ]
}
Field Values Description
theme light, dark, system UI theme preference
pinnedKinds Array of {name, kind, group} Resource kinds pinned to the sidebar

Cluster Connection Precedence

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

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

KUBECONFIG vs In-Cluster Detection

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

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

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

Example: Override in-cluster config

bash
# Inside a pod, connect to a different cluster
export KUBECONFIG=/path/to/other-cluster.yaml
kubectl radar

This behavior matches kubectl and follows the Kubernetes client-go precedence rules.

Multiple Kubeconfig Files

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

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

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

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

Context Switching

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

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

Namespace Picker

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

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

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

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

bash
kubectl radar --namespaces ns1,ns2,ns3

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

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

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

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

Radar Cloud

Radar is free and fully functional without an account. A Cloud button in the
header offers to connect the cluster to Radar Cloud —
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.


File: docs/gitops.md

GitOps (Argo CD & Flux)

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

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

Supported CRDs

Tool Kinds
FluxCD GitRepository, OCIRepository, HelmRepository, Bucket, Kustomization, HelmRelease, Alert
ArgoCD Application, ApplicationSet, AppProject

Fleet view

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

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

Per-app detail page

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

Topology tab

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

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

Changes tab

Per managed resource, in one row:

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

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

Activity tab

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

Diagnosis pipeline

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

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

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

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

Lifecycle awareness

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

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

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

Operations

Argo CD

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

Flux

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

Keyboard shortcuts (per-app detail page)

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

Argo CD API integration (deep diff)

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

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

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

Behavior and guarantees:

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

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

Provisioning the token per deployment shape

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

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

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

Environment variables (read once at startup):

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

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

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

Scoping and lifecycle:

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

Cross-linking from the rest of Radar

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

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

MCP integration

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

  • Argo: sync, suspend, resume
  • Flux: reconcile, suspend, resume

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

See MCP server for the full tool list and security model.

Demo cluster

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

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

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

RBAC

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

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

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

Single-cluster scope

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

See also

  • MCP server — 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 KUBECONFIG to override in-cluster detection), see the Configuration Guide.

Quick Start

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

Access via port-forward:

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

Exposing with Ingress

Basic (No Authentication)

yaml
# values.yaml
ingress:
  enabled: true
  className: nginx
  hosts:
    - host: radar.your-domain.com
      paths:
        - path: /
          pathType: Prefix
bash
helm upgrade --install radar skyhook/radar \
  -n radar -f values.yaml

Subpath Ingress (No Strip-Prefix)

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

yaml
# values.yaml
basePath: /radar

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

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

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

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

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

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

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

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

With Basic Authentication

  1. Create the auth secret:

    bash
    # Install htpasswd if needed: brew install httpd (macOS) or apt install apache2-utils (Linux)
    
    # Generate credentials (replace 'admin' and 'your-password')
    htpasswd -nb admin 'your-password' > auth
    
    # Create the secret
    kubectl create secret generic radar-basic-auth \
      --from-file=auth \
      -n radar
    
    rm auth  # Clean up local file
  2. Configure ingress:

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

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

With TLS (HTTPS)

Requires cert-manager installed in your cluster.

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

DNS Setup

  1. Get your ingress IP:

    bash
    kubectl get ingress -n radar
  2. Create a DNS A record pointing your domain to the ingress IP.

Multi-cluster naming convention:

text
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 patch on nodes, list on pods, and create on pods/eviction, which are not included in the default ClusterRole. Add them via rbac.additionalRules or use per-user authentication so each user's own RBAC governs node operations.

Enable features as needed:

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

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

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

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

CRD Permissions

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

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

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

Graceful RBAC Degradation

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

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

RBAC granularity (auth enabled):

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

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

Example: Namespace-scoped deployment

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

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

Authentication

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

Quick start with proxy auth:

yaml
# values.yaml
auth:
  mode: proxy

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

Quick start with OIDC:

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

Security Considerations

When deploying Radar in-cluster:

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

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

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

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

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

Timeline Storage: memory vs sqlite

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

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

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

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

Configuration Reference

See Helm Chart README for all available values.

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

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

Troubleshooting

Pod not starting

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

Ingress not working

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

Basic auth prompt not appearing

Verify the secret format:

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

Upgrading

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

Uninstalling

bash
helm uninstall radar -n radar
kubectl delete namespace radar

File: docs/INTEGRATION_GUIDE.md

CRD Integration Guide

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

Study an existing integration that's similar to yours before starting. Good references:

  • Simple (no collisions): Karpenter, KEDA, Velero
  • With topology: FluxCD, ArgoCD
  • With kind collisions: Istio (Gateway), Knative (Service, Ingress, Certificate)

Pre-Implementation: Collision Check

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

Common collisions:

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

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


Checklist

1. Backend: CRD Warmup

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

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

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

2. Backend: Topology Types

File: pkg/topology/types.go

Add NodeKind constants for each resource that will appear in topology:

go
KindKnativeService NodeKind = "KnativeService"
  • ** Collision:** Use a prefixed kind name (e.g., KnativeService not Service)

3. Backend: Topology Builder

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

Add a new section that:

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

Edge type semantics (choose carefully):

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

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

File: pkg/topology/relationships.go

Add your kinds to buildNodeID and normalizeKind maps.

  • ** Collision:** Use a unique ID prefix (e.g., knativeservice/, istiogateway/)

4. Frontend: Group Name Mapping

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

Map API group to display group name:

typescript
'serving.knative.dev': 'Knative',
'eventing.knative.dev': 'Knative',

5. Frontend: Resource Utils

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

Status extraction functions. Most CRDs use the standard status.conditions pattern:

typescript
export function getMyResourceStatus(data: any): { text: string; color: string } {
  const conditions = data?.status?.conditions || []
  const ready = conditions.find((c: any) => c.type === 'Ready')
  // ...
}

6. Frontend: Table Columns

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

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

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

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

7. Frontend: Cell Renderers

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

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

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

Add cases for your kinds.

  • ** Collision:** Use apiVersion checks:
    typescript
    if (kind === 'services' && resource.apiVersion?.includes('serving.knative.dev')) {
      return <KnativeServiceCell ... />
    }

8. Frontend: Detail Renderers

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

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

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

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

Three wiring points (all must be updated):

a. `KNOWN_KINDS` set

Add your kinds so the dispatch shows the custom renderer instead of generic YAML.

b. Render lines

Add conditional render for each kind:

tsx
{kind === 'nodepools' && <NodePoolRenderer data={data} />}

** COLLISION GUARD (critical):** If your kind collides with a core kind, you must ALSO guard the existing core renderer:

tsx
// Guard core renderer — exclude when it's actually a Knative Service
{kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceRenderer ... />}

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

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

c. `getResourceStatus()` function

Add your status function. ** COLLISION GUARD:** Insert apiVersion check INSIDE the existing kind's block, BEFORE the core status function:

typescript
if (k === 'services') {
  if (data.apiVersion?.includes('serving.knative.dev')) {
    return getKnativeServiceStatus(data)  // Must come first!
  }
  return getServiceStatus(data)  // Core fallback
}

9. Frontend: Topology UI

Update these files to support new topology node kinds:

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

10. Documentation

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

Verification Checklist

After implementation, verify:

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

Common Gotchas

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

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

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

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

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

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

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