diff --git a/golang/http.go b/golang/http.go index f1161fc..f124fb1 100644 --- a/golang/http.go +++ b/golang/http.go @@ -55,6 +55,10 @@ func (p *GolangPlugin) httpProxyPprof(w http.ResponseWriter, r *http.Request) { http.Error(w, "session not found", http.StatusNotFound) return } + if !sessionMatchesConfig(sess, configItemIDFromRequest(r)) { + http.Error(w, "session does not belong to the current config item", http.StatusForbidden) + return + } if !sess.PprofAvailable || sess.PprofLocal == 0 { http.Error(w, "pprof is not available for this session", http.StatusBadRequest) return @@ -83,6 +87,10 @@ func (p *GolangPlugin) httpProfile(w http.ResponseWriter, r *http.Request) { http.Error(w, "session not found", http.StatusNotFound) return } + if !sessionMatchesConfig(sess, configItemIDFromRequest(r)) { + http.Error(w, "session does not belong to the current config item", http.StatusForbidden) + return + } runIDOrKind, subPath, _ := strings.Cut(tail, "/") if run, ok := p.profiles.Get(runIDOrKind); ok { @@ -139,6 +147,13 @@ func (p *GolangPlugin) proxyProfileViewer(w http.ResponseWriter, r *http.Request proxy.ServeHTTP(w, r) } +func configItemIDFromRequest(r *http.Request) string { + if id := sdk.ConfigItemIDFromContext(r.Context()); id != "" { + return id + } + return r.URL.Query().Get("config_id") +} + func operationSubpath(r *http.Request, operation string) string { if p := strings.Trim(r.URL.Query().Get("path"), "/"); p != "" { return p diff --git a/golang/http_test.go b/golang/http_test.go index be37d79..a414f2e 100644 --- a/golang/http_test.go +++ b/golang/http_test.go @@ -22,7 +22,7 @@ var _ = ginkgo.Describe("HTTP handler", func() { ginkgo.It("validates profile paths", func() { p := newPlugin() - sess := NewSession("default", "pod", "app", "app-0", "app", nil) + sess := NewSession("", "default", "pod", "app", "app-0", "app", nil) p.sessions.Add(sess) req := httptest.NewRequest(http.MethodGet, "/__mc/operations/profiles?path="+sess.ID+"/unknown", nil) rec := httptest.NewRecorder() @@ -34,7 +34,7 @@ var _ = ginkgo.Describe("HTTP handler", func() { ginkgo.It("serves completed profile runs from the registry", func() { p := newPlugin() - sess := NewSession("default", "pod", "app", "app-0", "app", nil) + sess := NewSession("", "default", "pod", "app", "app-0", "app", nil) p.sessions.Add(sess) run, _ := NewProfileRun(sess.ID, "heap", "pprof", 30) run.MarkDone([]byte("profile-bytes"), "pprof", nil) @@ -53,7 +53,7 @@ var _ = ginkgo.Describe("HTTP handler", func() { ginkgo.It("does not download running profile runs", func() { p := newPlugin() - sess := NewSession("default", "pod", "app", "app-0", "app", nil) + sess := NewSession("", "default", "pod", "app", "app-0", "app", nil) p.sessions.Add(sess) run, _ := NewProfileRun(sess.ID, "cpu", "auto", 30) p.profiles.Add(run) diff --git a/golang/ops.go b/golang/ops.go index 2aa135a..1360c1b 100644 --- a/golang/ops.go +++ b/golang/ops.go @@ -97,8 +97,8 @@ func (p *GolangPlugin) podsList(ctx context.Context, req sdk.InvokeCtx) (any, er return listRunningPodsForTarget(ctx, cli, target) } -func (p *GolangPlugin) sessionsList(_ context.Context, _ sdk.InvokeCtx) (any, error) { - return p.sessions.List(), nil +func (p *GolangPlugin) sessionsList(_ context.Context, req sdk.InvokeCtx) (any, error) { + return p.sessions.List(req.ConfigItemID), nil } func (p *GolangPlugin) sessionCreate(ctx context.Context, req sdk.InvokeCtx) (any, error) { @@ -211,7 +211,7 @@ func (p *GolangPlugin) sessionCreate(ctx context.Context, req sdk.InvokeCtx) (an _ = fwd.Close() return nil, fmt.Errorf("port-forward not ready: %w", err) } - sess := NewSession(pod.Namespace, target.Kind, target.Name, pod.Name, container, func() error { return fwd.Close() }) + sess := NewSession(req.ConfigItemID, pod.Namespace, target.Kind, target.Name, pod.Name, container, func() error { return fwd.Close() }) sess.PID = pid if match, ok := firstWorkingGops(ctx, gopsCandidates); ok { sess.GopsRemote = match.Remote @@ -276,6 +276,9 @@ func (p *GolangPlugin) sessionDelete(_ context.Context, req sdk.InvokeCtx) (any, if params.ID == "" { return nil, fmt.Errorf("id is required") } + if _, err := p.getSessionForConfig(params.ID, req.ConfigItemID); err != nil { + return nil, err + } removed, err := p.sessions.Remove(params.ID) if !removed { return nil, fmt.Errorf("session %q not found", params.ID) @@ -288,7 +291,7 @@ func (p *GolangPlugin) sessionDelete(_ context.Context, req sdk.InvokeCtx) (any, } func (p *GolangPlugin) runtimeSnapshot(ctx context.Context, req sdk.InvokeCtx) (any, error) { - sess, err := p.sessionFromRequest(req.ParamsJSON) + sess, err := p.sessionFromRequest(req) if err != nil { return nil, err } @@ -307,7 +310,7 @@ func (p *GolangPlugin) runtimeSnapshot(ctx context.Context, req sdk.InvokeCtx) ( } func (p *GolangPlugin) goroutines(ctx context.Context, req sdk.InvokeCtx) (any, error) { - sess, err := p.sessionFromRequest(req.ParamsJSON) + sess, err := p.sessionFromRequest(req) if err != nil { return nil, err } @@ -333,9 +336,9 @@ func (p *GolangPlugin) profileCollect(ctx context.Context, req sdk.InvokeCtx) (a if err := json.Unmarshal(req.ParamsJSON, ¶ms); err != nil { return nil, fmt.Errorf("decode params: %w", err) } - sess, ok := p.sessions.Get(params.SessionID) - if !ok { - return nil, fmt.Errorf("session %q not found", params.SessionID) + sess, err := p.getSessionForConfig(params.SessionID, req.ConfigItemID) + if err != nil { + return nil, err } kind := normalizeProfileKind(params.Kind) if kind == "" { @@ -369,9 +372,9 @@ func (p *GolangPlugin) profileStart(_ context.Context, req sdk.InvokeCtx) (any, if err := json.Unmarshal(req.ParamsJSON, ¶ms); err != nil { return nil, fmt.Errorf("decode params: %w", err) } - sess, ok := p.sessions.Get(params.SessionID) - if !ok { - return nil, fmt.Errorf("session %q not found", params.SessionID) + sess, err := p.getSessionForConfig(params.SessionID, req.ConfigItemID) + if err != nil { + return nil, err } kind := normalizeProfileKind(params.Kind) if kind == "" { @@ -412,6 +415,9 @@ func (p *GolangPlugin) profileStatus(_ context.Context, req sdk.InvokeCtx) (any, if params.SessionID != "" && run.SessionID != params.SessionID { return nil, fmt.Errorf("profile run %q does not belong to session %q", params.RunID, params.SessionID) } + if _, err := p.getSessionForConfig(run.SessionID, req.ConfigItemID); err != nil { + return nil, err + } return run.Snapshot(), nil } @@ -430,6 +436,9 @@ func (p *GolangPlugin) profileStop(_ context.Context, req sdk.InvokeCtx) (any, e if params.SessionID != "" && run.SessionID != params.SessionID { return nil, fmt.Errorf("profile run %q does not belong to session %q", params.RunID, params.SessionID) } + if _, err := p.getSessionForConfig(run.SessionID, req.ConfigItemID); err != nil { + return nil, err + } run.Stop() return run.Snapshot(), nil } @@ -442,27 +451,38 @@ func (p *GolangPlugin) profileRunsList(_ context.Context, req sdk.InvokeCtx) (an if params.SessionID == "" { return nil, fmt.Errorf("sessionId is required") } - if _, ok := p.sessions.Get(params.SessionID); !ok { - return nil, fmt.Errorf("session %q not found", params.SessionID) + if _, err := p.getSessionForConfig(params.SessionID, req.ConfigItemID); err != nil { + return nil, err } return p.profiles.List(params.SessionID), nil } -func (p *GolangPlugin) sessionFromRequest(raw []byte) (*Session, error) { +func (p *GolangPlugin) sessionFromRequest(req sdk.InvokeCtx) (*Session, error) { var params SessionIDParams - if err := json.Unmarshal(raw, ¶ms); err != nil { + if err := json.Unmarshal(req.ParamsJSON, ¶ms); err != nil { return nil, fmt.Errorf("decode params: %w", err) } if params.SessionID == "" { return nil, fmt.Errorf("sessionId is required") } - sess, ok := p.sessions.Get(params.SessionID) + return p.getSessionForConfig(params.SessionID, req.ConfigItemID) +} + +func (p *GolangPlugin) getSessionForConfig(sessionID, configItemID string) (*Session, error) { + sess, ok := p.sessions.Get(sessionID) if !ok { - return nil, fmt.Errorf("session %q not found", params.SessionID) + return nil, fmt.Errorf("session %q not found", sessionID) + } + if !sessionMatchesConfig(sess, configItemID) { + return nil, fmt.Errorf("session %q does not belong to the current config item", sessionID) } return sess, nil } +func sessionMatchesConfig(sess *Session, configItemID string) bool { + return configItemID == "" || sess.ConfigItemID == configItemID +} + func selectPodContainer(pods []RunningPod, podName, container string) (RunningPod, string, error) { if len(pods) == 0 { return RunningPod{}, "", fmt.Errorf("no ready pods found") diff --git a/golang/ops_test.go b/golang/ops_test.go index 70edbb2..14fd290 100644 --- a/golang/ops_test.go +++ b/golang/ops_test.go @@ -1,10 +1,26 @@ package main import ( + "github.com/flanksource/incident-commander/plugin/sdk" ginkgo "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = ginkgo.Describe("sessions", func() { + ginkgo.It("lists only sessions for the current config item", func() { + p := newPlugin() + current := NewSession("config-a", "default", "pod", "app-a", "app-a-0", "app", nil) + other := NewSession("config-b", "default", "pod", "app-b", "app-b-0", "app", nil) + p.sessions.Add(current) + p.sessions.Add(other) + + result, err := p.sessionsList(ginkgo.GinkgoT().Context(), sdk.InvokeCtx{ConfigItemID: "config-a"}) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal([]Session{current.Snapshot()})) + }) +}) + var _ = ginkgo.Describe("port candidates", func() { ginkgo.It("prefers discovered gops ports over defaults", func() { Expect(gopsCandidatePorts(4321, 6061, []int{6061, 7070})).To(Equal([]int{4321})) diff --git a/golang/session.go b/golang/session.go index 81a0725..28e9330 100644 --- a/golang/session.go +++ b/golang/session.go @@ -11,6 +11,7 @@ import ( type Session struct { ID string `json:"id"` + ConfigItemID string `json:"configItemId,omitempty"` Namespace string `json:"namespace"` Kind string `json:"kind"` Name string `json:"name"` @@ -44,6 +45,7 @@ func (s *Session) Stop() error { func (s *Session) Snapshot() Session { return Session{ ID: s.ID, + ConfigItemID: s.ConfigItemID, Namespace: s.Namespace, Kind: s.Kind, Name: s.Name, @@ -84,10 +86,13 @@ func (r *SessionRegistry) Get(id string) (*Session, bool) { return s, ok } -func (r *SessionRegistry) List() []Session { +func (r *SessionRegistry) List(configItemID string) []Session { r.mu.RLock() out := make([]Session, 0, len(r.sessions)) for _, s := range r.sessions { + if configItemID != "" && s.ConfigItemID != configItemID { + continue + } out = append(out, s.Snapshot()) } r.mu.RUnlock() @@ -114,16 +119,17 @@ func (r *SessionRegistry) RunningCount() int { return len(r.sessions) } -func NewSession(namespace, kind, name, pod, container string, stop func() error) *Session { +func NewSession(configItemID, namespace, kind, name, pod, container string, stop func() error) *Session { return &Session{ - ID: newID(), - Namespace: namespace, - Kind: kind, - Name: name, - Pod: pod, - Container: container, - StartedAt: time.Now().UTC(), - stop: stop, + ID: newID(), + ConfigItemID: configItemID, + Namespace: namespace, + Kind: kind, + Name: name, + Pod: pod, + Container: container, + StartedAt: time.Now().UTC(), + stop: stop, } } diff --git a/golang/ui-src/src/App.tsx b/golang/ui-src/src/App.tsx index 51c993e..d5761da 100644 --- a/golang/ui-src/src/App.tsx +++ b/golang/ui-src/src/App.tsx @@ -25,8 +25,8 @@ import { type TargetOption, } from "./api"; -const SESSIONS_KEY = ["golang", "sessions"] as const; -const PODS_KEY = ["golang", "pods"] as const; +const sessionsKey = (configID: string) => ["golang", configID, "sessions"] as const; +const podsKey = (configID: string) => ["golang", configID, "pods"] as const; const PROFILE_KINDS: ProfileKind[] = ["cpu", "trace", "heap"]; const PROFILE_SOURCES: ProfileSource[] = ["auto", "pprof", "gops"]; const HEAP_PALETTE = ["bg-emerald-500", "bg-sky-500", "bg-amber-500", "bg-violet-500"]; @@ -43,32 +43,39 @@ export function App() { const qc = useQueryClient(); const podsQ = useQuery({ - queryKey: PODS_KEY, + queryKey: podsKey(configID), queryFn: () => callOp("pods-list"), enabled: !!configID, refetchInterval: 15_000, }); const sessionsQ = useQuery({ - queryKey: SESSIONS_KEY, + queryKey: sessionsKey(configID), queryFn: () => callOp("sessions-list"), + enabled: !!configID, refetchInterval: 5_000, }); const targets = useMemo(() => flattenTargets(podsQ.data ?? []), [podsQ.data]); const sessions = sessionsQ.data ?? []; + const selectedTargetSession = useMemo( + () => (selectedTarget ? sessions.find((s) => sessionMatchesTarget(s, selectedTarget)) ?? null : null), + [sessions, selectedTarget], + ); const selectedSession = useMemo( - () => sessions.find((s) => s.id === selectedSessionID) ?? sessions[0] ?? null, - [sessions, selectedSessionID], + () => (selectedTarget ? selectedTargetSession : sessions.find((s) => s.id === selectedSessionID) ?? null), + [sessions, selectedSessionID, selectedTarget, selectedTargetSession], ); useEffect(() => { - if (!selectedTarget && targets.length > 0) setSelectedTarget(targets[0]); - }, [selectedTarget, targets]); + if (selectedTarget || targets.length === 0) return; + const activeTarget = targets.find((target) => sessions.some((session) => sessionMatchesTarget(session, target))); + setSelectedTarget(activeTarget ?? targets[0]); + }, [selectedTarget, targets, sessions]); useEffect(() => { - if (!selectedSessionID && sessions.length > 0) setSelectedSessionID(sessions[0].id); - }, [selectedSessionID, sessions]); + setSelectedSessionID(selectedTargetSession?.id ?? null); + }, [selectedTargetSession]); const startSession = useMutation({ mutationFn: (target: TargetOption) => @@ -79,7 +86,11 @@ export function App() { }), onSuccess: (session) => { setSelectedSessionID(session.id); - qc.invalidateQueries({ queryKey: SESSIONS_KEY }); + qc.setQueryData(sessionsKey(configID), (old = []) => [ + session, + ...old.filter((item) => item.id !== session.id), + ]); + qc.invalidateQueries({ queryKey: sessionsKey(configID) }); }, }); @@ -87,7 +98,8 @@ export function App() { mutationFn: (id: string) => callOp("session-delete", { id }), onSuccess: (_, id) => { if (selectedSessionID === id) setSelectedSessionID(null); - qc.invalidateQueries({ queryKey: SESSIONS_KEY }); + qc.setQueryData(sessionsKey(configID), (old = []) => old.filter((item) => item.id !== id)); + qc.invalidateQueries({ queryKey: sessionsKey(configID) }); }, }); @@ -113,7 +125,7 @@ export function App() {
-