diff --git a/component/component_provider.go b/component/component_provider.go index e1b60f3a28d0..fdad3ba691cf 100644 --- a/component/component_provider.go +++ b/component/component_provider.go @@ -2,25 +2,37 @@ package component import ( "encoding/json" + "errors" + "strings" "time" "github.com/grafana/agent/pkg/river/encoding/riverjson" ) +var ( + // ErrComponentNotFound is returned by [Provider.GetComponent] when the + // specified component isn't found. + ErrComponentNotFound = errors.New("component not found") + + // ErrModuleNotFound is returned by [Provider.ListComponents] when the + // specified module isn't found. + ErrModuleNotFound = errors.New("module not found") +) + // A Provider is a system which exposes a list of running components. type Provider interface { // GetComponent returns information about an individual running component // given its global ID. The provided opts field configures how much detail to // return; see [InfoOptions] for more information. // - // GetComponent returns an error if a component is not found. + // GetComponent returns ErrComponentNotFound if a component is not found. GetComponent(id ID, opts InfoOptions) (*Info, error) // ListComponents returns the list of active components. The provided opts // field configures how much detail to return; see [InfoOptions] for more // information. // - // Returns an error if the provided moduleID doesn't exist. + // Returns ErrModuleNotFound if the provided moduleID doesn't exist. ListComponents(moduleID string, opts InfoOptions) ([]*Info, error) } @@ -30,6 +42,20 @@ type ID struct { LocalID string // Local ID of the component, unique to the module it is running in. } +// ParseID parses an input string of the form "LOCAL_ID" or +// "MODULE_ID/LOCAL_ID" into an ID. The final slash character is used to +// separate the ModuleID and LocalID. +func ParseID(input string) ID { + slashIndex := strings.LastIndexByte(input, '/') + if slashIndex == -1 { + return ID{LocalID: input} + } + return ID{ + ModuleID: input[:slashIndex], + LocalID: input[slashIndex+1:], + } +} + // InfoOptions is used by to determine how much information to return with // [Info]. type InfoOptions struct { diff --git a/component/module/file/file.go b/component/module/file/file.go index 01500ab4741a..f6db71112af1 100644 --- a/component/module/file/file.go +++ b/component/module/file/file.go @@ -2,7 +2,6 @@ package file import ( "context" - "net/http" "sync" "go.uber.org/atomic" @@ -55,7 +54,6 @@ type Component struct { var ( _ component.Component = (*Component)(nil) _ component.HealthComponent = (*Component)(nil) - _ component.HTTPComponent = (*Component)(nil) ) // New creates a new module.file component. @@ -140,11 +138,6 @@ func (c *Component) Update(args component.Arguments) error { return c.mod.LoadFlowContent(newArgs.Arguments, c.getContent().Value) } -// Handler implements component.HTTPComponent. -func (c *Component) Handler() http.Handler { - return c.mod.HTTPHandler() -} - // CurrentHealth implements component.HealthComponent. func (c *Component) CurrentHealth() component.Health { leastHealthy := component.LeastHealthy( diff --git a/component/module/git/git.go b/component/module/git/git.go index 5eb0e57d12d5..77fad653f2af 100644 --- a/component/module/git/git.go +++ b/component/module/git/git.go @@ -3,7 +3,6 @@ package git import ( "context" - "net/http" "path/filepath" "reflect" "sync" @@ -70,7 +69,6 @@ type Component struct { var ( _ component.Component = (*Component)(nil) _ component.HealthComponent = (*Component)(nil) - _ component.HTTPComponent = (*Component)(nil) ) // New creates a new module.git component. @@ -238,11 +236,6 @@ func (c *Component) CurrentHealth() component.Health { return component.LeastHealthy(c.health, c.mod.CurrentHealth()) } -// Handler implements component.HTTPComponent. -func (c *Component) Handler() http.Handler { - return c.mod.HTTPHandler() -} - // DebugInfo implements component.DebugComponent. func (c *Component) DebugInfo() interface{} { type DebugInfo struct { diff --git a/component/module/module.go b/component/module/module.go index 5666d467496b..2456269647c3 100644 --- a/component/module/module.go +++ b/component/module/module.go @@ -3,7 +3,6 @@ package module import ( "context" "fmt" - "net/http" "sync" "time" @@ -72,11 +71,6 @@ func (c *ModuleComponent) CurrentHealth() component.Health { return c.health } -// HTTPHandler returns the underlying module handle for the UI. -func (c *ModuleComponent) HTTPHandler() http.Handler { - return c.mod.ComponentHandler() -} - // SetHealth contains the implementation details for setHealth in a module component. func (c *ModuleComponent) setHealth(h component.Health) { c.mut.Lock() diff --git a/component/module/string/string.go b/component/module/string/string.go index a40c8a84970f..a6d413071561 100644 --- a/component/module/string/string.go +++ b/component/module/string/string.go @@ -2,7 +2,6 @@ package string import ( "context" - "net/http" "github.com/grafana/agent/component" "github.com/grafana/agent/component/module" @@ -39,7 +38,6 @@ type Component struct { var ( _ component.Component = (*Component)(nil) _ component.HealthComponent = (*Component)(nil) - _ component.HTTPComponent = (*Component)(nil) ) // New creates a new module.string component. @@ -71,10 +69,6 @@ func (c *Component) Update(args component.Arguments) error { return c.mod.LoadFlowContent(newArgs.Arguments, newArgs.Content.Value) } -func (c *Component) Handler() http.Handler { - return c.mod.HTTPHandler() -} - // CurrentHealth implements component.HealthComponent. func (c *Component) CurrentHealth() component.Health { return c.mod.CurrentHealth() diff --git a/component/registry.go b/component/registry.go index e06c38034d70..623a96b764d9 100644 --- a/component/registry.go +++ b/component/registry.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net" - "net/http" "reflect" "strings" @@ -53,10 +52,6 @@ type Module interface { // Run blocks until the provided context is canceled. The ID of a module as defined in // ModuleController.NewModule will not be released until Run returns. Run(context.Context) - - // ComponentHandler returns an HTTP handler which exposes endpoints of - // components managed by the Module. - ComponentHandler() http.Handler } // ExportFunc is used for onExport of the Module diff --git a/pkg/flow/flow_components.go b/pkg/flow/flow_components.go index 569d8f7c5a24..0899971339b7 100644 --- a/pkg/flow/flow_components.go +++ b/pkg/flow/flow_components.go @@ -16,7 +16,7 @@ func (f *Flow) GetComponent(id component.ID, opts component.InfoOptions) (*compo if id.ModuleID != "" { mod, ok := f.modules.Get(id.ModuleID) if !ok { - return nil, fmt.Errorf("component %q does not exist", id) + return nil, component.ErrComponentNotFound } return mod.f.GetComponent(component.ID{LocalID: id.LocalID}, opts) @@ -26,7 +26,7 @@ func (f *Flow) GetComponent(id component.ID, opts component.InfoOptions) (*compo node := graph.GetByID(id.LocalID) if node == nil { - return nil, fmt.Errorf("component %q does not exist", id) + return nil, component.ErrComponentNotFound } cn, ok := node.(*controller.ComponentNode) @@ -45,7 +45,7 @@ func (f *Flow) ListComponents(moduleID string, opts component.InfoOptions) ([]*c if moduleID != "" { mod, ok := f.modules.Get(moduleID) if !ok { - return nil, fmt.Errorf("module %q does not exist", moduleID) + return nil, component.ErrModuleNotFound } return mod.f.ListComponents("", opts) diff --git a/pkg/flow/flow_http.go b/pkg/flow/flow_http.go deleted file mode 100644 index 044eece030da..000000000000 --- a/pkg/flow/flow_http.go +++ /dev/null @@ -1,42 +0,0 @@ -package flow - -import ( - "net/http" - "path" - "strings" - - "github.com/gorilla/mux" - "github.com/grafana/agent/pkg/flow/internal/controller" -) - -// ComponentHandler returns an http.HandlerFunc which will delegate all requests to -// a component named by the first path segment -func (f *Flow) ComponentHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - // find node with ID - var node *controller.ComponentNode - for _, n := range f.loader.Components() { - if n.ID().String() == id { - node = n - break - } - } - if node == nil { - w.WriteHeader(http.StatusNotFound) - return - } - // TODO: potentially cache these handlers, and invalidate on component state change. - handler := node.HTTPHandler() - if handler == nil { - w.WriteHeader(http.StatusNotFound) - return - } - // Remove prefix from path, so each component can handle paths from their - // own root path. - r.URL.Path = strings.TrimPrefix(r.URL.Path, path.Join(f.opts.HTTPPathPrefix, id)) - handler.ServeHTTP(w, r) - } -} diff --git a/pkg/flow/module.go b/pkg/flow/module.go index bc8257f0247a..d85954266541 100644 --- a/pkg/flow/module.go +++ b/pkg/flow/module.go @@ -3,11 +3,9 @@ package flow import ( "context" "fmt" - "net/http" "path" "sync" - "github.com/gorilla/mux" "github.com/grafana/agent/component" "github.com/grafana/agent/pkg/cluster" "github.com/grafana/agent/pkg/flow/internal/controller" @@ -144,22 +142,6 @@ func (c *module) Run(ctx context.Context) { c.f.Run(ctx) } -// ComponentHandler returns an HTTP handler which exposes endpoints of -// components managed by the underlying flow system. -func (c *module) ComponentHandler() (_ http.Handler) { - r := mux.NewRouter() - - r.PathPrefix("/{id}/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Re-add the full path to ensure that nested controllers propagate - // requests properly. - r.URL.Path = path.Join(c.o.HTTPPath, r.URL.Path) - - c.f.ComponentHandler().ServeHTTP(w, r) - }) - - return r -} - // moduleControllerOptions holds static options for module controller. type moduleControllerOptions struct { // Logger to use for controller logs and components. A no-op logger will be diff --git a/service/http/http.go b/service/http/http.go index 1dc4a86486fb..de0e7c8d01e1 100644 --- a/service/http/http.go +++ b/service/http/http.go @@ -132,7 +132,7 @@ func (s *Service) Run(ctx context.Context, host service.Host) error { r.PathPrefix("/debug/pprof").Handler(http.DefaultServeMux) } - r.PathPrefix(s.componentHttpPathPrefix + "{id}/").Handler(s.componentHandler(host)) + r.PathPrefix(s.componentHttpPathPrefix).Handler(s.componentHandler(host)) if s.node != nil { cr, ch := s.node.Handler() @@ -196,20 +196,18 @@ func (s *Service) Run(ctx context.Context, host service.Host) error { } func (s *Service) componentHandler(host service.Host) http.HandlerFunc { - // TODO(rfratto): make this work across modules. Right now this handler - // only works for the top-level module, and forwards requests to inner - // modules. - // - // This means that the Flow controller still has HTTP logic until this is - // resolved. - return func(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] + // Trim the path prefix to get our full path. + trimmedPath := strings.TrimPrefix(r.URL.Path, s.componentHttpPathPrefix) + + // splitURLPath should only fail given an unexpected path. + componentID, componentPath, err := splitURLPath(host, trimmedPath) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "failed to parse URL path %q: %s\n", r.URL.Path, err) + } - // We pass no options in component.InfoOptions, because we're only - // interested in the component instance. - info, err := host.GetComponent(component.ID{LocalID: id}, component.InfoOptions{}) + info, err := host.GetComponent(componentID, component.InfoOptions{}) if err != nil { w.WriteHeader(http.StatusNotFound) return @@ -226,9 +224,9 @@ func (s *Service) componentHandler(host service.Host) http.HandlerFunc { return } - // Remove prefix from path, so each component can handle paths from their - // own root path. - r.URL.Path = strings.TrimPrefix(r.URL.Path, path.Join(s.componentHttpPathPrefix, id)) + // Send just the remaining path to our component so each component can + // handle paths from their own root path. + r.URL.Path = componentPath handler.ServeHTTP(w, r) } } diff --git a/service/http/split_path.go b/service/http/split_path.go new file mode 100644 index 000000000000..882dd823dde1 --- /dev/null +++ b/service/http/split_path.go @@ -0,0 +1,112 @@ +package http + +import ( + "errors" + "fmt" + "strings" + + "github.com/grafana/agent/component" + "github.com/grafana/agent/service" +) + +// splitURLPath splits a path from a URL into two parts: a component ID and the +// remaining string. +// +// For example, given a path of /prometheus.exporter.unix/metrics, the result +// will be prometheus.exporter.unix and /metrics. +// +// The "remain" portion is optional; it's valid to give a path just containing +// a component name. +func splitURLPath(host service.Host, path string) (id component.ID, remain string, err error) { + if len(path) == 0 { + return component.ID{}, "", fmt.Errorf("invalid path") + } + + // Trim leading and tailing slashes so it's not treated as part of a + // component name. + var trimmedLeadingSlash, trimmedTailingSlash bool + if path[0] == '/' { + path = path[1:] + trimmedLeadingSlash = true + } + if path[len(path)-1] == '/' { + path = path[:len(path)-1] + trimmedTailingSlash = true + } + + it := newReversePathIterator(path) + for it.Next() { + idText, path := it.Value() + componentID := component.ParseID(idText) + + _, err := host.GetComponent(componentID, component.InfoOptions{}) + if errors.Is(err, component.ErrComponentNotFound) { + continue + } else if err != nil { + return component.ID{}, "", err + } + + return componentID, preparePath(path, trimmedLeadingSlash, trimmedTailingSlash), nil + } + + return component.ID{}, "", fmt.Errorf("invalid path") +} + +func preparePath(path string, addLeadingSlash, addTrailingSlash bool) string { + if addLeadingSlash && !strings.HasPrefix(path, "/") { + path = "/" + path + } + if addTrailingSlash && !strings.HasSuffix(path, "/") { + path += "/" + } + return path +} + +type reversePathIterator struct { + path string + + slashIndex int + searchIndex int +} + +// newReversePathIterator creates a new reversePathIterator for the given path, +// where each iteration will split on another / character. +// +// The returned reversePathIterator is uninitialized; call Next to prepare it. +func newReversePathIterator(path string) *reversePathIterator { + return &reversePathIterator{ + path: path, + + slashIndex: -1, + searchIndex: -1, + } +} + +// Next advances the iterator and prepares the next element. +func (it *reversePathIterator) Next() bool { + // Special case: first iteration. + if it.searchIndex == -1 { + it.slashIndex = len(it.path) + it.searchIndex = len(it.path) + return true + } + + it.slashIndex = strings.LastIndexByte(it.path[:it.searchIndex], '/') + if it.slashIndex != -1 { + it.searchIndex = it.slashIndex + } else { + it.searchIndex = 0 + } + return it.slashIndex != -1 +} + +// Value returns the current iterator value. The before string is the string +// before the / character being split on, and the after string is the string +// after the / character being split on. +// +// The first iteration will use the entire input string as the "before". +// The final interation will split on the first / character found in the +// input path. +func (it *reversePathIterator) Value() (before string, after string) { + return it.path[:it.slashIndex], it.path[it.slashIndex:] +} diff --git a/service/http/split_path_test.go b/service/http/split_path_test.go new file mode 100644 index 000000000000..281fd8a9f7be --- /dev/null +++ b/service/http/split_path_test.go @@ -0,0 +1,117 @@ +package http + +import ( + "testing" + + "github.com/grafana/agent/component" + "github.com/grafana/agent/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_splitURLPath(t *testing.T) { + host := &fakeServiceHost{ + components: map[component.ID]struct{}{ + component.ParseID("prometheus.exporter.unix"): {}, + component.ParseID("module.string.example/prometheus.exporter.mysql.example"): {}, + component.ParseID("module.string.example/module.git.example/prometheus.exporter.mysql.example"): {}, + }, + } + + tt := []struct { + testPath string + expectID component.ID + expectPath string + }{ + // Root module component + { + testPath: "/prometheus.exporter.unix/metrics", + expectID: component.ID{LocalID: "prometheus.exporter.unix"}, + expectPath: "/metrics", + }, + // Trailing slash + { + testPath: "/prometheus.exporter.unix/metrics/", + expectID: component.ID{LocalID: "prometheus.exporter.unix"}, + expectPath: "/metrics/", + }, + // Component in module + { + testPath: "/module.string.example/prometheus.exporter.mysql.example/metrics", + expectID: component.ID{ModuleID: "module.string.example", LocalID: "prometheus.exporter.mysql.example"}, + expectPath: "/metrics", + }, + // Component in nested module + { + testPath: "/module.string.example/module.git.example/prometheus.exporter.mysql.example/metrics", + expectID: component.ID{ModuleID: "module.string.example/module.git.example", LocalID: "prometheus.exporter.mysql.example"}, + expectPath: "/metrics", + }, + // Path with multiple elements + { + testPath: "/prometheus.exporter.unix/some/path/from/component", + expectID: component.ID{LocalID: "prometheus.exporter.unix"}, + expectPath: "/some/path/from/component", + }, + // Empty path + { + testPath: "/prometheus.exporter.unix", + expectID: component.ID{LocalID: "prometheus.exporter.unix"}, + expectPath: "/", + }, + // Empty path with trailing slash + { + testPath: "/prometheus.exporter.unix/", + expectID: component.ID{LocalID: "prometheus.exporter.unix"}, + expectPath: "/", + }, + } + + for _, tc := range tt { + t.Run(tc.testPath, func(t *testing.T) { + id, remain, err := splitURLPath(host, tc.testPath) + assert.NoError(t, err) + assert.Equal(t, tc.expectID, id) + assert.Equal(t, tc.expectPath, remain) + }) + } +} + +type fakeServiceHost struct { + service.Host + components map[component.ID]struct{} +} + +func (h *fakeServiceHost) GetComponent(id component.ID, opts component.InfoOptions) (*component.Info, error) { + _, exist := h.components[id] + if exist { + return &component.Info{ID: id}, nil + } + + return nil, component.ErrComponentNotFound +} + +func Test_reversePathIterator(t *testing.T) { + path := "hello/world/this/is/a/split/path" + + type pair struct{ before, after string } + + var actual []pair + it := newReversePathIterator(path) + for it.Next() { + before, after := it.Value() + actual = append(actual, pair{before, after}) + } + + expect := []pair{ + {"hello/world/this/is/a/split/path", ""}, + {"hello/world/this/is/a/split", "/path"}, + {"hello/world/this/is/a", "/split/path"}, + {"hello/world/this/is", "/a/split/path"}, + {"hello/world/this", "/is/a/split/path"}, + {"hello/world", "/this/is/a/split/path"}, + {"hello", "/world/this/is/a/split/path"}, + } + + require.Equal(t, expect, actual) +} diff --git a/service/service.go b/service/service.go index be84dc77f41a..a8c46fdca92c 100644 --- a/service/service.go +++ b/service/service.go @@ -39,11 +39,15 @@ type Definition struct { // Host is a controller for services and Flow components. type Host interface { // GetComponent gets a running component by ID. + // + // GetComponent returns [component.ErrComponentNotFound] if a component is + // not found. GetComponent(id component.ID, opts component.InfoOptions) (*component.Info, error) // ListComponents lists all running components within a given module. // - // Returns an error if the provided moduleID doesn't exist. + // Returns [component.ErrModuleNotFound] if the provided moduleID doesn't + // exist. ListComponents(moduleID string, opts component.InfoOptions) ([]*component.Info, error) // GetServiceConsumers gets the list of components and diff --git a/web/api/api.go b/web/api/api.go index 2c34b263a3e5..874d83bd73bb 100644 --- a/web/api/api.go +++ b/web/api/api.go @@ -8,7 +8,6 @@ import ( "encoding/json" "net/http" "path" - "strings" "github.com/gorilla/mux" "github.com/grafana/agent/component" @@ -68,7 +67,7 @@ func (f *FlowAPI) listComponentsHandler() http.HandlerFunc { func (f *FlowAPI) getComponentHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) - requestedComponent := parseID(vars["id"]) + requestedComponent := component.ParseID(vars["id"]) component, err := f.flow.GetComponent(requestedComponent, component.InfoOptions{ GetHealth: true, @@ -90,17 +89,6 @@ func (f *FlowAPI) getComponentHandler() http.HandlerFunc { } } -func parseID(input string) component.ID { - slashIndex := strings.LastIndexByte(input, '/') - if slashIndex == -1 { - return component.ID{LocalID: input} - } - return component.ID{ - ModuleID: input[:slashIndex], - LocalID: input[slashIndex+1:], - } -} - func (f *FlowAPI) getClusteringPeersHandler() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { // TODO(@tpaschalis) Detect if clustering is disabled and propagate to