diff --git a/CHANGELOG.md b/CHANGELOG.md index adb0005cea..96a2fc54c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,14 @@ 1. [#1115](https://github.com/influxdata/chronograf/pull/1115): Fix Basepath issue where content would fail to render under certain circumstances 1. [#1173](https://github.com/influxdata/chronograf/pull/1173): Fix saving email in Kapacitor alerts 1. [#979](https://github.com/influxdata/chronograf/issues/979): Fix empty tags for non-default retention policies + 1. [#1179](https://github.com/influxdata/chronograf/pull/1179): Admin Databases Page will render a database without retention policies ### Features 1. [#1112](https://github.com/influxdata/chronograf/pull/1112): Add ability to delete a dashboard 1. [#1120](https://github.com/influxdata/chronograf/pull/1120): Allow users to update user passwords. 1. [#1129](https://github.com/influxdata/chronograf/pull/1129): Allow InfluxDB and Kapacitor configuration via ENV vars or CLI options 1. [#1130](https://github.com/influxdata/chronograf/pull/1130): Add loading spinner to Alert History page. + 1. [#1168](https://github.com/influxdata/chronograf/issue/1168): Expand support for --basepath on some load balancers ### UI Improvements 1. [#1101](https://github.com/influxdata/chronograf/pull/1101): Compress InfluxQL responses with gzip diff --git a/chronograf.go b/chronograf.go index 83a246703b..33ab74f204 100644 --- a/chronograf.go +++ b/chronograf.go @@ -42,6 +42,19 @@ type Logger interface { Writer() *io.PipeWriter } +// Router is an abstracted Router based on the API provided by the +// julienschmidt/httprouter package. +type Router interface { + http.Handler + GET(string, http.HandlerFunc) + PATCH(string, http.HandlerFunc) + POST(string, http.HandlerFunc) + DELETE(string, http.HandlerFunc) + PUT(string, http.HandlerFunc) + + Handler(string, string, http.Handler) +} + // Assets returns a handler to serve the website. type Assets interface { Handler() http.Handler diff --git a/server/databases.go b/server/databases.go index 1e04566098..805e2d3f90 100644 --- a/server/databases.go +++ b/server/databases.go @@ -16,12 +16,12 @@ type dbLinks struct { } type dbResponse struct { - Name string `json:"name"` // a unique string identifier for the database - Duration string `json:"duration,omitempty"` // the duration (when creating a default retention policy) - Replication int32 `json:"replication,omitempty"` // the replication factor (when creating a default retention policy) - ShardDuration string `json:"shardDuration,omitempty"` // the shard duration (when creating a default retention policy) - RPs []rpResponse `json:"retentionPolicies,omitempty"` // RPs are the retention policies for a database - Links dbLinks `json:"links"` // Links are URI locations related to the database + Name string `json:"name"` // a unique string identifier for the database + Duration string `json:"duration,omitempty"` // the duration (when creating a default retention policy) + Replication int32 `json:"replication,omitempty"` // the replication factor (when creating a default retention policy) + ShardDuration string `json:"shardDuration,omitempty"` // the shard duration (when creating a default retention policy) + RPs []rpResponse `json:"retentionPolicies"` // RPs are the retention policies for a database + Links dbLinks `json:"links"` // Links are URI locations related to the database } // newDBResponse creates the response for the /databases endpoint diff --git a/server/mountable_router.go b/server/mountable_router.go new file mode 100644 index 0000000000..387c0016b5 --- /dev/null +++ b/server/mountable_router.go @@ -0,0 +1,58 @@ +package server + +import ( + "net/http" + + "github.com/influxdata/chronograf" +) + +var _ chronograf.Router = &MountableRouter{} + +// MountableRouter is an implementation of a chronograf.Router which supports +// prefixing each route of a Delegated chronograf.Router with a prefix. +type MountableRouter struct { + Prefix string + Delegate chronograf.Router +} + +// DELETE defines a route responding to a DELETE request that will be prefixed +// with the configured route prefix +func (mr *MountableRouter) DELETE(path string, handler http.HandlerFunc) { + mr.Delegate.DELETE(mr.Prefix+path, handler) +} + +// GET defines a route responding to a GET request that will be prefixed +// with the configured route prefix +func (mr *MountableRouter) GET(path string, handler http.HandlerFunc) { + mr.Delegate.GET(mr.Prefix+path, handler) +} + +// POST defines a route responding to a POST request that will be prefixed +// with the configured route prefix +func (mr *MountableRouter) POST(path string, handler http.HandlerFunc) { + mr.Delegate.POST(mr.Prefix+path, handler) +} + +// PUT defines a route responding to a PUT request that will be prefixed +// with the configured route prefix +func (mr *MountableRouter) PUT(path string, handler http.HandlerFunc) { + mr.Delegate.PUT(mr.Prefix+path, handler) +} + +// PATCH defines a route responding to a PATCH request that will be prefixed +// with the configured route prefix +func (mr *MountableRouter) PATCH(path string, handler http.HandlerFunc) { + mr.Delegate.PATCH(mr.Prefix+path, handler) +} + +// Handler defines a prefixed route responding to a request type specified in +// the method parameter +func (mr *MountableRouter) Handler(method string, path string, handler http.Handler) { + mr.Delegate.Handler(method, mr.Prefix+path, handler) +} + +// ServeHTTP is an implementation of http.Handler which delegates to the +// configured Delegate's implementation of http.Handler +func (mr *MountableRouter) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + mr.Delegate.ServeHTTP(rw, r) +} diff --git a/server/mountable_router_test.go b/server/mountable_router_test.go new file mode 100644 index 0000000000..6c8b2bb392 --- /dev/null +++ b/server/mountable_router_test.go @@ -0,0 +1,240 @@ +package server_test + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf/server" +) + +func Test_MountableRouter_MountsRoutesUnderPrefix(t *testing.T) { + t.Parallel() + + mr := &server.MountableRouter{ + Prefix: "/chronograf", + Delegate: httprouter.New(), + } + + expected := "Hello?! McFly?! Anybody in there?!" + mr.GET("/biff", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + fmt.Fprintf(rw, expected) + })) + + ts := httptest.NewServer(mr) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/chronograf/biff") + if err != nil { + t.Fatal("Unexpected error fetching from mounted router: err:", err) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal("Unexpected error decoding response body: err:", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatal("Expected 200 but received", resp.StatusCode) + } + + if string(body) != expected { + t.Fatalf("Unexpected response body: Want: \"%s\". Got: \"%s\"", expected, string(body)) + } +} + +func Test_MountableRouter_PrefixesPosts(t *testing.T) { + t.Parallel() + + mr := &server.MountableRouter{ + Prefix: "/chronograf", + Delegate: httprouter.New(), + } + + expected := "Great Scott!" + actual := make([]byte, len(expected)) + mr.POST("/doc", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if _, err := io.ReadFull(r.Body, actual); err != nil { + rw.WriteHeader(http.StatusInternalServerError) + } else { + rw.WriteHeader(http.StatusOK) + } + })) + + ts := httptest.NewServer(mr) + defer ts.Close() + + resp, err := http.Post(ts.URL+"/chronograf/doc", "text/plain", strings.NewReader(expected)) + if err != nil { + t.Fatal("Unexpected error posting to mounted router: err:", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatal("Expected 200 but received", resp.StatusCode) + } + + if string(actual) != expected { + t.Fatalf("Unexpected request body: Want: \"%s\". Got: \"%s\"", expected, string(actual)) + } +} + +func Test_MountableRouter_PrefixesPuts(t *testing.T) { + t.Parallel() + + mr := &server.MountableRouter{ + Prefix: "/chronograf", + Delegate: httprouter.New(), + } + + expected := "Great Scott!" + actual := make([]byte, len(expected)) + mr.PUT("/doc", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if _, err := io.ReadFull(r.Body, actual); err != nil { + rw.WriteHeader(http.StatusInternalServerError) + } else { + rw.WriteHeader(http.StatusOK) + } + })) + + ts := httptest.NewServer(mr) + defer ts.Close() + + req := httptest.NewRequest(http.MethodPut, ts.URL+"/chronograf/doc", strings.NewReader(expected)) + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + req.Header.Set("Content-Length", fmt.Sprintf("%d", len(expected))) + req.RequestURI = "" + + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatal("Unexpected error posting to mounted router: err:", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatal("Expected 200 but received", resp.StatusCode) + } + + if string(actual) != expected { + t.Fatalf("Unexpected request body: Want: \"%s\". Got: \"%s\"", expected, string(actual)) + } +} + +func Test_MountableRouter_PrefixesDeletes(t *testing.T) { + t.Parallel() + + mr := &server.MountableRouter{ + Prefix: "/chronograf", + Delegate: httprouter.New(), + } + + mr.DELETE("/proto1985", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.WriteHeader(http.StatusNoContent) + })) + + ts := httptest.NewServer(mr) + defer ts.Close() + + req := httptest.NewRequest(http.MethodDelete, ts.URL+"/chronograf/proto1985", nil) + req.RequestURI = "" + + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatal("Unexpected error sending request to mounted router: err:", err) + } + + if resp.StatusCode != http.StatusNoContent { + t.Fatal("Expected 204 but received", resp.StatusCode) + } +} + +func Test_MountableRouter_PrefixesPatches(t *testing.T) { + t.Parallel() + + type Character struct { + Name string + Items []string + } + + mr := &server.MountableRouter{ + Prefix: "/chronograf", + Delegate: httprouter.New(), + } + + biff := Character{"biff", []string{"sports almanac"}} + mr.PATCH("/1955", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + c := Character{} + err := json.NewDecoder(r.Body).Decode(&c) + if err != nil { + rw.WriteHeader(http.StatusBadRequest) + } else { + biff.Items = c.Items + rw.WriteHeader(http.StatusOK) + } + })) + + ts := httptest.NewServer(mr) + defer ts.Close() + + r, w := io.Pipe() + go func() { + _ = json.NewEncoder(w).Encode(Character{"biff", []string{}}) + w.Close() + }() + + req := httptest.NewRequest(http.MethodPatch, ts.URL+"/chronograf/1955", r) + req.RequestURI = "" + + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatal("Unexpected error sending request to mounted router: err:", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatal("Expected 200 but received", resp.StatusCode) + } + + if len(biff.Items) != 0 { + t.Fatal("Failed to alter history, biff still has the sports almanac") + } +} + +func Test_MountableRouter_PrefixesHandler(t *testing.T) { + t.Parallel() + + mr := &server.MountableRouter{ + Prefix: "/chronograf", + Delegate: httprouter.New(), + } + + mr.Handler(http.MethodGet, "/recklessAmountOfPower", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte("1.21 Gigawatts!")) + })) + + ts := httptest.NewServer(mr) + defer ts.Close() + + req := httptest.NewRequest(http.MethodGet, ts.URL+"/chronograf/recklessAmountOfPower", nil) + req.RequestURI = "" + + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatal("Unexpected error sending request to mounted router: err:", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatal("Expected 200 but received", resp.StatusCode) + } +} diff --git a/server/mux.go b/server/mux.go index 49c69cdc76..a32345e103 100644 --- a/server/mux.go +++ b/server/mux.go @@ -20,18 +20,19 @@ const ( // MuxOpts are the options for the router. Mostly related to auth. type MuxOpts struct { - Logger chronograf.Logger - Develop bool // Develop loads assets from filesystem instead of bindata - Basepath string // URL path prefix under which all chronograf routes will be mounted - UseAuth bool // UseAuth turns on Github OAuth and JWT - TokenSecret string + Logger chronograf.Logger + Develop bool // Develop loads assets from filesystem instead of bindata + Basepath string // URL path prefix under which all chronograf routes will be mounted + PrefixRoutes bool // Mounts all backend routes under route specified by the Basepath + UseAuth bool // UseAuth turns on Github OAuth and JWT + TokenSecret string ProviderFuncs []func(func(oauth2.Provider, oauth2.Mux)) } // NewMux attaches all the route handlers; handler returned servers chronograf. func NewMux(opts MuxOpts, service Service) http.Handler { - router := httprouter.New() + hr := httprouter.New() /* React Application */ assets := Assets(AssetsOpts{ @@ -46,9 +47,23 @@ func NewMux(opts MuxOpts, service Service) http.Handler { compressed := gziphandler.GzipHandler(prefixedAssets) // The react application handles all the routing if the server does not - // know about the route. This means that we never have unknown - // routes on the server. - router.NotFound = compressed + // know about the route. This means that we never have unknown routes on + // the server. + hr.NotFound = compressed + + var router chronograf.Router = hr + + // Set route prefix for all routes if basepath is present + if opts.PrefixRoutes { + router = &MountableRouter{ + Prefix: opts.Basepath, + Delegate: hr, + } + + //The assets handler is always unaware of basepaths, so the + // basepath needs to always be removed before sending requests to it + hr.NotFound = http.StripPrefix(opts.Basepath, hr.NotFound) + } /* Documentation */ router.GET("/swagger.json", Spec()) @@ -178,7 +193,7 @@ func NewMux(opts MuxOpts, service Service) http.Handler { // AuthAPI adds the OAuth routes if auth is enabled. // TODO: this function is not great. Would be good if providers added their routes. -func AuthAPI(opts MuxOpts, router *httprouter.Router) (http.Handler, AuthRoutes) { +func AuthAPI(opts MuxOpts, router chronograf.Router) (http.Handler, AuthRoutes) { auth := oauth2.NewJWT(opts.TokenSecret) routes := AuthRoutes{} for _, pf := range opts.ProviderFuncs { diff --git a/server/server.go b/server/server.go index 4efb881234..bb77d6006c 100644 --- a/server/server.go +++ b/server/server.go @@ -69,6 +69,7 @@ type Server struct { ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"` LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"error" default:"info" description:"Set the logging level" env:"LOG_LEVEL"` Basepath string `short:"p" long:"basepath" description:"A URL path prefix under which all chronograf routes will be mounted" env:"BASE_PATH"` + PrefixRoutes bool `long:"prefix-routes" description:"Force chronograf server to require that all requests to it are prefixed with the value set in --basepath" env:"PREFIX_ROUTES"` ShowVersion bool `short:"v" long:"version" description:"Show Chronograf version info"` BuildInfo BuildInfo Listener net.Listener @@ -217,6 +218,8 @@ func (s *Server) Serve(ctx context.Context) error { Logger: logger, UseAuth: s.useAuth(), ProviderFuncs: providerFuncs, + Basepath: basepath, + PrefixRoutes: s.PrefixRoutes, }, service) // Add chronograf's version header to all requests diff --git a/server/swagger.json b/server/swagger.json index 0fec463ee1..c6d96293ba 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -2259,6 +2259,18 @@ "duration": "3d", "replication": 3, "shardDuration": "3h", + "retentionPolicies": [ + { + "name": "weekly", + "duration": "7d", + "replication": 1, + "shardDuration": "7d", + "default": true, + "links": { + "self": "/chronograf/v1/ousrces/1/dbs/NOAA_water_database/rps/liquid" + } + } + ], "links": { "self": "/chronograf/v1/sources/1/dbs/NOAA_water_database", "rps": "/chronograf/v1/sources/1/dbs/NOAA_water_database/rps" @@ -2282,6 +2294,12 @@ "type": "string", "description": "the interval spanned by each shard group" }, + "retentionPolicies": { + "type": "array", + "items": { + "$ref": "#/definitions/RetentionPolicy" + } + }, "links": { "type": "object", "properties": {