History

feat(pluginhost): introduce browser-navigable plugin resources in Management API

- Added `resources` field in `management.register` for defining browser-accessible resources. - Updated examples and documentation to reflect resource-based paths under `/v0/resource/plugins/<pluginID>/...`. - Replaced legacy `GET` menu routes with resource-based implementations for consistent plugin behavior. - Enhanced request handling for resource paths, including proper response headers and streamlined test coverage.

Luis Pater committed 5 months agov7.1.59

Commit 44ea9ab1 parent 2aeb41c22 files · +341 −69Browse files

Showing the changes to internal (7 of 22 files). Show all

Unified · Split

7 files
  1. internal/api/server.go +23 −1
  2. internal/pluginhost/host.go +7 −0
  3. internal/pluginhost/management.go +156 −4
  4. internal/pluginhost/management_test.go +76 −15
  5. internal/pluginhost/rpc_client.go +6 −1
  6. internal/pluginhost/rpc_schema.go +2 −1
  7. internal/pluginhost/snapshot.go +2 −6
internal/api/server.go+23−1
@@ -382,7 +382,7 @@ func (s *Server) homeHeartbeatMiddleware() gin.HandlerFunc { · 381 unmodified lines
382382 }
383383 if c != nil && c.Request != nil {
384384 path := c.Request.URL.Path
385− if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || path == "/management.html" {
385+ if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || strings.HasPrefix(path, "/v0/resource/plugins/") || path == "/management.html" {
386386 c.Next()
387387 return
388388 }
@@ -811,6 +811,10 @@ func (s *Server) pluginManagementNoRoute(c *gin.Context) { · 422 unmodified lines
811811 return
812812 }
813813 path := c.Request.URL.Path
814+ if strings.HasPrefix(path, "/v0/resource/plugins/") {
815+ s.pluginResourceNoRoute(c)
816+ return
817+ }
814818 if path != "/v0/management" && !strings.HasPrefix(path, "/v0/management/") {
815819 c.AbortWithStatus(http.StatusNotFound)
816820 return
@@ -837,6 +841,24 @@ func (s *Server) pluginManagementNoRoute(c *gin.Context) { · 20 unmodified lines
837841 c.AbortWithStatus(http.StatusNotFound)
838842 }
839843
844+func (s *Server) pluginResourceNoRoute(c *gin.Context) {
845+ if s == nil || c == nil || c.Request == nil || c.Request.URL == nil {
846+ if c != nil {
847+ c.AbortWithStatus(http.StatusNotFound)
848+ }
849+ return
850+ }
851+ if s.cfg == nil || s.cfg.Home.Enabled || s.pluginHost == nil {
852+ c.AbortWithStatus(http.StatusNotFound)
853+ return
854+ }
855+ if s.pluginHost.ServeResourceHTTP(c.Writer, c.Request) {
856+ c.Abort()
857+ return
858+ }
859+ c.AbortWithStatus(http.StatusNotFound)
860+}
861+
840862 func (s *Server) serveManagementControlPanel(c *gin.Context) {
841863 cfg := s.cfg
842864 if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel {
internal/pluginhost/host.go+7−0
@@ -37,6 +37,7 @@ type Host struct { · 36 unmodified lines
3737 commandLineFlags map[string]commandLineFlagRecord
3838 commandLineHits map[string]struct{}
3939 managementRoutes map[string]managementRouteRecord
40+ resourceRoutes map[string]resourceRouteRecord
4041 streams *streamBridge
4142 httpStreams *hostHTTPStreamBridge
4243 callbackContexts *callbackContextRegistry
@@ -58,6 +59,7 @@ func New() *Host { · 15 unmodified lines
5859 commandLineFlags: make(map[string]commandLineFlagRecord),
5960 commandLineHits: make(map[string]struct{}),
6061 managementRoutes: make(map[string]managementRouteRecord),
62+ resourceRoutes: make(map[string]resourceRouteRecord),
6163 streams: newStreamBridge(),
6264 httpStreams: newHostHTTPStreamBridge(),
6365 callbackContexts: newCallbackContextRegistry(),
@@ -93,6 +95,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { · 29 unmodified lines
9395 h.runtimeConfig = cfg
9496
9597 if !rc.Enabled {
98+ h.managementRoutes = make(map[string]managementRouteRecord)
99+ h.resourceRoutes = make(map[string]resourceRouteRecord)
96100 h.snapshot.Store(emptySnapshot())
97101 h.mu.Unlock()
98102 h.refreshThinkingProviders(nil)
@@ -102,6 +106,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { · 3 unmodified lines
102106 files, errSelect := selectPluginFiles(rc.Dir)
103107 if errSelect != nil {
104108 log.Warnf("pluginhost: failed to select plugin files: %v", errSelect)
109+ h.managementRoutes = make(map[string]managementRouteRecord)
110+ h.resourceRoutes = make(map[string]resourceRouteRecord)
105111 h.snapshot.Store(emptySnapshot())
106112 h.mu.Unlock()
107113 h.refreshThinkingProviders(nil)
@@ -187,6 +193,7 @@ func (h *Host) ShutdownAll() { · 79 unmodified lines
187193 h.commandLineFlags = make(map[string]commandLineFlagRecord)
188194 h.commandLineHits = make(map[string]struct{})
189195 h.managementRoutes = make(map[string]managementRouteRecord)
196+ h.resourceRoutes = make(map[string]resourceRouteRecord)
190197 h.snapshot.Store(emptySnapshot())
191198 h.mu.Unlock()
192199
internal/pluginhost/management.go+156−4
@@ -12,20 +12,30 @@ import ( · 11 unmodified lines
1212 log "github.com/sirupsen/logrus"
1313 )
1414
15−const managementBasePath = "/v0/management"
15+const (
16+ managementBasePath = "/v0/management"
17+ resourcePluginBasePath = "/v0/resource/plugins"
18+ legacyPluginRoutePrefix = "/plugins"
19+)
1620
1721 type managementRouteRecord struct {
1822 pluginID string
1923 route pluginapi.ManagementRoute
2024 }
2125
22−// RegisterManagementRoutes rebuilds the plugin-owned Management API route table.
26+type resourceRouteRecord struct {
27+ pluginID string
28+ route pluginapi.ResourceRoute
29+}
30+
31+// RegisterManagementRoutes rebuilds the plugin-owned Management API and resource route tables.
2332 func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string]struct{}) {
2433 if h == nil {
2534 return
2635 }
2736
2837 nextRoutes := make(map[string]managementRouteRecord)
38+ nextResources := make(map[string]resourceRouteRecord)
2939 for _, record := range h.Snapshot().records {
3040 plugin := record.plugin.Capabilities.ManagementAPI
3141 if plugin == nil || h.isPluginFused(record.id) {
@@ -36,12 +46,19 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string · 4 unmodified lines
3646 log.Warnf("pluginhost: management registrar %s failed: %v", record.id, errRegister)
3747 continue
3848 }
49+
3950 for _, item := range resp.Routes {
4051 method, path, okRoute := normalizeManagementRoute(item)
4152 if !okRoute {
4253 log.Warnf("pluginhost: plugin %s declared invalid management route %s %s", record.id, item.Method, item.Path)
4354 continue
4455 }
56+ if routeDeclaresLegacyMenuResource(method, item) {
57+ if !registerResourceRoute(nextResources, record.id, resourceRouteFromManagementRoute(item)) {
58+ log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path)
59+ }
60+ continue
61+ }
4562 key := managementRouteKey(method, path)
4663 if _, exists := reserved[key]; exists {
4764 log.Warnf("pluginhost: plugin %s management route %s conflicts with an existing route and was skipped", record.id, key)
@@ -58,10 +75,17 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string · 10 unmodified lines
5875 route: item,
5976 }
6077 }
78+
79+ for _, item := range resp.Resources {
80+ if !registerResourceRoute(nextResources, record.id, item) {
81+ log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path)
82+ }
83+ }
6184 }
6285
6386 h.mu.Lock()
6487 h.managementRoutes = nextRoutes
88+ h.resourceRoutes = nextResources
6589 h.mu.Unlock()
6690 }
6791
@@ -77,8 +101,9 @@ func (h *Host) callManagementRegistrar(ctx context.Context, record capabilityRec · 9 unmodified lines
77101 }
78102 }()
79103 return plugin.RegisterManagement(ctx, pluginapi.ManagementRegistrationRequest{
80− Plugin: record.meta,
81− BasePath: managementBasePath,
104+ Plugin: record.meta,
105+ BasePath: managementBasePath,
106+ ResourceBasePath: resourcePluginBasePath + "/" + record.id,
82107 })
83108 }
84109
@@ -118,6 +143,75 @@ func normalizeManagementRoute(item pluginapi.ManagementRoute) (string, string, b · 33 unmodified lines
118143 return method, fullPath, true
119144 }
120145
146+func routeDeclaresLegacyMenuResource(method string, item pluginapi.ManagementRoute) bool {
147+ return strings.EqualFold(strings.TrimSpace(method), http.MethodGet) && strings.TrimSpace(item.Menu) != ""
148+}
149+
150+func resourceRouteFromManagementRoute(item pluginapi.ManagementRoute) pluginapi.ResourceRoute {
151+ return pluginapi.ResourceRoute{
152+ Path: item.Path,
153+ Menu: item.Menu,
154+ Description: item.Description,
155+ Handler: item.Handler,
156+ }
157+}
158+
159+func registerResourceRoute(routes map[string]resourceRouteRecord, pluginID string, item pluginapi.ResourceRoute) bool {
160+ path, okRoute := normalizeResourceRoute(pluginID, item)
161+ if !okRoute {
162+ return false
163+ }
164+ key := managementRouteKey(http.MethodGet, path)
165+ if _, exists := routes[key]; exists {
166+ log.Warnf("pluginhost: plugin %s resource route %s conflicts with a higher-priority plugin and was skipped", pluginID, key)
167+ return true
168+ }
169+ item.Path = path
170+ routes[key] = resourceRouteRecord{
171+ pluginID: pluginID,
172+ route: item,
173+ }
174+ return true
175+}
176+
177+func normalizeResourceRoute(pluginID string, item pluginapi.ResourceRoute) (string, bool) {
178+ if item.Handler == nil {
179+ return "", false
180+ }
181+ pluginID = strings.TrimSpace(pluginID)
182+ if pluginID == "" {
183+ return "", false
184+ }
185+
186+ path := strings.TrimSpace(item.Path)
187+ if path == "" {
188+ return "", false
189+ }
190+ if !strings.HasPrefix(path, "/") {
191+ path = "/" + path
192+ }
193+
194+ pluginBasePath := resourcePluginBasePath + "/" + pluginID
195+ if strings.HasPrefix(path, pluginBasePath+"/") {
196+ path = strings.TrimPrefix(path, pluginBasePath)
197+ } else if strings.HasPrefix(path, legacyPluginRoutePrefix+"/"+pluginID+"/") {
198+ path = strings.TrimPrefix(path, legacyPluginRoutePrefix+"/"+pluginID)
199+ }
200+ path = strings.TrimRight(path, "/")
201+ if path == "" {
202+ return "", false
203+ }
204+
205+ fullPath := pluginBasePath + path
206+ if !strings.HasPrefix(fullPath, pluginBasePath+"/") {
207+ return "", false
208+ }
209+ if strings.ContainsAny(fullPath, " \t\r\n") || strings.Contains(fullPath, ":") || strings.Contains(fullPath, "*") || strings.Contains(fullPath, "..") {
210+ return "", false
211+ }
212+ return fullPath, true
213+}
214+
121215 func managementRouteKey(method, path string) string {
122216 return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
123217 }
@@ -178,6 +272,50 @@ func (h *Host) ServeManagementHTTP(w http.ResponseWriter, r *http.Request) bool · 54 unmodified lines
178272 return true
179273 }
180274
275+// ServeResourceHTTP dispatches an unauthenticated browser-navigable resource request to a plugin route.
276+func (h *Host) ServeResourceHTTP(w http.ResponseWriter, r *http.Request) bool {
277+ if h == nil || w == nil || r == nil || r.URL == nil {
278+ return false
279+ }
280+ if !strings.EqualFold(r.Method, http.MethodGet) {
281+ return false
282+ }
283+ key := managementRouteKey(http.MethodGet, r.URL.Path)
284+ h.mu.Lock()
285+ record, okRoute := h.resourceRoutes[key]
286+ h.mu.Unlock()
287+ if !okRoute || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
288+ return false
289+ }
290+
291+ resp, errHandle := h.callResourceHandler(r.Context(), record, pluginapi.ManagementRequest{
292+ Method: http.MethodGet,
293+ Path: r.URL.Path,
294+ Headers: cloneHeader(r.Header),
295+ Query: cloneValues(r.URL.Query()),
296+ })
297+ if errHandle != nil {
298+ log.Warnf("pluginhost: resource handler %s failed: %v", record.pluginID, errHandle)
299+ http.Error(w, "plugin resource handler failed", http.StatusBadGateway)
300+ return true
301+ }
302+
303+ for keyHeader, values := range resp.Headers {
304+ for _, value := range values {
305+ w.Header().Add(keyHeader, value)
306+ }
307+ }
308+ statusCode := resp.StatusCode
309+ if statusCode == 0 {
310+ statusCode = http.StatusOK
311+ }
312+ w.WriteHeader(statusCode)
313+ if _, errWrite := w.Write(resp.Body); errWrite != nil {
314+ log.Warnf("pluginhost: failed to write plugin resource response: %v", errWrite)
315+ }
316+ return true
317+}
318+
181319 func (h *Host) callManagementHandler(ctx context.Context, record managementRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {
182320 if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
183321 return pluginapi.ManagementResponse{}, nil
@@ -191,3 +329,17 @@ func (h *Host) callManagementHandler(ctx context.Context, record managementRoute · 7 unmodified lines
191329 }()
192330 return record.route.Handler.HandleManagement(ctx, req)
193331 }
332+
333+func (h *Host) callResourceHandler(ctx context.Context, record resourceRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {
334+ if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
335+ return pluginapi.ManagementResponse{}, nil
336+ }
337+ defer func() {
338+ if recovered := recover(); recovered != nil {
339+ h.fusePlugin(record.pluginID, "ResourceHandler.HandleManagement", recovered)
340+ resp = pluginapi.ManagementResponse{}
341+ err = fmt.Errorf("resource handler panic: %v", recovered)
342+ }
343+ }()
344+ return record.route.Handler.HandleManagement(ctx, req)
345+}
internal/pluginhost/management_test.go+76−15
@@ -91,18 +91,77 @@ func TestManagementHandlerPanicFusesPlugin(t *testing.T) { · 90 unmodified lines
9191 }
9292 }
9393
94−func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) {
95− plugin := &managementPluginDouble{
96− routes: []pluginapi.ManagementRoute{
97− {
98− Method: http.MethodGet,
99− Path: "/plugins/menu/status",
94+func TestServeResourceHTTPDispatchesPluginResource(t *testing.T) {
95+ host := newHostWithRecords(capabilityRecord{
96+ id: "resource",
97+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
98+ ManagementAPI: &managementPluginDouble{resources: []pluginapi.ResourceRoute{{
99+ Path: "/status",
100100 Menu: "Status",
101101 Description: "Shows plugin status.",
102+ Handler: managementHandlerFunc(func(_ context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
103+ if req.Path != "/v0/resource/plugins/resource/status" {
104+ t.Fatalf("resource request path = %q, want normalized resource path", req.Path)
105+ }
106+ return pluginapi.ManagementResponse{
107+ Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
108+ Body: []byte("<!doctype html><title>resource</title>"),
109+ }, nil
110+ }),
111+ }}},
112+ }},
113+ })
114+ host.RegisterManagementRoutes(context.Background(), nil)
115+
116+ req := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/resource/status", nil)
117+ rec := httptest.NewRecorder()
118+ if !host.ServeResourceHTTP(rec, req) {
119+ t.Fatal("ServeResourceHTTP() = false, want true")
120+ }
121+ if rec.Code != http.StatusOK || rec.Body.String() != "<!doctype html><title>resource</title>" {
122+ t.Fatalf("response = %d %q, want 200 html", rec.Code, rec.Body.String())
123+ }
124+ if got := rec.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
125+ t.Fatalf("Content-Type = %q, want text/html; charset=utf-8", got)
126+ }
127+}
128+
129+func TestLegacyGETManagementMenuRegistersAsResource(t *testing.T) {
130+ host := newHostWithRecords(capabilityRecord{
131+ id: "legacy",
132+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
133+ ManagementAPI: &managementPluginDouble{routes: []pluginapi.ManagementRoute{{
134+ Method: http.MethodGet,
135+ Path: "/plugins/legacy/status",
136+ Menu: "Legacy Status",
137+ Description: "Shows legacy plugin status.",
102138 Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
103− return pluginapi.ManagementResponse{}, nil
139+ return pluginapi.ManagementResponse{Body: []byte("legacy")}, nil
104140 }),
105− },
141+ }}},
142+ }},
143+ })
144+ host.RegisterManagementRoutes(context.Background(), nil)
145+
146+ managementReq := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/legacy/status", nil)
147+ managementRec := httptest.NewRecorder()
148+ if host.ServeManagementHTTP(managementRec, managementReq) {
149+ t.Fatal("legacy menu route was served as Management API route")
150+ }
151+
152+ resourceReq := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/legacy/status", nil)
153+ resourceRec := httptest.NewRecorder()
154+ if !host.ServeResourceHTTP(resourceRec, resourceReq) {
155+ t.Fatal("legacy menu route was not served as resource route")
156+ }
157+ if resourceRec.Body.String() != "legacy" {
158+ t.Fatalf("resource body = %q, want legacy", resourceRec.Body.String())
159+ }
160+}
161+
162+func TestRegisteredPluginsIncludesResourceMenus(t *testing.T) {
163+ plugin := &managementPluginDouble{
164+ routes: []pluginapi.ManagementRoute{
106165 {
107166 Method: http.MethodGet,
108167 Path: "/plugins/menu/hidden",
@@ -110,11 +169,12 @@ func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) { · 1 unmodified line
110169 return pluginapi.ManagementResponse{}, nil
111170 }),
112171 },
172+ },
173+ resources: []pluginapi.ResourceRoute{
113174 {
114− Method: http.MethodPost,
115− Path: "/plugins/menu/run",
116− Menu: "Run",
117− Description: "Runs a plugin action.",
175+ Path: "/status",
176+ Menu: "Status",
177+ Description: "Shows plugin status.",
118178 Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
119179 return pluginapi.ManagementResponse{}, nil
120180 }),
@@ -136,17 +196,18 @@ func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) { · 15 unmodified lines
136196 t.Fatalf("RegisteredPlugins()[0].Menus = %#v, want one visible GET menu", plugins[0].Menus)
137197 }
138198 menu := plugins[0].Menus[0]
139− if menu.Path != "/v0/management/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." {
199+ if menu.Path != "/v0/resource/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." {
140200 t.Fatalf("menu = %#v, want normalized status menu", menu)
141201 }
142202 }
143203
144204 type managementPluginDouble struct {
145− routes []pluginapi.ManagementRoute
205+ routes []pluginapi.ManagementRoute
206+ resources []pluginapi.ResourceRoute
146207 }
147208
148209 func (p *managementPluginDouble) RegisterManagement(context.Context, pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) {
149− return pluginapi.ManagementRegistrationResponse{Routes: p.routes}, nil
210+ return pluginapi.ManagementRegistrationResponse{Routes: p.routes, Resources: p.resources}, nil
150211 }
151212
152213 type managementHandlerFunc func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error)
internal/pluginhost/rpc_client.go+6−1
@@ -498,7 +498,12 @@ func (a *rpcPluginAdapter) RegisterManagement(ctx context.Context, req pluginapi · 497 unmodified lines
498498 route.Handler = a
499499 routes = append(routes, route)
500500 }
501− return pluginapi.ManagementRegistrationResponse{Routes: routes}, nil
501+ resources := make([]pluginapi.ResourceRoute, 0, len(resp.Resources))
502+ for _, route := range resp.Resources {
503+ route.Handler = a
504+ resources = append(resources, route)
505+ }
506+ return pluginapi.ManagementRegistrationResponse{Routes: routes, Resources: resources}, nil
502507 }
503508
504509 func (a *rpcPluginAdapter) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
internal/pluginhost/rpc_schema.go+2−1
@@ -103,7 +103,8 @@ type rpcThinkingApplyRequest struct { · 102 unmodified lines
103103 }
104104
105105 type rpcManagementRegistrationResponse struct {
106− Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
106+ Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
107+ Resources []pluginapi.ResourceRoute `json:"resources,omitempty"`
107108 }
108109
109110 type rpcEmptyResponse struct{}
internal/pluginhost/snapshot.go+2−6
@@ -1,7 +1,6 @@
11 package pluginhost
22
33 import (
4− "net/http"
54 "sort"
65 "strings"
76
@@ -29,7 +28,7 @@ type RegisteredPluginInfo struct { · 21 unmodified lines
2928 Menus []RegisteredPluginMenu
3029 }
3130
32−// RegisteredPluginMenu describes a plugin-owned GET Management API menu entry.
31+// RegisteredPluginMenu describes a plugin-owned resource menu entry.
3332 type RegisteredPluginMenu struct {
3433 Path string
3534 Menu string
@@ -67,10 +66,7 @@ func (h *Host) registeredPluginMenus() map[string][]RegisteredPluginMenu { · 31 unmodified lines
6766 }
6867 h.mu.Lock()
6968 defer h.mu.Unlock()
70− for _, record := range h.managementRoutes {
71− if !strings.EqualFold(strings.TrimSpace(record.route.Method), http.MethodGet) {
72− continue
73− }
69+ for _, record := range h.resourceRoutes {
7470 menu := strings.TrimSpace(record.route.Menu)
7571 if menu == "" {
7672 continue