Browse code

api: Service Logs support

Signed-off-by: Andrea Luzzardi <aluzzardi@gmail.com>

Andrea Luzzardi authored on 2016/10/26 17:17:31
Showing 8 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
... ...
@@ -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()