Add support for configurable URL path prefix in podinfo application

This commit is contained in:
Piotr Roszatycki
2026-05-25 12:47:45 +02:00
parent a547e00b6c
commit 51f14f8fc5
12 changed files with 175 additions and 49 deletions
+1
View File
@@ -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 |
+4 -3
View File
@@ -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 }}
+1 -1
View File
@@ -11,7 +11,7 @@ metadata:
{{- end }}
spec:
endpoints:
- path: /metrics
- path: {{ trimSuffix "/" (.Values.prefix | default "/") }}/metrics
port: http
interval: {{ .Values.serviceMonitor.interval }}
namespaceSelector:
+1 -1
View File
@@ -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 }}"
+2 -2
View File
@@ -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 }}
{{- end }}
+2
View File
@@ -12,6 +12,8 @@ image:
pullPolicy: IfNotPresent
pullSecrets: []
prefix: /
ui:
color: "#34577c"
message: ""
+1
View File
@@ -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")
+7 -4
View File
@@ -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 {
+1
View File
@@ -22,6 +22,7 @@ func NewMockServer() *Server {
UIPath: ".ui",
UIMessage: "Greetings",
Hostname: "localhost",
Prefix: "/",
}
logger, _ := zap.NewDevelopment()
+80 -35
View File
@@ -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))
})
+68
View File
@@ -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)
}
}
+7 -3
View File
@@ -62,7 +62,7 @@
Powered
by <a class="white--text" href="https://github.com/stefanprodan/podinfo" target="_blank">podinfo</a>
version ${ info.version } revision ${ info.revision }
Swagger <a class="white--text" href="swagger/">docs</a>
Swagger <a class="white--text" :href="prefix + '/swagger/'">docs</a>
</div>
</v-flex>
</v-layout>
@@ -73,6 +73,9 @@
<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
<script>
window.podinfoPrefix = {{ .Prefix }};
</script>
<script>
new Vue({
delimiters: ['${', '}'],
@@ -85,6 +88,7 @@
color: '',
pings: 0,
calls: 0,
prefix: window.podinfoPrefix === '/' ? '' : window.podinfoPrefix,
tlName1: '',
tlColor1: 'grey',
tlName2: '',
@@ -102,7 +106,7 @@
getInfo: function() {
const xhr = new XMLHttpRequest();
let self = this;
xhr.open('GET', "api/info")
xhr.open('GET', self.prefix + "/api/info")
xhr.onload = function () {
data = JSON.parse(xhr.responseText)
if (self.info.version) {
@@ -138,7 +142,7 @@
},
postBackend: function() {
var self = this
fetch("api/echo", {
fetch(self.prefix + "/api/echo", {
method: 'post',
headers: {
"Content-type": "application/json; charset=UTF-8",