diff --git a/api/regions.go b/api/regions.go new file mode 100644 index 000000000000..282df1db0e55 --- /dev/null +++ b/api/regions.go @@ -0,0 +1,23 @@ +package api + +import "sort" + +// Regions is used to query the regions in the cluster. +type Regions struct { + client *Client +} + +// Regions returns a handle on the allocs endpoints. +func (c *Client) Regions() *Regions { + return &Regions{client: c} +} + +// List returns a list of all of the regions. +func (r *Regions) List() ([]string, error) { + var resp []string + if _, err := r.client.query("/v1/regions", &resp, nil); err != nil { + return nil, err + } + sort.Strings(resp) + return resp, nil +} diff --git a/api/regions_test.go b/api/regions_test.go new file mode 100644 index 000000000000..d35e7fc592e6 --- /dev/null +++ b/api/regions_test.go @@ -0,0 +1,42 @@ +package api + +import ( + "fmt" + "testing" + + "github.com/hashicorp/nomad/testutil" +) + +func TestRegionsList(t *testing.T) { + c1, s1 := makeClient(t, nil, func(c *testutil.TestServerConfig) { + c.Region = "regionA" + }) + defer s1.Stop() + + c2, s2 := makeClient(t, nil, func(c *testutil.TestServerConfig) { + c.Region = "regionB" + }) + defer s2.Stop() + + // Join the servers + if _, err := c2.Agent().Join(s1.SerfAddr); err != nil { + t.Fatalf("err: %v", err) + } + + // Regions returned and sorted + testutil.WaitForResult(func() (bool, error) { + regions, err := c1.Regions().List() + if err != nil { + return false, err + } + if n := len(regions); n != 2 { + return false, fmt.Errorf("expected 2 regions, got: %d", n) + } + if regions[0] != "regionA" || regions[1] != "regionB" { + return false, fmt.Errorf("bad: %#v", regions) + } + return true, nil + }, func(err error) { + t.Fatalf("err: %v", err) + }) +} diff --git a/command/agent/http.go b/command/agent/http.go index ec306d00b5ae..1478c9d5a5b0 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -109,6 +109,8 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) { s.mux.HandleFunc("/v1/agent/force-leave", s.wrap(s.AgentForceLeaveRequest)) s.mux.HandleFunc("/v1/agent/servers", s.wrap(s.AgentServersRequest)) + s.mux.HandleFunc("/v1/regions", s.wrap(s.RegionListRequest)) + s.mux.HandleFunc("/v1/status/leader", s.wrap(s.StatusLeaderRequest)) s.mux.HandleFunc("/v1/status/peers", s.wrap(s.StatusPeersRequest)) diff --git a/command/agent/region_endpoint.go b/command/agent/region_endpoint.go new file mode 100644 index 000000000000..538adefb4f72 --- /dev/null +++ b/command/agent/region_endpoint.go @@ -0,0 +1,24 @@ +package agent + +import ( + "net/http" + + "github.com/hashicorp/nomad/nomad/structs" +) + +func (s *HTTPServer) RegionListRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + if req.Method != "GET" { + return nil, CodedError(405, ErrInvalidMethod) + } + + var args structs.GenericRequest + if s.parse(resp, req, &args.Region, &args.QueryOptions) { + return nil, nil + } + + var regions []string + if err := s.agent.RPC("Region.List", &args, ®ions); err != nil { + return nil, err + } + return regions, nil +} diff --git a/command/agent/region_endpoint_test.go b/command/agent/region_endpoint_test.go new file mode 100644 index 000000000000..006c7a1596d2 --- /dev/null +++ b/command/agent/region_endpoint_test.go @@ -0,0 +1,29 @@ +package agent + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestHTTP_RegionList(t *testing.T) { + httpTest(t, nil, func(s *TestServer) { + // Make the HTTP request + req, err := http.NewRequest("GET", "/v1/regions", nil) + if err != nil { + t.Fatalf("err: %v", err) + } + respW := httptest.NewRecorder() + + // Make the request + obj, err := s.Server.RegionListRequest(respW, req) + if err != nil { + t.Fatalf("err: %v", err) + } + + out := obj.([]string) + if len(out) != 1 || out[0] != "global" { + t.Fatalf("unexpected regions: %#v", out) + } + }) +} diff --git a/nomad/regions_endpoint.go b/nomad/regions_endpoint.go new file mode 100644 index 000000000000..b9d2cd0f14b5 --- /dev/null +++ b/nomad/regions_endpoint.go @@ -0,0 +1,16 @@ +package nomad + +import "github.com/hashicorp/nomad/nomad/structs" + +// Region is used to query and list the known regions +type Region struct { + srv *Server +} + +// List is used to list all of the known regions. No leader forwarding is +// required for this endpoint because memberlist is used to populate the +// peers list we read from. +func (r *Region) List(args *structs.GenericRequest, reply *[]string) error { + *reply = r.srv.Regions() + return nil +} diff --git a/nomad/regions_endpoint_test.go b/nomad/regions_endpoint_test.go new file mode 100644 index 000000000000..cca212427c2e --- /dev/null +++ b/nomad/regions_endpoint_test.go @@ -0,0 +1,46 @@ +package nomad + +import ( + "fmt" + "testing" + + "github.com/hashicorp/net-rpc-msgpackrpc" + "github.com/hashicorp/nomad/nomad/structs" + "github.com/hashicorp/nomad/testutil" +) + +func TestRegionList(t *testing.T) { + // Make the servers + s1 := testServer(t, func(c *Config) { + c.Region = "region1" + }) + defer s1.Shutdown() + codec := rpcClient(t, s1) + + s2 := testServer(t, func(c *Config) { + c.Region = "region2" + }) + defer s2.Shutdown() + + // Join the servers + s2Addr := fmt.Sprintf("127.0.0.1:%d", + s2.config.SerfConfig.MemberlistConfig.BindPort) + if n, err := s1.Join([]string{s2Addr}); err != nil || n != 1 { + t.Fatalf("Failed joining: %v (%d joined)", err, n) + } + + // Query the regions list + testutil.WaitForResult(func() (bool, error) { + var arg structs.GenericRequest + var out []string + if err := msgpackrpc.CallWithCodec(codec, "Region.List", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + if len(out) != 2 || out[0] != "region1" || out[1] != "region2" { + t.Fatalf("unexpected regions: %v", out) + } + return true, nil + }, func(err error) { + t.Fatalf("err: %v", err) + }) +} diff --git a/nomad/server.go b/nomad/server.go index 1c16f55d057e..a7236e00474f 100644 --- a/nomad/server.go +++ b/nomad/server.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "reflect" + "sort" "strconv" "sync" "time" @@ -134,6 +135,7 @@ type endpoints struct { Eval *Eval Plan *Plan Alloc *Alloc + Region *Region } // NewServer is used to construct a new Nomad server from the @@ -353,6 +355,7 @@ func (s *Server) setupRPC(tlsWrap tlsutil.DCWrapper) error { s.endpoints.Eval = &Eval{s} s.endpoints.Plan = &Plan{s} s.endpoints.Alloc = &Alloc{s} + s.endpoints.Region = &Region{s} // Register the handlers s.rpcServer.Register(s.endpoints.Status) @@ -361,6 +364,7 @@ func (s *Server) setupRPC(tlsWrap tlsutil.DCWrapper) error { s.rpcServer.Register(s.endpoints.Eval) s.rpcServer.Register(s.endpoints.Plan) s.rpcServer.Register(s.endpoints.Alloc) + s.rpcServer.Register(s.endpoints.Region) list, err := net.ListenTCP("tcp", s.config.RPCAddr) if err != nil { @@ -612,6 +616,19 @@ func (s *Server) State() *state.StateStore { return s.fsm.State() } +// Regions returns the known regions in the cluster. +func (s *Server) Regions() []string { + s.peerLock.RLock() + defer s.peerLock.RUnlock() + + regions := make([]string, 0, len(s.peers)) + for region, _ := range s.peers { + regions = append(regions, region) + } + sort.Strings(regions) + return regions +} + // inmemCodec is used to do an RPC call without going over a network type inmemCodec struct { method string diff --git a/nomad/server_test.go b/nomad/server_test.go index b35d293cb803..1ee4e7a65ffb 100644 --- a/nomad/server_test.go +++ b/nomad/server_test.go @@ -7,6 +7,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/hashicorp/nomad/testutil" ) var nextPort uint32 = 15000 @@ -86,3 +88,34 @@ func TestServer_RPC(t *testing.T) { t.Fatalf("err: %v", err) } } + +func TestServer_Regions(t *testing.T) { + // Make the servers + s1 := testServer(t, func(c *Config) { + c.Region = "region1" + }) + defer s1.Shutdown() + + s2 := testServer(t, func(c *Config) { + c.Region = "region2" + }) + defer s2.Shutdown() + + // Join them together + s2Addr := fmt.Sprintf("127.0.0.1:%d", + s2.config.SerfConfig.MemberlistConfig.BindPort) + if n, err := s1.Join([]string{s2Addr}); err != nil || n != 1 { + t.Fatalf("Failed joining: %v (%d joined)", err, n) + } + + // Try listing the regions + testutil.WaitForResult(func() (bool, error) { + out := s1.Regions() + if len(out) != 2 || out[0] != "region1" || out[1] != "region2" { + return false, fmt.Errorf("unexpected regions: %v", out) + } + return true, nil + }, func(err error) { + t.Fatalf("err: %v", err) + }) +} diff --git a/website/source/docs/http/regions.html.md b/website/source/docs/http/regions.html.md new file mode 100644 index 000000000000..5de35509c597 --- /dev/null +++ b/website/source/docs/http/regions.html.md @@ -0,0 +1,38 @@ +--- +layout: "http" +page_title: "HTTP API: /v1/regions" +sidebar_current: "docs-http-regions" +description: > + The '/v1/regions' endpoint lists the known cluster regions. +--- + +# /v1/regions + +## GET + +
+
Description
+
+ Returns the known region names. +
+ +
Method
+
GET
+ +
URL
+
`/v1/regions`
+ +
Parameters
+
+ None +
+ +
Returns
+
+ + ```javascript + ["region1","region2"] + ``` + +
+
diff --git a/website/source/layouts/http.erb b/website/source/layouts/http.erb index 1589df8088ec..f22413041c13 100644 --- a/website/source/layouts/http.erb +++ b/website/source/layouts/http.erb @@ -89,6 +89,10 @@ + > + Regions + + > Status