Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8096d83
GitOps drift detection + Argo CD API deep diff
nadaverell Jul 9, 2026
e994a35
Drift diff: descend into arrays instead of dumping raw JSON
nadaverell Jul 9, 2026
030848e
ignoreDifferences note: legible for operators, detail for experts
nadaverell Jul 9, 2026
15e78d9
GitOps Resources row: vertically center the status/action cells
nadaverell Jul 9, 2026
3a4a2f6
Settings: two-pane sidebar nav + honest per-section apply semantics
nadaverell Jul 9, 2026
0617c0d
Settings: render My permissions inline, not as a launcher to another …
nadaverell Jul 9, 2026
696433e
Remove stray session scratch files from the branch
nadaverell Jul 9, 2026
5577506
Settings: use Tooltip instead of native title= on nav items
nadaverell Jul 9, 2026
35cdf74
Review fixes: integration-field ownership, array-index ignore rules, …
nadaverell Jul 9, 2026
f29c484
Harden Argo CD credential model and fix CodeQL log-injection
nadaverell Jul 9, 2026
83c420c
Settings: add Overview landing tab + clearer integration copy
nadaverell Jul 9, 2026
9c33042
Settings: fix the dialog to a constant height
nadaverell Jul 9, 2026
f5edaf4
Settings: consistent tab layout + stronger Argo CD auth copy
nadaverell Jul 9, 2026
24b9ce8
GitOps: show Git commit metadata for the deployed revision (Argo API)
nadaverell Jul 9, 2026
fb67519
GitOps: flag a broken Argo CD repo connection as an issue
nadaverell Jul 10, 2026
ae07134
GitOps repo health: carry Argo's raw connection error for diagnosis
nadaverell Jul 10, 2026
a9ea424
Merge origin/main into feature/argo-api-diff
nadaverell Jul 10, 2026
4a4cc02
Argo: clearer context-mismatch error + normalize '_' namespace
nadaverell Jul 10, 2026
a98fb3b
Argo: persist the auto-discovery token's context binding
nadaverell Jul 10, 2026
e47bb03
Settings: honest footer + clearer server-launch copy
nadaverell Jul 10, 2026
bbab5e5
Settings: detect the Argo CD CLI session and offer it, don't ask
nadaverell Jul 10, 2026
b1cc62d
Argo: don't offer a loopback CLI session (port-forward artifact)
nadaverell Jul 10, 2026
260ad86
GitOps: a detail page's own root node isn't a portal to itself
nadaverell Jul 10, 2026
f3357d1
GitOps: collapse a ComparisonError's derivative noise into one diagnosis
nadaverell Jul 10, 2026
4e9240a
Merge remote-tracking branch 'origin/main' into feature/argo-api-diff
nadaverell Jul 10, 2026
562629c
Argo: keep the cross-cluster guard intact on failed-connect rollback …
nadaverell Jul 10, 2026
951918a
GitOps: only offer the Argo diff when it can actually be served, inli…
nadaverell Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ Use `/visual-test` command for the full workflow (cluster check, Playwright MCP,
- Helm: `/api/helm/releases/...`
- Workloads: `/api/workloads/{kind}/{ns}/{name}/...` (logs, restart, scale, rollback)
- GitOps controller actions: `/api/argo/applications/...` (sync, refresh, terminate, suspend, resume, rollback, selective-sync), `/api/flux/{kind}/...` (reconcile, suspend, resume, sync-with-source)
- Argo CD API integration: `PUT /api/integrations/argocd` (URL/token, probe-before-persist, token preserved across GET-redaction round-trips); `/api/argo/applications/{ns}/{name}/resource-diff` (Git-rendered desired vs live via argocd-server managed-resources; dual RBAC gate + structural Secret redaction; see docs/gitops.md)
- GitOps detail data: `/api/gitops/tree/{kind}/{ns}/{name}` (resource tree + ownership edges), `/api/gitops/insights/{kind}/{ns}/{name}` (curated diagnosis: summary + issues + drift + events + plan + history + capabilities)
- Nodes: `/api/nodes/{name}/...` (cordon, uncordon, drain, debug)
- Audit: `/api/audit`, `/api/audit/resource/{kind}/{ns}/{name}`, `/api/settings/audit` (GET/PUT)
Expand Down
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ All fields are optional — omitted fields use built-in defaults.
| `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. |
Expand Down
47 changes: 47 additions & 0 deletions docs/gitops.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,53 @@ Radar treats Terminating as a distinct lifecycle phase that dominates other stat
| `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).

## 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:
Expand Down
254 changes: 254 additions & 0 deletions internal/argocd/discovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package argocd

import (
"context"
"errors"
"fmt"
"log"
"sort"
"strings"
"sync"
"time"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"

pfpkg "github.com/skyhook-io/radar/pkg/portforward"
)

const serverLabelSelector = "app.kubernetes.io/name=argocd-server"

type candidate struct {
namespace string
name string
scheme string
port int
targetPort int
}

func (c candidate) clusterURL() string {
return fmt.Sprintf("%s://%s.%s.svc:%d", c.scheme, c.name, c.namespace, c.port)
}

// discoverCandidates lists Services labeled app.kubernetes.io/name=argocd-server
// across all namespaces. Sorted with the conventional "argocd" namespace first
// so multi-install clusters probe the default install before exotic ones.
func discoverCandidates(ctx context.Context, client kubernetes.Interface) ([]candidate, error) {
svcs, err := client.CoreV1().Services(metav1.NamespaceAll).List(ctx, metav1.ListOptions{
LabelSelector: serverLabelSelector,
})
if err != nil {
return nil, err
}
var out []candidate
for i := range svcs.Items {
if c, ok := pickPort(svcs.Items[i]); ok {
out = append(out, c)
}
}
sort.Slice(out, func(i, j int) bool {
if (out[i].namespace == "argocd") != (out[j].namespace == "argocd") {
return out[i].namespace == "argocd"
}
if out[i].namespace != out[j].namespace {
return out[i].namespace < out[j].namespace
}
return out[i].name < out[j].name
})
return out, nil
}

// pickPort selects the service port to target: https (443) preferred, http
// (80) fallback, else the first declared port.
func pickPort(svc corev1.Service) (candidate, bool) {
var httpsPort, httpPort *corev1.ServicePort
for i := range svc.Spec.Ports {
p := &svc.Spec.Ports[i]
switch {
case p.Port == 443 || strings.EqualFold(p.Name, "https"):
if httpsPort == nil {
httpsPort = p
}
case p.Port == 80 || strings.EqualFold(p.Name, "http"):
if httpPort == nil {
httpPort = p
}
}
}

pick := httpsPort
scheme := "https"
if pick == nil {
pick = httpPort
scheme = "http"
}
if pick == nil {
if len(svc.Spec.Ports) == 0 {
return candidate{}, false
}
pick = &svc.Spec.Ports[0]
scheme = "http"
}

return candidate{
namespace: svc.Namespace,
name: svc.Name,
scheme: scheme,
port: int(pick.Port),
targetPort: resolveTargetPort(pick),
}, true
}

// resolveTargetPort returns the container port for port-forwarding, which
// bypasses the Service (e.g., service:443 → container:8080).
func resolveTargetPort(p *corev1.ServicePort) int {
if p.TargetPort.IntVal > 0 {
return int(p.TargetPort.IntVal)
}
return int(p.Port)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Named Service targetPort mis-forwarded

Low Severity

Out-of-cluster discovery port-forwards using resolveTargetPort, which falls back to the Service port when TargetPort is a name (not an integer). Forwarding the Service port onto the pod often fails, so auto-discovery can incorrectly report Argo CD unreachable.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8096d83. Configure here.

}

// discover finds a reachable Argo CD API server:
// 1. Every candidate at its in-cluster Service address (works in-cluster, or
// when the user's machine can route to cluster DNS).
// 2. Out-of-cluster only: port-forward candidates in priority order, probing
// https then http on the forwarded port (argocd-server serves both on one
// port via cmux).
//
// The port-forward is owned by this package rather than internal/portforward:
// that package manages a single shared forward for the metrics stack
// (Prometheus/traffic), and starting an argocd-server forward through it
// would tear down the active metrics forward.
func (m *Manager) discover(ctx context.Context, snap probeSnapshot) (string, error) {
// The client is captured in the snapshot, NOT read live — so a context
// switch that swaps the live client mid-probe can't redirect this token's
// discovery at a different cluster's argocd-server.
k8sc := snap.k8sClient
if k8sc == nil {
return "", fmt.Errorf("%w: no Kubernetes client available for discovery", ErrUnreachable)
}

cands, err := discoverCandidates(ctx, k8sc)
if err != nil {
return "", fmt.Errorf("%w: listing argocd-server services: %v", ErrUnreachable, err)
}
if len(cands) == 0 {
return "", fmt.Errorf("%w: no Service labeled %s found in cluster — set an explicit Argo CD URL", ErrUnreachable, serverLabelSelector)
}

for _, cand := range cands {
addr := cand.clusterURL()
if m.probeEndpoint(ctx, addr, snap) == nil {
log.Printf("[argocd] Discovered Argo CD at %s (service %s/%s)", addr, cand.namespace, cand.name)
return addr, nil
}
}

if m.inCluster() {
return "", fmt.Errorf("%w: argocd-server service found but not reachable in-cluster (if it serves a self-signed certificate, set argoCdInsecureTls)", ErrUnreachable)
}

var lastErr error
for _, cand := range cands {
log.Printf("[argocd] No candidate reachable in-cluster, starting port-forward to %s/%s...", cand.namespace, cand.name)
fwd, pfErr := m.startPortForward(ctx, snap, cand.namespace, cand.name, cand.targetPort)
if pfErr != nil {
lastErr = fmt.Errorf("port-forward to %s/%s failed: %w", cand.namespace, cand.name, pfErr)
continue
}

connected := ""
for _, scheme := range []string{"https", "http"} {
addr := fmt.Sprintf("%s://localhost:%d", scheme, fwd.localPort)
if m.probeEndpoint(ctx, addr, snap) == nil {
connected = addr
break
}
}
if connected != "" {
m.mu.Lock()
if m.staleLocked(snap) {
// A config change (SetConfig/Reset) superseded this probe while
// we were forwarding. Don't install the forward — the caller
// will discard the whole result; leaving it would dangle a
// forward to a target the user has moved away from.
m.mu.Unlock()
fwd.stop()
return "", errStaleProbe
}
old := m.forward
m.forward = fwd
m.mu.Unlock()
if old != nil {
old.stop()
}
log.Printf("[argocd] Connected to %s/%s via port-forward at %s", cand.namespace, cand.name, connected)
return connected, nil
}

fwd.stop()
lastErr = fmt.Errorf("argocd-server at %s/%s not responding after port-forward (if it serves a self-signed certificate, set argoCdInsecureTls)", cand.namespace, cand.name)
}

return "", fmt.Errorf("%w: %v", ErrUnreachable, lastErr)
}

type activeForward struct {
localPort int
stopCh chan struct{}
cancel context.CancelFunc
stopOnce sync.Once
}

func (f *activeForward) stop() {
f.stopOnce.Do(func() {
f.cancel()
close(f.stopCh)
})
}

func (m *Manager) startPortForward(ctx context.Context, snap probeSnapshot, namespace, service string, targetPort int) (*activeForward, error) {
// Use the snapshot's client/config (frozen at probe start), not the live
// ones — otherwise a context switch mid-probe would dial the forward through
// the NEW cluster, sending this probe's token to a different cluster's Argo.
client := snap.k8sClient
cfg := snap.k8sConfig
if client == nil || cfg == nil {
return nil, errors.New("kubernetes client not initialized")
}

podName, err := pfpkg.FindPodForService(ctx, client, namespace, service)
if err != nil {
return nil, fmt.Errorf("find pod for service %s/%s: %w", namespace, service, err)
}
localPort, err := pfpkg.FindFreePort()
if err != nil {
return nil, fmt.Errorf("find free port: %w", err)
}

stopCh := make(chan struct{})
readyCh := make(chan struct{})
pfCtx, cancel := context.WithCancel(context.Background())
fwd := &activeForward{localPort: localPort, stopCh: stopCh, cancel: cancel}

errCh := make(chan error, 1)
go func() {
errCh <- pfpkg.RunPortForward(pfCtx, client, cfg, namespace, podName, localPort, targetPort, stopCh, readyCh)
}()

select {
case <-readyCh:
log.Printf("[argocd] Port-forward ready: localhost:%d -> %s/%s:%d", localPort, namespace, service, targetPort)
return fwd, nil
case err := <-errCh:
fwd.stop()
return nil, fmt.Errorf("port-forward failed: %w", err)
case <-time.After(10 * time.Second):
fwd.stop()
return nil, errors.New("port-forward timed out")
case <-ctx.Done():
fwd.stop()
return nil, ctx.Err()
}
}
Loading
Loading