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{