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 @@
+