Browse code

Merge pull request #28089 from aluzzardi/service-logs

service logs

Andrea Luzzardi authored on 2016/11/11 08:13:41
Showing 17 changed files
... ...
@@ -2,7 +2,9 @@ package swarm
2 2
 
3 3
 import (
4 4
 	basictypes "github.com/docker/docker/api/types"
5
+	"github.com/docker/docker/api/types/backend"
5 6
 	types "github.com/docker/docker/api/types/swarm"
7
+	"golang.org/x/net/context"
6 8
 )
7 9
 
8 10
 // Backend abstracts an swarm commands manager.
... ...
@@ -19,6 +21,7 @@ type Backend interface {
19 19
 	CreateService(types.ServiceSpec, string) (string, error)
20 20
 	UpdateService(string, uint64, types.ServiceSpec, string, string) error
21 21
 	RemoveService(string) error
22
+	ServiceLogs(context.Context, string, *backend.ContainerLogsConfig, chan struct{}) error
22 23
 	GetNodes(basictypes.NodeListOptions) ([]types.Node, error)
23 24
 	GetNode(string) (types.Node, error)
24 25
 	UpdateNode(string, uint64, types.NodeSpec) error
... ...
@@ -1,6 +1,9 @@
1 1
 package swarm
2 2
 
3
-import "github.com/docker/docker/api/server/router"
3
+import (
4
+	"github.com/docker/docker/api/server/router"
5
+	"github.com/docker/docker/daemon"
6
+)
4 7
 
5 8
 // buildRouter is a router to talk with the build controller
6 9
 type swarmRouter struct {
... ...
@@ -9,11 +12,14 @@ type swarmRouter struct {
9 9
 }
10 10
 
11 11
 // NewRouter initializes a new build router
12
-func NewRouter(b Backend) router.Router {
12
+func NewRouter(d *daemon.Daemon, b Backend) router.Router {
13 13
 	r := &swarmRouter{
14 14
 		backend: b,
15 15
 	}
16 16
 	r.initRoutes()
17
+	if d.HasExperimental() {
18
+		r.addExperimentalRoutes()
19
+	}
17 20
 	return r
18 21
 }
19 22
 
... ...
@@ -22,6 +28,12 @@ func (sr *swarmRouter) Routes() []router.Route {
22 22
 	return sr.routes
23 23
 }
24 24
 
25
+func (sr *swarmRouter) addExperimentalRoutes() {
26
+	sr.routes = append(sr.routes,
27
+		router.Cancellable(router.NewGetRoute("/services/{id}/logs", sr.getServiceLogs)),
28
+	)
29
+}
30
+
25 31
 func (sr *swarmRouter) initRoutes() {
26 32
 	sr.routes = []router.Route{
27 33
 		router.NewPostRoute("/swarm/init", sr.initCluster),
... ...
@@ -32,20 +44,20 @@ func (sr *swarmRouter) initRoutes() {
32 32
 		router.NewPostRoute("/swarm/update", sr.updateCluster),
33 33
 		router.NewPostRoute("/swarm/unlock", sr.unlockCluster),
34 34
 		router.NewGetRoute("/services", sr.getServices),
35
-		router.NewGetRoute("/services/{id:.*}", sr.getService),
35
+		router.NewGetRoute("/services/{id}", sr.getService),
36 36
 		router.NewPostRoute("/services/create", sr.createService),
37
-		router.NewPostRoute("/services/{id:.*}/update", sr.updateService),
38
-		router.NewDeleteRoute("/services/{id:.*}", sr.removeService),
37
+		router.NewPostRoute("/services/{id}/update", sr.updateService),
38
+		router.NewDeleteRoute("/services/{id}", sr.removeService),
39 39
 		router.NewGetRoute("/nodes", sr.getNodes),
40
-		router.NewGetRoute("/nodes/{id:.*}", sr.getNode),
41
-		router.NewDeleteRoute("/nodes/{id:.*}", sr.removeNode),
42
-		router.NewPostRoute("/nodes/{id:.*}/update", sr.updateNode),
40
+		router.NewGetRoute("/nodes/{id}", sr.getNode),
41
+		router.NewDeleteRoute("/nodes/{id}", sr.removeNode),
42
+		router.NewPostRoute("/nodes/{id}/update", sr.updateNode),
43 43
 		router.NewGetRoute("/tasks", sr.getTasks),
44
-		router.NewGetRoute("/tasks/{id:.*}", sr.getTask),
44
+		router.NewGetRoute("/tasks/{id}", sr.getTask),
45 45
 		router.NewGetRoute("/secrets", sr.getSecrets),
46 46
 		router.NewPostRoute("/secrets", sr.createSecret),
47
-		router.NewDeleteRoute("/secrets/{id:.*}", sr.removeSecret),
48
-		router.NewGetRoute("/secrets/{id:.*}", sr.getSecret),
49
-		router.NewPostRoute("/secrets/{id:.*}/update", sr.updateSecret),
47
+		router.NewDeleteRoute("/secrets/{id}", sr.removeSecret),
48
+		router.NewGetRoute("/secrets/{id}", sr.getSecret),
49
+		router.NewPostRoute("/secrets/{id}/update", sr.updateSecret),
50 50
 	}
51 51
 }
... ...
@@ -10,6 +10,7 @@ import (
10 10
 	"github.com/docker/docker/api/errors"
11 11
 	"github.com/docker/docker/api/server/httputils"
12 12
 	basictypes "github.com/docker/docker/api/types"
13
+	"github.com/docker/docker/api/types/backend"
13 14
 	"github.com/docker/docker/api/types/filters"
14 15
 	types "github.com/docker/docker/api/types/swarm"
15 16
 	"golang.org/x/net/context"
... ...
@@ -208,6 +209,59 @@ func (sr *swarmRouter) removeService(ctx context.Context, w http.ResponseWriter,
208 208
 	return nil
209 209
 }
210 210
 
211
+func (sr *swarmRouter) getServiceLogs(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
212
+	if err := httputils.ParseForm(r); err != nil {
213
+		return err
214
+	}
215
+
216
+	// Args are validated before the stream starts because when it starts we're
217
+	// sending HTTP 200 by writing an empty chunk of data to tell the client that
218
+	// daemon is going to stream. By sending this initial HTTP 200 we can't report
219
+	// any error after the stream starts (i.e. container not found, wrong parameters)
220
+	// with the appropriate status code.
221
+	stdout, stderr := httputils.BoolValue(r, "stdout"), httputils.BoolValue(r, "stderr")
222
+	if !(stdout || stderr) {
223
+		return fmt.Errorf("Bad parameters: you must choose at least one stream")
224
+	}
225
+
226
+	serviceName := vars["id"]
227
+	logsConfig := &backend.ContainerLogsConfig{
228
+		ContainerLogsOptions: basictypes.ContainerLogsOptions{
229
+			Follow:     httputils.BoolValue(r, "follow"),
230
+			Timestamps: httputils.BoolValue(r, "timestamps"),
231
+			Since:      r.Form.Get("since"),
232
+			Tail:       r.Form.Get("tail"),
233
+			ShowStdout: stdout,
234
+			ShowStderr: stderr,
235
+			Details:    httputils.BoolValue(r, "details"),
236
+		},
237
+		OutStream: w,
238
+	}
239
+
240
+	if !logsConfig.Follow {
241
+		return fmt.Errorf("Bad parameters: Only follow mode is currently supported")
242
+	}
243
+
244
+	if logsConfig.Details {
245
+		return fmt.Errorf("Bad parameters: details is not currently supported")
246
+	}
247
+
248
+	chStarted := make(chan struct{})
249
+	if err := sr.backend.ServiceLogs(ctx, serviceName, logsConfig, chStarted); err != nil {
250
+		select {
251
+		case <-chStarted:
252
+			// The client may be expecting all of the data we're sending to
253
+			// be multiplexed, so send it through OutStream, which will
254
+			// have been set up to handle that if needed.
255
+			fmt.Fprintf(logsConfig.OutStream, "Error grabbing service logs: %v\n", err)
256
+		default:
257
+			return err
258
+		}
259
+	}
260
+
261
+	return nil
262
+}
263
+
211 264
 func (sr *swarmRouter) getNodes(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
212 265
 	if err := httputils.ParseForm(r); err != nil {
213 266
 		return err
... ...
@@ -26,6 +26,7 @@ func NewServiceCommand(dockerCli *command.DockerCli) *cobra.Command {
26 26
 		newRemoveCommand(dockerCli),
27 27
 		newScaleCommand(dockerCli),
28 28
 		newUpdateCommand(dockerCli),
29
+		newLogsCommand(dockerCli),
29 30
 	)
30 31
 	return cmd
31 32
 }
32 33
new file mode 100644
... ...
@@ -0,0 +1,163 @@
0
+package service
1
+
2
+import (
3
+	"bytes"
4
+	"fmt"
5
+	"io"
6
+	"strings"
7
+
8
+	"golang.org/x/net/context"
9
+
10
+	"github.com/docker/docker/api/types"
11
+	"github.com/docker/docker/api/types/swarm"
12
+	"github.com/docker/docker/cli"
13
+	"github.com/docker/docker/cli/command"
14
+	"github.com/docker/docker/cli/command/idresolver"
15
+	"github.com/docker/docker/pkg/stdcopy"
16
+	"github.com/spf13/cobra"
17
+)
18
+
19
+type logsOptions struct {
20
+	noResolve  bool
21
+	follow     bool
22
+	since      string
23
+	timestamps bool
24
+	details    bool
25
+	tail       string
26
+
27
+	service string
28
+}
29
+
30
+func newLogsCommand(dockerCli *command.DockerCli) *cobra.Command {
31
+	var opts logsOptions
32
+
33
+	cmd := &cobra.Command{
34
+		Use:   "logs [OPTIONS] SERVICE",
35
+		Short: "Fetch the logs of a service",
36
+		Args:  cli.ExactArgs(1),
37
+		RunE: func(cmd *cobra.Command, args []string) error {
38
+			opts.service = args[0]
39
+			return runLogs(dockerCli, &opts)
40
+		},
41
+		Tags: map[string]string{"experimental": ""},
42
+	}
43
+
44
+	flags := cmd.Flags()
45
+	flags.BoolVar(&opts.noResolve, "no-resolve", false, "Do not map IDs to Names")
46
+	flags.BoolVarP(&opts.follow, "follow", "f", false, "Follow log output")
47
+	flags.StringVar(&opts.since, "since", "", "Show logs since timestamp")
48
+	flags.BoolVarP(&opts.timestamps, "timestamps", "t", false, "Show timestamps")
49
+	flags.BoolVar(&opts.details, "details", false, "Show extra details provided to logs")
50
+	flags.StringVar(&opts.tail, "tail", "all", "Number of lines to show from the end of the logs")
51
+	return cmd
52
+}
53
+
54
+func runLogs(dockerCli *command.DockerCli, opts *logsOptions) error {
55
+	ctx := context.Background()
56
+
57
+	options := types.ContainerLogsOptions{
58
+		ShowStdout: true,
59
+		ShowStderr: true,
60
+		Since:      opts.since,
61
+		Timestamps: opts.timestamps,
62
+		Follow:     opts.follow,
63
+		Tail:       opts.tail,
64
+		Details:    opts.details,
65
+	}
66
+
67
+	client := dockerCli.Client()
68
+	responseBody, err := client.ServiceLogs(ctx, opts.service, options)
69
+	if err != nil {
70
+		return err
71
+	}
72
+	defer responseBody.Close()
73
+
74
+	resolver := idresolver.New(client, opts.noResolve)
75
+
76
+	stdout := &logWriter{ctx: ctx, opts: opts, r: resolver, w: dockerCli.Out()}
77
+	stderr := &logWriter{ctx: ctx, opts: opts, r: resolver, w: dockerCli.Err()}
78
+
79
+	// TODO(aluzzardi): Do an io.Copy for services with TTY enabled.
80
+	_, err = stdcopy.StdCopy(stdout, stderr, responseBody)
81
+	return err
82
+}
83
+
84
+type logWriter struct {
85
+	ctx  context.Context
86
+	opts *logsOptions
87
+	r    *idresolver.IDResolver
88
+	w    io.Writer
89
+}
90
+
91
+func (lw *logWriter) Write(buf []byte) (int, error) {
92
+	contextIndex := 0
93
+	numParts := 2
94
+	if lw.opts.timestamps {
95
+		contextIndex++
96
+		numParts++
97
+	}
98
+
99
+	parts := bytes.SplitN(buf, []byte(" "), numParts)
100
+	if len(parts) != numParts {
101
+		return 0, fmt.Errorf("invalid context in log message: %v", string(buf))
102
+	}
103
+
104
+	taskName, nodeName, err := lw.parseContext(string(parts[contextIndex]))
105
+	if err != nil {
106
+		return 0, err
107
+	}
108
+
109
+	output := []byte{}
110
+	for i, part := range parts {
111
+		// First part doesn't get space separation.
112
+		if i > 0 {
113
+			output = append(output, []byte(" ")...)
114
+		}
115
+
116
+		if i == contextIndex {
117
+			// TODO(aluzzardi): Consider constant padding.
118
+			output = append(output, []byte(fmt.Sprintf("%s@%s    |", taskName, nodeName))...)
119
+		} else {
120
+			output = append(output, part...)
121
+		}
122
+	}
123
+	_, err = lw.w.Write(output)
124
+	if err != nil {
125
+		return 0, err
126
+	}
127
+
128
+	return len(buf), nil
129
+}
130
+
131
+func (lw *logWriter) parseContext(input string) (string, string, error) {
132
+	context := make(map[string]string)
133
+
134
+	components := strings.Split(input, ",")
135
+	for _, component := range components {
136
+		parts := strings.SplitN(component, "=", 2)
137
+		if len(parts) != 2 {
138
+			return "", "", fmt.Errorf("invalid context: %s", input)
139
+		}
140
+		context[parts[0]] = parts[1]
141
+	}
142
+
143
+	taskID, ok := context["com.docker.swarm.task.id"]
144
+	if !ok {
145
+		return "", "", fmt.Errorf("missing task id in context: %s", input)
146
+	}
147
+	taskName, err := lw.r.Resolve(lw.ctx, swarm.Task{}, taskID)
148
+	if err != nil {
149
+		return "", "", err
150
+	}
151
+
152
+	nodeID, ok := context["com.docker.swarm.node.id"]
153
+	if !ok {
154
+		return "", "", fmt.Errorf("missing node id in context: %s", input)
155
+	}
156
+	nodeName, err := lw.r.Resolve(lw.ctx, swarm.Node{}, nodeID)
157
+	if err != nil {
158
+		return "", "", err
159
+	}
160
+
161
+	return taskName, nodeName, nil
162
+}
... ...
@@ -111,6 +111,7 @@ type ServiceAPIClient interface {
111 111
 	ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error)
112 112
 	ServiceRemove(ctx context.Context, serviceID string) error
113 113
 	ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options types.ServiceUpdateOptions) error
114
+	ServiceLogs(ctx context.Context, serviceID string, options types.ContainerLogsOptions) (io.ReadCloser, error)
114 115
 	TaskInspectWithRaw(ctx context.Context, taskID string) (swarm.Task, []byte, error)
115 116
 	TaskList(ctx context.Context, options types.TaskListOptions) ([]swarm.Task, error)
116 117
 }
117 118
new file mode 100644
... ...
@@ -0,0 +1,52 @@
0
+package client
1
+
2
+import (
3
+	"io"
4
+	"net/url"
5
+	"time"
6
+
7
+	"golang.org/x/net/context"
8
+
9
+	"github.com/docker/docker/api/types"
10
+	timetypes "github.com/docker/docker/api/types/time"
11
+)
12
+
13
+// ServiceLogs returns the logs generated by a service in an io.ReadCloser.
14
+// It's up to the caller to close the stream.
15
+func (cli *Client) ServiceLogs(ctx context.Context, serviceID string, options types.ContainerLogsOptions) (io.ReadCloser, error) {
16
+	query := url.Values{}
17
+	if options.ShowStdout {
18
+		query.Set("stdout", "1")
19
+	}
20
+
21
+	if options.ShowStderr {
22
+		query.Set("stderr", "1")
23
+	}
24
+
25
+	if options.Since != "" {
26
+		ts, err := timetypes.GetTimestamp(options.Since, time.Now())
27
+		if err != nil {
28
+			return nil, err
29
+		}
30
+		query.Set("since", ts)
31
+	}
32
+
33
+	if options.Timestamps {
34
+		query.Set("timestamps", "1")
35
+	}
36
+
37
+	if options.Details {
38
+		query.Set("details", "1")
39
+	}
40
+
41
+	if options.Follow {
42
+		query.Set("follow", "1")
43
+	}
44
+	query.Set("tail", options.Tail)
45
+
46
+	resp, err := cli.get(ctx, "/services/"+serviceID+"/logs", query, nil)
47
+	if err != nil {
48
+		return nil, err
49
+	}
50
+	return resp.body, nil
51
+}
0 52
new file mode 100644
... ...
@@ -0,0 +1,133 @@
0
+package client
1
+
2
+import (
3
+	"bytes"
4
+	"fmt"
5
+	"io"
6
+	"io/ioutil"
7
+	"log"
8
+	"net/http"
9
+	"os"
10
+	"strings"
11
+	"testing"
12
+	"time"
13
+
14
+	"github.com/docker/docker/api/types"
15
+
16
+	"golang.org/x/net/context"
17
+)
18
+
19
+func TestServiceLogsError(t *testing.T) {
20
+	client := &Client{
21
+		client: newMockClient(errorMock(http.StatusInternalServerError, "Server error")),
22
+	}
23
+	_, err := client.ServiceLogs(context.Background(), "service_id", types.ContainerLogsOptions{})
24
+	if err == nil || err.Error() != "Error response from daemon: Server error" {
25
+		t.Fatalf("expected a Server Error, got %v", err)
26
+	}
27
+	_, err = client.ServiceLogs(context.Background(), "service_id", types.ContainerLogsOptions{
28
+		Since: "2006-01-02TZ",
29
+	})
30
+	if err == nil || !strings.Contains(err.Error(), `parsing time "2006-01-02TZ"`) {
31
+		t.Fatalf("expected a 'parsing time' error, got %v", err)
32
+	}
33
+}
34
+
35
+func TestServiceLogs(t *testing.T) {
36
+	expectedURL := "/services/service_id/logs"
37
+	cases := []struct {
38
+		options             types.ContainerLogsOptions
39
+		expectedQueryParams map[string]string
40
+	}{
41
+		{
42
+			expectedQueryParams: map[string]string{
43
+				"tail": "",
44
+			},
45
+		},
46
+		{
47
+			options: types.ContainerLogsOptions{
48
+				Tail: "any",
49
+			},
50
+			expectedQueryParams: map[string]string{
51
+				"tail": "any",
52
+			},
53
+		},
54
+		{
55
+			options: types.ContainerLogsOptions{
56
+				ShowStdout: true,
57
+				ShowStderr: true,
58
+				Timestamps: true,
59
+				Details:    true,
60
+				Follow:     true,
61
+			},
62
+			expectedQueryParams: map[string]string{
63
+				"tail":       "",
64
+				"stdout":     "1",
65
+				"stderr":     "1",
66
+				"timestamps": "1",
67
+				"details":    "1",
68
+				"follow":     "1",
69
+			},
70
+		},
71
+		{
72
+			options: types.ContainerLogsOptions{
73
+				// An complete invalid date, timestamp or go duration will be
74
+				// passed as is
75
+				Since: "invalid but valid",
76
+			},
77
+			expectedQueryParams: map[string]string{
78
+				"tail":  "",
79
+				"since": "invalid but valid",
80
+			},
81
+		},
82
+	}
83
+	for _, logCase := range cases {
84
+		client := &Client{
85
+			client: newMockClient(func(r *http.Request) (*http.Response, error) {
86
+				if !strings.HasPrefix(r.URL.Path, expectedURL) {
87
+					return nil, fmt.Errorf("Expected URL '%s', got '%s'", expectedURL, r.URL)
88
+				}
89
+				// Check query parameters
90
+				query := r.URL.Query()
91
+				for key, expected := range logCase.expectedQueryParams {
92
+					actual := query.Get(key)
93
+					if actual != expected {
94
+						return nil, fmt.Errorf("%s not set in URL query properly. Expected '%s', got %s", key, expected, actual)
95
+					}
96
+				}
97
+				return &http.Response{
98
+					StatusCode: http.StatusOK,
99
+					Body:       ioutil.NopCloser(bytes.NewReader([]byte("response"))),
100
+				}, nil
101
+			}),
102
+		}
103
+		body, err := client.ServiceLogs(context.Background(), "service_id", logCase.options)
104
+		if err != nil {
105
+			t.Fatal(err)
106
+		}
107
+		defer body.Close()
108
+		content, err := ioutil.ReadAll(body)
109
+		if err != nil {
110
+			t.Fatal(err)
111
+		}
112
+		if string(content) != "response" {
113
+			t.Fatalf("expected response to contain 'response', got %s", string(content))
114
+		}
115
+	}
116
+}
117
+
118
+func ExampleClient_ServiceLogs_withTimeout() {
119
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
120
+	defer cancel()
121
+
122
+	client, _ := NewEnvClient()
123
+	reader, err := client.ServiceLogs(ctx, "service_id", types.ContainerLogsOptions{})
124
+	if err != nil {
125
+		log.Fatal(err)
126
+	}
127
+
128
+	_, err = io.Copy(os.Stdout, reader)
129
+	if err != nil && err != io.EOF {
130
+		log.Fatal(err)
131
+	}
132
+}
... ...
@@ -456,7 +456,7 @@ func initRouter(s *apiserver.Server, d *daemon.Daemon, c *cluster.Cluster) {
456 456
 		systemrouter.NewRouter(d, c),
457 457
 		volume.NewRouter(d),
458 458
 		build.NewRouter(dockerfile.NewBuildManager(d)),
459
-		swarmrouter.NewRouter(c),
459
+		swarmrouter.NewRouter(d, c),
460 460
 	}...)
461 461
 
462 462
 	if d.NetworkControllerEnabled() {
... ...
@@ -4,6 +4,7 @@ import (
4 4
 	"encoding/base64"
5 5
 	"encoding/json"
6 6
 	"fmt"
7
+	"io"
7 8
 	"io/ioutil"
8 9
 	"net"
9 10
 	"os"
... ...
@@ -16,20 +17,24 @@ import (
16 16
 	"github.com/Sirupsen/logrus"
17 17
 	apierrors "github.com/docker/docker/api/errors"
18 18
 	apitypes "github.com/docker/docker/api/types"
19
+	"github.com/docker/docker/api/types/backend"
19 20
 	"github.com/docker/docker/api/types/filters"
20 21
 	"github.com/docker/docker/api/types/network"
21 22
 	types "github.com/docker/docker/api/types/swarm"
22 23
 	"github.com/docker/docker/daemon/cluster/convert"
23 24
 	executorpkg "github.com/docker/docker/daemon/cluster/executor"
24 25
 	"github.com/docker/docker/daemon/cluster/executor/container"
26
+	"github.com/docker/docker/daemon/logger"
25 27
 	"github.com/docker/docker/opts"
26 28
 	"github.com/docker/docker/pkg/ioutils"
27 29
 	"github.com/docker/docker/pkg/signal"
30
+	"github.com/docker/docker/pkg/stdcopy"
28 31
 	"github.com/docker/docker/reference"
29 32
 	"github.com/docker/docker/runconfig"
30 33
 	swarmapi "github.com/docker/swarmkit/api"
31 34
 	"github.com/docker/swarmkit/manager/encryption"
32 35
 	swarmnode "github.com/docker/swarmkit/node"
36
+	"github.com/docker/swarmkit/protobuf/ptypes"
33 37
 	"github.com/pkg/errors"
34 38
 	"golang.org/x/net/context"
35 39
 	"google.golang.org/grpc"
... ...
@@ -45,6 +50,7 @@ const defaultAddr = "0.0.0.0:2377"
45 45
 const (
46 46
 	initialReconnectDelay = 100 * time.Millisecond
47 47
 	maxReconnectDelay     = 30 * time.Second
48
+	contextPrefix         = "com.docker.swarm"
48 49
 )
49 50
 
50 51
 // ErrNoSwarm is returned on leaving a cluster that was never initialized
... ...
@@ -120,6 +126,7 @@ type node struct {
120 120
 	ready          bool
121 121
 	conn           *grpc.ClientConn
122 122
 	client         swarmapi.ControlClient
123
+	logs           swarmapi.LogsClient
123 124
 	reconnectDelay time.Duration
124 125
 	config         nodeStartConfig
125 126
 }
... ...
@@ -371,8 +378,10 @@ func (c *Cluster) startNewNode(conf nodeStartConfig) (*node, error) {
371 371
 			if node.conn != conn {
372 372
 				if conn == nil {
373 373
 					node.client = nil
374
+					node.logs = nil
374 375
 				} else {
375 376
 					node.client = swarmapi.NewControlClient(conn)
377
+					node.logs = swarmapi.NewLogsClient(conn)
376 378
 				}
377 379
 			}
378 380
 			node.conn = conn
... ...
@@ -1205,6 +1214,88 @@ func (c *Cluster) RemoveService(input string) error {
1205 1205
 	return nil
1206 1206
 }
1207 1207
 
1208
+// ServiceLogs collects service logs and writes them back to `config.OutStream`
1209
+func (c *Cluster) ServiceLogs(ctx context.Context, input string, config *backend.ContainerLogsConfig, started chan struct{}) error {
1210
+	c.RLock()
1211
+	if !c.isActiveManager() {
1212
+		c.RUnlock()
1213
+		return c.errNoManager()
1214
+	}
1215
+
1216
+	service, err := getService(ctx, c.client, input)
1217
+	if err != nil {
1218
+		c.RUnlock()
1219
+		return err
1220
+	}
1221
+
1222
+	stream, err := c.logs.SubscribeLogs(ctx, &swarmapi.SubscribeLogsRequest{
1223
+		Selector: &swarmapi.LogSelector{
1224
+			ServiceIDs: []string{service.ID},
1225
+		},
1226
+		Options: &swarmapi.LogSubscriptionOptions{
1227
+			Follow: true,
1228
+		},
1229
+	})
1230
+	if err != nil {
1231
+		c.RUnlock()
1232
+		return err
1233
+	}
1234
+
1235
+	wf := ioutils.NewWriteFlusher(config.OutStream)
1236
+	defer wf.Close()
1237
+	close(started)
1238
+	wf.Flush()
1239
+
1240
+	outStream := stdcopy.NewStdWriter(wf, stdcopy.Stdout)
1241
+	errStream := stdcopy.NewStdWriter(wf, stdcopy.Stderr)
1242
+
1243
+	// Release the lock before starting the stream.
1244
+	c.RUnlock()
1245
+	for {
1246
+		// Check the context before doing anything.
1247
+		select {
1248
+		case <-ctx.Done():
1249
+			return ctx.Err()
1250
+		default:
1251
+		}
1252
+
1253
+		subscribeMsg, err := stream.Recv()
1254
+		if err == io.EOF {
1255
+			return nil
1256
+		}
1257
+		if err != nil {
1258
+			return err
1259
+		}
1260
+
1261
+		for _, msg := range subscribeMsg.Messages {
1262
+			data := []byte{}
1263
+
1264
+			if config.Timestamps {
1265
+				ts, err := ptypes.Timestamp(msg.Timestamp)
1266
+				if err != nil {
1267
+					return err
1268
+				}
1269
+				data = append(data, []byte(ts.Format(logger.TimeFormat)+" ")...)
1270
+			}
1271
+
1272
+			data = append(data, []byte(fmt.Sprintf("%s.node.id=%s,%s.service.id=%s,%s.task.id=%s ",
1273
+				contextPrefix, msg.Context.NodeID,
1274
+				contextPrefix, msg.Context.ServiceID,
1275
+				contextPrefix, msg.Context.TaskID,
1276
+			))...)
1277
+
1278
+			data = append(data, msg.Data...)
1279
+
1280
+			switch msg.Stream {
1281
+			case swarmapi.LogStreamStdout:
1282
+				outStream.Write(data)
1283
+			case swarmapi.LogStreamStderr:
1284
+				errStream.Write(data)
1285
+			}
1286
+		}
1287
+	}
1288
+}
1289
+
1208 1290
 // GetNodes returns a list of all nodes known to a cluster.
1209 1291
 func (c *Cluster) GetNodes(options apitypes.NodeListOptions) ([]types.Node, error) {
1210 1292
 	c.RLock()
... ...
@@ -6,6 +6,7 @@ import (
6 6
 
7 7
 	"github.com/docker/distribution"
8 8
 	"github.com/docker/docker/api/types"
9
+	"github.com/docker/docker/api/types/backend"
9 10
 	"github.com/docker/docker/api/types/container"
10 11
 	"github.com/docker/docker/api/types/events"
11 12
 	"github.com/docker/docker/api/types/filters"
... ...
@@ -28,6 +29,7 @@ type Backend interface {
28 28
 	CreateManagedContainer(config types.ContainerCreateConfig, validateHostname bool) (container.ContainerCreateCreatedBody, error)
29 29
 	ContainerStart(name string, hostConfig *container.HostConfig, validateHostname bool, checkpoint string, checkpointDir string) error
30 30
 	ContainerStop(name string, seconds *int) error
31
+	ContainerLogs(context.Context, string, *backend.ContainerLogsConfig, chan struct{}) error
31 32
 	ConnectContainerToNetwork(containerName, networkName string, endpointConfig *network.EndpointSettings) error
32 33
 	ActivateContainerServiceBinding(containerName string) error
33 34
 	DeactivateContainerServiceBinding(containerName string) error
... ...
@@ -12,6 +12,7 @@ import (
12 12
 	"github.com/Sirupsen/logrus"
13 13
 	"github.com/docker/docker/api/server/httputils"
14 14
 	"github.com/docker/docker/api/types"
15
+	"github.com/docker/docker/api/types/backend"
15 16
 	containertypes "github.com/docker/docker/api/types/container"
16 17
 	"github.com/docker/docker/api/types/events"
17 18
 	"github.com/docker/docker/api/types/versions"
... ...
@@ -20,6 +21,7 @@ import (
20 20
 	"github.com/docker/swarmkit/agent/exec"
21 21
 	"github.com/docker/swarmkit/api"
22 22
 	"github.com/docker/swarmkit/log"
23
+	"github.com/docker/swarmkit/protobuf/ptypes"
23 24
 	"golang.org/x/net/context"
24 25
 	"golang.org/x/time/rate"
25 26
 )
... ...
@@ -376,6 +378,56 @@ func (c *containerAdapter) deactivateServiceBinding() error {
376 376
 	return c.backend.DeactivateContainerServiceBinding(c.container.name())
377 377
 }
378 378
 
379
+func (c *containerAdapter) logs(ctx context.Context, options api.LogSubscriptionOptions) (io.ReadCloser, error) {
380
+	reader, writer := io.Pipe()
381
+
382
+	apiOptions := &backend.ContainerLogsConfig{
383
+		ContainerLogsOptions: types.ContainerLogsOptions{
384
+			Follow: options.Follow,
385
+
386
+			// TODO(stevvooe): Parse timestamp out of message. This
387
+			// absolutely needs to be done before going to production with
388
+			// this, at it is completely redundant.
389
+			Timestamps: true,
390
+			Details:    false, // no clue what to do with this, let's just deprecate it.
391
+		},
392
+		OutStream: writer,
393
+	}
394
+
395
+	if options.Since != nil {
396
+		since, err := ptypes.Timestamp(options.Since)
397
+		if err != nil {
398
+			return nil, err
399
+		}
400
+		apiOptions.Since = since.Format(time.RFC3339Nano)
401
+	}
402
+
403
+	if options.Tail < 0 {
404
+		// See protobuf documentation for details of how this works.
405
+		apiOptions.Tail = fmt.Sprint(-options.Tail - 1)
406
+	} else if options.Tail > 0 {
407
+		return nil, fmt.Errorf("tail relative to start of logs not supported via docker API")
408
+	}
409
+
410
+	if len(options.Streams) == 0 {
411
+		// empty == all
412
+		apiOptions.ShowStdout, apiOptions.ShowStderr = true, true
413
+	} else {
414
+		for _, stream := range options.Streams {
415
+			switch stream {
416
+			case api.LogStreamStdout:
417
+				apiOptions.ShowStdout = true
418
+			case api.LogStreamStderr:
419
+				apiOptions.ShowStderr = true
420
+			}
421
+		}
422
+	}
423
+
424
+	chStarted := make(chan struct{})
425
+	go c.backend.ContainerLogs(ctx, c.container.name(), apiOptions, chStarted)
426
+	return reader, nil
427
+}
428
+
379 429
 // todo: typed/wrapped errors
380 430
 func isContainerCreateNameConflict(err error) bool {
381 431
 	return strings.Contains(err.Error(), "Conflict. The name")
... ...
@@ -1,8 +1,13 @@
1 1
 package container
2 2
 
3 3
 import (
4
+	"bufio"
5
+	"bytes"
6
+	"encoding/binary"
4 7
 	"fmt"
8
+	"io"
5 9
 	"os"
10
+	"time"
6 11
 
7 12
 	"github.com/docker/docker/api/types"
8 13
 	"github.com/docker/docker/api/types/events"
... ...
@@ -11,8 +16,10 @@ import (
11 11
 	"github.com/docker/swarmkit/agent/exec"
12 12
 	"github.com/docker/swarmkit/api"
13 13
 	"github.com/docker/swarmkit/log"
14
+	"github.com/docker/swarmkit/protobuf/ptypes"
14 15
 	"github.com/pkg/errors"
15 16
 	"golang.org/x/net/context"
17
+	"golang.org/x/time/rate"
16 18
 )
17 19
 
18 20
 // controller implements agent.Controller against docker's API.
... ...
@@ -374,6 +381,128 @@ func (r *controller) Remove(ctx context.Context) error {
374 374
 	return nil
375 375
 }
376 376
 
377
+// waitReady waits for a container to be "ready".
378
+// Ready means it's past the started state.
379
+func (r *controller) waitReady(pctx context.Context) error {
380
+	if err := r.checkClosed(); err != nil {
381
+		return err
382
+	}
383
+
384
+	ctx, cancel := context.WithCancel(pctx)
385
+	defer cancel()
386
+
387
+	eventq := r.adapter.events(ctx)
388
+
389
+	ctnr, err := r.adapter.inspect(ctx)
390
+	if err != nil {
391
+		if !isUnknownContainer(err) {
392
+			return errors.Wrap(err, "inspect container failed")
393
+		}
394
+	} else {
395
+		switch ctnr.State.Status {
396
+		case "running", "exited", "dead":
397
+			return nil
398
+		}
399
+	}
400
+
401
+	for {
402
+		select {
403
+		case event := <-eventq:
404
+			if !r.matchevent(event) {
405
+				continue
406
+			}
407
+
408
+			switch event.Action {
409
+			case "start":
410
+				return nil
411
+			}
412
+		case <-ctx.Done():
413
+			return ctx.Err()
414
+		case <-r.closed:
415
+			return r.err
416
+		}
417
+	}
418
+}
419
+
420
+func (r *controller) Logs(ctx context.Context, publisher exec.LogPublisher, options api.LogSubscriptionOptions) error {
421
+	if err := r.checkClosed(); err != nil {
422
+		return err
423
+	}
424
+
425
+	if err := r.waitReady(ctx); err != nil {
426
+		return errors.Wrap(err, "container not ready for logs")
427
+	}
428
+
429
+	rc, err := r.adapter.logs(ctx, options)
430
+	if err != nil {
431
+		return errors.Wrap(err, "failed getting container logs")
432
+	}
433
+	defer rc.Close()
434
+
435
+	var (
436
+		// use a rate limiter to keep things under control but also provides some
437
+		// ability coalesce messages.
438
+		limiter = rate.NewLimiter(rate.Every(time.Second), 10<<20) // 10 MB/s
439
+		msgctx  = api.LogContext{
440
+			NodeID:    r.task.NodeID,
441
+			ServiceID: r.task.ServiceID,
442
+			TaskID:    r.task.ID,
443
+		}
444
+	)
445
+
446
+	brd := bufio.NewReader(rc)
447
+	for {
448
+		// so, message header is 8 bytes, treat as uint64, pull stream off MSB
449
+		var header uint64
450
+		if err := binary.Read(brd, binary.BigEndian, &header); err != nil {
451
+			if err == io.EOF {
452
+				return nil
453
+			}
454
+
455
+			return errors.Wrap(err, "failed reading log header")
456
+		}
457
+
458
+		stream, size := (header>>(7<<3))&0xFF, header & ^(uint64(0xFF)<<(7<<3))
459
+
460
+		// limit here to decrease allocation back pressure.
461
+		if err := limiter.WaitN(ctx, int(size)); err != nil {
462
+			return errors.Wrap(err, "failed rate limiter")
463
+		}
464
+
465
+		buf := make([]byte, size)
466
+		_, err := io.ReadFull(brd, buf)
467
+		if err != nil {
468
+			return errors.Wrap(err, "failed reading buffer")
469
+		}
470
+
471
+		// Timestamp is RFC3339Nano with 1 space after. Lop, parse, publish
472
+		parts := bytes.SplitN(buf, []byte(" "), 2)
473
+		if len(parts) != 2 {
474
+			return fmt.Errorf("invalid timestamp in log message: %v", buf)
475
+		}
476
+
477
+		ts, err := time.Parse(time.RFC3339Nano, string(parts[0]))
478
+		if err != nil {
479
+			return errors.Wrap(err, "failed to parse timestamp")
480
+		}
481
+
482
+		tsp, err := ptypes.TimestampProto(ts)
483
+		if err != nil {
484
+			return errors.Wrap(err, "failed to convert timestamp")
485
+		}
486
+
487
+		if err := publisher.Publish(ctx, api.LogMessage{
488
+			Context:   msgctx,
489
+			Timestamp: tsp,
490
+			Stream:    api.LogStream(stream),
491
+
492
+			Data: parts[1],
493
+		}); err != nil {
494
+			return errors.Wrap(err, "failed to publish log message")
495
+		}
496
+	}
497
+}
498
+
377 499
 // Close the runner and clean up any ephemeral resources.
378 500
 func (r *controller) Close() error {
379 501
 	select {
... ...
@@ -149,7 +149,7 @@ This section lists each version from latest to oldest.  Each listing includes a
149 149
 * `POST /containers/create` now takes `AutoRemove` in HostConfig, to enable auto-removal of the container on daemon side when the container's process exits.
150 150
 * `GET /containers/json` and `GET /containers/(id or name)/json` now return `"removing"` as a value for the `State.Status` field if the container is being removed. Previously, "exited" was returned as status.
151 151
 * `GET /containers/json` now accepts `removing` as a valid value for the `status` filter.
152
-* `GET /containers/json` now supports filtering containers by `health` status. 
152
+* `GET /containers/json` now supports filtering containers by `health` status.
153 153
 * `DELETE /volumes/(name)` now accepts a `force` query parameter to force removal of volumes that were already removed out of band by the volume driver plugin.
154 154
 * `POST /containers/create/` and `POST /containers/(name)/update` now validates restart policies.
155 155
 * `POST /containers/create` now validates IPAMConfig in NetworkingConfig, and returns error for invalid IPv4 and IPv6 addresses (`--ip` and `--ip6` in `docker create/run`).
... ...
@@ -5631,6 +5631,49 @@ image](#create-an-image) section for more details.
5631 5631
 -   **404** – no such service
5632 5632
 -   **500** – server error
5633 5633
 
5634
+### Get service logs
5635
+
5636
+`GET /services/(id or name)/logs`
5637
+
5638
+Get `stdout` and `stderr` logs from the service ``id``
5639
+
5640
+> **Note**:
5641
+> This endpoint works only for services with the `json-file` or `journald` logging drivers.
5642
+
5643
+**Example request**:
5644
+
5645
+     GET /services/4fa6e0f0c678/logs?stderr=1&stdout=1&timestamps=1&follow=1&tail=10&since=1428990821 HTTP/1.1
5646
+
5647
+**Example response**:
5648
+
5649
+     HTTP/1.1 101 UPGRADED
5650
+     Content-Type: application/vnd.docker.raw-stream
5651
+     Connection: Upgrade
5652
+     Upgrade: tcp
5653
+
5654
+     {% raw %}
5655
+     {{ STREAM }}
5656
+     {% endraw %}
5657
+
5658
+**Query parameters**:
5659
+
5660
+-   **details** - 1/True/true or 0/False/flase, Show extra details provided to logs. Default `false`.
5661
+-   **follow** – 1/True/true or 0/False/false, return stream. Default `false`.
5662
+-   **stdout** – 1/True/true or 0/False/false, show `stdout` log. Default `false`.
5663
+-   **stderr** – 1/True/true or 0/False/false, show `stderr` log. Default `false`.
5664
+-   **since** – UNIX timestamp (integer) to filter logs. Specifying a timestamp
5665
+    will only output log-entries since that timestamp. Default: 0 (unfiltered)
5666
+-   **timestamps** – 1/True/true or 0/False/false, print timestamps for
5667
+        every log line. Default `false`.
5668
+-   **tail** – Output specified number of lines at the end of logs: `all` or `<number>`. Default all.
5669
+
5670
+**Status codes**:
5671
+
5672
+-   **101** – no error, hints proxy about hijacking
5673
+-   **200** – no error, no upgrade header found
5674
+-   **404** – no such service
5675
+-   **500** – server error
5676
+
5634 5677
 ## 3.10 Tasks
5635 5678
 
5636 5679
 **Note**: Task operations require the engine to be part of a swarm.
5637 5680
new file mode 100644
... ...
@@ -0,0 +1,67 @@
0
+---
1
+title: "service logs (experimental)"
2
+description: "The service logs command description and usage"
3
+keywords: "service, logs"
4
+advisory: "experimental"
5
+---
6
+
7
+<!-- This file is maintained within the docker/docker Github
8
+     repository at https://github.com/docker/docker/. Make all
9
+     pull requests against that repo. If you see this file in
10
+     another repository, consider it read-only there, as it will
11
+     periodically be overwritten by the definitive file. Pull
12
+     requests which include edits to this file in other repositories
13
+     will be rejected.
14
+-->
15
+
16
+# service logs
17
+
18
+```Markdown
19
+Usage:  docker service logs [OPTIONS] SERVICE
20
+
21
+Fetch the logs of a service
22
+
23
+Options:
24
+      --details        Show extra details provided to logs
25
+  -f, --follow         Follow log output
26
+      --help           Print usage
27
+      --since string   Show logs since timestamp
28
+      --tail string    Number of lines to show from the end of the logs (default "all")
29
+  -t, --timestamps     Show timestamps
30
+```
31
+
32
+The `docker service logs` command batch-retrieves logs present at the time of execution.
33
+
34
+> **Note**: this command is only functional for services that are started with
35
+> the `json-file` or `journald` logging driver.
36
+
37
+For more information about selecting and configuring login-drivers, refer to
38
+[Configure logging drivers](https://docs.docker.com/engine/admin/logging/overview/).
39
+
40
+The `docker service logs --follow` command will continue streaming the new output from
41
+the service's `STDOUT` and `STDERR`.
42
+
43
+Passing a negative number or a non-integer to `--tail` is invalid and the
44
+value is set to `all` in that case.
45
+
46
+The `docker service logs --timestamps` command will add an [RFC3339Nano timestamp](https://golang.org/pkg/time/#pkg-constants)
47
+, for example `2014-09-16T06:17:46.000000000Z`, to each
48
+log entry. To ensure that the timestamps are aligned the
49
+nano-second part of the timestamp will be padded with zero when necessary.
50
+
51
+The `docker service logs --details` command will add on extra attributes, such as
52
+environment variables and labels, provided to `--log-opt` when creating the
53
+service.
54
+
55
+The `--since` option shows only the service logs generated after
56
+a given date. You can specify the date as an RFC 3339 date, a UNIX
57
+timestamp, or a Go duration string (e.g. `1m30s`, `3h`). Besides RFC3339 date
58
+format you may also use RFC3339Nano, `2006-01-02T15:04:05`,
59
+`2006-01-02T15:04:05.999999999`, `2006-01-02Z07:00`, and `2006-01-02`. The local
60
+timezone on the client will be used if you do not provide either a `Z` or a
61
+`+-00:00` timezone offset at the end of the timestamp. When providing Unix
62
+timestamps enter seconds[.nanoseconds], where seconds is the number of seconds
63
+that have elapsed since January 1, 1970 (midnight UTC/GMT), not counting leap
64
+seconds (aka Unix epoch or Unix time), and the optional .nanoseconds field is a
65
+fraction of a second no more than nine digits long. You can combine the
66
+`--since` option with either or both of the `--follow` or `--tail` options.
0 67
new file mode 100644
... ...
@@ -0,0 +1,55 @@
0
+// +build !windows
1
+
2
+package main
3
+
4
+import (
5
+	"bufio"
6
+	"io"
7
+	"os/exec"
8
+	"strings"
9
+
10
+	"github.com/docker/docker/pkg/integration/checker"
11
+	"github.com/go-check/check"
12
+)
13
+
14
+type logMessage struct {
15
+	err  error
16
+	data []byte
17
+}
18
+
19
+func (s *DockerSwarmSuite) TestServiceLogs(c *check.C) {
20
+	testRequires(c, ExperimentalDaemon)
21
+
22
+	d := s.AddDaemon(c, true, true)
23
+
24
+	name := "TestServiceLogs"
25
+
26
+	out, err := d.Cmd("service", "create", "--name", name, "busybox", "sh", "-c", "while true; do echo log test; sleep 1; done")
27
+	c.Assert(err, checker.IsNil)
28
+	c.Assert(strings.TrimSpace(out), checker.Not(checker.Equals), "")
29
+
30
+	// make sure task has been deployed.
31
+	waitAndAssert(c, defaultReconciliationTimeout, d.checkActiveContainerCount, checker.Equals, 1)
32
+
33
+	args := []string{"service", "logs", "-f", name}
34
+	cmd := exec.Command(dockerBinary, d.prependHostArg(args)...)
35
+	r, w := io.Pipe()
36
+	cmd.Stdout = w
37
+	cmd.Stderr = w
38
+	c.Assert(cmd.Start(), checker.IsNil)
39
+
40
+	// Make sure pipe is written to
41
+	ch := make(chan *logMessage)
42
+	go func() {
43
+		reader := bufio.NewReader(r)
44
+		msg := &logMessage{}
45
+		msg.data, _, msg.err = reader.ReadLine()
46
+		ch <- msg
47
+	}()
48
+
49
+	msg := <-ch
50
+	c.Assert(msg.err, checker.IsNil)
51
+	c.Assert(string(msg.data), checker.Contains, "log test")
52
+
53
+	c.Assert(cmd.Process.Kill(), checker.IsNil)
54
+}