From e64131d1bf7c79358d5ee7a1a98626730db3593b Mon Sep 17 00:00:00 2001 From: Vincent Batts Date: Thu, 1 May 2014 00:26:24 -0400 Subject: [PATCH] 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 (github: vbatts) --- api/client/commands.go | 20 +++- api/server/server.go | 11 ++- .../reference/api/docker_remote_api_v1.15.md | 39 +++++++- docs/sources/reference/commandline/cli.md | 17 ++-- graph/export.go | 93 ++++++++++++------- graph/tags.go | 18 ++++ 6 files changed, 147 insertions(+), 51 deletions(-) diff --git a/api/client/commands.go b/api/client/commands.go index cd954b92e2..e5495dbf84 100644 --- a/api/client/commands.go +++ b/api/client/commands.go @@ -2257,14 +2257,14 @@ func (cli *DockerCli) CmdCp(args ...string) error { } func (cli *DockerCli) CmdSave(args ...string) error { - cmd := cli.Subcmd("save", "IMAGE", "Save an image to a tar archive (streamed to STDOUT by default)") + cmd := cli.Subcmd("save", "IMAGE [IMAGE...]", "Save an image(s) to a tar archive (streamed to STDOUT by default)") outfile := cmd.String([]string{"o", "-output"}, "", "Write to an file, instead of STDOUT") if err := cmd.Parse(args); err != nil { return err } - if cmd.NArg() != 1 { + if cmd.NArg() < 1 { cmd.Usage() return nil } @@ -2279,9 +2279,19 @@ func (cli *DockerCli) CmdSave(args ...string) error { return err } } - image := cmd.Arg(0) - if err := cli.stream("GET", "/images/"+image+"/get", nil, output, nil); err != nil { - return err + if len(cmd.Args()) == 1 { + image := cmd.Arg(0) + if err := cli.stream("GET", "/images/"+image+"/get", nil, output, nil); err != nil { + return err + } + } else { + v := url.Values{} + for _, arg := range cmd.Args() { + v.Add("names", arg) + } + if err := cli.stream("GET", "/images/get?"+v.Encode(), nil, output, nil); err != nil { + return err + } } return nil } diff --git a/api/server/server.go b/api/server/server.go index 906e3d8e38..fd13489a83 100644 --- a/api/server/server.go +++ b/api/server/server.go @@ -611,10 +611,18 @@ func getImagesGet(eng *engine.Engine, version version.Version, w http.ResponseWr if vars == nil { return fmt.Errorf("Missing parameter") } + if err := parseForm(r); err != nil { + return err + } if version.GreaterThan("1.0") { w.Header().Set("Content-Type", "application/x-tar") } - job := eng.Job("image_export", vars["name"]) + var job *engine.Job + if name, ok := vars["name"]; ok { + job = eng.Job("image_export", name) + } else { + job = eng.Job("image_export", r.Form["names"]...) + } job.Stdout.Add(w) return job.Run() } @@ -1105,6 +1113,7 @@ func createRouter(eng *engine.Engine, logging, enableCors bool, dockerVersion st "/images/json": getImagesJSON, "/images/viz": getImagesViz, "/images/search": getImagesSearch, + "/images/get": getImagesGet, "/images/{name:.*}/get": getImagesGet, "/images/{name:.*}/history": getImagesHistory, "/images/{name:.*}/json": getImagesByName, diff --git a/docs/sources/reference/api/docker_remote_api_v1.15.md b/docs/sources/reference/api/docker_remote_api_v1.15.md index e2f96980c0..03446c2870 100644 --- a/docs/sources/reference/api/docker_remote_api_v1.15.md +++ b/docs/sources/reference/api/docker_remote_api_v1.15.md @@ -1333,12 +1333,17 @@ via polling (using since) - **200** – no error - **500** – server error -### Get a tarball containing all images and tags in a repository +### Get a tarball containing all images in a repository `GET /images/(name)/get` -Get a tarball containing all images and metadata for the repository -specified by `name`. +Get a tarball containing all images and metadata for the repository specified +by `name`. + +If `name` is a specific name and tag (e.g. ubuntu:latest), then only that image +(and its parents) are returned. If `name` is an image ID, similarly only that +image (and its parents) are returned, but with the exclusion of the +'repositories' file in the tarball, as there were no image names referenced. **Example request** @@ -1356,6 +1361,34 @@ specified by `name`. - **200** – no error - **500** – server error +### Get a tarball containing of images. + +`GET /images/get` + +Get a tarball containing all images and metadata for one or more repositories. + +For each value of the `names` parameter: if it is a specific name and tag (e.g. +ubuntu:latest), then only that image (and its parents) are returned; if it is +an image ID, similarly only that image (and its parents) are returned and there +would be no names referenced in the 'repositories' file for this image ID. + + + **Example request** + + GET /images/get?names=myname%2Fmyapp%3Alatest&names=busybox + + **Example response**: + + HTTP/1.1 200 OK + Content-Type: application/x-tar + + Binary data stream + + Status Codes: + + - **200** – no error + - **500** – server error + ### Load a tarball with a set of images and tags into docker `POST /images/load` diff --git a/docs/sources/reference/commandline/cli.md b/docs/sources/reference/commandline/cli.md index f67527b380..ef30113882 100644 --- a/docs/sources/reference/commandline/cli.md +++ b/docs/sources/reference/commandline/cli.md @@ -1249,17 +1249,17 @@ Providing a maximum restart limit is only valid for the ** on-failure ** policy. ## save - Usage: docker save [OPTIONS] IMAGE + Usage: docker save [OPTIONS] IMAGE [IMAGE...] - Save an image to a tar archive (streamed to STDOUT by default) + Save an image(s) to a tar archive (streamed to STDOUT by default) -o, --output="" Write to an file, instead of STDOUT -Produces a tarred repository to the standard output stream. Contains all -parent layers, and all tags + versions, or specified repo:tag. +Produces a tarred repository to the standard output stream. +Contains all parent layers, and all tags + versions, or specified repo:tag, for +each argument provided. -It is used to create a backup that can then be used with -`docker load` +It is used to create a backup that can then be used with ``docker load`` $ sudo docker save busybox > busybox.tar $ ls -sh busybox.tar @@ -1270,6 +1270,11 @@ It is used to create a backup that can then be used with $ sudo docker save -o fedora-all.tar fedora $ sudo docker save -o fedora-latest.tar fedora:latest +It is even useful to cherry-pick particular tags of an image repository + + $ sudo docker save -o ubuntu.tar ubuntu:lucid ubuntu:saucy + + ## search Search [Docker Hub](https://hub.docker.com) for images diff --git a/graph/export.go b/graph/export.go index d24d97146b..3f095e796c 100644 --- a/graph/export.go +++ b/graph/export.go @@ -19,10 +19,9 @@ import ( // name is the set of tags to export. // out is the writer where the images are written to. func (s *TagStore) CmdImageExport(job *engine.Job) engine.Status { - if len(job.Args) != 1 { - return job.Errorf("Usage: %s IMAGE\n", job.Name) + if len(job.Args) < 1 { + return job.Errorf("Usage: %s IMAGE [IMAGE...]\n", job.Name) } - name := job.Args[0] // get image json tempdir, err := ioutil.TempDir("", "docker-export-") if err != nil { @@ -30,49 +29,71 @@ func (s *TagStore) CmdImageExport(job *engine.Job) engine.Status { } defer os.RemoveAll(tempdir) - log.Debugf("Serializing %s", name) - rootRepoMap := map[string]Repository{} - rootRepo, err := s.Get(name) - if err != nil { - return job.Error(err) - } - if rootRepo != nil { - // this is a base repo name, like 'busybox' + for _, name := range job.Args { + log.Debugf("Serializing %s", name) + rootRepo := s.Repositories[name] + if rootRepo != nil { + // this is a base repo name, like 'busybox' + for _, id := range rootRepo { + if _, ok := rootRepoMap[name]; !ok { + rootRepoMap[name] = rootRepo + } else { + log.Debugf("Duplicate key [%s]", name) + if rootRepoMap[name].Contains(rootRepo) { + log.Debugf("skipping, because it is present [%s:%q]", name, rootRepo) + continue + } + log.Debugf("updating [%s]: [%q] with [%q]", name, rootRepoMap[name], rootRepo) + rootRepoMap[name].Update(rootRepo) + } - for _, id := range rootRepo { - if err := s.exportImage(job.Eng, id, tempdir); err != nil { - return job.Error(err) - } - } - rootRepoMap[name] = rootRepo - } else { - img, err := s.LookupImage(name) - if err != nil { - return job.Error(err) - } - if img != nil { - // This is a named image like 'busybox:latest' - repoName, repoTag := parsers.ParseRepositoryTag(name) - if err := s.exportImage(job.Eng, img.ID, tempdir); err != nil { - return job.Error(err) - } - // check this length, because a lookup of a truncated has will not have a tag - // and will not need to be added to this map - if len(repoTag) > 0 { - rootRepoMap[repoName] = Repository{repoTag: img.ID} + if err := s.exportImage(job.Eng, id, tempdir); err != nil { + return job.Error(err) + } } } else { - // this must be an ID that didn't get looked up just right? - if err := s.exportImage(job.Eng, name, tempdir); err != nil { + img, err := s.LookupImage(name) + if err != nil { return job.Error(err) } + + if img != nil { + // This is a named image like 'busybox:latest' + repoName, repoTag := parsers.ParseRepositoryTag(name) + + // check this length, because a lookup of a truncated has will not have a tag + // and will not need to be added to this map + if len(repoTag) > 0 { + if _, ok := rootRepoMap[repoName]; !ok { + rootRepoMap[repoName] = Repository{repoTag: img.ID} + } else { + log.Debugf("Duplicate key [%s]", repoName) + newRepo := Repository{repoTag: img.ID} + if rootRepoMap[repoName].Contains(newRepo) { + log.Debugf("skipping, because it is present [%s:%q]", repoName, newRepo) + continue + } + log.Debugf("updating [%s]: [%q] with [%q]", repoName, rootRepoMap[repoName], newRepo) + rootRepoMap[repoName].Update(newRepo) + } + } + if err := s.exportImage(job.Eng, img.ID, tempdir); err != nil { + return job.Error(err) + } + + } else { + // this must be an ID that didn't get looked up just right? + if err := s.exportImage(job.Eng, name, tempdir); err != nil { + return job.Error(err) + } + } } + log.Debugf("End Serializing %s", name) } // write repositories, if there is something to write if len(rootRepoMap) > 0 { rootRepoJson, _ := json.Marshal(rootRepoMap) - if err := ioutil.WriteFile(path.Join(tempdir, "repositories"), rootRepoJson, os.FileMode(0644)); err != nil { return job.Error(err) } @@ -89,7 +110,7 @@ func (s *TagStore) CmdImageExport(job *engine.Job) engine.Status { if _, err := io.Copy(job.Stdout, fs); err != nil { return job.Error(err) } - log.Debugf("End Serializing %s", name) + log.Debugf("End export job: %s", job.Name) return engine.StatusOK } diff --git a/graph/tags.go b/graph/tags.go index 30176ae071..9fc3abd21b 100644 --- a/graph/tags.go +++ b/graph/tags.go @@ -30,6 +30,24 @@ type TagStore struct { type Repository map[string]string +// update Repository mapping with content of u +func (r Repository) Update(u Repository) { + for k, v := range u { + r[k] = v + } +} + +// return true if the contents of u Repository, are wholly contained in r Repository +func (r Repository) Contains(u Repository) bool { + for k, v := range u { + // if u's key is not present in r OR u's key is present, but not the same value + if rv, ok := r[k]; !ok || (ok && rv != v) { + return false + } + } + return true +} + func NewTagStore(path string, graph *Graph) (*TagStore, error) { abspath, err := filepath.Abs(path) if err != nil {