diff --git a/cmd/daemon-poc/main.go b/cmd/daemon-poc/main.go new file mode 100644 index 000000000..cccaa049a --- /dev/null +++ b/cmd/daemon-poc/main.go @@ -0,0 +1,49 @@ +// Copyright 2019 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "log" + "os" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/logs" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/daemon" +) + +func init() { + logs.Warn.SetOutput(os.Stderr) + logs.Progress.SetOutput(os.Stderr) +} + +func main() { + img, err := crane.Pull("ubuntu") + if err != nil { + log.Fatal(err) + } + + tag, err := name.NewTag("example.com/ubuntu:latest") + if err != nil { + log.Fatal(err) + } + + if _, err := daemon.Write(tag, img); err != nil { + log.Fatal(err) + } + if _, err := daemon.Write(tag, img); err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod index 8c2053d3c..8f3ebefe4 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,10 @@ go 1.13 require ( cloud.google.com/go v0.25.0 // indirect github.com/Azure/azure-sdk-for-go v19.1.1+incompatible // indirect + github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect github.com/Azure/go-autorest v10.15.5+incompatible // indirect github.com/Microsoft/go-winio v0.4.14 // indirect + github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/aws/aws-sdk-go v1.15.90 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/docker/cli v0.0.0-20190925022749-754388324470 @@ -41,7 +43,7 @@ require ( golang.org/x/sync v0.0.0-20190423024810-112230192c58 golang.org/x/text v0.3.2 // indirect golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2 // indirect - golang.org/x/tools v0.0.0-20191001184121-329c8d646ebe // indirect + golang.org/x/tools v0.0.0-20191004191047-d89860af3b4b // indirect google.golang.org/appengine v1.1.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gotest.tools v2.2.0+incompatible // indirect diff --git a/go.sum b/go.sum index e1bf4279a..b3202f6d3 100644 --- a/go.sum +++ b/go.sum @@ -2,12 +2,16 @@ cloud.google.com/go v0.25.0 h1:6vD6xZTc8Jo6To8gHxFDRVsMvWFDgY3rugNszcDalN8= cloud.google.com/go v0.25.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/Azure/azure-sdk-for-go v19.1.1+incompatible h1:0nNLU6QNN8FGd3FCQa2e8LAtB3THCJ24aOZ4KbA4Jtk= github.com/Azure/azure-sdk-for-go v19.1.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-autorest v10.15.5+incompatible h1:vdxx6wM1rVkKt/3niByPVjguoLWkWImOcJNvEykgBzY= github.com/Azure/go-autorest v10.15.5+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/aws/aws-sdk-go v1.15.90 h1:W8qBrxaKIK6nPqO/UUDcjgokmS9wJQ45D273clpoBA4= github.com/aws/aws-sdk-go v1.15.90/go.mod h1:es1KtYUFs7le0xQ3rOihkuoVD90z7D0fR2Qm4S00/gU= @@ -67,6 +71,7 @@ github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht github.com/json-iterator/go v0.0.0-20180701071628-ab8a2e0c74be h1:AHimNtVIpiBjPUhEF5KNCkrUyqTSA5zWUl8sQ2bfGBE= github.com/json-iterator/go v0.0.0-20180701071628-ab8a2e0c74be/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -99,6 +104,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= @@ -163,6 +169,8 @@ golang.org/x/tools v0.0.0-20190926165942-a8d5d34286bd h1:L7bTtbmMojUZYEAt0OrTU0Z golang.org/x/tools v0.0.0-20190926165942-a8d5d34286bd/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191001184121-329c8d646ebe h1:hFr8KcN0dM0/dqbUW0KZYN+YXJeZBpBWIG9ZkMuX1vQ= golang.org/x/tools v0.0.0-20191001184121-329c8d646ebe/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191004191047-d89860af3b4b h1:kSR8ong/7Drin1Cjf9ekLO+iG4RpysVAm/CC2Q5hIAg= +golang.org/x/tools v0.0.0-20191004191047-d89860af3b4b/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0 h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= diff --git a/pkg/v1/daemon/write.go b/pkg/v1/daemon/write.go index 7edbf76d4..6d5e3dc6c 100644 --- a/pkg/v1/daemon/write.go +++ b/pkg/v1/daemon/write.go @@ -15,16 +15,21 @@ package daemon import ( + "bytes" "context" + "encoding/json" + "fmt" "io" "io/ioutil" "github.com/docker/docker/api/types" "github.com/docker/docker/client" + "github.com/google/go-containerregistry/pkg/logs" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/tarball" - "github.com/pkg/errors" ) // ImageLoader is an interface for testing. @@ -55,6 +60,15 @@ func Tag(src, dest name.Tag) error { // Write saves the image into the daemon as the given tag. func Write(tag name.Tag, img v1.Image) (string, error) { + filter, err := probeIncremental(tag, img) + if err != nil { + logs.Warn.Printf("Determining incremental load: %v", err) + return write(tag, img, keepLayers) + } + return write(tag, img, filter) +} + +func write(tag name.Tag, img v1.Image, lf tarball.LayerFilter) (string, error) { cli, err := GetImageLoader() if err != nil { return "", err @@ -62,19 +76,153 @@ func Write(tag name.Tag, img v1.Image) (string, error) { pr, pw := io.Pipe() go func() { - pw.CloseWithError(tarball.Write(tag, img, pw)) + pw.CloseWithError(tarball.Write(tag, img, pw, tarball.WithLayerFilter(lf))) }() // write the image in docker save format first, then load it resp, err := cli.ImageLoad(context.Background(), pr, false) if err != nil { - return "", errors.Wrapf(err, "error loading image") + return "", fmt.Errorf("loading image: %v", err) } defer resp.Body.Close() - b, readErr := ioutil.ReadAll(resp.Body) - response := string(b) - if readErr != nil { - return response, errors.Wrapf(err, "error reading load response body") + + var buf bytes.Buffer + r := io.TeeReader(resp.Body, &buf) + + var displayErr error + + // Let's try to parse this thing as a structured response. + if resp.JSON { + decoder := json.NewDecoder(r) + for { + var msg JSONMessage + if err := decoder.Decode(&msg); err == io.EOF { + break + } else if err != nil { + return buf.String(), fmt.Errorf("reading load response body: %v", err) + } + displayErr = display(msg) + } + } + + // Copy the rest of the response. + if _, err := io.Copy(ioutil.Discard, r); err != nil { + return buf.String(), err + } + + return buf.String(), displayErr +} + +func display(msg JSONMessage) error { + if msg.Error != nil { + return msg.Error + } + if msg.Progress != nil { + logs.Progress.Printf("%s %s", msg.Status, msg.Progress) + } else if msg.Stream != "" { + logs.Progress.Print(msg.Stream) + } else { + logs.Progress.Print(msg.Status) + } + return nil +} + +func discardLayers(v1.Layer) (bool, error) { + return false, nil +} + +func keepLayers(v1.Layer) (bool, error) { + return true, nil +} + +func probeIncremental(tag name.Tag, img v1.Image) (tarball.LayerFilter, error) { + layers, err := img.Layers() + if err != nil { + return nil, err } - return response, nil + + // Set + have := make(map[v1.Hash]struct{}) + + probe := empty.Image + for i := 0; i < len(layers); i++ { + // Image with first i layers. + probe, err = mutate.AppendLayers(probe, layers[i]) + if err != nil { + return nil, err + } + + // TODO: Inline the tarball stuff so we can omit RepoTags. + probeTag, err := name.NewTag(fmt.Sprintf("%s:%s-layer_%d_probe", tag.Context(), tag.Identifier(), i)) + if err != nil { + return nil, err + } + + if _, err := write(probeTag, probe, discardLayers); err != nil { + return func(layer v1.Layer) (bool, error) { + diffid, err := layers[i].DiffID() + if err != nil { + return true, err + } + + if _, ok := have[diffid]; ok { + return false, nil + } + + return true, nil + }, nil + } + + // We don't need to include this layer in the tarball. + diffid, err := layers[i].DiffID() + if err != nil { + return nil, err + } + have[diffid] = struct{}{} + } + + return discardLayers, nil +} + +// TODO: move to a different file? +// Inlined from github.com/docker/docker/pkg/jsonmessage to avoid pulling in +// a ton of dependencies. + +// JSONMessage defines a message struct. It describes +// the created time, where it from, status, ID of the +// message. It's used for docker events. +type JSONMessage struct { + Stream string `json:"stream,omitempty"` + Status string `json:"status,omitempty"` + Progress *JSONProgress `json:"progressDetail,omitempty"` + Error *JSONError `json:"errorDetail,omitempty"` +} + +// JSONProgress describes a Progress. terminalFd is the fd of the current terminal, +// Start is the initial value for the operation. Current is the current status and +// value of the progress made towards Total. Total is the end value describing when +// we made 100% progress for an operation. +type JSONProgress struct { + Current int64 `json:"current,omitempty"` + Total int64 `json:"total,omitempty"` + Units string `json:"units,omitempty"` +} + +func (p *JSONProgress) String() string { + if p.Current <= 0 && p.Total <= 0 { + return "" + } + + return fmt.Sprintf("%d / %d %s", p.Current, p.Total, p.Units) +} + +// JSONError wraps a concrete Code and Message, `Code` is +// is an integer error code, `Message` is the error message. +type JSONError struct { + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +func (e *JSONError) Error() string { + return e.Message } diff --git a/pkg/v1/tarball/options.go b/pkg/v1/tarball/options.go new file mode 100644 index 000000000..63d9c4e0d --- /dev/null +++ b/pkg/v1/tarball/options.go @@ -0,0 +1,55 @@ +// Copyright 2019 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tarball + +import ( + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +// Option is a functional option for tarball operations. +type Option func(*options) error + +// LayerFilter defines a function for filtering layers. +// True - indicates the layer should be kept, +// False - indicates the layer should be excluded. +type LayerFilter func(v1.Layer) (bool, error) + +type options struct { + filter LayerFilter +} + +func makeOptions(opts ...Option) (*options, error) { + o := &options{ + filter: func(v1.Layer) (bool, error) { + return true, nil + }, + } + + for _, option := range opts { + if err := option(o); err != nil { + return nil, err + } + } + + return o, nil +} + +// WithLayerFilter allows omitting layers when writing a tarball. +func WithLayerFilter(lf LayerFilter) Option { + return func(o *options) error { + o.filter = lf + return nil + } +} diff --git a/pkg/v1/tarball/write.go b/pkg/v1/tarball/write.go index 13a4a655e..fbab8ac14 100644 --- a/pkg/v1/tarball/write.go +++ b/pkg/v1/tarball/write.go @@ -29,41 +29,41 @@ import ( // WriteToFile writes in the compressed format to a tarball, on disk. // This is just syntactic sugar wrapping tarball.Write with a new file. -func WriteToFile(p string, ref name.Reference, img v1.Image) error { +func WriteToFile(p string, ref name.Reference, img v1.Image, opt ...Option) error { w, err := os.Create(p) if err != nil { return err } defer w.Close() - return Write(ref, img, w) + return Write(ref, img, w, opt...) } // MultiWriteToFile writes in the compressed format to a tarball, on disk. // This is just syntactic sugar wrapping tarball.MultiWrite with a new file. -func MultiWriteToFile(p string, tagToImage map[name.Tag]v1.Image) error { +func MultiWriteToFile(p string, tagToImage map[name.Tag]v1.Image, opt ...Option) error { refToImage := make(map[name.Reference]v1.Image, len(tagToImage)) for i, d := range tagToImage { refToImage[i] = d } - return MultiRefWriteToFile(p, refToImage) + return MultiRefWriteToFile(p, refToImage, opt...) } // MultiRefWriteToFile writes in the compressed format to a tarball, on disk. // This is just syntactic sugar wrapping tarball.MultiRefWrite with a new file. -func MultiRefWriteToFile(p string, refToImage map[name.Reference]v1.Image) error { +func MultiRefWriteToFile(p string, refToImage map[name.Reference]v1.Image, opt ...Option) error { w, err := os.Create(p) if err != nil { return err } defer w.Close() - return MultiRefWrite(refToImage, w) + return MultiRefWrite(refToImage, w, opt...) } // Write is a wrapper to write a single image and tag to a tarball. -func Write(ref name.Reference, img v1.Image, w io.Writer) error { - return MultiRefWrite(map[name.Reference]v1.Image{ref: img}, w) +func Write(ref name.Reference, img v1.Image, w io.Writer, opt ...Option) error { + return MultiRefWrite(map[name.Reference]v1.Image{ref: img}, w, opt...) } // MultiWrite writes the contents of each image to the provided reader, in the compressed format. @@ -71,12 +71,12 @@ func Write(ref name.Reference, img v1.Image, w io.Writer) error { // One manifest.json file at the top level containing information about several images. // One file for each layer, named after the layer's SHA. // One file for the config blob, named after its SHA. -func MultiWrite(tagToImage map[name.Tag]v1.Image, w io.Writer) error { +func MultiWrite(tagToImage map[name.Tag]v1.Image, w io.Writer, opt ...Option) error { refToImage := make(map[name.Reference]v1.Image, len(tagToImage)) for i, d := range tagToImage { refToImage[i] = d } - return MultiRefWrite(refToImage, w) + return MultiRefWrite(refToImage, w, opt...) } // MultiRefWrite writes the contents of each image to the provided reader, in the compressed format. @@ -84,7 +84,12 @@ func MultiWrite(tagToImage map[name.Tag]v1.Image, w io.Writer) error { // One manifest.json file at the top level containing information about several images. // One file for each layer, named after the layer's SHA. // One file for the config blob, named after its SHA. -func MultiRefWrite(refToImage map[name.Reference]v1.Image, w io.Writer) error { +func MultiRefWrite(refToImage map[name.Reference]v1.Image, w io.Writer, opt ...Option) error { + o, err := makeOptions(opt...) + if err != nil { + return err + } + tf := tar.NewWriter(w) defer tf.Close() @@ -144,6 +149,15 @@ func MultiRefWrite(refToImage map[name.Reference]v1.Image, w io.Writer) error { // https://www.gnu.org/software/gzip/manual/html_node/Overview.html layerFiles[i] = fmt.Sprintf("%s.tar.gz", hex) + // We filter late because the length of layerFiles must match the diff_ids + // in config file. It is ok if the file doesn't exist when the daemon + // already has a given layer, since it won't try to read it. + if keep, err := o.filter(l); err != nil { + return err + } else if !keep { + continue + } + r, err := l.Compressed() if err != nil { return err diff --git a/pkg/v1/tarball/write_test.go b/pkg/v1/tarball/write_test.go index c6c69ea8d..5fc4560b1 100644 --- a/pkg/v1/tarball/write_test.go +++ b/pkg/v1/tarball/write_test.go @@ -15,8 +15,11 @@ package tarball_test import ( + "archive/tar" + "io" "io/ioutil" "os" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -350,3 +353,67 @@ func getDiffIDs(t *testing.T, layers []v1.Layer) []v1.Hash { return diffIDs } + +func TestFilteredWrite(t *testing.T) { + // Make a tempfile for tarball writes. + fp, err := ioutil.TempFile("", "") + if err != nil { + t.Fatalf("Error creating temp file.") + } + t.Log(fp.Name()) + defer fp.Close() + defer os.Remove(fp.Name()) + + // Make a random image + randImage, err := random.Image(256, 8) + if err != nil { + t.Fatalf("Error creating random image.") + } + tag, err := name.NewTag("gcr.io/foo/bar:latest", name.StrictValidation) + if err != nil { + t.Fatalf("Error creating test tag.") + } + + layers, err := randImage.Layers() + if err != nil { + t.Fatalf("Layers() = %v", err) + } + rld, err := layers[0].Digest() + if err != nil { + t.Fatalf("Digest() = %v", err) + } + + lf := func(l v1.Layer) (bool, error) { + // Filter the first layer in the image. + if ld, err := l.Digest(); err != nil { + return false, err + } else { + return ld != rld, nil + } + } + + if err := tarball.WriteToFile(fp.Name(), tag, randImage, tarball.WithLayerFilter(lf)); err != nil { + t.Fatalf("Unexpected error writing tarball: %v", err) + } + + f, err := os.Open(fp.Name()) + if err != nil { + t.Fatalf("os.Open() = %v", err) + } + defer f.Close() + + tarReader := tar.NewReader(f) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("scanning tarfile: %v", err) + } + + if strings.Contains(header.Name, rld.Hex) { + t.Errorf("Saw file %v in tarball, want %v elided.", header.Name, rld) + } + } +}