Add support for sending down service Running and Desired task counts
Adds a new ServiceStatus field to the Service object, which includes the running and desired task counts. This new field is gated behind a "status" query parameter. Signed-off-by: Drew Erny <drew.erny@docker.com>
This commit is contained in:
parent
e582a10b59
commit
f36042d259
8 changed files with 237 additions and 2 deletions
|
@ -167,7 +167,19 @@ func (sr *swarmRouter) getServices(ctx context.Context, w http.ResponseWriter, r
|
||||||
return errdefs.InvalidParameter(err)
|
return errdefs.InvalidParameter(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
services, err := sr.backend.GetServices(basictypes.ServiceListOptions{Filters: filter})
|
// the status query parameter is only support in API versions >= 1.41. If
|
||||||
|
// the client is using a lesser version, ignore the parameter.
|
||||||
|
cliVersion := httputils.VersionFromContext(ctx)
|
||||||
|
var status bool
|
||||||
|
if value := r.URL.Query().Get("status"); value != "" && !versions.LessThan(cliVersion, "1.41") {
|
||||||
|
var err error
|
||||||
|
status, err = strconv.ParseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(errdefs.InvalidParameter(err), "invalid value for status: %s", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
services, err := sr.backend.GetServices(basictypes.ServiceListOptions{Filters: filter, Status: status})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorf("Error getting services: %v", err)
|
logrus.Errorf("Error getting services: %v", err)
|
||||||
return err
|
return err
|
||||||
|
@ -178,15 +190,21 @@ func (sr *swarmRouter) getServices(ctx context.Context, w http.ResponseWriter, r
|
||||||
|
|
||||||
func (sr *swarmRouter) getService(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
|
func (sr *swarmRouter) getService(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
|
||||||
var insertDefaults bool
|
var insertDefaults bool
|
||||||
|
|
||||||
if value := r.URL.Query().Get("insertDefaults"); value != "" {
|
if value := r.URL.Query().Get("insertDefaults"); value != "" {
|
||||||
var err error
|
var err error
|
||||||
insertDefaults, err = strconv.ParseBool(value)
|
insertDefaults, err = strconv.ParseBool(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err := fmt.Errorf("invalid value for insertDefaults: %s", value)
|
|
||||||
return errors.Wrapf(errdefs.InvalidParameter(err), "invalid value for insertDefaults: %s", value)
|
return errors.Wrapf(errdefs.InvalidParameter(err), "invalid value for insertDefaults: %s", value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// you may note that there is no code here to handle the "status" query
|
||||||
|
// parameter, as in getServices. the Status field is not supported when
|
||||||
|
// retrieving an individual service because the Backend API changes
|
||||||
|
// required to accommodate it would be too disruptive, and because that
|
||||||
|
// field is so rarely needed as part of an individual service inspection.
|
||||||
|
|
||||||
service, err := sr.backend.GetService(vars["id"], insertDefaults)
|
service, err := sr.backend.GetService(vars["id"], insertDefaults)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorf("Error getting service %s: %v", vars["id"], err)
|
logrus.Errorf("Error getting service %s: %v", vars["id"], err)
|
||||||
|
|
|
@ -3369,6 +3369,27 @@ definitions:
|
||||||
format: "dateTime"
|
format: "dateTime"
|
||||||
Message:
|
Message:
|
||||||
type: "string"
|
type: "string"
|
||||||
|
ServiceStatus:
|
||||||
|
description: |
|
||||||
|
The status of the service's tasks. Provided only when requested as
|
||||||
|
part of a ServiceList operation.
|
||||||
|
type: "object"
|
||||||
|
properties:
|
||||||
|
RunningTasks:
|
||||||
|
description: "The number of tasks for the service currently in the Running state"
|
||||||
|
type: "integer"
|
||||||
|
format: "uint64"
|
||||||
|
example: 7
|
||||||
|
DesiredTasks:
|
||||||
|
description: |
|
||||||
|
The number of tasks for the service desired to be running.
|
||||||
|
For replicated services, this is the replica count from the
|
||||||
|
service spec. For global services, this is computed by taking
|
||||||
|
count of all tasks for the service with a Desired State other
|
||||||
|
than Shutdown.
|
||||||
|
type: "integer"
|
||||||
|
format: "uint64"
|
||||||
|
example: 10
|
||||||
example:
|
example:
|
||||||
ID: "9mnpnzenvg8p8tdbtq4wvbkcz"
|
ID: "9mnpnzenvg8p8tdbtq4wvbkcz"
|
||||||
Version:
|
Version:
|
||||||
|
@ -9316,6 +9337,10 @@ paths:
|
||||||
- `label=<service label>`
|
- `label=<service label>`
|
||||||
- `mode=["replicated"|"global"]`
|
- `mode=["replicated"|"global"]`
|
||||||
- `name=<service name>`
|
- `name=<service name>`
|
||||||
|
- name: "status"
|
||||||
|
in: "query"
|
||||||
|
type: "boolean"
|
||||||
|
description: "Include service status, with count of running and desired tasks"
|
||||||
tags: ["Service"]
|
tags: ["Service"]
|
||||||
/services/create:
|
/services/create:
|
||||||
post:
|
post:
|
||||||
|
|
|
@ -363,6 +363,10 @@ type ServiceUpdateOptions struct {
|
||||||
// ServiceListOptions holds parameters to list services with.
|
// ServiceListOptions holds parameters to list services with.
|
||||||
type ServiceListOptions struct {
|
type ServiceListOptions struct {
|
||||||
Filters filters.Args
|
Filters filters.Args
|
||||||
|
|
||||||
|
// Status indicates whether the server should include the service task
|
||||||
|
// count of running and desired tasks.
|
||||||
|
Status bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceInspectOptions holds parameters related to the "service inspect"
|
// ServiceInspectOptions holds parameters related to the "service inspect"
|
||||||
|
|
|
@ -10,6 +10,13 @@ type Service struct {
|
||||||
PreviousSpec *ServiceSpec `json:",omitempty"`
|
PreviousSpec *ServiceSpec `json:",omitempty"`
|
||||||
Endpoint Endpoint `json:",omitempty"`
|
Endpoint Endpoint `json:",omitempty"`
|
||||||
UpdateStatus *UpdateStatus `json:",omitempty"`
|
UpdateStatus *UpdateStatus `json:",omitempty"`
|
||||||
|
|
||||||
|
// ServiceStatus is an optional, extra field indicating the number of
|
||||||
|
// desired and running tasks. It is provided primarily as a shortcut to
|
||||||
|
// calculating these values client-side, which otherwise would require
|
||||||
|
// listing all tasks for a service, an operation that could be
|
||||||
|
// computation and network expensive.
|
||||||
|
ServiceStatus *ServiceStatus `json:",omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceSpec represents the spec of a service.
|
// ServiceSpec represents the spec of a service.
|
||||||
|
@ -122,3 +129,17 @@ type UpdateConfig struct {
|
||||||
// started, or the new task is started before the old task is shut down.
|
// started, or the new task is started before the old task is shut down.
|
||||||
Order string
|
Order string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServiceStatus represents the number of running tasks in a service and the
|
||||||
|
// number of tasks desired to be running.
|
||||||
|
type ServiceStatus struct {
|
||||||
|
// RunningTasks is the number of tasks for the service actually in the
|
||||||
|
// Running state
|
||||||
|
RunningTasks uint64
|
||||||
|
|
||||||
|
// DesiredTasks is the number of tasks desired to be running by the
|
||||||
|
// service. For replicated services, this is the replica count. For global
|
||||||
|
// services, this is computed by taking the number of tasks with desired
|
||||||
|
// state of not-Shutdown.
|
||||||
|
DesiredTasks uint64
|
||||||
|
}
|
||||||
|
|
|
@ -23,6 +23,10 @@ func (cli *Client) ServiceList(ctx context.Context, options types.ServiceListOpt
|
||||||
query.Set("filters", filterJSON)
|
query.Set("filters", filterJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if options.Status {
|
||||||
|
query.Set("status", "true")
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := cli.get(ctx, "/services", query, nil)
|
resp, err := cli.get(ctx, "/services", query, nil)
|
||||||
defer ensureReaderClosed(resp)
|
defer ensureReaderClosed(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -77,6 +77,12 @@ func (c *Cluster) GetServices(options apitypes.ServiceListOptions) ([]types.Serv
|
||||||
|
|
||||||
services := make([]types.Service, 0, len(r.Services))
|
services := make([]types.Service, 0, len(r.Services))
|
||||||
|
|
||||||
|
// if the user requests the service statuses, we'll store the IDs needed
|
||||||
|
// in this slice
|
||||||
|
var serviceIDs []string
|
||||||
|
if options.Status {
|
||||||
|
serviceIDs = make([]string, 0, len(r.Services))
|
||||||
|
}
|
||||||
for _, service := range r.Services {
|
for _, service := range r.Services {
|
||||||
if options.Filters.Contains("mode") {
|
if options.Filters.Contains("mode") {
|
||||||
var mode string
|
var mode string
|
||||||
|
@ -91,6 +97,9 @@ func (c *Cluster) GetServices(options apitypes.ServiceListOptions) ([]types.Serv
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if options.Status {
|
||||||
|
serviceIDs = append(serviceIDs, service.ID)
|
||||||
|
}
|
||||||
svcs, err := convert.ServiceFromGRPC(*service)
|
svcs, err := convert.ServiceFromGRPC(*service)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -98,6 +107,49 @@ func (c *Cluster) GetServices(options apitypes.ServiceListOptions) ([]types.Serv
|
||||||
services = append(services, svcs)
|
services = append(services, svcs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if options.Status {
|
||||||
|
// Listing service statuses is a separate call because, while it is the
|
||||||
|
// most common UI operation, it is still just a UI operation, and it
|
||||||
|
// would be improper to include this data in swarm's Service object.
|
||||||
|
// We pay the cost with some complexity here, but this is still way
|
||||||
|
// more efficient than marshalling and unmarshalling all the JSON
|
||||||
|
// needed to list tasks and get this data otherwise client-side
|
||||||
|
resp, err := state.controlClient.ListServiceStatuses(
|
||||||
|
ctx,
|
||||||
|
&swarmapi.ListServiceStatusesRequest{Services: serviceIDs},
|
||||||
|
grpc.MaxCallRecvMsgSize(defaultRecvSizeForListResponse),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// we'll need to match up statuses in the response with the services in
|
||||||
|
// the list operation. if we did this by operating on two lists, the
|
||||||
|
// result would be quadratic. instead, make a mapping of service IDs to
|
||||||
|
// service statuses so that this is roughly linear. additionally,
|
||||||
|
// convert the status response to an engine api service status here.
|
||||||
|
serviceMap := map[string]*types.ServiceStatus{}
|
||||||
|
for _, status := range resp.Statuses {
|
||||||
|
serviceMap[status.ServiceID] = &types.ServiceStatus{
|
||||||
|
RunningTasks: status.RunningTasks,
|
||||||
|
DesiredTasks: status.DesiredTasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// because this is a list of values and not pointers, make sure we
|
||||||
|
// actually alter the value when iterating.
|
||||||
|
for i, service := range services {
|
||||||
|
// the return value of the ListServiceStatuses operation is
|
||||||
|
// guaranteed to contain a value in the response for every argument
|
||||||
|
// in the request, so we can safely do this assignment. and even if
|
||||||
|
// it wasn't, and the service ID was for some reason absent from
|
||||||
|
// this map, the resulting value of service.Status would just be
|
||||||
|
// nil -- the same thing it was before
|
||||||
|
service.ServiceStatus = serviceMap[service.ID]
|
||||||
|
services[i] = service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return services, nil
|
return services, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,9 @@ keywords: "API, Docker, rcli, REST, documentation"
|
||||||
* `GET /info` now returns an `OSVersion` field, containing the operating system's
|
* `GET /info` now returns an `OSVersion` field, containing the operating system's
|
||||||
version. This change is not versioned, and affects all API versions if the daemon
|
version. This change is not versioned, and affects all API versions if the daemon
|
||||||
has this patch.
|
has this patch.
|
||||||
|
* `GET /services` now accepts query parameter `status`. When set `true`,
|
||||||
|
services returned will include `ServiceStatus`, which provides Desired and
|
||||||
|
Running task counts for the service.
|
||||||
|
|
||||||
## v1.40 API changes
|
## v1.40 API changes
|
||||||
|
|
||||||
|
|
108
integration/service/list_test.go
Normal file
108
integration/service/list_test.go
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
package service // import "github.com/docker/docker/integration/service"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
swarmtypes "github.com/docker/docker/api/types/swarm"
|
||||||
|
"github.com/docker/docker/api/types/versions"
|
||||||
|
"github.com/docker/docker/integration/internal/swarm"
|
||||||
|
"gotest.tools/assert"
|
||||||
|
is "gotest.tools/assert/cmp"
|
||||||
|
"gotest.tools/poll"
|
||||||
|
"gotest.tools/skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestServiceListWithStatuses tests that performing a ServiceList operation
|
||||||
|
// correctly uses the Status parameter, and that the resulting response
|
||||||
|
// contains correct service statuses.
|
||||||
|
//
|
||||||
|
// NOTE(dperny): because it's a pain to elicit the behavior of an unconverged
|
||||||
|
// service reliably, I'm not testing that an unconverged service returns X
|
||||||
|
// running and Y desired tasks. Instead, I'm just going to trust that I can
|
||||||
|
// successfully assign a value to another value without screwing it up. The
|
||||||
|
// logic for computing service statuses is in swarmkit anyway, not in the
|
||||||
|
// engine, and is well-tested there, so this test just needs to make sure that
|
||||||
|
// statuses get correctly associated with the right services.
|
||||||
|
func TestServiceListWithStatuses(t *testing.T) {
|
||||||
|
skip.If(t, testEnv.IsRemoteDaemon)
|
||||||
|
skip.If(t, testEnv.DaemonInfo.OSType == "windows")
|
||||||
|
// statuses were added in API version 1.41
|
||||||
|
skip.If(t, versions.LessThan(testEnv.DaemonInfo.ServerVersion, "1.41"))
|
||||||
|
defer setupTest(t)()
|
||||||
|
d := swarm.NewSwarm(t, testEnv)
|
||||||
|
defer d.Stop(t)
|
||||||
|
client := d.NewClientT(t)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
serviceCount := 3
|
||||||
|
// create some services.
|
||||||
|
for i := 0; i < serviceCount; i++ {
|
||||||
|
spec := fullSwarmServiceSpec(fmt.Sprintf("test-list-%d", i), uint64(i+1))
|
||||||
|
// for whatever reason, the args "-u root", when included, cause these
|
||||||
|
// tasks to fail and exit. instead, we'll just pass no args, which
|
||||||
|
// works.
|
||||||
|
spec.TaskTemplate.ContainerSpec.Args = []string{}
|
||||||
|
resp, err := client.ServiceCreate(ctx, spec, types.ServiceCreateOptions{
|
||||||
|
QueryRegistry: false,
|
||||||
|
})
|
||||||
|
assert.NilError(t, err)
|
||||||
|
id := resp.ID
|
||||||
|
// we need to wait specifically for the tasks to be running, which the
|
||||||
|
// serviceContainerCount function does not do. instead, we'll use a
|
||||||
|
// bespoke closure right here.
|
||||||
|
poll.WaitOn(t, func(log poll.LogT) poll.Result {
|
||||||
|
filter := filters.NewArgs()
|
||||||
|
filter.Add("service", id)
|
||||||
|
tasks, err := client.TaskList(context.Background(), types.TaskListOptions{
|
||||||
|
Filters: filter,
|
||||||
|
})
|
||||||
|
|
||||||
|
running := 0
|
||||||
|
for _, task := range tasks {
|
||||||
|
if task.Status.State == swarmtypes.TaskStateRunning {
|
||||||
|
running++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
return poll.Error(err)
|
||||||
|
case running == i+1:
|
||||||
|
return poll.Success()
|
||||||
|
default:
|
||||||
|
return poll.Continue(
|
||||||
|
"running task count %d (%d total), waiting for %d",
|
||||||
|
running, len(tasks), i+1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// now, let's do the list operation with no status arg set.
|
||||||
|
resp, err := client.ServiceList(ctx, types.ServiceListOptions{})
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Check(t, is.Len(resp, serviceCount))
|
||||||
|
for _, service := range resp {
|
||||||
|
assert.Check(t, is.Nil(service.ServiceStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
// now try again, but with Status: true. This time, we should have statuses
|
||||||
|
resp, err = client.ServiceList(ctx, types.ServiceListOptions{Status: true})
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Check(t, is.Len(resp, serviceCount))
|
||||||
|
for _, service := range resp {
|
||||||
|
replicas := *service.Spec.Mode.Replicated.Replicas
|
||||||
|
|
||||||
|
assert.Assert(t, service.ServiceStatus != nil)
|
||||||
|
// Use assert.Check to not fail out of the test if this fails
|
||||||
|
assert.Check(t, is.Equal(service.ServiceStatus.DesiredTasks, replicas))
|
||||||
|
assert.Check(t, is.Equal(service.ServiceStatus.RunningTasks, replicas))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in a new issue