Browse code

docker save: ability to save multiple images

Now from a single invocation of `docker save`, you can specify multiple
images to include in the output tar, or even just multiple tags of a
particular image/repo.

```
> docker save -o bundle.tar busybox ubuntu:lucid ubuntu:saucy fedora:latest
> tar tf ./bundle.tar | wc -l
42
> tar xOf ./bundle.tar repositories
{"busybox":{"latest":"2d8e5b282c81244037eb15b2068e1c46319c1a42b80493acb128da24b2090739"},"fedora":{"latest":"58394af373423902a1b97f209a31e3777932d9321ef10e64feaaa7b4df609cf9"},"ubuntu":{"lucid":"9cc9ea5ea540116b89e41898dd30858107c1175260fb7ff50322b34704092232","saucy":"9f676bd305a43a931a8d98b13e5840ffbebcd908370765373315926024c7c35e"}}
```

Further, this fixes the bug where the `repositories` file is not created
when saving a specific tag of an image (e.g. ubuntu:latest)

document multi-image save and updated API docs

Docker-DCO-1.1-Signed-off-by: Vincent Batts <vbatts@redhat.com> (github: vbatts)

Vincent Batts authored on 2014/05/01 13:26:24
Showing 6 changed files
... ...
@@ -2257,14 +2257,14 @@ func (cli *DockerCli) CmdCp(args ...string) error {
2257 2257
 }
2258 2258
 
2259 2259
 func (cli *DockerCli) CmdSave(args ...string) error {
2260
-	cmd := cli.Subcmd("save", "IMAGE", "Save an image to a tar archive (streamed to STDOUT by default)")
2260
+	cmd := cli.Subcmd("save", "IMAGE [IMAGE...]", "Save an image(s) to a tar archive (streamed to STDOUT by default)")
2261 2261
 	outfile := cmd.String([]string{"o", "-output"}, "", "Write to an file, instead of STDOUT")
2262 2262
 
2263 2263
 	if err := cmd.Parse(args); err != nil {
2264 2264
 		return err
2265 2265
 	}
2266 2266
 
2267
-	if cmd.NArg() != 1 {
2267
+	if cmd.NArg() < 1 {
2268 2268
 		cmd.Usage()
2269 2269
 		return nil
2270 2270
 	}
... ...
@@ -2279,9 +2279,19 @@ func (cli *DockerCli) CmdSave(args ...string) error {
2279 2279
 			return err
2280 2280
 		}
2281 2281
 	}
2282
-	image := cmd.Arg(0)
2283
-	if err := cli.stream("GET", "/images/"+image+"/get", nil, output, nil); err != nil {
2284
-		return err
2282
+	if len(cmd.Args()) == 1 {
2283
+		image := cmd.Arg(0)
2284
+		if err := cli.stream("GET", "/images/"+image+"/get", nil, output, nil); err != nil {
2285
+			return err
2286
+		}
2287
+	} else {
2288
+		v := url.Values{}
2289
+		for _, arg := range cmd.Args() {
2290
+			v.Add("names", arg)
2291
+		}
2292
+		if err := cli.stream("GET", "/images/get?"+v.Encode(), nil, output, nil); err != nil {
2293
+			return err
2294
+		}
2285 2295
 	}
2286 2296
 	return nil
2287 2297
 }
... ...
@@ -611,10 +611,18 @@ func getImagesGet(eng *engine.Engine, version version.Version, w http.ResponseWr
611 611
 	if vars == nil {
612 612
 		return fmt.Errorf("Missing parameter")
613 613
 	}
614
+	if err := parseForm(r); err != nil {
615
+		return err
616
+	}
614 617
 	if version.GreaterThan("1.0") {
615 618
 		w.Header().Set("Content-Type", "application/x-tar")
616 619
 	}
617
-	job := eng.Job("image_export", vars["name"])
620
+	var job *engine.Job
621
+	if name, ok := vars["name"]; ok {
622
+		job = eng.Job("image_export", name)
623
+	} else {
624
+		job = eng.Job("image_export", r.Form["names"]...)
625
+	}
618 626
 	job.Stdout.Add(w)
619 627
 	return job.Run()
620 628
 }
... ...
@@ -1105,6 +1113,7 @@ func createRouter(eng *engine.Engine, logging, enableCors bool, dockerVersion st
1105 1105
 			"/images/json":                    getImagesJSON,
1106 1106
 			"/images/viz":                     getImagesViz,
1107 1107
 			"/images/search":                  getImagesSearch,
1108
+			"/images/get":                     getImagesGet,
1108 1109
 			"/images/{name:.*}/get":           getImagesGet,
1109 1110
 			"/images/{name:.*}/history":       getImagesHistory,
1110 1111
 			"/images/{name:.*}/json":          getImagesByName,
... ...
@@ -1333,12 +1333,17 @@ via polling (using since)
1333 1333
     -   **200** – no error
1334 1334
     -   **500** – server error
1335 1335
 
1336
-### Get a tarball containing all images and tags in a repository
1336
+### Get a tarball containing all images in a repository
1337 1337
 
1338 1338
 `GET /images/(name)/get`
1339 1339
 
1340
-Get a tarball containing all images and metadata for the repository
1341
-specified by `name`.
1340
+Get a tarball containing all images and metadata for the repository specified
1341
+by `name`.
1342
+
1343
+If `name` is a specific name and tag (e.g. ubuntu:latest), then only that image
1344
+(and its parents) are returned. If `name` is an image ID, similarly only that
1345
+image (and its parents) are returned, but with the exclusion of the
1346
+'repositories' file in the tarball, as there were no image names referenced.
1342 1347
 
1343 1348
     **Example request**
1344 1349
 
... ...
@@ -1356,6 +1361,34 @@ specified by `name`.
1356 1356
     -   **200** – no error
1357 1357
     -   **500** – server error
1358 1358
 
1359
+### Get a tarball containing of images.
1360
+
1361
+`GET /images/get`
1362
+
1363
+Get a tarball containing all images and metadata for one or more repositories.
1364
+
1365
+For each value of the `names` parameter: if it is a specific name and tag (e.g.
1366
+ubuntu:latest), then only that image (and its parents) are returned; if it is
1367
+an image ID, similarly only that image (and its parents) are returned and there
1368
+would be no names referenced in the 'repositories' file for this image ID.
1369
+
1370
+
1371
+    **Example request**
1372
+
1373
+        GET /images/get?names=myname%2Fmyapp%3Alatest&names=busybox
1374
+
1375
+    **Example response**:
1376
+
1377
+        HTTP/1.1 200 OK
1378
+        Content-Type: application/x-tar
1379
+
1380
+        Binary data stream
1381
+
1382
+    Status Codes:
1383
+
1384
+    -   **200** – no error
1385
+    -   **500** – server error
1386
+
1359 1387
 ### Load a tarball with a set of images and tags into docker
1360 1388
 
1361 1389
 `POST /images/load`
... ...
@@ -1249,17 +1249,17 @@ Providing a maximum restart limit is only valid for the ** on-failure ** policy.
1249 1249
 
1250 1250
 ## save
1251 1251
 
1252
-    Usage: docker save [OPTIONS] IMAGE
1252
+    Usage: docker save [OPTIONS] IMAGE [IMAGE...]
1253 1253
 
1254
-    Save an image to a tar archive (streamed to STDOUT by default)
1254
+    Save an image(s) to a tar archive (streamed to STDOUT by default)
1255 1255
 
1256 1256
       -o, --output=""    Write to an file, instead of STDOUT
1257 1257
 
1258
-Produces a tarred repository to the standard output stream. Contains all
1259
-parent layers, and all tags + versions, or specified repo:tag.
1258
+Produces a tarred repository to the standard output stream.
1259
+Contains all parent layers, and all tags + versions, or specified repo:tag, for
1260
+each argument provided.
1260 1261
 
1261
-It is used to create a backup that can then be used with
1262
-`docker load`
1262
+It is used to create a backup that can then be used with ``docker load``
1263 1263
 
1264 1264
     $ sudo docker save busybox > busybox.tar
1265 1265
     $ ls -sh busybox.tar
... ...
@@ -1270,6 +1270,11 @@ It is used to create a backup that can then be used with
1270 1270
     $ sudo docker save -o fedora-all.tar fedora
1271 1271
     $ sudo docker save -o fedora-latest.tar fedora:latest
1272 1272
 
1273
+It is even useful to cherry-pick particular tags of an image repository
1274
+
1275
+   $ sudo docker save -o ubuntu.tar ubuntu:lucid ubuntu:saucy
1276
+
1277
+
1273 1278
 ## search
1274 1279
 
1275 1280
 Search [Docker Hub](https://hub.docker.com) for images
... ...
@@ -19,10 +19,9 @@ import (
19 19
 // name is the set of tags to export.
20 20
 // out is the writer where the images are written to.
21 21
 func (s *TagStore) CmdImageExport(job *engine.Job) engine.Status {
22
-	if len(job.Args) != 1 {
23
-		return job.Errorf("Usage: %s IMAGE\n", job.Name)
22
+	if len(job.Args) < 1 {
23
+		return job.Errorf("Usage: %s IMAGE [IMAGE...]\n", job.Name)
24 24
 	}
25
-	name := job.Args[0]
26 25
 	// get image json
27 26
 	tempdir, err := ioutil.TempDir("", "docker-export-")
28 27
 	if err != nil {
... ...
@@ -30,49 +29,71 @@ func (s *TagStore) CmdImageExport(job *engine.Job) engine.Status {
30 30
 	}
31 31
 	defer os.RemoveAll(tempdir)
32 32
 
33
-	log.Debugf("Serializing %s", name)
34
-
35 33
 	rootRepoMap := map[string]Repository{}
36
-	rootRepo, err := s.Get(name)
37
-	if err != nil {
38
-		return job.Error(err)
39
-	}
40
-	if rootRepo != nil {
41
-		// this is a base repo name, like 'busybox'
34
+	for _, name := range job.Args {
35
+		log.Debugf("Serializing %s", name)
36
+		rootRepo := s.Repositories[name]
37
+		if rootRepo != nil {
38
+			// this is a base repo name, like 'busybox'
39
+			for _, id := range rootRepo {
40
+				if _, ok := rootRepoMap[name]; !ok {
41
+					rootRepoMap[name] = rootRepo
42
+				} else {
43
+					log.Debugf("Duplicate key [%s]", name)
44
+					if rootRepoMap[name].Contains(rootRepo) {
45
+						log.Debugf("skipping, because it is present [%s:%q]", name, rootRepo)
46
+						continue
47
+					}
48
+					log.Debugf("updating [%s]: [%q] with [%q]", name, rootRepoMap[name], rootRepo)
49
+					rootRepoMap[name].Update(rootRepo)
50
+				}
42 51
 
43
-		for _, id := range rootRepo {
44
-			if err := s.exportImage(job.Eng, id, tempdir); err != nil {
45
-				return job.Error(err)
46
-			}
47
-		}
48
-		rootRepoMap[name] = rootRepo
49
-	} else {
50
-		img, err := s.LookupImage(name)
51
-		if err != nil {
52
-			return job.Error(err)
53
-		}
54
-		if img != nil {
55
-			// This is a named image like 'busybox:latest'
56
-			repoName, repoTag := parsers.ParseRepositoryTag(name)
57
-			if err := s.exportImage(job.Eng, img.ID, tempdir); err != nil {
58
-				return job.Error(err)
59
-			}
60
-			// check this length, because a lookup of a truncated has will not have a tag
61
-			// and will not need to be added to this map
62
-			if len(repoTag) > 0 {
63
-				rootRepoMap[repoName] = Repository{repoTag: img.ID}
52
+				if err := s.exportImage(job.Eng, id, tempdir); err != nil {
53
+					return job.Error(err)
54
+				}
64 55
 			}
65 56
 		} else {
66
-			// this must be an ID that didn't get looked up just right?
67
-			if err := s.exportImage(job.Eng, name, tempdir); err != nil {
57
+			img, err := s.LookupImage(name)
58
+			if err != nil {
68 59
 				return job.Error(err)
69 60
 			}
61
+
62
+			if img != nil {
63
+				// This is a named image like 'busybox:latest'
64
+				repoName, repoTag := parsers.ParseRepositoryTag(name)
65
+
66
+				// check this length, because a lookup of a truncated has will not have a tag
67
+				// and will not need to be added to this map
68
+				if len(repoTag) > 0 {
69
+					if _, ok := rootRepoMap[repoName]; !ok {
70
+						rootRepoMap[repoName] = Repository{repoTag: img.ID}
71
+					} else {
72
+						log.Debugf("Duplicate key [%s]", repoName)
73
+						newRepo := Repository{repoTag: img.ID}
74
+						if rootRepoMap[repoName].Contains(newRepo) {
75
+							log.Debugf("skipping, because it is present [%s:%q]", repoName, newRepo)
76
+							continue
77
+						}
78
+						log.Debugf("updating [%s]: [%q] with [%q]", repoName, rootRepoMap[repoName], newRepo)
79
+						rootRepoMap[repoName].Update(newRepo)
80
+					}
81
+				}
82
+				if err := s.exportImage(job.Eng, img.ID, tempdir); err != nil {
83
+					return job.Error(err)
84
+				}
85
+
86
+			} else {
87
+				// this must be an ID that didn't get looked up just right?
88
+				if err := s.exportImage(job.Eng, name, tempdir); err != nil {
89
+					return job.Error(err)
90
+				}
91
+			}
70 92
 		}
93
+		log.Debugf("End Serializing %s", name)
71 94
 	}
72 95
 	// write repositories, if there is something to write
73 96
 	if len(rootRepoMap) > 0 {
74 97
 		rootRepoJson, _ := json.Marshal(rootRepoMap)
75
-
76 98
 		if err := ioutil.WriteFile(path.Join(tempdir, "repositories"), rootRepoJson, os.FileMode(0644)); err != nil {
77 99
 			return job.Error(err)
78 100
 		}
... ...
@@ -89,7 +110,7 @@ func (s *TagStore) CmdImageExport(job *engine.Job) engine.Status {
89 89
 	if _, err := io.Copy(job.Stdout, fs); err != nil {
90 90
 		return job.Error(err)
91 91
 	}
92
-	log.Debugf("End Serializing %s", name)
92
+	log.Debugf("End export job: %s", job.Name)
93 93
 	return engine.StatusOK
94 94
 }
95 95
 
... ...
@@ -30,6 +30,24 @@ type TagStore struct {
30 30
 
31 31
 type Repository map[string]string
32 32
 
33
+// update Repository mapping with content of u
34
+func (r Repository) Update(u Repository) {
35
+	for k, v := range u {
36
+		r[k] = v
37
+	}
38
+}
39
+
40
+// return true if the contents of u Repository, are wholly contained in r Repository
41
+func (r Repository) Contains(u Repository) bool {
42
+	for k, v := range u {
43
+		// if u's key is not present in r OR u's key is present, but not the same value
44
+		if rv, ok := r[k]; !ok || (ok && rv != v) {
45
+			return false
46
+		}
47
+	}
48
+	return true
49
+}
50
+
33 51
 func NewTagStore(path string, graph *Graph) (*TagStore, error) {
34 52
 	abspath, err := filepath.Abs(path)
35 53
 	if err != nil {