diff --git a/README.md b/README.md index 6eb764933..244fabea1 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ radar | `--namespace` | (all) | Initial namespace filter (supports multi-select in the UI; also used as RBAC fallback for namespace-scoped users) | | `--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 | +| `--base-path` | | Serve Radar under a URL prefix such as `/radar`. Use when an ingress forwards a subpath without stripping it. | | `--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` | diff --git a/cmd/explorer/main.go b/cmd/explorer/main.go index f4258527d..6d4b64c32 100644 --- a/cmd/explorer/main.go +++ b/cmd/explorer/main.go @@ -50,6 +50,7 @@ func main() { kubeconfigDir := flag.String("kubeconfig-dir", fileCfg.KubeconfigDirsFlag(), "Comma-separated directories containing kubeconfig files (mutually exclusive with --kubeconfig)") namespace := flag.String("namespace", fileCfg.Namespace, "Initial namespace filter (empty = all namespaces)") port := flag.Int("port", fileCfg.PortOr(9280), "Server port") + basePath := flag.String("base-path", "", "URL path prefix to serve Radar under, e.g. /radar (empty = root). Use when an ingress forwards a subpath without stripping it.") noBrowser := flag.Bool("no-browser", fileCfg.NoBrowser, "Don't auto-open browser") browser := flag.String("browser", fileCfg.Browser, "Browser to use when opening the UI (default: OS default browser; macOS app names supported)") devMode := flag.Bool("dev", false, "Development mode (serve frontend from filesystem)") @@ -171,6 +172,10 @@ func main() { if *kubeconfig != "" && *kubeconfigDir != "" { log.Fatalf("--kubeconfig and --kubeconfig-dir are mutually exclusive") } + normalizedBasePath, err := server.NormalizeBasePath(*basePath) + if err != nil { + log.Fatalf("Invalid --base-path %q: %v", *basePath, err) + } timelineMaxSizeBytes, err := config.ParseByteSize(*timelineMaxSize) if err != nil { log.Fatalf("Invalid --timeline-max-size %q: %v", *timelineMaxSize, err) @@ -201,6 +206,7 @@ func main() { KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir), Namespace: *namespace, Port: *port, + BasePath: normalizedBasePath, NoBrowser: *noBrowser, Browser: *browser, DevMode: *devMode, @@ -302,7 +308,10 @@ func main() { // Open browser — server is confirmed ready to accept connections if !cfg.NoBrowser { - url := fmt.Sprintf("http://localhost:%d", cfg.Port) + url := fmt.Sprintf("http://localhost:%d%s", cfg.Port, cfg.BasePath) + if cfg.BasePath != "" { + url += "/" + } if cfg.Namespace != "" { url += fmt.Sprintf("?namespace=%s", cfg.Namespace) } diff --git a/deploy/helm/radar/README.md b/deploy/helm/radar/README.md index 9e3d8f9cb..88f6329a9 100644 --- a/deploy/helm/radar/README.md +++ b/deploy/helm/radar/README.md @@ -86,6 +86,7 @@ touches its contents. | `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` | diff --git a/deploy/helm/radar/templates/deployment.yaml b/deploy/helm/radar/templates/deployment.yaml index f048157d9..953909710 100644 --- a/deploy/helm/radar/templates/deployment.yaml +++ b/deploy/helm/radar/templates/deployment.yaml @@ -1,6 +1,11 @@ {{- if and .Values.persistence.enabled (eq .Values.timeline.storage "sqlite") (gt (int .Values.replicaCount) 1) }} {{- fail "replicaCount must be 1 when persistence.enabled=true and timeline.storage=sqlite: a single PVC cannot be safely shared across pods (RWO volumes won't attach to a second pod, RWX volumes risk SQLite corruption)" }} {{- end }} +{{- $basePath := "" -}} +{{- $trimmedBasePath := trimAll "/" (default "" .Values.basePath) -}} +{{- if $trimmedBasePath -}} +{{- $basePath = printf "/%s" $trimmedBasePath -}} +{{- end -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -48,6 +53,9 @@ spec: args: - --port={{ .Values.service.port }} - --no-browser + {{- if $basePath }} + - {{ printf "--base-path=%s" $basePath | quote }} + {{- end }} - --timeline-storage={{ .Values.timeline.storage }} {{- if eq .Values.timeline.storage "sqlite" }} - --timeline-db={{ .Values.timeline.dbPath }} @@ -197,7 +205,7 @@ spec: {{- if .Values.probes.liveness.enabled }} livenessProbe: httpGet: - path: /api/health + path: {{ printf "%s/api/health" $basePath | quote }} port: http initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} periodSeconds: {{ .Values.probes.liveness.periodSeconds }} @@ -207,7 +215,7 @@ spec: {{- if .Values.probes.readiness.enabled }} readinessProbe: httpGet: - path: /api/health + path: {{ printf "%s/api/health" $basePath | quote }} port: http initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} periodSeconds: {{ .Values.probes.readiness.periodSeconds }} diff --git a/deploy/helm/radar/values.schema.json b/deploy/helm/radar/values.schema.json index ae857ae2f..8a586263b 100644 --- a/deploy/helm/radar/values.schema.json +++ b/deploy/helm/radar/values.schema.json @@ -116,6 +116,11 @@ "port": { "type": "integer", "minimum": 1, "maximum": 65535 } } }, + "basePath": { + "type": "string", + "pattern": "^$|^/?[^:?#{}*]+$", + "description": "Optional URL path prefix served by Radar itself, e.g. /radar. Use when ingress forwards the subpath without stripping it." + }, "ingress": { "type": "object", "additionalProperties": true, diff --git a/deploy/helm/radar/values.yaml b/deploy/helm/radar/values.yaml index aa9fbc63e..53dac7189 100644 --- a/deploy/helm/radar/values.yaml +++ b/deploy/helm/radar/values.yaml @@ -197,6 +197,11 @@ service: type: ClusterIP port: 9280 +# URL path prefix that Radar itself serves under, e.g. /radar. Set this when +# your ingress forwards a subpath through to the service without stripping it. +# Leave empty for root serving or for ingresses that rewrite /radar -> /. +basePath: "" + ingress: enabled: false className: "" diff --git a/docs/in-cluster.md b/docs/in-cluster.md index f36a5ac50..7a7dc9161 100644 --- a/docs/in-cluster.md +++ b/docs/in-cluster.md @@ -39,6 +39,26 @@ 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 `/`. + ### With Basic Authentication 1. **Create the auth secret:** @@ -295,6 +315,7 @@ See [Helm Chart README](../deploy/helm/radar/README.md) for all available values | `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` | diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index e09618252..47cf93a68 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -33,6 +33,7 @@ type AppConfig struct { KubeconfigDirs []string Namespace string Port int + BasePath string NoBrowser bool Browser string DevMode bool @@ -245,6 +246,7 @@ func CreateServer(cfg AppConfig) *server.Server { serverCfg := server.Config{ Port: cfg.Port, + BasePath: cfg.BasePath, DevMode: cfg.DevMode, StaticFS: static.FS, StaticRoot: "dist", diff --git a/internal/server/base_path_test.go b/internal/server/base_path_test.go new file mode 100644 index 000000000..6d6addb50 --- /dev/null +++ b/internal/server/base_path_test.go @@ -0,0 +1,164 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" +) + +func TestNormalizeBasePath(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "empty", raw: "", want: ""}, + {name: "root", raw: "/", want: ""}, + {name: "adds leading slash", raw: "radar", want: "/radar"}, + {name: "trims trailing slash", raw: "/radar/", want: "/radar"}, + {name: "nested path", raw: "/tools/radar/", want: "/tools/radar"}, + {name: "query rejected", raw: "/radar?x=1", wantErr: true}, + {name: "fragment rejected", raw: "/radar#top", wantErr: true}, + {name: "url rejected", raw: "https://example.com/radar", wantErr: true}, + {name: "route pattern rejected", raw: "/{tenant}/radar", wantErr: true}, + {name: "dot segment rejected", raw: "/radar/../api", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeBasePath(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatalf("NormalizeBasePath(%q) returned nil error", tt.raw) + } + return + } + if err != nil { + t.Fatalf("NormalizeBasePath(%q) returned error: %v", tt.raw, err) + } + if got != tt.want { + t.Fatalf("NormalizeBasePath(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +func TestRewriteFrontendIndexInjectsBasePathRuntimeConfig(t *testing.T) { + input := []byte(` + +
+ + + + + +