Skip to content
Merged
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
51 changes: 38 additions & 13 deletions golang/http.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package main

import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httputil"
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
25 changes: 17 additions & 8 deletions golang/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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))
})
Expand All @@ -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"))
Expand All @@ -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()
}
32 changes: 29 additions & 3 deletions golang/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -26,6 +27,8 @@ const (
OpProfileStatus = "profile-status"
OpProfileStop = "profile-stop"
OpProfileRunsList = "profile-runs-list"
OpHTTPPprof = "pprof"
OpHTTPProfiles = "profiles"
pluginName = "golang"
)

Expand Down Expand Up @@ -143,16 +146,37 @@ 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
}

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."),
Expand All @@ -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."),
}
}

Expand Down
15 changes: 11 additions & 4 deletions golang/ui-src/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(op: string, params: Record<string, unknown> = {}): Promise<T> {
Expand Down
2 changes: 1 addition & 1 deletion golang/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Golang Diagnostics</title>
<script type="module" crossorigin src="./assets/index-DicyVXGD.js"></script>
<script type="module" crossorigin src="./assets/index-CZbqXDFw.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CQGCJwfv.css">
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion golang/ui_checksum.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading