diff --git a/pkg/grafana/dashboard-handler.go b/pkg/grafana/dashboard-handler.go index d6d1dbca..c2860940 100644 --- a/pkg/grafana/dashboard-handler.go +++ b/pkg/grafana/dashboard-handler.go @@ -259,22 +259,22 @@ func (h *DashboardHandler) Detect(data map[string]any) bool { func (h *DashboardHandler) GetProxyEndpoints(s grizzly.Server) []grizzly.HTTPEndpoint { return []grizzly.HTTPEndpoint{ { - Method: "GET", + Method: http.MethodGet, URL: "/d/{uid}/{slug}", - Handler: h.resourceFromQueryParameterMiddleware(s, "grizzly_from_file", h.RootDashboardPageHandler(s)), + Handler: h.resourceFromQueryParameterMiddleware(s, "grizzly_from_file", authenticateAndProxyHandler(s, h.Provider)), }, { - Method: "GET", + Method: http.MethodGet, URL: "/api/dashboards/uid/{uid}", Handler: h.DashboardJSONGetHandler(s), }, { - Method: "POST", + Method: http.MethodPost, URL: "/api/dashboards/db", Handler: h.DashboardJSONPostHandler(s), }, { - Method: "POST", + Method: http.MethodPost, URL: "/api/dashboards/db/", Handler: h.DashboardJSONPostHandler(s), }, @@ -294,64 +294,17 @@ func (h *DashboardHandler) resourceFromQueryParameterMiddleware(s grizzly.Server } } -func (h *DashboardHandler) RootDashboardPageHandler(s grizzly.Server) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Content-Type", "text/html") - config := h.Provider.(ClientProvider).Config() - if config.URL == "" { - grizzly.SendError(w, "Error: No Grafana URL configured", fmt.Errorf("no Grafana URL configured"), 400) - return - } - req, err := http.NewRequest("GET", config.URL+r.URL.Path, nil) - if err != nil { - grizzly.SendError(w, http.StatusText(500), err, 500) - return - } - - if config.User != "" { - req.SetBasicAuth(config.User, config.Token) - } else if config.Token != "" { - req.Header.Set("Authorization", "Bearer "+config.Token) - } - - req.Header.Set("User-Agent", s.UserAgent) - - client := &http.Client{} - resp, err := client.Do(req) - - if err == nil { - body, _ := io.ReadAll(resp.Body) - writeOrLog(w, body) - return - } - - msg := "" - if config.Token == "" { - msg += "

Warning: No service account token specified.

" - } - - if resp.StatusCode == 302 { - w.WriteHeader(http.StatusUnauthorized) - fmt.Fprintf(w, "%s

Authentication error

", msg) - } else { - body, _ := io.ReadAll(resp.Body) - w.WriteHeader(resp.StatusCode) - fmt.Fprintf(w, "%s%s", msg, string(body)) - } - } -} - func (h *DashboardHandler) DashboardJSONGetHandler(s grizzly.Server) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { uid := chi.URLParam(r, "uid") if uid == "" { - grizzly.SendError(w, "No UID specified", fmt.Errorf("no UID specified within the URL"), 400) + grizzly.SendError(w, "No UID specified", fmt.Errorf("no UID specified within the URL"), http.StatusBadRequest) return } resource, found := s.Resources.Find(grizzly.NewResourceRef("Dashboard", uid)) if !found { - grizzly.SendError(w, fmt.Sprintf("Dashboard with UID %s not found", uid), fmt.Errorf("dashboard with UID %s not found", uid), 404) + grizzly.SendError(w, fmt.Sprintf("Dashboard with UID %s not found", uid), fmt.Errorf("dashboard with UID %s not found", uid), http.StatusNotFound) return } if resource.GetSpecValue("version") == nil { @@ -379,18 +332,18 @@ func (h *DashboardHandler) DashboardJSONPostHandler(s grizzly.Server) http.Handl content, _ := io.ReadAll(r.Body) err := json.Unmarshal(content, &resp) if err != nil { - grizzly.SendError(w, "Error parsing JSON", err, 400) + grizzly.SendError(w, "Error parsing JSON", err, http.StatusBadRequest) return } uid, ok := resp.Dashboard["uid"].(string) if !ok || uid == "" { - grizzly.SendError(w, "Dashboard has no UID", fmt.Errorf("dashboard has no UID"), 400) + grizzly.SendError(w, "Dashboard has no UID", fmt.Errorf("dashboard has no UID"), http.StatusBadRequest) return } resource, ok := s.Resources.Find(grizzly.NewResourceRef(h.Kind(), uid)) if !ok { err := fmt.Errorf("unknown dashboard: %s", uid) - grizzly.SendError(w, err.Error(), err, 400) + grizzly.SendError(w, err.Error(), err, http.StatusBadRequest) return } @@ -398,7 +351,7 @@ func (h *DashboardHandler) DashboardJSONPostHandler(s grizzly.Server) http.Handl err = s.UpdateResource(uid, resource) if err != nil { - grizzly.SendError(w, err.Error(), err, 500) + grizzly.SendError(w, err.Error(), err, http.StatusInternalServerError) return } jout := map[string]interface{}{ diff --git a/pkg/grafana/datasource-handler.go b/pkg/grafana/datasource-handler.go index 1c550da4..85151e6e 100644 --- a/pkg/grafana/datasource-handler.go +++ b/pkg/grafana/datasource-handler.go @@ -9,12 +9,15 @@ import ( "strconv" "strings" + "github.com/go-chi/chi" "github.com/go-openapi/runtime" "github.com/grafana/grafana-openapi-client-go/client/datasources" "github.com/grafana/grafana-openapi-client-go/models" "github.com/grafana/grizzly/pkg/grizzly" ) +const DatasourceKind = "Datasource" + // DatasourceHandler is a Grizzly Handler for Grafana datasources type DatasourceHandler struct { grizzly.BaseHandler @@ -23,7 +26,7 @@ type DatasourceHandler struct { // NewDatasourceHandler returns a new Grizzly Handler for Grafana datasources func NewDatasourceHandler(provider grizzly.Provider) *DatasourceHandler { return &DatasourceHandler{ - BaseHandler: grizzly.NewBaseHandler(provider, "Datasource", false), + BaseHandler: grizzly.NewBaseHandler(provider, DatasourceKind, false), } } @@ -77,6 +80,67 @@ func (h *DatasourceHandler) GetSpecUID(resource grizzly.Resource) (string, error } } +func (h *DatasourceHandler) ProxyURL(uid string) string { + return fmt.Sprintf("/connections/datasources/edit/%s", uid) +} + +func (h *DatasourceHandler) GetProxyEndpoints(s grizzly.Server) []grizzly.HTTPEndpoint { + return []grizzly.HTTPEndpoint{ + { + Method: http.MethodGet, + URL: "/connections/datasources/edit/{uid}", + Handler: authenticateAndProxyHandler(s, h.Provider), + }, + { + Method: http.MethodGet, + URL: "/api/datasources/uid/{uid}", + Handler: h.DatasourceJSONGetHandler(s), + }, + } +} + +func (h *DatasourceHandler) DatasourceJSONGetHandler(s grizzly.Server) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid := chi.URLParam(r, "uid") + if uid == "" { + grizzly.SendError(w, "No UID specified", fmt.Errorf("no UID specified within the URL"), http.StatusBadRequest) + return + } + + resource, found := s.Resources.Find(grizzly.NewResourceRef(DatasourceKind, uid)) + if !found { + grizzly.SendError(w, fmt.Sprintf("Datasource with UID %s not found", uid), fmt.Errorf("datasource with UID %s not found", uid), http.StatusNotFound) + return + } + + // These values are required for the page to load properly. + if resource.GetSpecValue("version") == nil { + resource.SetSpecValue("version", 1) + } + if resource.GetSpecValue("id") == nil { + resource.SetSpecValue("id", 1) + } + + // we don't support saving datasources via the proxy yet + resource.SetSpecValue("readOnly", true) + + // to remove some "missing permissions warning" and enable some features + resource.SetSpecValue("accessControl", map[string]any{ + "datasources.caching:read": true, + "datasources.caching:write": false, + "datasources.id:read": true, + "datasources.permissions:read": true, + "datasources.permissions:write": true, + "datasources:delete": false, + "datasources:query": true, + "datasources:read": true, + "datasources:write": true, + }) + + writeJSONOrLog(w, resource.Spec()) + } +} + // GetByUID retrieves JSON for a resource from an endpoint, by UID func (h *DatasourceHandler) GetByUID(uid string) (*grizzly.Resource, error) { return h.getRemoteDatasource(uid) @@ -151,11 +215,12 @@ func (h *DatasourceHandler) getRemoteDatasourceList() ([]string, error) { return nil, err } - datasourcesOk, err := client.Datasources.GetDataSources() + response, err := client.Datasources.GetDataSources() if err != nil { return nil, err } - datasources := datasourcesOk.GetPayload() + + datasources := response.GetPayload() uids := make([]string, len(datasources)) for i, datasource := range datasources { @@ -176,10 +241,12 @@ func (h *DatasourceHandler) postDatasource(resource grizzly.Resource) error { if err != nil { return err } + client, err := h.Provider.(ClientProvider).Client() if err != nil { return err } + _, err = client.Datasources.AddDataSource(&datasource, nil) return err } @@ -202,6 +269,7 @@ func (h *DatasourceHandler) putDatasource(resource grizzly.Resource) error { if err != nil { return err } + client, err := h.Provider.(ClientProvider).Client() if err != nil { return err diff --git a/pkg/grafana/errors.go b/pkg/grafana/errors.go index 625e671f..54c58dee 100644 --- a/pkg/grafana/errors.go +++ b/pkg/grafana/errors.go @@ -34,5 +34,6 @@ func writeJSONOrLog(w http.ResponseWriter, content any) { log.Errorf("error marshalling response to JSON: %v", err) } + w.Header().Set("Content-Type", "application/json") writeOrLog(w, responseJSON) } diff --git a/pkg/grafana/utils.go b/pkg/grafana/utils.go index d7120a46..2a46c13d 100644 --- a/pkg/grafana/utils.go +++ b/pkg/grafana/utils.go @@ -2,10 +2,14 @@ package grafana import ( "encoding/json" + "fmt" + "io" + "net/http" "regexp" gclient "github.com/grafana/grafana-openapi-client-go/client" "github.com/grafana/grafana-openapi-client-go/models" + "github.com/grafana/grizzly/pkg/grizzly" ) var ( @@ -45,3 +49,52 @@ func structToMap(s interface{}) (map[string]interface{}, error) { return result, nil } + +func authenticateAndProxyHandler(s grizzly.Server, provider grizzly.Provider) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "text/html") + + config := provider.(ClientProvider).Config() + if config.URL == "" { + grizzly.SendError(w, "Error: No Grafana URL configured", fmt.Errorf("no Grafana URL configured"), http.StatusBadRequest) + return + } + + req, err := http.NewRequest(http.MethodGet, config.URL+r.URL.Path, nil) + if err != nil { + grizzly.SendError(w, http.StatusText(http.StatusInternalServerError), err, http.StatusInternalServerError) + return + } + + if config.User != "" { + req.SetBasicAuth(config.User, config.Token) + } else if config.Token != "" { + req.Header.Set("Authorization", "Bearer "+config.Token) + } + + req.Header.Set("User-Agent", s.UserAgent) + + client := &http.Client{} + resp, err := client.Do(req) + + if err == nil { + body, _ := io.ReadAll(resp.Body) + writeOrLog(w, body) + return + } + + msg := "" + if config.Token == "" { + msg += "

Warning: No service account token specified.

" + } + + if resp.StatusCode == http.StatusFound { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, "%s

Authentication error

", msg) + } else { + body, _ := io.ReadAll(resp.Body) + w.WriteHeader(resp.StatusCode) + fmt.Fprintf(w, "%s%s", msg, string(body)) + } + } +} diff --git a/pkg/grizzly/embed/templates/proxy/index.html.tmpl b/pkg/grizzly/embed/templates/proxy/index.html.tmpl index 641e48a7..544f293e 100644 --- a/pkg/grizzly/embed/templates/proxy/index.html.tmpl +++ b/pkg/grizzly/embed/templates/proxy/index.html.tmpl @@ -10,7 +10,7 @@
- {{ if ne (len .ParseErrors) 0 }} + {{ if ne (len .ParseErrors) 0 }}

Errors

{{ range .ParseErrors }} @@ -20,18 +20,31 @@ {{ end }} {{ end }} - {{ end }} -

Available dashboards

+ {{ end }} + +

Dashboards

- + +

Datasources

+ +
diff --git a/pkg/grizzly/resources.go b/pkg/grizzly/resources.go index 978868ff..1d984fc2 100644 --- a/pkg/grizzly/resources.go +++ b/pkg/grizzly/resources.go @@ -240,6 +240,12 @@ func (r Resources) Filter(predicate func(Resource) bool) Resources { return NewResources(filtered...) } +func (r Resources) OfKind(kind string) Resources { + return r.Filter(func(resource Resource) bool { + return resource.Kind() == kind + }) +} + func (r Resources) ForEach(callback func(Resource) error) error { for pair := r.collection.Oldest(); pair != nil; pair = pair.Next() { if err := callback(pair.Value); err != nil { diff --git a/pkg/grizzly/server.go b/pkg/grizzly/server.go index 5885417d..12d4b2f2 100644 --- a/pkg/grizzly/server.go +++ b/pkg/grizzly/server.go @@ -103,7 +103,12 @@ var mustProxyGET = []string{ "/public/*", "/api/datasources/proxy/*", "/api/datasources/*", + "/api/plugins", "/api/plugins/*", + "/api/plugin-proxy/*", + "/api/instance/plugins", + "/api/instance/provisioned-plugins", + "/api/usage/datasource/*", "/avatar/*", } var mustProxyPOST = []string{ @@ -253,14 +258,17 @@ func (s *Server) ParseBytes(b []byte) (Resources, error) { return Resources{}, err } defer os.Remove(f.Name()) + _, err = f.Write(b) if err != nil { return Resources{}, err } + err = f.Close() if err != nil { return Resources{}, err } + resources, err := s.parser.Parse(f.Name(), s.parserOpts) s.parserErr = err s.Resources.Merge(resources) @@ -358,11 +366,13 @@ func (s *Server) IframeHandler(w http.ResponseWriter, r *http.Request) { SendError(w, fmt.Sprintf("Error getting handler for %s/%s", kind, name), err, 500) return } + proxyHandler, ok := handler.(ProxyHandler) if !ok { SendError(w, fmt.Sprintf("%s is not supported by the Grizzly server", kind), fmt.Errorf("%s is not supported by the Grizzly server", kind), 500) return } + url := proxyHandler.ProxyURL(name) templateVars := map[string]any{ "Port": s.port, @@ -388,7 +398,7 @@ func (s *Server) RootHandler(w http.ResponseWriter, _ *http.Request) { } templateVars := map[string]any{ - "Resources": s.Resources.AsList(), + "Resources": s.Resources, "ParseErrors": parseErrors, "ServerPort": s.port, "CurrentContext": s.CurrentContext, @@ -422,8 +432,10 @@ func (s *Server) UpdateResource(name string, resource Resource) error { if !found { return fmt.Errorf("dashboard with UID %s not found", name) } + if !existing.Source.Rewritable { return fmt.Errorf("the source for this dashboard is not rewritable") } + return os.WriteFile(existing.Source.Path, out, 0644) }