From 7e7e100be04f09834768c86ee1afd6397a734b35 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Mon, 14 Jan 2019 18:08:49 +0100 Subject: [PATCH] Add HEAD support for /_ping endpoint Monitoring systems and load balancers are usually configured to use HEAD requests for health monitoring. The /_ping endpoint currently does not support this type of request, which means that those systems have fallback to GET requests. This patch adds support for HEAD requests on the /_ping endpoint. Although optional, this patch also returns `Content-Type` and `Content-Length` headers in case of a HEAD request; Refering to RFC 7231, section 4.3.2: The HEAD method is identical to GET except that the server MUST NOT send a message body in the response (i.e., the response terminates at the end of the header section). The server SHOULD send the same header fields in response to a HEAD request as it would have sent if the request had been a GET, except that the payload header fields (Section 3.3) MAY be omitted. This method can be used for obtaining metadata about the selected representation without transferring the representation data and is often used for testing hypertext links for validity, accessibility, and recent modification. A payload within a HEAD request message has no defined semantics; sending a payload body on a HEAD request might cause some existing implementations to reject the request. The response to a HEAD request is cacheable; a cache MAY use it to satisfy subsequent HEAD requests unless otherwise indicated by the Cache-Control header field (Section 5.2 of [RFC7234]). A HEAD response might also have an effect on previously cached responses to GET; see Section 4.3.5 of [RFC7234]. With this patch applied, either `GET` or `HEAD` requests work; the only difference is that the body is empty in case of a `HEAD` request; curl -i --unix-socket /var/run/docker.sock http://localhost/_ping HTTP/1.1 200 OK Api-Version: 1.40 Cache-Control: no-cache, no-store, must-revalidate Docker-Experimental: false Ostype: linux Pragma: no-cache Server: Docker/dev (linux) Date: Mon, 14 Jan 2019 12:35:16 GMT Content-Length: 2 Content-Type: text/plain; charset=utf-8 OK curl --head -i --unix-socket /var/run/docker.sock http://localhost/_ping HTTP/1.1 200 OK Api-Version: 1.40 Cache-Control: no-cache, no-store, must-revalidate Content-Length: 0 Content-Type: text/plain; charset=utf-8 Docker-Experimental: false Ostype: linux Pragma: no-cache Server: Docker/dev (linux) Date: Mon, 14 Jan 2019 12:34:15 GMT The client is also updated to use `HEAD` by default, but fallback to `GET` if the daemon does not support this method. Signed-off-by: Sebastiaan van Stijn --- api/server/router/system/system.go | 1 + api/server/router/system/system_routes.go | 5 +++ api/swagger.yaml | 32 ++++++++++++++ client/ping.go | 52 ++++++++++++++++------- client/ping_test.go | 46 ++++++++++++++++++++ client/request.go | 26 +++++++----- docs/api/version-history.md | 11 +++-- integration/system/ping_test.go | 29 +++++++++++++ internal/test/request/request.go | 5 +++ 9 files changed, 178 insertions(+), 29 deletions(-) diff --git a/api/server/router/system/system.go b/api/server/router/system/system.go index e0c4a3eefb..459ee50bf7 100644 --- a/api/server/router/system/system.go +++ b/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), diff --git a/api/server/router/system/system_routes.go b/api/server/router/system/system_routes.go index 950d9d89a5..365095159a 100644 --- a/api/server/router/system/system_routes.go +++ b/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 } diff --git a/api/swagger.yaml b/api/swagger.yaml index 52d3f085bf..a1f608fad2 100644 --- a/api/swagger.yaml +++ b/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" diff --git a/client/ping.go b/client/ping.go index dec1423e38..0ebb6b752b 100644 --- a/client/ping.go +++ b/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) - - 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) - } - } - return ping, cli.checkResponseErr(serverResp) + return parsePingResponse(cli, serverResp) +} + +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(resp) } diff --git a/client/ping_test.go b/client/ping_test.go index 1f57a8d1ce..5b6a33cb1f 100644 --- a/client/ping_test.go +++ b/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)) + }) + } +} diff --git a/client/request.go b/client/request.go index f1c256ad0e..52ed12446d 100644 --- a/client/request.go +++ b/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) diff --git a/docs/api/version-history.md b/docs/api/version-history.md index e7060759f8..87e64753c0 100644 --- a/docs/api/version-history.md +++ b/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`. diff --git a/integration/system/ping_test.go b/integration/system/ping_test.go index fc57b68606..6eba927aa8 100644 --- a/integration/system/ping_test.go +++ b/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 { diff --git a/internal/test/request/request.go b/internal/test/request/request.go index 1986d370f1..261ed8b780 100644 --- a/internal/test/request/request.go +++ b/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{