diff --git a/golang/http.go b/golang/http.go index 8447f22..f1161fc 100644 --- a/golang/http.go +++ b/golang/http.go @@ -1,7 +1,10 @@ package main import ( + "context" + "encoding/json" "fmt" + "io" "log" "net/http" "net/http/httputil" @@ -12,21 +15,36 @@ import ( "github.com/flanksource/incident-commander/plugin/sdk" ) -func (p *GolangPlugin) HTTPHandler() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("/pprof/", p.httpProxyPprof) - mux.HandleFunc("/profiles/", p.httpProfile) - mux.Handle("/version", sdk.VersionHandler(sdk.BuildInfo{ - Name: pluginName, - Version: Version, - BuildDate: BuildDate, - UIChecksum: uiChecksum, - })) - return mux +func (p *GolangPlugin) httpInvoke(operation string, handler func(context.Context, sdk.InvokeCtx) (any, error)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { _ = r.Body.Close() }() + params, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(strings.TrimSpace(string(params))) == 0 { + params = []byte("{}") + } + res, err := handler(r.Context(), sdk.InvokeCtx{ + Operation: operation, + ParamsJSON: params, + ConfigItemID: sdk.ConfigItemIDFromContext(r.Context()), + Host: sdk.HostClientFromContext(r.Context()), + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(res); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) } func (p *GolangPlugin) httpProxyPprof(w http.ResponseWriter, r *http.Request) { - rest := strings.TrimPrefix(r.URL.Path, "/pprof/") + rest := operationSubpath(r, OpHTTPPprof) id, tail, _ := strings.Cut(rest, "/") if id == "" { http.Error(w, "missing session id", http.StatusBadRequest) @@ -54,7 +72,7 @@ func (p *GolangPlugin) httpProxyPprof(w http.ResponseWriter, r *http.Request) { } func (p *GolangPlugin) httpProfile(w http.ResponseWriter, r *http.Request) { - rest := strings.TrimPrefix(r.URL.Path, "/profiles/") + rest := operationSubpath(r, OpHTTPProfiles) id, tail, _ := strings.Cut(rest, "/") if id == "" || tail == "" { http.Error(w, "expected /profiles/{sessionID}/{runID|heap|cpu|trace}", http.StatusBadRequest) @@ -121,6 +139,13 @@ func (p *GolangPlugin) proxyProfileViewer(w http.ResponseWriter, r *http.Request proxy.ServeHTTP(w, r) } +func operationSubpath(r *http.Request, operation string) string { + if p := strings.Trim(r.URL.Query().Get("path"), "/"); p != "" { + return p + } + return strings.Trim(strings.TrimPrefix(r.URL.Path, "/__mc/operations/"+operation), "/") +} + func writeProfileDownload(w http.ResponseWriter, sessionID, name, kind, source string, data []byte) { filename := fmt.Sprintf("%s-%s-%s.%s", pluginName, sessionID, name, profileExtension(kind)) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename)) diff --git a/golang/http_test.go b/golang/http_test.go index d4607d6..be37d79 100644 --- a/golang/http_test.go +++ b/golang/http_test.go @@ -11,10 +11,10 @@ import ( var _ = ginkgo.Describe("HTTP handler", func() { ginkgo.It("returns a useful error for missing pprof session", func() { p := newPlugin() - req := httptest.NewRequest(http.MethodGet, "/pprof/missing/", nil) + req := httptest.NewRequest(http.MethodGet, "/__mc/operations/pprof?path=missing", nil) rec := httptest.NewRecorder() - p.HTTPHandler().ServeHTTP(rec, req) + httpOp(p, OpHTTPPprof).ServeHTTP(rec, req) Expect(rec.Code).To(Equal(http.StatusNotFound)) Expect(rec.Body.String()).To(ContainSubstring("session not found")) @@ -24,10 +24,10 @@ var _ = ginkgo.Describe("HTTP handler", func() { p := newPlugin() sess := NewSession("default", "pod", "app", "app-0", "app", nil) p.sessions.Add(sess) - req := httptest.NewRequest(http.MethodGet, "/profiles/"+sess.ID+"/unknown", nil) + req := httptest.NewRequest(http.MethodGet, "/__mc/operations/profiles?path="+sess.ID+"/unknown", nil) rec := httptest.NewRecorder() - p.HTTPHandler().ServeHTTP(rec, req) + httpOp(p, OpHTTPProfiles).ServeHTTP(rec, req) Expect(rec.Code).To(Equal(http.StatusBadRequest)) }) @@ -40,10 +40,10 @@ var _ = ginkgo.Describe("HTTP handler", func() { run.MarkDone([]byte("profile-bytes"), "pprof", nil) p.profiles.Add(run) - req := httptest.NewRequest(http.MethodGet, "/profiles/"+sess.ID+"/"+run.ID, nil) + req := httptest.NewRequest(http.MethodGet, "/__mc/operations/profiles?path="+sess.ID+"/"+run.ID, nil) rec := httptest.NewRecorder() - p.HTTPHandler().ServeHTTP(rec, req) + httpOp(p, OpHTTPProfiles).ServeHTTP(rec, req) Expect(rec.Code).To(Equal(http.StatusOK)) Expect(rec.Body.String()).To(Equal("profile-bytes")) @@ -58,12 +58,21 @@ var _ = ginkgo.Describe("HTTP handler", func() { run, _ := NewProfileRun(sess.ID, "cpu", "auto", 30) p.profiles.Add(run) - req := httptest.NewRequest(http.MethodGet, "/profiles/"+sess.ID+"/"+run.ID, nil) + req := httptest.NewRequest(http.MethodGet, "/__mc/operations/profiles?path="+sess.ID+"/"+run.ID, nil) rec := httptest.NewRecorder() - p.HTTPHandler().ServeHTTP(rec, req) + httpOp(p, OpHTTPProfiles).ServeHTTP(rec, req) Expect(rec.Code).To(Equal(http.StatusConflict)) Expect(rec.Body.String()).To(ContainSubstring("not completed")) }) }) + +func httpOp(p *GolangPlugin, name string) http.Handler { + for _, op := range p.Operations() { + if op.Def.Name == name { + return op.HTTPHandler + } + } + return http.NotFoundHandler() +} diff --git a/golang/main.go b/golang/main.go index 64aa09e..da1e377 100644 --- a/golang/main.go +++ b/golang/main.go @@ -9,6 +9,7 @@ import ( "context" "embed" "io/fs" + "net/http" pluginpb "github.com/flanksource/incident-commander/plugin/proto" "github.com/flanksource/incident-commander/plugin/sdk" @@ -26,6 +27,8 @@ const ( OpProfileStatus = "profile-status" OpProfileStop = "profile-stop" OpProfileRunsList = "profile-runs-list" + OpHTTPPprof = "pprof" + OpHTTPProfiles = "profiles" pluginName = "golang" ) @@ -143,8 +146,15 @@ func (p *GolangPlugin) Operations() []sdk.Operation { defs := operationDefs() out := make([]sdk.Operation, 0, len(defs)) for _, d := range defs { - if h, ok := handlers[d.Name]; ok { - out = append(out, sdk.Operation{Def: d, Handler: h}) + switch d.Name { + case OpHTTPPprof: + out = append(out, sdk.Operation{Def: d, HTTPHandler: http.HandlerFunc(p.httpProxyPprof)}) + case OpHTTPProfiles: + out = append(out, sdk.Operation{Def: d, HTTPHandler: http.HandlerFunc(p.httpProfile)}) + default: + if h, ok := handlers[d.Name]; ok { + out = append(out, sdk.Operation{Def: d, Handler: h, HTTPHandler: p.httpInvoke(d.Name, h)}) + } } } return out @@ -152,7 +162,21 @@ func (p *GolangPlugin) Operations() []sdk.Operation { func operationDefs() []*pluginpb.OperationDef { mk := func(name, desc string) *pluginpb.OperationDef { - return &pluginpb.OperationDef{Name: name, Description: desc, Scope: "config", ResultMime: sdk.ClickyResultMimeType} + return &pluginpb.OperationDef{ + Name: name, + Description: desc, + Scope: "config", + ResultMime: sdk.ClickyResultMimeType, + Http: []*pluginpb.HTTPBinding{{Method: http.MethodPost}}, + } + } + mkHTTP := func(name, desc string) *pluginpb.OperationDef { + return &pluginpb.OperationDef{ + Name: name, + Description: desc, + Scope: "config", + Http: []*pluginpb.HTTPBinding{{Method: http.MethodGet}}, + } } return []*pluginpb.OperationDef{ mk(OpPodsList, "List ready target pods for the selected Kubernetes workload."), @@ -166,6 +190,8 @@ func operationDefs() []*pluginpb.OperationDef { mk(OpProfileStatus, "Read a profile run status."), mk(OpProfileStop, "Stop a running profile run."), mk(OpProfileRunsList, "List recent profile runs for a diagnostics session."), + mkHTTP(OpHTTPPprof, "Proxy the selected session's pprof HTTP endpoint."), + mkHTTP(OpHTTPProfiles, "Download captured profiles or proxy the interactive pprof viewer."), } } diff --git a/golang/ui-src/src/api.ts b/golang/ui-src/src/api.ts index 8599789..c104b39 100644 --- a/golang/ui-src/src/api.ts +++ b/golang/ui-src/src/api.ts @@ -71,17 +71,24 @@ export function configIDFromURL(): string { return new URLSearchParams(window.location.search).get("config_id") ?? ""; } +function pluginBasePath(): string { + return window.location.pathname.replace(/\/ui(?:\/.*)?$/, ""); +} + function operationURL(op: string): string { - const base = window.location.pathname.replace(/\/ui\/.*$/, ""); - const url = new URL(base + "/operations/" + op, window.location.origin); + const url = new URL(pluginBasePath() + "/proxy/" + op, window.location.origin); const configID = configIDFromURL(); if (configID) url.searchParams.set("config_id", configID); return url.toString(); } export function pluginURL(path: string): string { - const base = window.location.pathname.replace(/\/ui\/.*$/, ""); - return new URL(base + "/" + path.replace(/^\//, ""), window.location.origin).toString(); + const [op, ...rest] = path.replace(/^\//, "").split("/"); + const url = new URL(pluginBasePath() + "/proxy/" + op, window.location.origin); + const configID = configIDFromURL(); + if (configID) url.searchParams.set("config_id", configID); + if (rest.length > 0) url.searchParams.set("path", rest.join("/")); + return url.toString(); } export async function callOp(op: string, params: Record = {}): Promise { diff --git a/golang/ui/index.html b/golang/ui/index.html index 0fccced..035fe78 100644 --- a/golang/ui/index.html +++ b/golang/ui/index.html @@ -4,7 +4,7 @@ Golang Diagnostics - + diff --git a/golang/ui_checksum.go b/golang/ui_checksum.go index 8ba5e9c..7f592d8 100644 --- a/golang/ui_checksum.go +++ b/golang/ui_checksum.go @@ -2,4 +2,4 @@ package main -const uiChecksum = "511ae3231b3d80ecf85b3d2817372ae35539fd4e0e1abc7ade8923191dba7db5" +const uiChecksum = "f51cd46ec6ac149687e350989bd630284c52b2ea3f4cc64d00e23cc85b48fab0"