Browse code

api/client: New and Improved `docker cp` behavior

Supports copying things INTO a container from a local file or from a tar
archive read from stdin.

Docker-DCO-1.1-Signed-off-by: Josh Hawn <josh.hawn@docker.com> (github: jlhawn)

Josh Hawn authored on 2015/05/14 10:37:11
Showing 1 changed files
... ...
@@ -1,8 +1,14 @@
1 1
 package client
2 2
 
3 3
 import (
4
+	"encoding/base64"
5
+	"encoding/json"
4 6
 	"fmt"
5 7
 	"io"
8
+	"net/http"
9
+	"net/url"
10
+	"os"
11
+	"path/filepath"
6 12
 	"strings"
7 13
 
8 14
 	"github.com/docker/docker/api/types"
... ...
@@ -10,48 +16,289 @@ import (
10 10
 	flag "github.com/docker/docker/pkg/mflag"
11 11
 )
12 12
 
13
-// CmdCp copies files/folders from a path on the container to a directory on the host running the command.
13
+type copyDirection int
14
+
15
+const (
16
+	fromContainer copyDirection = (1 << iota)
17
+	toContainer
18
+	acrossContainers = fromContainer | toContainer
19
+)
20
+
21
+// CmdCp copies files/folders to or from a path in a container.
14 22
 //
15
-// If HOSTDIR is '-', the data is written as a tar file to STDOUT.
23
+// When copying from a container, if LOCALPATH is '-' the data is written as a
24
+// tar archive file to STDOUT.
16 25
 //
17
-// Usage: docker cp CONTAINER:PATH HOSTDIR
26
+// When copying to a container, if LOCALPATH is '-' the data is read as a tar
27
+// archive file from STDIN, and the destination CONTAINER:PATH, must specify
28
+// a directory.
29
+//
30
+// Usage:
31
+// 	docker cp CONTAINER:PATH LOCALPATH|-
32
+// 	docker cp LOCALPATH|- CONTAINER:PATH
18 33
 func (cli *DockerCli) CmdCp(args ...string) error {
19
-	cmd := cli.Subcmd("cp", []string{"CONTAINER:PATH HOSTDIR|-"}, "Copy files/folders from a container's PATH to a HOSTDIR on the host\nrunning the command. Use '-' to write the data as a tar file to STDOUT.", true)
20
-	cmd.Require(flag.Exact, 2)
34
+	cmd := cli.Subcmd(
35
+		"cp",
36
+		[]string{"CONTAINER:PATH LOCALPATH|-", "LOCALPATH|- CONTAINER:PATH"},
37
+		strings.Join([]string{
38
+			"Copy files/folders between a container and your host.\n",
39
+			"Use '-' as the source to read a tar archive from stdin\n",
40
+			"and extract it to a directory destination in a container.\n",
41
+			"Use '-' as the destination to stream a tar archive of a\n",
42
+			"container source to stdout.",
43
+		}, ""),
44
+		true,
45
+	)
21 46
 
47
+	cmd.Require(flag.Exact, 2)
22 48
 	cmd.ParseFlags(args, true)
23 49
 
24
-	// deal with path name with `:`
25
-	info := strings.SplitN(cmd.Arg(0), ":", 2)
50
+	if cmd.Arg(0) == "" {
51
+		return fmt.Errorf("source can not be empty")
52
+	}
53
+	if cmd.Arg(1) == "" {
54
+		return fmt.Errorf("destination can not be empty")
55
+	}
56
+
57
+	srcContainer, srcPath := splitCpArg(cmd.Arg(0))
58
+	dstContainer, dstPath := splitCpArg(cmd.Arg(1))
59
+
60
+	var direction copyDirection
61
+	if srcContainer != "" {
62
+		direction |= fromContainer
63
+	}
64
+	if dstContainer != "" {
65
+		direction |= toContainer
66
+	}
67
+
68
+	switch direction {
69
+	case fromContainer:
70
+		return cli.copyFromContainer(srcContainer, srcPath, dstPath)
71
+	case toContainer:
72
+		return cli.copyToContainer(srcPath, dstContainer, dstPath)
73
+	case acrossContainers:
74
+		// Copying between containers isn't supported.
75
+		return fmt.Errorf("copying between containers is not supported")
76
+	default:
77
+		// User didn't specify any container.
78
+		return fmt.Errorf("must specify at least one container source")
79
+	}
80
+}
26 81
 
27
-	if len(info) != 2 {
28
-		return fmt.Errorf("Error: Path not specified")
82
+// We use `:` as a delimiter between CONTAINER and PATH, but `:` could also be
83
+// in a valid LOCALPATH, like `file:name.txt`. We can resolve this ambiguity by
84
+// requiring a LOCALPATH with a `:` to be made explicit with a relative or
85
+// absolute path:
86
+// 	`/path/to/file:name.txt` or `./file:name.txt`
87
+//
88
+// This is apparently how `scp` handles this as well:
89
+// 	http://www.cyberciti.biz/faq/rsync-scp-file-name-with-colon-punctuation-in-it/
90
+//
91
+// We can't simply check for a filepath separator because container names may
92
+// have a separator, e.g., "host0/cname1" if container is in a Docker cluster,
93
+// so we have to check for a `/` or `.` prefix. Also, in the case of a Windows
94
+// client, a `:` could be part of an absolute Windows path, in which case it
95
+// is immediately proceeded by a backslash.
96
+func splitCpArg(arg string) (container, path string) {
97
+	if filepath.IsAbs(arg) {
98
+		// Explicit local absolute path, e.g., `C:\foo` or `/foo`.
99
+		return "", arg
29 100
 	}
30 101
 
31
-	cfg := &types.CopyConfig{
32
-		Resource: info[1],
102
+	parts := strings.SplitN(arg, ":", 2)
103
+
104
+	if len(parts) == 1 || strings.HasPrefix(parts[0], ".") {
105
+		// Either there's no `:` in the arg
106
+		// OR it's an explicit local relative path like `./file:name.txt`.
107
+		return "", arg
33 108
 	}
34
-	serverResp, err := cli.call("POST", "/containers/"+info[0]+"/copy", cfg, nil)
35
-	if serverResp.body != nil {
36
-		defer serverResp.body.Close()
109
+
110
+	return parts[0], parts[1]
111
+}
112
+
113
+func (cli *DockerCli) statContainerPath(containerName, path string) (types.ContainerPathStat, error) {
114
+	var stat types.ContainerPathStat
115
+
116
+	query := make(url.Values, 1)
117
+	query.Set("path", filepath.ToSlash(path)) // Normalize the paths used in the API.
118
+
119
+	urlStr := fmt.Sprintf("/containers/%s/archive?%s", containerName, query.Encode())
120
+
121
+	response, err := cli.call("HEAD", urlStr, nil, nil)
122
+	if err != nil {
123
+		return stat, err
37 124
 	}
38
-	if serverResp.statusCode == 404 {
39
-		return fmt.Errorf("No such container: %v", info[0])
125
+	defer response.body.Close()
126
+
127
+	if response.statusCode != http.StatusOK {
128
+		return stat, fmt.Errorf("unexpected status code from daemon: %d", response.statusCode)
40 129
 	}
130
+
131
+	return getContainerPathStatFromHeader(response.header)
132
+}
133
+
134
+func getContainerPathStatFromHeader(header http.Header) (types.ContainerPathStat, error) {
135
+	var stat types.ContainerPathStat
136
+
137
+	encodedStat := header.Get("X-Docker-Container-Path-Stat")
138
+	statDecoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(encodedStat))
139
+
140
+	err := json.NewDecoder(statDecoder).Decode(&stat)
141
+	if err != nil {
142
+		err = fmt.Errorf("unable to decode container path stat header: %s", err)
143
+	}
144
+
145
+	return stat, err
146
+}
147
+
148
+func resolveLocalPath(localPath string) (absPath string, err error) {
149
+	if absPath, err = filepath.Abs(localPath); err != nil {
150
+		return
151
+	}
152
+
153
+	return archive.PreserveTrailingDotOrSeparator(absPath, localPath), nil
154
+}
155
+
156
+func (cli *DockerCli) copyFromContainer(srcContainer, srcPath, dstPath string) (err error) {
157
+	if dstPath != "-" {
158
+		// Get an absolute destination path.
159
+		dstPath, err = resolveLocalPath(dstPath)
160
+		if err != nil {
161
+			return err
162
+		}
163
+	}
164
+
165
+	query := make(url.Values, 1)
166
+	query.Set("path", filepath.ToSlash(srcPath)) // Normalize the paths used in the API.
167
+
168
+	urlStr := fmt.Sprintf("/containers/%s/archive?%s", srcContainer, query.Encode())
169
+
170
+	response, err := cli.call("GET", urlStr, nil, nil)
41 171
 	if err != nil {
42 172
 		return err
43 173
 	}
174
+	defer response.body.Close()
175
+
176
+	if response.statusCode != http.StatusOK {
177
+		return fmt.Errorf("unexpected status code from daemon: %d", response.statusCode)
178
+	}
179
+
180
+	if dstPath == "-" {
181
+		// Send the response to STDOUT.
182
+		_, err = io.Copy(os.Stdout, response.body)
183
+
184
+		return err
185
+	}
186
+
187
+	// In order to get the copy behavior right, we need to know information
188
+	// about both the source and the destination. The response headers include
189
+	// stat info about the source that we can use in deciding exactly how to
190
+	// copy it locally. Along with the stat info about the local destination,
191
+	// we have everything we need to handle the multiple possibilities there
192
+	// can be when copying a file/dir from one location to another file/dir.
193
+	stat, err := getContainerPathStatFromHeader(response.header)
194
+	if err != nil {
195
+		return fmt.Errorf("unable to get resource stat from response: %s", err)
196
+	}
197
+
198
+	// Prepare source copy info.
199
+	srcInfo := archive.CopyInfo{
200
+		Path:   srcPath,
201
+		Exists: true,
202
+		IsDir:  stat.Mode.IsDir(),
203
+	}
44 204
 
45
-	hostPath := cmd.Arg(1)
46
-	if serverResp.statusCode == 200 {
47
-		if hostPath == "-" {
48
-			_, err = io.Copy(cli.out, serverResp.body)
49
-		} else {
50
-			err = archive.Untar(serverResp.body, hostPath, &archive.TarOptions{NoLchown: true})
205
+	// See comments in the implementation of `archive.CopyTo` for exactly what
206
+	// goes into deciding how and whether the source archive needs to be
207
+	// altered for the correct copy behavior.
208
+	return archive.CopyTo(response.body, srcInfo, dstPath)
209
+}
210
+
211
+func (cli *DockerCli) copyToContainer(srcPath, dstContainer, dstPath string) (err error) {
212
+	if srcPath != "-" {
213
+		// Get an absolute source path.
214
+		srcPath, err = resolveLocalPath(srcPath)
215
+		if err != nil {
216
+			return err
51 217
 		}
218
+	}
219
+
220
+	// In order to get the copy behavior right, we need to know information
221
+	// about both the source and destination. The API is a simple tar
222
+	// archive/extract API but we can use the stat info header about the
223
+	// destination to be more informed about exactly what the destination is.
224
+
225
+	// Prepare destination copy info by stat-ing the container path.
226
+	dstInfo := archive.CopyInfo{Path: dstPath}
227
+	dstStat, err := cli.statContainerPath(dstContainer, dstPath)
228
+	// Ignore any error and assume that the parent directory of the destination
229
+	// path exists, in which case the copy may still succeed. If there is any
230
+	// type of conflict (e.g., non-directory overwriting an existing directory
231
+	// or vice versia) the extraction will fail. If the destination simply did
232
+	// not exist, but the parent directory does, the extraction will still
233
+	// succeed.
234
+	if err == nil {
235
+		dstInfo.Exists, dstInfo.IsDir = true, dstStat.Mode.IsDir()
236
+	}
237
+
238
+	var content io.Reader
239
+	if srcPath == "-" {
240
+		// Use STDIN.
241
+		content = os.Stdin
242
+		if !dstInfo.IsDir {
243
+			return fmt.Errorf("destination %q must be a directory", fmt.Sprintf("%s:%s", dstContainer, dstPath))
244
+		}
245
+	} else {
246
+		srcArchive, err := archive.TarResource(srcPath)
247
+		if err != nil {
248
+			return err
249
+		}
250
+		defer srcArchive.Close()
251
+
252
+		// With the stat info about the local source as well as the
253
+		// destination, we have enough information to know whether we need to
254
+		// alter the archive that we upload so that when the server extracts
255
+		// it to the specified directory in the container we get the disired
256
+		// copy behavior.
257
+
258
+		// Prepare source copy info.
259
+		srcInfo, err := archive.CopyInfoStatPath(srcPath, true)
260
+		if err != nil {
261
+			return err
262
+		}
263
+
264
+		// See comments in the implementation of `archive.PrepareArchiveCopy`
265
+		// for exactly what goes into deciding how and whether the source
266
+		// archive needs to be altered for the correct copy behavior when it is
267
+		// extracted. This function also infers from the source and destination
268
+		// info which directory to extract to, which may be the parent of the
269
+		// destination that the user specified.
270
+		dstDir, preparedArchive, err := archive.PrepareArchiveCopy(srcArchive, srcInfo, dstInfo)
52 271
 		if err != nil {
53 272
 			return err
54 273
 		}
274
+		defer preparedArchive.Close()
275
+
276
+		dstPath = dstDir
277
+		content = preparedArchive
278
+	}
279
+
280
+	query := make(url.Values, 2)
281
+	query.Set("path", filepath.ToSlash(dstPath)) // Normalize the paths used in the API.
282
+	// Do not allow for an existing directory to be overwritten by a non-directory and vice versa.
283
+	query.Set("noOverwriteDirNonDir", "true")
284
+
285
+	urlStr := fmt.Sprintf("/containers/%s/archive?%s", dstContainer, query.Encode())
286
+
287
+	response, err := cli.stream("PUT", urlStr, &streamOpts{in: content})
288
+	if err != nil {
289
+		return err
55 290
 	}
291
+	defer response.body.Close()
292
+
293
+	if response.statusCode != http.StatusOK {
294
+		return fmt.Errorf("unexpected status code from daemon: %d", response.statusCode)
295
+	}
296
+
56 297
 	return nil
57 298
 }