Parcourir la source

Merge pull request #38570 from thaJeztah/keep_your_head_up

Add HEAD support for /_ping endpoint
Sebastiaan van Stijn il y a 6 ans
Parent
commit
8a43b7bb99

+ 1 - 0
api/server/router/system/system.go

@@ -30,6 +30,7 @@ func NewRouter(b Backend, c ClusterBackend, fscache *fscache.FSCache, builder *b
 	r.routes = []router.Route{
 		router.NewOptionsRoute("/{anyroute:.*}", optionsHandler),
 		router.NewGetRoute("/_ping", r.pingHandler),
+		router.NewHeadRoute("/_ping", r.pingHandler),
 		router.NewGetRoute("/events", r.getEvents),
 		router.NewGetRoute("/info", r.getInfo),
 		router.NewGetRoute("/version", r.getVersion),

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

@@ -34,6 +34,11 @@ func (s *systemRouter) pingHandler(ctx context.Context, w http.ResponseWriter, r
 	if bv := builderVersion; bv != "" {
 		w.Header().Set("Builder-Version", string(bv))
 	}
+	if r.Method == http.MethodHead {
+		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+		w.Header().Set("Content-Length", "0")
+		return nil
+	}
 	_, err := w.Write([]byte{'O', 'K'})
 	return err
 }

+ 32 - 0
api/swagger.yaml

@@ -7133,6 +7133,38 @@ paths:
               type: "string"
               default: "no-cache"
       tags: ["System"]
+    head:
+      summary: "Ping"
+      description: "This is a dummy endpoint you can use to test if the server is accessible."
+      operationId: "SystemPingHead"
+      produces: ["text/plain"]
+      responses:
+        200:
+          description: "no error"
+          schema:
+            type: "string"
+            example: "(empty)"
+          headers:
+            API-Version:
+              type: "string"
+              description: "Max API Version the server supports"
+            BuildKit-Version:
+              type: "string"
+              description: "Default version of docker image builder"
+            Docker-Experimental:
+              type: "boolean"
+              description: "If the server is running with experimental mode enabled"
+            Cache-Control:
+              type: "string"
+              default: "no-cache, no-store, must-revalidate"
+            Pragma:
+              type: "string"
+              default: "no-cache"
+        500:
+          description: "server error"
+          schema:
+            $ref: "#/definitions/ErrorResponse"
+      tags: ["System"]
   /commit:
     post:
       summary: "Create a new image from a container"

+ 35 - 13
client/ping.go

@@ -2,34 +2,56 @@ package client // import "github.com/docker/docker/client"
 
 import (
 	"context"
+	"net/http"
 	"path"
 
 	"github.com/docker/docker/api/types"
 )
 
-// Ping pings the server and returns the value of the "Docker-Experimental", "Builder-Version", "OS-Type" & "API-Version" headers
+// Ping pings the server and returns the value of the "Docker-Experimental",
+// "Builder-Version", "OS-Type" & "API-Version" headers. It attempts to use
+// a HEAD request on the endpoint, but falls back to GET if HEAD is not supported
+// by the daemon.
 func (cli *Client) Ping(ctx context.Context) (types.Ping, error) {
 	var ping types.Ping
-	req, err := cli.buildRequest("GET", path.Join(cli.basePath, "/_ping"), nil, nil)
+	req, err := cli.buildRequest("HEAD", path.Join(cli.basePath, "/_ping"), nil, nil)
 	if err != nil {
 		return ping, err
 	}
 	serverResp, err := cli.doRequest(ctx, req)
+	if err == nil {
+		defer ensureReaderClosed(serverResp)
+		switch serverResp.statusCode {
+		case http.StatusOK, http.StatusInternalServerError:
+			// Server handled the request, so parse the response
+			return parsePingResponse(cli, serverResp)
+		}
+	}
+
+	req, err = cli.buildRequest("GET", path.Join(cli.basePath, "/_ping"), nil, nil)
+	if err != nil {
+		return ping, err
+	}
+	serverResp, err = cli.doRequest(ctx, req)
 	if err != nil {
 		return ping, err
 	}
 	defer ensureReaderClosed(serverResp)
+	return parsePingResponse(cli, serverResp)
+}
 
-	if serverResp.header != nil {
-		ping.APIVersion = serverResp.header.Get("API-Version")
-
-		if serverResp.header.Get("Docker-Experimental") == "true" {
-			ping.Experimental = true
-		}
-		ping.OSType = serverResp.header.Get("OSType")
-		if bv := serverResp.header.Get("Builder-Version"); bv != "" {
-			ping.BuilderVersion = types.BuilderVersion(bv)
-		}
+func parsePingResponse(cli *Client, resp serverResponse) (types.Ping, error) {
+	var ping types.Ping
+	if resp.header == nil {
+		return ping, cli.checkResponseErr(resp)
+	}
+	ping.APIVersion = resp.header.Get("API-Version")
+	ping.OSType = resp.header.Get("OSType")
+	if resp.header.Get("Docker-Experimental") == "true" {
+		ping.Experimental = true
+	}
+	if bv := resp.header.Get("Builder-Version"); bv != "" {
+		ping.BuilderVersion = types.BuilderVersion(bv)
 	}
-	return ping, cli.checkResponseErr(serverResp)
+	return ping, cli.checkResponseErr(resp)
 }

+ 46 - 0
client/ping_test.go

@@ -81,3 +81,49 @@ func TestPingSuccess(t *testing.T) {
 	assert.Check(t, is.Equal(true, ping.Experimental))
 	assert.Check(t, is.Equal("awesome", ping.APIVersion))
 }
+
+// TestPingHeadFallback tests that the client falls back to GET if HEAD fails.
+func TestPingHeadFallback(t *testing.T) {
+	tests := []struct {
+		status   int
+		expected string
+	}{
+		{
+			status:   http.StatusOK,
+			expected: "HEAD",
+		},
+		{
+			status:   http.StatusInternalServerError,
+			expected: "HEAD",
+		},
+		{
+			status:   http.StatusNotFound,
+			expected: "HEAD, GET",
+		},
+		{
+			status:   http.StatusMethodNotAllowed,
+			expected: "HEAD, GET",
+		},
+	}
+
+	for _, tc := range tests {
+		tc := tc
+		t.Run(http.StatusText(tc.status), func(t *testing.T) {
+			var reqs []string
+			client := &Client{
+				client: newMockClient(func(req *http.Request) (*http.Response, error) {
+					reqs = append(reqs, req.Method)
+					resp := &http.Response{StatusCode: http.StatusOK}
+					if req.Method == http.MethodHead {
+						resp.StatusCode = tc.status
+					}
+					resp.Header = http.Header{}
+					resp.Header.Add("API-Version", strings.Join(reqs, ", "))
+					return resp, nil
+				}),
+			}
+			ping, _ := client.Ping(context.Background())
+			assert.Check(t, is.Equal(ping.APIVersion, tc.expected))
+		})
+	}
+}

+ 15 - 11
client/request.go

@@ -195,17 +195,21 @@ func (cli *Client) checkResponseErr(serverResp serverResponse) error {
 		return nil
 	}
 
-	bodyMax := 1 * 1024 * 1024 // 1 MiB
-	bodyR := &io.LimitedReader{
-		R: serverResp.body,
-		N: int64(bodyMax),
-	}
-	body, err := ioutil.ReadAll(bodyR)
-	if err != nil {
-		return err
-	}
-	if bodyR.N == 0 {
-		return fmt.Errorf("request returned %s with a message (> %d bytes) for API route and version %s, check if the server supports the requested API version", http.StatusText(serverResp.statusCode), bodyMax, serverResp.reqURL)
+	var body []byte
+	var err error
+	if serverResp.body != nil {
+		bodyMax := 1 * 1024 * 1024 // 1 MiB
+		bodyR := &io.LimitedReader{
+			R: serverResp.body,
+			N: int64(bodyMax),
+		}
+		body, err = ioutil.ReadAll(bodyR)
+		if err != nil {
+			return err
+		}
+		if bodyR.N == 0 {
+			return fmt.Errorf("request returned %s with a message (> %d bytes) for API route and version %s, check if the server supports the requested API version", http.StatusText(serverResp.statusCode), bodyMax, serverResp.reqURL)
+		}
 	}
 	if len(body) == 0 {
 		return fmt.Errorf("request returned %s for API route and version %s, check if the server supports the requested API version", http.StatusText(serverResp.statusCode), serverResp.reqURL)

+ 8 - 3
docs/api/version-history.md

@@ -17,9 +17,14 @@ keywords: "API, Docker, rcli, REST, documentation"
 
 [Docker Engine API v1.40](https://docs.docker.com/engine/api/v1.40/) documentation
 
-* `GET /_ping` now sets `Cache-Control` and `Pragma` headers to prevent the result
-  from being cached. This change is not versioned, and affects all API versions
-  if the daemon has this patch.
+* The `/_ping` endpoint can now be accessed both using `GET` or `HEAD` requests.
+  when accessed using a `HEAD` request, all headers are returned, but the body
+  is empty (`Content-Length: 0`). This change is not versioned, and affects all
+  API versions if the daemon has this patch. Clients are recommended to try
+  using `HEAD`, but fallback to `GET` if the `HEAD` requests fails.
+* `GET /_ping` and `HEAD /_ping` now set `Cache-Control` and `Pragma` headers to
+  prevent the result from being cached. This change is not versioned, and affects
+  all API versions if the daemon has this patch.
 * `GET /services` now returns `Sysctls` as part of the `ContainerSpec`.
 * `GET /services/{id}` now returns `Sysctls` as part of the `ContainerSpec`.
 * `POST /services/create` now accepts `Sysctls` as part of the `ContainerSpec`.

+ 29 - 0
integration/system/ping_test.go

@@ -5,8 +5,10 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/docker/docker/api/types/versions"
 	"github.com/docker/docker/internal/test/request"
 	"gotest.tools/assert"
+	"gotest.tools/skip"
 )
 
 func TestPingCacheHeaders(t *testing.T) {
@@ -20,6 +22,33 @@ func TestPingCacheHeaders(t *testing.T) {
 	assert.Equal(t, hdr(res, "Pragma"), "no-cache")
 }
 
+func TestPingGet(t *testing.T) {
+	defer setupTest(t)()
+
+	res, body, err := request.Get("/_ping")
+	assert.NilError(t, err)
+
+	b, err := request.ReadBody(body)
+	assert.NilError(t, err)
+	assert.Equal(t, string(b), "OK")
+	assert.Equal(t, res.StatusCode, http.StatusOK)
+	assert.Check(t, hdr(res, "API-Version") != "")
+}
+
+func TestPingHead(t *testing.T) {
+	skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.40"), "skip test from new feature")
+	defer setupTest(t)()
+
+	res, body, err := request.Head("/_ping")
+	assert.NilError(t, err)
+
+	b, err := request.ReadBody(body)
+	assert.NilError(t, err)
+	assert.Equal(t, 0, len(b))
+	assert.Equal(t, res.StatusCode, http.StatusOK)
+	assert.Check(t, hdr(res, "API-Version") != "")
+}
+
 func hdr(res *http.Response, name string) string {
 	val, ok := res.Header[http.CanonicalHeaderKey(name)]
 	if !ok || len(val) == 0 {

+ 5 - 0
internal/test/request/request.go

@@ -77,6 +77,11 @@ func Get(endpoint string, modifiers ...func(*Options)) (*http.Response, io.ReadC
 	return Do(endpoint, modifiers...)
 }
 
+// Head creates and execute a HEAD request on the specified host and endpoint, with the specified request modifiers
+func Head(endpoint string, modifiers ...func(*Options)) (*http.Response, io.ReadCloser, error) {
+	return Do(endpoint, append(modifiers, Method(http.MethodHead))...)
+}
+
 // Do creates and execute a request on the specified endpoint, with the specified request modifiers
 func Do(endpoint string, modifiers ...func(*Options)) (*http.Response, io.ReadCloser, error) {
 	opts := &Options{