Merge pull request #51293 from thaJeztah/merge_stats

client: ContainerStats: add option, output-structs, remove ContainerStatsOneShot
This commit is contained in:
Sebastiaan van Stijn
2025-10-27 16:13:47 +01:00
committed by GitHub
8 changed files with 163 additions and 109 deletions

View File

@@ -73,8 +73,7 @@ type ContainerAPIClient interface {
ContainerResize(ctx context.Context, container string, options ContainerResizeOptions) error
ContainerRestart(ctx context.Context, container string, options ContainerStopOptions) error
ContainerStatPath(ctx context.Context, container, path string) (container.PathStat, error)
ContainerStats(ctx context.Context, container string, stream bool) (StatsResponseReader, error)
ContainerStatsOneShot(ctx context.Context, container string) (StatsResponseReader, error)
ContainerStats(ctx context.Context, container string, options ContainerStatsOptions) (ContainerStatsResult, error)
ContainerStart(ctx context.Context, container string, options ContainerStartOptions) error
ContainerStop(ctx context.Context, container string, options ContainerStopOptions) error
ContainerTop(ctx context.Context, container string, arguments []string) (container.TopResponse, error)

View File

@@ -6,61 +6,72 @@ import (
"net/url"
)
// StatsResponseReader wraps an [io.ReadCloser] to read (a stream of) stats
// for a container, as produced by the GET "/stats" endpoint.
//
// The OSType field is set to the server's platform to allow
// platform-specific handling of the response.
//
// TODO(thaJeztah): remove this wrapper, and make OSType part of [github.com/moby/moby/api/types/container.StatsResponse].
type StatsResponseReader struct {
Body io.ReadCloser `json:"body"`
OSType string `json:"ostype"`
// ContainerStatsOptions holds parameters to retrieve container statistics
// using the [Client.ContainerStats] method.
type ContainerStatsOptions struct {
// Stream enables streaming [container.StatsResponse] results instead
// of collecting a single sample. If enabled, the client remains attached
// until the [ContainerStatsResult.Body] is closed or the context is
// cancelled.
Stream bool
// IncludePreviousSample asks the daemon to collect a prior sample to populate the
// [container.StatsResponse.PreRead] and [container.StatsResponse.PreCPUStats]
// fields.
//
// It set, the daemon collects two samples at a one-second interval before
// returning the result. The first sample populates the PreCPUStats (“previous
// CPU”) field, allowing delta calculations for CPU usage. If false, only
// a single sample is taken and returned immediately, leaving PreRead and
// PreCPUStats empty.
//
// This option has no effect if Stream is enabled. If Stream is enabled,
// [container.StatsResponse.PreCPUStats] is never populated for the first
// record.
IncludePreviousSample bool
}
// ContainerStats returns near realtime stats for a given container.
// It's up to the caller to close the [io.ReadCloser] returned.
func (cli *Client) ContainerStats(ctx context.Context, containerID string, stream bool) (StatsResponseReader, error) {
// ContainerStatsResult holds the result from [Client.ContainerStats].
//
// It wraps an [io.ReadCloser] that provides one or more [container.StatsResponse]
// objects for a container, as produced by the "GET /containers/{id}/stats" endpoint.
// If streaming is disabled, the stream contains a single record.
//
// The OSType field reports the daemon's operating system, allowing platform-specific
// handling of the response.
type ContainerStatsResult struct {
Body io.ReadCloser
OSType string // TODO(thaJeztah): consider moving OSType into [container.StatsResponse].
}
// ContainerStats retrieves live resource usage statistics for the specified
// container. The caller must close the [io.ReadCloser] in the returned result
// to release associated resources.
func (cli *Client) ContainerStats(ctx context.Context, containerID string, options ContainerStatsOptions) (ContainerStatsResult, error) {
containerID, err := trimID("container", containerID)
if err != nil {
return StatsResponseReader{}, err
return ContainerStatsResult{}, err
}
query := url.Values{}
query.Set("stream", "0")
if stream {
query.Set("stream", "1")
if options.Stream {
query.Set("stream", "true")
} else {
// Note: daemons before v29.0 return an error if both set: "cannot have stream=true and one-shot=true"
//
// TODO(thaJeztah): consider making "stream=false" the default for the API as well, or using Accept Header to switch.
query.Set("stream", "false")
if !options.IncludePreviousSample {
query.Set("one-shot", "true")
}
}
resp, err := cli.get(ctx, "/containers/"+containerID+"/stats", query, nil)
if err != nil {
return StatsResponseReader{}, err
return ContainerStatsResult{}, err
}
return StatsResponseReader{
Body: resp.Body,
OSType: resp.Header.Get("Ostype"),
}, nil
}
// ContainerStatsOneShot gets a single stat entry from a container.
// It differs from `ContainerStats` in that the API should not wait to prime the stats
func (cli *Client) ContainerStatsOneShot(ctx context.Context, containerID string) (StatsResponseReader, error) {
containerID, err := trimID("container", containerID)
if err != nil {
return StatsResponseReader{}, err
}
query := url.Values{}
query.Set("stream", "0")
query.Set("one-shot", "1")
resp, err := cli.get(ctx, "/containers/"+containerID+"/stats", query, nil)
if err != nil {
return StatsResponseReader{}, err
}
return StatsResponseReader{
return ContainerStatsResult{
Body: resp.Body,
OSType: resp.Header.Get("Ostype"),
}, nil

View File

@@ -1,13 +1,14 @@
package client
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
@@ -15,14 +16,14 @@ import (
func TestContainerStatsError(t *testing.T) {
client, err := NewClientWithOpts(WithMockClient(errorMock(http.StatusInternalServerError, "Server error")))
assert.NilError(t, err)
_, err = client.ContainerStats(context.Background(), "nothing", false)
_, err = client.ContainerStats(t.Context(), "nothing", ContainerStatsOptions{})
assert.Check(t, is.ErrorType(err, cerrdefs.IsInternal))
_, err = client.ContainerStats(context.Background(), "", false)
_, err = client.ContainerStats(t.Context(), "", ContainerStatsOptions{})
assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument))
assert.Check(t, is.ErrorContains(err, "value is empty"))
_, err = client.ContainerStats(context.Background(), " ", false)
_, err = client.ContainerStats(t.Context(), " ", ContainerStatsOptions{})
assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument))
assert.Check(t, is.ErrorContains(err, "value is empty"))
}
@@ -34,11 +35,11 @@ func TestContainerStats(t *testing.T) {
expectedStream string
}{
{
expectedStream: "0",
expectedStream: "false",
},
{
stream: true,
expectedStream: "1",
expectedStream: "true",
},
}
for _, tc := range tests {
@@ -52,16 +53,22 @@ func TestContainerStats(t *testing.T) {
if stream != tc.expectedStream {
return nil, fmt.Errorf("stream not set in URL query properly. Expected '%s', got %s", tc.expectedStream, stream)
}
return mockResponse(http.StatusOK, nil, "response")(req)
return mockJSONResponse(http.StatusOK, nil, container.StatsResponse{ID: "container_id"})(req)
}))
assert.NilError(t, err)
resp, err := client.ContainerStats(context.Background(), "container_id", tc.stream)
resp, err := client.ContainerStats(t.Context(), "container_id", ContainerStatsOptions{
Stream: tc.stream,
})
assert.NilError(t, err)
t.Cleanup(func() {
_ = resp.Body.Close()
})
content, err := io.ReadAll(resp.Body)
assert.NilError(t, err)
assert.Check(t, is.Equal(string(content), "response"))
var stats container.StatsResponse
err = json.Unmarshal(content, &stats)
assert.NilError(t, err)
assert.Check(t, is.Equal(stats.ID, "container_id"))
}
}

View File

@@ -148,7 +148,7 @@ func (s *DockerAPISuite) TestGetContainerStats(c *testing.T) {
runSleepingContainer(c, "--name", name)
type b struct {
stats client.StatsResponseReader
stats client.ContainerStatsResult
err error
}
@@ -158,7 +158,9 @@ func (s *DockerAPISuite) TestGetContainerStats(c *testing.T) {
assert.NilError(c, err)
defer apiClient.Close()
stats, err := apiClient.ContainerStats(testutil.GetContext(c), name, true)
stats, err := apiClient.ContainerStats(testutil.GetContext(c), name, client.ContainerStatsOptions{
Stream: true,
})
assert.NilError(c, err)
bc <- b{stats, err}
}()
@@ -192,7 +194,9 @@ func (s *DockerAPISuite) TestGetContainerStatsRmRunning(c *testing.T) {
assert.NilError(c, err)
defer apiClient.Close()
stats, err := apiClient.ContainerStats(testutil.GetContext(c), id, true)
stats, err := apiClient.ContainerStats(testutil.GetContext(c), id, client.ContainerStatsOptions{
Stream: true,
})
assert.NilError(c, err)
defer stats.Body.Close()
@@ -253,7 +257,7 @@ func (s *DockerAPISuite) TestGetContainerStatsStream(c *testing.T) {
runSleepingContainer(c, "--name", name)
type b struct {
stats client.StatsResponseReader
stats client.ContainerStatsResult
err error
}
@@ -263,7 +267,9 @@ func (s *DockerAPISuite) TestGetContainerStatsStream(c *testing.T) {
assert.NilError(c, err)
defer apiClient.Close()
stats, err := apiClient.ContainerStats(testutil.GetContext(c), name, true)
stats, err := apiClient.ContainerStats(testutil.GetContext(c), name, client.ContainerStatsOptions{
Stream: true,
})
assert.NilError(c, err)
bc <- b{stats, err}
}()
@@ -302,7 +308,10 @@ func (s *DockerAPISuite) TestGetContainerStatsNoStream(c *testing.T) {
ctx, cancel := context.WithTimeout(testutil.GetContext(c), 10*time.Second)
defer cancel()
stats, err := apiClient.ContainerStats(ctx, cID, false)
stats, err := apiClient.ContainerStats(ctx, cID, client.ContainerStatsOptions{
Stream: false,
IncludePreviousSample: true,
})
assert.NilError(c, err)
defer func() { _ = stats.Body.Close() }()
@@ -327,7 +336,10 @@ func (s *DockerAPISuite) TestGetStoppedContainerStats(c *testing.T) {
ctx, cancel := context.WithTimeout(testutil.GetContext(c), 10*time.Second)
defer cancel()
stats, err := apiClient.ContainerStats(ctx, name, false)
stats, err := apiClient.ContainerStats(ctx, name, client.ContainerStatsOptions{
Stream: false,
IncludePreviousSample: true,
})
assert.NilError(c, err)
defer func() { _ = stats.Body.Close() }()
@@ -1133,7 +1145,10 @@ func (s *DockerAPISuite) TestContainerAPIStatsWithNetworkDisabled(c *testing.T)
ctx, cancel := context.WithTimeout(testutil.GetContext(c), 10*time.Second)
defer cancel()
stats, err := apiClient.ContainerStats(ctx, name, false)
stats, err := apiClient.ContainerStats(ctx, name, client.ContainerStatsOptions{
Stream: false,
IncludePreviousSample: true,
})
assert.NilError(c, err)
defer func() { _ = stats.Body.Close() }()

View File

@@ -184,11 +184,16 @@ func (s *DockerAPISuite) TestAPIStatsContainerNotFound(c *testing.T) {
assert.NilError(c, err)
defer func() { _ = apiClient.Close() }()
_, err = apiClient.ContainerStats(testutil.GetContext(c), "no-such-container", true)
_, err = apiClient.ContainerStats(testutil.GetContext(c), "no-such-container", client.ContainerStatsOptions{
Stream: true,
})
assert.ErrorType(c, err, cerrdefs.IsNotFound)
assert.ErrorContains(c, err, "no-such-container")
_, err = apiClient.ContainerStats(testutil.GetContext(c), "no-such-container", false)
_, err = apiClient.ContainerStats(testutil.GetContext(c), "no-such-container", client.ContainerStatsOptions{
Stream: false,
IncludePreviousSample: true,
})
assert.ErrorType(c, err, cerrdefs.IsNotFound)
assert.ErrorContains(c, err, "no-such-container")
}

View File

@@ -7,6 +7,7 @@ import (
"testing"
containertypes "github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
"github.com/moby/moby/v2/integration/internal/container"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
@@ -25,7 +26,10 @@ func TestStats(t *testing.T) {
cID := container.Run(ctx, t, apiClient)
t.Run("no-stream", func(t *testing.T) {
resp, err := apiClient.ContainerStats(ctx, cID, false)
resp, err := apiClient.ContainerStats(ctx, cID, client.ContainerStatsOptions{
Stream: false,
IncludePreviousSample: true,
})
assert.NilError(t, err)
defer func() { _ = resp.Body.Close() }()
@@ -39,7 +43,10 @@ func TestStats(t *testing.T) {
})
t.Run("one-shot", func(t *testing.T) {
resp, err := apiClient.ContainerStatsOneShot(ctx, cID)
resp, err := apiClient.ContainerStats(ctx, cID, client.ContainerStatsOptions{
Stream: false,
IncludePreviousSample: false,
})
assert.NilError(t, err)
defer func() { _ = resp.Body.Close() }()

View File

@@ -73,8 +73,7 @@ type ContainerAPIClient interface {
ContainerResize(ctx context.Context, container string, options ContainerResizeOptions) error
ContainerRestart(ctx context.Context, container string, options ContainerStopOptions) error
ContainerStatPath(ctx context.Context, container, path string) (container.PathStat, error)
ContainerStats(ctx context.Context, container string, stream bool) (StatsResponseReader, error)
ContainerStatsOneShot(ctx context.Context, container string) (StatsResponseReader, error)
ContainerStats(ctx context.Context, container string, options ContainerStatsOptions) (ContainerStatsResult, error)
ContainerStart(ctx context.Context, container string, options ContainerStartOptions) error
ContainerStop(ctx context.Context, container string, options ContainerStopOptions) error
ContainerTop(ctx context.Context, container string, arguments []string) (container.TopResponse, error)

View File

@@ -6,61 +6,72 @@ import (
"net/url"
)
// StatsResponseReader wraps an [io.ReadCloser] to read (a stream of) stats
// for a container, as produced by the GET "/stats" endpoint.
//
// The OSType field is set to the server's platform to allow
// platform-specific handling of the response.
//
// TODO(thaJeztah): remove this wrapper, and make OSType part of [github.com/moby/moby/api/types/container.StatsResponse].
type StatsResponseReader struct {
Body io.ReadCloser `json:"body"`
OSType string `json:"ostype"`
// ContainerStatsOptions holds parameters to retrieve container statistics
// using the [Client.ContainerStats] method.
type ContainerStatsOptions struct {
// Stream enables streaming [container.StatsResponse] results instead
// of collecting a single sample. If enabled, the client remains attached
// until the [ContainerStatsResult.Body] is closed or the context is
// cancelled.
Stream bool
// IncludePreviousSample asks the daemon to collect a prior sample to populate the
// [container.StatsResponse.PreRead] and [container.StatsResponse.PreCPUStats]
// fields.
//
// It set, the daemon collects two samples at a one-second interval before
// returning the result. The first sample populates the PreCPUStats (“previous
// CPU”) field, allowing delta calculations for CPU usage. If false, only
// a single sample is taken and returned immediately, leaving PreRead and
// PreCPUStats empty.
//
// This option has no effect if Stream is enabled. If Stream is enabled,
// [container.StatsResponse.PreCPUStats] is never populated for the first
// record.
IncludePreviousSample bool
}
// ContainerStats returns near realtime stats for a given container.
// It's up to the caller to close the [io.ReadCloser] returned.
func (cli *Client) ContainerStats(ctx context.Context, containerID string, stream bool) (StatsResponseReader, error) {
// ContainerStatsResult holds the result from [Client.ContainerStats].
//
// It wraps an [io.ReadCloser] that provides one or more [container.StatsResponse]
// objects for a container, as produced by the "GET /containers/{id}/stats" endpoint.
// If streaming is disabled, the stream contains a single record.
//
// The OSType field reports the daemon's operating system, allowing platform-specific
// handling of the response.
type ContainerStatsResult struct {
Body io.ReadCloser
OSType string // TODO(thaJeztah): consider moving OSType into [container.StatsResponse].
}
// ContainerStats retrieves live resource usage statistics for the specified
// container. The caller must close the [io.ReadCloser] in the returned result
// to release associated resources.
func (cli *Client) ContainerStats(ctx context.Context, containerID string, options ContainerStatsOptions) (ContainerStatsResult, error) {
containerID, err := trimID("container", containerID)
if err != nil {
return StatsResponseReader{}, err
return ContainerStatsResult{}, err
}
query := url.Values{}
query.Set("stream", "0")
if stream {
query.Set("stream", "1")
if options.Stream {
query.Set("stream", "true")
} else {
// Note: daemons before v29.0 return an error if both set: "cannot have stream=true and one-shot=true"
//
// TODO(thaJeztah): consider making "stream=false" the default for the API as well, or using Accept Header to switch.
query.Set("stream", "false")
if !options.IncludePreviousSample {
query.Set("one-shot", "true")
}
}
resp, err := cli.get(ctx, "/containers/"+containerID+"/stats", query, nil)
if err != nil {
return StatsResponseReader{}, err
return ContainerStatsResult{}, err
}
return StatsResponseReader{
Body: resp.Body,
OSType: resp.Header.Get("Ostype"),
}, nil
}
// ContainerStatsOneShot gets a single stat entry from a container.
// It differs from `ContainerStats` in that the API should not wait to prime the stats
func (cli *Client) ContainerStatsOneShot(ctx context.Context, containerID string) (StatsResponseReader, error) {
containerID, err := trimID("container", containerID)
if err != nil {
return StatsResponseReader{}, err
}
query := url.Values{}
query.Set("stream", "0")
query.Set("one-shot", "1")
resp, err := cli.get(ctx, "/containers/"+containerID+"/stats", query, nil)
if err != nil {
return StatsResponseReader{}, err
}
return StatsResponseReader{
return ContainerStatsResult{
Body: resp.Body,
OSType: resp.Header.Get("Ostype"),
}, nil