From 51f14f8fc5cae6b52767b3232a179383a4b12c23 Mon Sep 17 00:00:00 2001 From: Piotr Roszatycki Date: Mon, 25 May 2026 12:47:45 +0200 Subject: [PATCH] Add support for configurable URL path prefix in podinfo application --- charts/podinfo/README.md | 1 + charts/podinfo/templates/deployment.yaml | 7 +- charts/podinfo/templates/servicemonitor.yaml | 2 +- charts/podinfo/templates/tests/service.yaml | 2 +- charts/podinfo/templates/tests/tls.yaml | 4 +- charts/podinfo/values.yaml | 2 + cmd/podinfo/main.go | 1 + pkg/api/http/index.go | 11 +- pkg/api/http/mock.go | 1 + pkg/api/http/server.go | 115 +++++++++++++------ pkg/api/http/server_prefix_test.go | 68 +++++++++++ ui/vue.html | 10 +- 12 files changed, 175 insertions(+), 49 deletions(-) create mode 100644 pkg/api/http/server_prefix_test.go diff --git a/charts/podinfo/README.md b/charts/podinfo/README.md index 613529f..346560c 100644 --- a/charts/podinfo/README.md +++ b/charts/podinfo/README.md @@ -66,6 +66,7 @@ The following tables lists the configurable parameters of the podinfo chart and | `ui.color` | `#34577c` | UI color | | `ui.message` | `None` | UI greetings message | | `ui.logo` | `None` | UI logo | +| `prefix` | `/` | URL path prefix for HTTP routes (e.g. `/foo` maps `/api/info` to `/foo/api/info`) | | `faults.delay` | `false` | Random HTTP response delays between 0 and 5 seconds | | `faults.error` | `false` | 1/3 chances of a random HTTP response error | | `faults.unhealthy` | `false` | When set, the healthy state is never reached | diff --git a/charts/podinfo/templates/deployment.yaml b/charts/podinfo/templates/deployment.yaml index b726745..2c7d82f 100644 --- a/charts/podinfo/templates/deployment.yaml +++ b/charts/podinfo/templates/deployment.yaml @@ -53,6 +53,7 @@ spec: command: - ./podinfo - --port={{ .Values.service.httpPort | default 9898 }} + - --prefix={{ .Values.prefix | default "/" }} {{- if .Values.host }} - --host={{ .Values.host }} {{- end }} @@ -152,7 +153,7 @@ spec: - podcli - check - http - - localhost:{{ .Values.service.httpPort | default 9898 }}/healthz + - localhost:{{ .Values.service.httpPort | default 9898 }}{{ trimSuffix "/" (.Values.prefix | default "/") }}/healthz {{- with .Values.probes.startup }} initialDelaySeconds: {{ .initialDelaySeconds | default 1 }} timeoutSeconds: {{ .timeoutSeconds | default 5 }} @@ -167,7 +168,7 @@ spec: - podcli - check - http - - localhost:{{ .Values.service.httpPort | default 9898 }}/healthz + - localhost:{{ .Values.service.httpPort | default 9898 }}{{ trimSuffix "/" (.Values.prefix | default "/") }}/healthz {{- with .Values.probes.liveness }} initialDelaySeconds: {{ .initialDelaySeconds | default 1 }} timeoutSeconds: {{ .timeoutSeconds | default 5 }} @@ -181,7 +182,7 @@ spec: - podcli - check - http - - localhost:{{ .Values.service.httpPort | default 9898 }}/readyz + - localhost:{{ .Values.service.httpPort | default 9898 }}{{ trimSuffix "/" (.Values.prefix | default "/") }}/readyz {{- with .Values.probes.readiness }} initialDelaySeconds: {{ .initialDelaySeconds | default 1 }} timeoutSeconds: {{ .timeoutSeconds | default 5 }} diff --git a/charts/podinfo/templates/servicemonitor.yaml b/charts/podinfo/templates/servicemonitor.yaml index f868d85..ed5c6d8 100644 --- a/charts/podinfo/templates/servicemonitor.yaml +++ b/charts/podinfo/templates/servicemonitor.yaml @@ -11,7 +11,7 @@ metadata: {{- end }} spec: endpoints: - - path: /metrics + - path: {{ trimSuffix "/" (.Values.prefix | default "/") }}/metrics port: http interval: {{ .Values.serviceMonitor.interval }} namespaceSelector: diff --git a/charts/podinfo/templates/tests/service.yaml b/charts/podinfo/templates/tests/service.yaml index 74b8bc7..0c6d4e4 100644 --- a/charts/podinfo/templates/tests/service.yaml +++ b/charts/podinfo/templates/tests/service.yaml @@ -19,7 +19,7 @@ spec: - sh - -c - | - curl -s ${PODINFO_SVC}/api/info | grep version + curl -s ${PODINFO_SVC}{{ trimSuffix "/" (.Values.prefix | default "/") }}/api/info | grep version env: - name: PODINFO_SVC value: "{{ template "podinfo.fullname" . }}.{{ include "podinfo.namespace" . }}:{{ .Values.service.externalPort }}" diff --git a/charts/podinfo/templates/tests/tls.yaml b/charts/podinfo/templates/tests/tls.yaml index 0cc659a..1e4ac61 100644 --- a/charts/podinfo/templates/tests/tls.yaml +++ b/charts/podinfo/templates/tests/tls.yaml @@ -20,9 +20,9 @@ spec: - sh - -c - | - curl -sk ${PODINFO_SVC}/api/info | grep version + curl -sk ${PODINFO_SVC}{{ trimSuffix "/" (.Values.prefix | default "/") }}/api/info | grep version env: - name: PODINFO_SVC value: "https://{{ template "podinfo.fullname" . }}.{{ include "podinfo.namespace" . }}:{{ .Values.tls.port }}" restartPolicy: Never -{{- end }} \ No newline at end of file +{{- end }} diff --git a/charts/podinfo/values.yaml b/charts/podinfo/values.yaml index b908d45..2e9be5d 100644 --- a/charts/podinfo/values.yaml +++ b/charts/podinfo/values.yaml @@ -12,6 +12,8 @@ image: pullPolicy: IfNotPresent pullSecrets: [] +prefix: / + ui: color: "#34577c" message: "" diff --git a/cmd/podinfo/main.go b/cmd/podinfo/main.go index 06b1976..c091da1 100644 --- a/cmd/podinfo/main.go +++ b/cmd/podinfo/main.go @@ -30,6 +30,7 @@ func main() { // flags definition fs := pflag.NewFlagSet("default", pflag.ContinueOnError) fs.String("host", "", "Host to bind service to") + fs.String("prefix", "/", "URL path prefix for HTTP routes") fs.Int("port", 9898, "HTTP port to bind service to") fs.Int("secure-port", 0, "HTTPS port") fs.Int("port-metrics", 0, "metrics port") diff --git a/pkg/api/http/index.go b/pkg/api/http/index.go index 1585576..c83bcdf 100644 --- a/pkg/api/http/index.go +++ b/pkg/api/http/index.go @@ -4,6 +4,7 @@ import ( "html/template" "net/http" "path" + "strings" ) // Index godoc @@ -25,11 +26,13 @@ func (s *Server) indexHandler(w http.ResponseWriter, r *http.Request) { } data := struct { - Title string - Logo string + Title string + Logo string + Prefix string }{ - Title: s.config.Hostname, - Logo: s.config.UILogo, + Title: s.config.Hostname, + Logo: s.config.UILogo, + Prefix: strings.TrimSuffix(s.config.Prefix, "/"), } if err := tmpl.Execute(w, data); err != nil { diff --git a/pkg/api/http/mock.go b/pkg/api/http/mock.go index 244649c..6bc8de8 100644 --- a/pkg/api/http/mock.go +++ b/pkg/api/http/mock.go @@ -22,6 +22,7 @@ func NewMockServer() *Server { UIPath: ".ui", UIMessage: "Greetings", Hostname: "localhost", + Prefix: "/", } logger, _ := zap.NewDevelopment() diff --git a/pkg/api/http/server.go b/pkg/api/http/server.go index 0e89b58..c0c4948 100644 --- a/pkg/api/http/server.go +++ b/pkg/api/http/server.go @@ -14,7 +14,7 @@ import ( "github.com/gomodule/redigo/redis" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus/promhttp" - _ "github.com/stefanprodan/podinfo/pkg/api/http/docs" + "github.com/stefanprodan/podinfo/pkg/api/http/docs" "github.com/stefanprodan/podinfo/pkg/fscache" httpSwagger "github.com/swaggo/http-swagger" "github.com/swaggo/swag" @@ -57,6 +57,7 @@ type Config struct { ConfigPath string `mapstructure:"config-path"` CertPath string `mapstructure:"cert-path"` Host string `mapstructure:"host"` + Prefix string `mapstructure:"prefix"` Port string `mapstructure:"port"` SecurePort string `mapstructure:"secure-port"` PortMetrics int `mapstructure:"port-metrics"` @@ -84,6 +85,9 @@ type Server struct { } func NewServer(config *Config, logger *zap.Logger) (*Server, error) { + config.Prefix = normalizePrefix(config.Prefix) + docs.SwaggerInfo.BasePath = config.Prefix + srv := &Server{ router: mux.NewRouter(), logger: logger, @@ -93,44 +97,85 @@ func NewServer(config *Config, logger *zap.Logger) (*Server, error) { return srv, nil } +func normalizePrefix(prefix string) string { + prefix = strings.TrimSpace(prefix) + if prefix == "" || prefix == "/" { + return "/" + } + + if !strings.HasPrefix(prefix, "/") { + prefix = "/" + prefix + } + + prefix = path.Clean(prefix) + if prefix == "." || prefix == "" { + return "/" + } + + return prefix +} + +func (s *Server) prefixedPath(route string) string { + prefix := s.config.Prefix + if prefix == "" || prefix == "/" { + return route + } + + if route == "/" { + return prefix + } + + if strings.HasPrefix(route, "/") { + return prefix + route + } + + return prefix + "/" + route +} + func (s *Server) registerHandlers() { - s.router.Handle("/metrics", promhttp.Handler()) - s.router.PathPrefix("/debug/pprof/").Handler(http.DefaultServeMux) - s.router.HandleFunc("/", s.indexHandler).HeadersRegexp("User-Agent", "^Mozilla.*").Methods("GET") - s.router.HandleFunc("/", s.infoHandler).Methods("GET") - s.router.HandleFunc("/version", s.versionHandler).Methods("GET") - s.router.HandleFunc("/echo", s.echoHandler) - s.router.PathPrefix("/echo/").HandlerFunc(s.echoHandler) - s.router.HandleFunc("/env", s.envHandler).Methods("GET", "POST") - s.router.HandleFunc("/headers", s.echoHeadersHandler).Methods("GET", "POST") - s.router.HandleFunc("/delay/{wait:[0-9]+}", s.delayHandler).Methods("GET").Name("delay") - s.router.HandleFunc("/healthz", s.healthzHandler).Methods("GET") - s.router.HandleFunc("/readyz", s.readyzHandler).Methods("GET") - s.router.HandleFunc("/readyz/enable", s.enableReadyHandler).Methods("POST") - s.router.HandleFunc("/readyz/disable", s.disableReadyHandler).Methods("POST") - s.router.HandleFunc("/panic", s.panicHandler).Methods("GET") - s.router.HandleFunc("/status/{code:[0-9]+}", s.statusHandler).Methods("GET", "POST", "PUT").Name("status") - s.router.HandleFunc("/store", s.storeWriteHandler).Methods("POST", "PUT") - s.router.HandleFunc("/store/{hash}", s.storeReadHandler).Methods("GET").Name("store") - s.router.HandleFunc("/cache/{key}", s.cacheWriteHandler).Methods("POST", "PUT") - s.router.HandleFunc("/cache/{key}", s.cacheDeleteHandler).Methods("DELETE") - s.router.HandleFunc("/cache/{key}", s.cacheReadHandler).Methods("GET").Name("cache") - s.router.HandleFunc("/configs", s.configReadHandler).Methods("GET") - s.router.HandleFunc("/token", s.tokenGenerateHandler).Methods("POST") - s.router.HandleFunc("/token/validate", s.tokenValidateHandler).Methods("GET") - s.router.HandleFunc("/api/info", s.infoHandler).Methods("GET") - s.router.HandleFunc("/api/echo", s.echoHandler) - s.router.PathPrefix("/api/echo/").HandlerFunc(s.echoHandler) - s.router.HandleFunc("/ws/echo", s.echoWsHandler) - s.router.HandleFunc("/chunked", s.chunkedHandler) - s.router.HandleFunc("/chunked/{wait:[0-9]+}", s.chunkedHandler) - s.router.PathPrefix("/swagger/").Handler(httpSwagger.Handler( - httpSwagger.URL("/swagger/doc.json"), + rootPath := s.prefixedPath("/") + + s.router.Handle(s.prefixedPath("/metrics"), promhttp.Handler()) + s.router.PathPrefix(s.prefixedPath("/debug/pprof/")).Handler(http.DefaultServeMux) + s.router.HandleFunc(rootPath, s.indexHandler).HeadersRegexp("User-Agent", "^Mozilla.*").Methods("GET") + s.router.HandleFunc(rootPath, s.infoHandler).Methods("GET") + if rootPath != "/" { + s.router.HandleFunc(rootPath+"/", s.indexHandler).HeadersRegexp("User-Agent", "^Mozilla.*").Methods("GET") + s.router.HandleFunc(rootPath+"/", s.infoHandler).Methods("GET") + } + s.router.HandleFunc(s.prefixedPath("/version"), s.versionHandler).Methods("GET") + s.router.HandleFunc(s.prefixedPath("/echo"), s.echoHandler) + s.router.PathPrefix(s.prefixedPath("/echo/")).HandlerFunc(s.echoHandler) + s.router.HandleFunc(s.prefixedPath("/env"), s.envHandler).Methods("GET", "POST") + s.router.HandleFunc(s.prefixedPath("/headers"), s.echoHeadersHandler).Methods("GET", "POST") + s.router.HandleFunc(s.prefixedPath("/delay/{wait:[0-9]+}"), s.delayHandler).Methods("GET").Name("delay") + s.router.HandleFunc(s.prefixedPath("/healthz"), s.healthzHandler).Methods("GET") + s.router.HandleFunc(s.prefixedPath("/readyz"), s.readyzHandler).Methods("GET") + s.router.HandleFunc(s.prefixedPath("/readyz/enable"), s.enableReadyHandler).Methods("POST") + s.router.HandleFunc(s.prefixedPath("/readyz/disable"), s.disableReadyHandler).Methods("POST") + s.router.HandleFunc(s.prefixedPath("/panic"), s.panicHandler).Methods("GET") + s.router.HandleFunc(s.prefixedPath("/status/{code:[0-9]+}"), s.statusHandler).Methods("GET", "POST", "PUT").Name("status") + s.router.HandleFunc(s.prefixedPath("/store"), s.storeWriteHandler).Methods("POST", "PUT") + s.router.HandleFunc(s.prefixedPath("/store/{hash}"), s.storeReadHandler).Methods("GET").Name("store") + s.router.HandleFunc(s.prefixedPath("/cache/{key}"), s.cacheWriteHandler).Methods("POST", "PUT") + s.router.HandleFunc(s.prefixedPath("/cache/{key}"), s.cacheDeleteHandler).Methods("DELETE") + s.router.HandleFunc(s.prefixedPath("/cache/{key}"), s.cacheReadHandler).Methods("GET").Name("cache") + s.router.HandleFunc(s.prefixedPath("/configs"), s.configReadHandler).Methods("GET") + s.router.HandleFunc(s.prefixedPath("/token"), s.tokenGenerateHandler).Methods("POST") + s.router.HandleFunc(s.prefixedPath("/token/validate"), s.tokenValidateHandler).Methods("GET") + s.router.HandleFunc(s.prefixedPath("/api/info"), s.infoHandler).Methods("GET") + s.router.HandleFunc(s.prefixedPath("/api/echo"), s.echoHandler) + s.router.PathPrefix(s.prefixedPath("/api/echo/")).HandlerFunc(s.echoHandler) + s.router.HandleFunc(s.prefixedPath("/ws/echo"), s.echoWsHandler) + s.router.HandleFunc(s.prefixedPath("/chunked"), s.chunkedHandler) + s.router.HandleFunc(s.prefixedPath("/chunked/{wait:[0-9]+}"), s.chunkedHandler) + s.router.PathPrefix(s.prefixedPath("/swagger/")).Handler(httpSwagger.Handler( + httpSwagger.URL(s.prefixedPath("/swagger/doc.json")), )) - s.router.HandleFunc("/swagger.json", func(w http.ResponseWriter, r *http.Request) { + s.router.HandleFunc(s.prefixedPath("/swagger.json"), func(w http.ResponseWriter, r *http.Request) { doc, err := swag.ReadDoc() if err != nil { - s.logger.Error("swagger error", zap.Error(err), zap.String("path", "/swagger.json")) + s.logger.Error("swagger error", zap.Error(err), zap.String("path", s.prefixedPath("/swagger.json"))) } w.Write([]byte(doc)) }) diff --git a/pkg/api/http/server_prefix_test.go b/pkg/api/http/server_prefix_test.go new file mode 100644 index 0000000..f25865e --- /dev/null +++ b/pkg/api/http/server_prefix_test.go @@ -0,0 +1,68 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestNormalizePrefix(t *testing.T) { + tests := []struct { + name string + input string + output string + }{ + {name: "default root", input: "/", output: "/"}, + {name: "empty", input: "", output: "/"}, + {name: "no leading slash", input: "foo", output: "/foo"}, + {name: "trailing slash", input: "/foo/", output: "/foo"}, + {name: "double slash", input: "//foo//bar//", output: "/foo/bar"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizePrefix(tt.input); got != tt.output { + t.Fatalf("normalizePrefix(%q) = %q, want %q", tt.input, got, tt.output) + } + }) + } +} + +func TestRegisterHandlersWithPrefix(t *testing.T) { + srv := NewMockServer() + srv.config.Prefix = normalizePrefix("/foo") + srv.registerHandlers() + + reqRootSlash, err := http.NewRequest("GET", "/foo/", nil) + if err != nil { + t.Fatal(err) + } + rrRootSlash := httptest.NewRecorder() + srv.router.ServeHTTP(rrRootSlash, reqRootSlash) + + if rrRootSlash.Code != http.StatusOK { + t.Fatalf("GET /foo/ returned %d, want %d", rrRootSlash.Code, http.StatusOK) + } + + req, err := http.NewRequest("GET", "/foo/api/info", nil) + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + srv.router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("GET /foo/api/info returned %d, want %d", rr.Code, http.StatusOK) + } + + reqNoPrefix, err := http.NewRequest("GET", "/api/info", nil) + if err != nil { + t.Fatal(err) + } + rrNoPrefix := httptest.NewRecorder() + srv.router.ServeHTTP(rrNoPrefix, reqNoPrefix) + + if rrNoPrefix.Code != http.StatusNotFound { + t.Fatalf("GET /api/info returned %d, want %d", rrNoPrefix.Code, http.StatusNotFound) + } +} diff --git a/ui/vue.html b/ui/vue.html index e4c26d5..859ad37 100644 --- a/ui/vue.html +++ b/ui/vue.html @@ -62,7 +62,7 @@ Powered by podinfo version ${ info.version } revision ${ info.revision } - Swagger docs + Swagger docs @@ -73,6 +73,9 @@ +