Browse Source

Merge pull request #42064 from thaJeztah/swarm_ping

API: add "Swarm" header to _ping endpoint
Sebastiaan van Stijn 3 years ago
parent
commit
aaf70b5c6b

+ 5 - 0
api/server/router/system/backend.go

@@ -38,3 +38,8 @@ type Backend interface {
 type ClusterBackend interface {
 	Info() swarm.Info
 }
+
+// StatusProvider provides methods to get the swarm status of the current node.
+type StatusProvider interface {
+	Status() string
+}

+ 13 - 0
api/server/router/system/system_routes.go

@@ -13,6 +13,7 @@ import (
 	"github.com/docker/docker/api/types/events"
 	"github.com/docker/docker/api/types/filters"
 	"github.com/docker/docker/api/types/registry"
+	"github.com/docker/docker/api/types/swarm"
 	timetypes "github.com/docker/docker/api/types/time"
 	"github.com/docker/docker/api/types/versions"
 	"github.com/docker/docker/pkg/ioutils"
@@ -34,6 +35,9 @@ func (s *systemRouter) pingHandler(ctx context.Context, w http.ResponseWriter, r
 	if bv := builderVersion; bv != "" {
 		w.Header().Set("Builder-Version", string(bv))
 	}
+
+	w.Header().Set("Swarm", s.swarmStatus())
+
 	if r.Method == http.MethodHead {
 		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
 		w.Header().Set("Content-Length", "0")
@@ -43,6 +47,15 @@ func (s *systemRouter) pingHandler(ctx context.Context, w http.ResponseWriter, r
 	return err
 }
 
+func (s *systemRouter) swarmStatus() string {
+	if s.cluster != nil {
+		if p, ok := s.cluster.(StatusProvider); ok {
+			return p.Status()
+		}
+	}
+	return string(swarm.LocalNodeStateInactive)
+}
+
 func (s *systemRouter) getInfo(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
 	info := s.backend.SystemInfo()
 

+ 14 - 0
api/swagger.yaml

@@ -8377,6 +8377,13 @@ paths:
             Docker-Experimental:
               type: "boolean"
               description: "If the server is running with experimental mode enabled"
+            Swarm:
+              type: "string"
+              enum: ["inactive", "pending", "error", "locked", "active/worker", "active/manager"]
+              description: |
+                Contains information about Swarm status of the daemon,
+                and if the daemon is acting as a manager or worker node.
+              default: "inactive"
             Cache-Control:
               type: "string"
               default: "no-cache, no-store, must-revalidate"
@@ -8416,6 +8423,13 @@ paths:
             Docker-Experimental:
               type: "boolean"
               description: "If the server is running with experimental mode enabled"
+            Swarm:
+              type: "string"
+              enum: ["inactive", "pending", "error", "locked", "active/worker", "active/manager"]
+              description: |
+                Contains information about Swarm status of the daemon,
+                and if the daemon is acting as a manager or worker node.
+              default: "inactive"
             Cache-Control:
               type: "string"
               default: "no-cache, no-store, must-revalidate"

+ 10 - 0
api/types/swarm/swarm.go

@@ -213,6 +213,16 @@ type Info struct {
 	Warnings []string `json:",omitempty"`
 }
 
+// Status provides information about the current swarm status and role,
+// obtained from the "Swarm" header in the API response.
+type Status struct {
+	// NodeState represents the state of the node.
+	NodeState LocalNodeState
+
+	// ControlAvailable indicates if the node is a swarm manager.
+	ControlAvailable bool
+}
+
 // Peer represents a peer.
 type Peer struct {
 	NodeID string

+ 9 - 0
api/types/types.go

@@ -188,6 +188,15 @@ type Ping struct {
 	OSType         string
 	Experimental   bool
 	BuilderVersion BuilderVersion
+
+	// SwarmStatus provides information about the current swarm status of the
+	// engine, obtained from the "Swarm" header in the API response.
+	//
+	// It can be a nil struct if the API version does not provide this header
+	// in the ping response, or if an error occurred, in which case the client
+	// should use other ways to get the current swarm status, such as the /swarm
+	// endpoint.
+	SwarmStatus *swarm.Status
 }
 
 // ComponentVersion describes the version information for a specific component.

+ 9 - 0
client/ping.go

@@ -4,8 +4,10 @@ import (
 	"context"
 	"net/http"
 	"path"
+	"strings"
 
 	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
 	"github.com/docker/docker/errdefs"
 )
 
@@ -61,6 +63,13 @@ func parsePingResponse(cli *Client, resp serverResponse) (types.Ping, error) {
 	if bv := resp.header.Get("Builder-Version"); bv != "" {
 		ping.BuilderVersion = types.BuilderVersion(bv)
 	}
+	if si := resp.header.Get("Swarm"); si != "" {
+		parts := strings.SplitN(si, "/", 2)
+		ping.SwarmStatus = &swarm.Status{
+			NodeState:        swarm.LocalNodeState(parts[0]),
+			ControlAvailable: len(parts) == 2 && parts[1] == "manager",
+		}
+	}
 	err := cli.checkResponseErr(resp)
 	return ping, errdefs.FromStatusCode(err, resp.statusCode)
 }

+ 10 - 0
client/ping_test.go

@@ -8,6 +8,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/docker/docker/api/types/swarm"
 	"gotest.tools/v3/assert"
 	is "gotest.tools/v3/assert/cmp"
 )
@@ -25,6 +26,7 @@ func TestPingFail(t *testing.T) {
 				resp.Header = http.Header{}
 				resp.Header.Set("API-Version", "awesome")
 				resp.Header.Set("Docker-Experimental", "true")
+				resp.Header.Set("Swarm", "inactive")
 			}
 			resp.Body = io.NopCloser(strings.NewReader("some error with the server"))
 			return resp, nil
@@ -35,12 +37,15 @@ func TestPingFail(t *testing.T) {
 	assert.ErrorContains(t, err, "some error with the server")
 	assert.Check(t, is.Equal(false, ping.Experimental))
 	assert.Check(t, is.Equal("", ping.APIVersion))
+	var si *swarm.Status
+	assert.Check(t, is.Equal(si, ping.SwarmStatus))
 
 	withHeader = true
 	ping2, err := client.Ping(context.Background())
 	assert.ErrorContains(t, err, "some error with the server")
 	assert.Check(t, is.Equal(true, ping2.Experimental))
 	assert.Check(t, is.Equal("awesome", ping2.APIVersion))
+	assert.Check(t, is.Equal(swarm.Status{NodeState: "inactive"}, *ping2.SwarmStatus))
 }
 
 // TestPingWithError tests the case where there is a protocol error in the ping.
@@ -52,6 +57,7 @@ func TestPingWithError(t *testing.T) {
 			resp.Header = http.Header{}
 			resp.Header.Set("API-Version", "awesome")
 			resp.Header.Set("Docker-Experimental", "true")
+			resp.Header.Set("Swarm", "active/manager")
 			resp.Body = io.NopCloser(strings.NewReader("some error with the server"))
 			return resp, errors.New("some error")
 		}),
@@ -61,6 +67,8 @@ func TestPingWithError(t *testing.T) {
 	assert.ErrorContains(t, err, "some error")
 	assert.Check(t, is.Equal(false, ping.Experimental))
 	assert.Check(t, is.Equal("", ping.APIVersion))
+	var si *swarm.Status
+	assert.Check(t, is.Equal(si, ping.SwarmStatus))
 }
 
 // TestPingSuccess tests that we are able to get the expected API headers/ping
@@ -72,6 +80,7 @@ func TestPingSuccess(t *testing.T) {
 			resp.Header = http.Header{}
 			resp.Header.Set("API-Version", "awesome")
 			resp.Header.Set("Docker-Experimental", "true")
+			resp.Header.Set("Swarm", "active/manager")
 			resp.Body = io.NopCloser(strings.NewReader("OK"))
 			return resp, nil
 		}),
@@ -80,6 +89,7 @@ func TestPingSuccess(t *testing.T) {
 	assert.NilError(t, err)
 	assert.Check(t, is.Equal(true, ping.Experimental))
 	assert.Check(t, is.Equal("awesome", ping.APIVersion))
+	assert.Check(t, is.Equal(swarm.Status{NodeState: "active", ControlAvailable: true}, *ping.SwarmStatus))
 }
 
 // TestPingHeadFallback tests that the client falls back to GET if HEAD fails.

+ 17 - 0
daemon/cluster/swarm.go

@@ -492,6 +492,23 @@ func (c *Cluster) Info() types.Info {
 	return info
 }
 
+// Status returns a textual representation of the node's swarm status and role (manager/worker)
+func (c *Cluster) Status() string {
+	c.mu.RLock()
+	s := c.currentNodeState()
+	c.mu.RUnlock()
+
+	state := string(s.status)
+	if s.status == types.LocalNodeStateActive {
+		if s.IsActiveManager() || s.IsManager() {
+			state += "/manager"
+		} else {
+			state += "/worker"
+		}
+	}
+	return state
+}
+
 func validateAndSanitizeInitRequest(req *types.InitRequest) error {
 	var err error
 	req.ListenAddr, err = validateAddr(req.ListenAddr)

+ 15 - 0
docs/api/version-history.md

@@ -50,6 +50,21 @@ keywords: "API, Docker, rcli, REST, documentation"
   if they are not set.
 * `GET /info` now omits the `KernelMemory` and `KernelMemoryTCP` if they are not
   supported by the host or host's configuration (if cgroups v2 are in use).
+* `GET /_ping` and `HEAD /_ping` now return a `Swarm` header, which allows a
+  client to detect if Swarm is enabled on the daemon, without having to call
+  additional endpoints.
+  This change is not versioned, and affects all API versions if the daemon has
+  this patch. Clients must consider this header "optional", and fall back to
+  using other endpoints to get this information if the header is not present.
+
+  The `Swarm` header can contain one of the following values:
+
+    - "inactive"
+    - "pending"
+    - "error"
+    - "locked"
+    - "active/worker"
+    - "active/manager"
 
 ## v1.41 API changes
 

+ 43 - 0
integration/system/ping_test.go

@@ -1,11 +1,14 @@
 package system // import "github.com/docker/docker/integration/system"
 
 import (
+	"context"
 	"net/http"
 	"strings"
 	"testing"
 
+	"github.com/docker/docker/api/types/swarm"
 	"github.com/docker/docker/api/types/versions"
+	"github.com/docker/docker/testutil/daemon"
 	"github.com/docker/docker/testutil/request"
 	"gotest.tools/v3/assert"
 	"gotest.tools/v3/skip"
@@ -50,6 +53,46 @@ func TestPingHead(t *testing.T) {
 	assert.Check(t, hdr(res, "API-Version") != "")
 }
 
+func TestPingSwarmHeader(t *testing.T) {
+	skip.If(t, testEnv.IsRemoteDaemon)
+	skip.If(t, testEnv.DaemonInfo.OSType == "windows")
+
+	defer setupTest(t)()
+	d := daemon.New(t)
+	d.Start(t)
+	defer d.Stop(t)
+	client := d.NewClientT(t)
+	defer client.Close()
+	ctx := context.TODO()
+
+	t.Run("before swarm init", func(t *testing.T) {
+		res, _, err := request.Get("/_ping")
+		assert.NilError(t, err)
+		assert.Equal(t, res.StatusCode, http.StatusOK)
+		assert.Equal(t, hdr(res, "Swarm"), "inactive")
+	})
+
+	_, err := client.SwarmInit(ctx, swarm.InitRequest{ListenAddr: "127.0.0.1", AdvertiseAddr: "127.0.0.1:2377"})
+	assert.NilError(t, err)
+
+	t.Run("after swarm init", func(t *testing.T) {
+		res, _, err := request.Get("/_ping", request.Host(d.Sock()))
+		assert.NilError(t, err)
+		assert.Equal(t, res.StatusCode, http.StatusOK)
+		assert.Equal(t, hdr(res, "Swarm"), "active/manager")
+	})
+
+	err = client.SwarmLeave(ctx, true)
+	assert.NilError(t, err)
+
+	t.Run("after swarm leave", func(t *testing.T) {
+		res, _, err := request.Get("/_ping", request.Host(d.Sock()))
+		assert.NilError(t, err)
+		assert.Equal(t, res.StatusCode, http.StatusOK)
+		assert.Equal(t, hdr(res, "Swarm"), "inactive")
+	})
+}
+
 func hdr(res *http.Response, name string) string {
 	val, ok := res.Header[http.CanonicalHeaderKey(name)]
 	if !ok || len(val) == 0 {