Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
11 changes: 10 additions & 1 deletion cmd/explorer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -201,6 +206,7 @@ func main() {
KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir),
Namespace: *namespace,
Port: *port,
BasePath: normalizedBasePath,
NoBrowser: *noBrowser,
Browser: *browser,
DevMode: *devMode,
Expand Down Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions deploy/helm/radar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
12 changes: 10 additions & 2 deletions deploy/helm/radar/templates/deployment.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand Down
5 changes: 5 additions & 0 deletions deploy/helm/radar/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions deploy/helm/radar/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down
21 changes: 21 additions & 0 deletions docs/in-cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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` |
Expand Down
2 changes: 2 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type AppConfig struct {
KubeconfigDirs []string
Namespace string
Port int
BasePath string
NoBrowser bool
Browser string
DevMode bool
Expand Down Expand Up @@ -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",
Expand Down
164 changes: 164 additions & 0 deletions internal/server/base_path_test.go
Original file line number Diff line number Diff line change
@@ -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(`<!doctype html>
<html>
<head>
<link rel="icon" href="/favicon.svg">
<script type="module" crossorigin src="/assets/index.js"></script>
<link rel="modulepreload" crossorigin href="./assets/vendor.js">
</head>
<body>
<img src="/images/radar/radar-icon.svg">
</body>
</html>`)

got := string(rewriteFrontendIndex(input, "/radar"))
for _, want := range []string{
`window.__RADAR_RUNTIME_CONFIG__={"basePath":"/radar","apiBase":"/radar/api","assetBase":"/radar"};`,
`href="/radar/favicon.svg"`,
`src="/radar/assets/index.js"`,
`href="/radar/assets/vendor.js"`,
`src="/radar/images/radar/radar-icon.svg"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("rewritten index missing %q:\n%s", want, got)
}
}

runtimeIdx := strings.Index(got, `window.__RADAR_RUNTIME_CONFIG__`)
moduleIdx := strings.Index(got, `<script type="module"`)
if runtimeIdx == -1 || moduleIdx == -1 || runtimeIdx > moduleIdx {
t.Fatalf("runtime config must be emitted before module script:\n%s", got)
}
}

func TestRewriteFrontendIndexRootLeavesHTMLUnchanged(t *testing.T) {
input := []byte(`<html><head><script type="module" src="/assets/index.js"></script></head></html>`)
got := rewriteFrontendIndex(input, "")
if string(got) != string(input) {
t.Fatalf("root base path should not rewrite index:\ngot %s\nwant %s", got, input)
}
}

func TestRewriteFrontendIndexRootMakesRelativeAssetsAbsolute(t *testing.T) {
input := []byte(`<html><head><script type="module" src="./assets/index.js"></script><link rel="stylesheet" href="./assets/index.css"></head></html>`)
got := string(rewriteFrontendIndex(input, ""))
for _, want := range []string{
`src="/assets/index.js"`,
`href="/assets/index.css"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("rewritten root index missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `__RADAR_RUNTIME_CONFIG__`) {
t.Fatalf("root base path should not inject runtime config:\n%s", got)
}
}

func TestFrontendHandlerRewritesIndexFallbackButNotAssets(t *testing.T) {
fsys := http.FS(fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte(`<!doctype html><html><head><script type="module" src="/assets/index.js"></script></head><body></body></html>`)},
"assets/index.js": &fstest.MapFile{Data: []byte(`console.log("ok")`)},
})
handler := frontendHandler(fsys, "/radar")

indexResp := httptest.NewRecorder()
handler.ServeHTTP(indexResp, httptest.NewRequest(http.MethodGet, "/topology", nil))
if indexResp.Code != http.StatusOK {
t.Fatalf("client-route fallback status = %d, want 200", indexResp.Code)
}
indexBody := indexResp.Body.String()
if !strings.Contains(indexBody, `window.__RADAR_RUNTIME_CONFIG__={"basePath":"/radar","apiBase":"/radar/api","assetBase":"/radar"};`) {
t.Fatalf("client-route fallback did not inject runtime config:\n%s", indexBody)
}
if !strings.Contains(indexBody, `src="/radar/assets/index.js"`) {
t.Fatalf("client-route fallback did not rewrite asset src:\n%s", indexBody)
}

assetResp := httptest.NewRecorder()
handler.ServeHTTP(assetResp, httptest.NewRequest(http.MethodGet, "/radar/assets/index.js", nil))
if assetResp.Code != http.StatusOK {
t.Fatalf("asset status = %d, want 200", assetResp.Code)
}
assetBody, err := io.ReadAll(assetResp.Result().Body)
if err != nil {
t.Fatalf("read asset response: %v", err)
}
if string(assetBody) != `console.log("ok")` {
t.Fatalf("asset body = %q", assetBody)
}
}

func TestServerMountsRoutesUnderBasePath(t *testing.T) {
srv := New(Config{DevMode: true, BasePath: "/radar"})

rootResp := httptest.NewRecorder()
srv.Handler().ServeHTTP(rootResp, httptest.NewRequest(http.MethodGet, "/?namespace=default", nil))
if rootResp.Code != http.StatusFound {
t.Fatalf("root status = %d, want 302", rootResp.Code)
}
if got := rootResp.Header().Get("Location"); got != "/radar/?namespace=default" {
t.Fatalf("root redirect Location = %q, want /radar/?namespace=default", got)
}

unprefixedResp := httptest.NewRecorder()
srv.Handler().ServeHTTP(unprefixedResp, httptest.NewRequest(http.MethodGet, "/api/health", nil))
if unprefixedResp.Code != http.StatusNotFound {
t.Fatalf("unprefixed API status = %d, want 404", unprefixedResp.Code)
}

prefixedResp := httptest.NewRecorder()
srv.Handler().ServeHTTP(prefixedResp, httptest.NewRequest(http.MethodGet, "/radar/api/health", nil))
if prefixedResp.Code != http.StatusOK {
t.Fatalf("prefixed API status = %d, want 200", prefixedResp.Code)
}
}
Loading
Loading