Skip to content

Conversation

@nlewo
Copy link
Contributor

@nlewo nlewo commented Oct 27, 2017

docker save generates image compatible with the legacy format, ie,
layers are tar, they have a configuration file and a repositories file
is created.

There are some external tools that still relie on this old format such
as mesos [1] and nixos [2].

[1] https://github.com/apache/mesos/blob//7ca46e24b3339ba27e88b99ea95362c956ef03c1/src/slave/containerizer/mesos/provisioner/docker/local_puller.cpp#L168
[2] https://github.com/NixOS/nixpkgs/blob/5c6dc717a66b6555e5757f92376527da7a553fec/pkgs/build-support/docker/default.nix#L143

@nlewo
Copy link
Contributor Author

nlewo commented Oct 27, 2017

This is related to this issue containers/skopeo#425.
I only tested this patch on images coming from docker-archive and docker sources.

@nlewo nlewo force-pushed the pr/docker-archive branch 2 times, most recently from 7edbab1 to dd91c5c Compare October 27, 2017 22:04
@nlewo
Copy link
Contributor Author

nlewo commented Oct 29, 2017

I'm not able to get the error message from the CI, and make locally fails :

INFO[0114] Fetching latest commits from 'origin' for 'github.com/gogo/protobuf/proto' 
ERRO[0114] `git fetch -f -t origin` failed:
fatal: 'origin' does not appear to be a git repository
fatal: Could not read from remote repository.

Any ideas?

@mtrmac
Copy link
Collaborator

mtrmac commented Oct 31, 2017

The CI failure is

docker/tarfile/dest.go
Makefile:62: recipe for target 'validate' failed

Probably reformat the file using gofmt -s -w docker/tarfile/dest.go. Yeah, the Makefile rule for gofmt could be improved to be clearer.

As for the local make failure checking out a vendored subpackage, I don’t know. Removing the vendor subdirectory entirely and running make vendor works for me.

@nlewo nlewo force-pushed the pr/docker-archive branch 3 times, most recently from 1968d6e to c4c8480 Compare November 2, 2017 20:12
@nlewo
Copy link
Contributor Author

nlewo commented Nov 3, 2017

Thanks @mtrmac
Regarding my local issue with make, I submitted PR #373. I also fixed several test errors in this patch but I still have an test error with Skopeo.
The error is related to dir and docker source/dest which should not be impacted by this patch:

Running skopeo --debug copy docker://busybox dir:/tmp/copy-1238023601
Running skopeo --tls-verify=false --debug copy dir:/tmp/copy-1238023601 docker://localhost:5555/busybox:unsigned
Running skopeo --tls-verify=false --debug copy docker://localhost:5555/busybox:unsigned dir:/tmp/copy-2608739164
Running diff -urN /tmp/copy-1238023601 /tmp/copy-2608739164
# the diff is not nil while it should be

Moreover, I cannot reproduce it when I use the docker registry.

Is there a trick with the test registry? (maybe it is using tarfile)
How could I get the debug messages of Skopeo in the test output?

@nlewo nlewo force-pushed the pr/docker-archive branch from c4c8480 to 5e0bcdd Compare November 4, 2017 08:06
@nlewo
Copy link
Contributor Author

nlewo commented Nov 4, 2017

Ok, I've finally fixed tests

Copy link
Collaborator

@mtrmac mtrmac left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m sorry, afraid I didn’t have time to review this in detail yet (the core changes in docker/tarfile in particular), here are a few mostly API-focused notes for now.

if isCompressed && ic.dest.ShouldDecompressLayers() {
logrus.Debugf("Blob will be decompressed")
destStream, err = decompressor(destStream)
if err != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is already a decompression code around getOriginalLayerCopyWriter. If at all possible, we should only decompress the data once and share the output between the two consumers, if they exist at the same time.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mtrmac getOriginalLayerCopyWriter is only used when the format of the source image is Docker SchemaV1. So, we could have two decompression if the source format image is Docker SchemaV1 and the destination is docker-archive.
But I think it's not trivial to change this behavior since getOriginalLayerCopyWriter is already a little bit tricky...
Do you know if SchemaV1 is still widely used?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

schema1 is still around (and old images will keep using it), but hopefully its will diminish over time… just like using the repositories archive format :)

I really don’t like decompressing the possibly gigabytes of data twice, but you’re right that the code is tricky and I don’t quite have the extra time necessary to get this right, so I guess let’s live with the two separate decompressions.

if err != nil {
return types.BlobInfo{}, err
}
inputInfo.Digest = digester.Digest()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would probably be cheaper to either compute the digest always, or use io.TeeReader/io.MultiWriter, so that we make as few passes over the data as possible.

types/types.go Outdated
type BlobInfo struct {
Digest digest.Digest // "" if unknown.
Size int64 // -1 if unknown
Config bool // true if the blob is a config blob
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not very elegant... conceptually, the information is already available in MediaType, and more importantly if Config:true is only set by Image.ConfigInfo(), the caller structurally knows that the blob is a config without having to ask.

Maybe we should have separate ImageDestination.{PutLayer,PutBlob}, or at the very least least move this Config bool into a separate parameter of PutBlob which can be set by the caller directly instead of relying on ConfigInfo. (@runcom WDYT?)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

…separate imageDestination.{PutLayer,PutConfig} that is.

Copy link
Contributor Author

@nlewo nlewo Nov 15, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is the only use case that needs the distinction between layers and the config, I think it would be less intrusive to add a bool paramater to the putBlob method.
Do you think there are other use cases that need this distinction?

types/types.go Outdated
// ShouldCompressLayers returns true iff it is desirable to compress layer blobs written to this destination.
ShouldCompressLayers() bool
// ShouldDecompressLayers returns true iff it is desirable to decompress layer blobs written to this destination.
ShouldDecompressLayers() bool
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use a single enum (with cases compress/decompress/preserveoriginal) instead of two booleans, to make structurally sure ShouldCompressLayers&&ShouldDecompressLayers can’t be true at the same time.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok.

@nlewo nlewo force-pushed the pr/docker-archive branch 3 times, most recently from ff42919 to bb5fb7a Compare December 19, 2017 12:44
@nlewo
Copy link
Contributor Author

nlewo commented Dec 19, 2017

CI is failing because Skopeo needs to be patched. Someone could still have a look to this PR?

@gilligan
Copy link

I'm a nixos / docker tooling user and I would very much love to see this merged :)

@gilligan
Copy link

@nlewo @mtrmac what is missing to move this forward? what can be done to support this?

@nlewo
Copy link
Contributor Author

nlewo commented Jan 17, 2018

This PR has never been really reviewed. I have had some comments from @mtrmac but I don't know if this "feature" could be merged.
One month ago, I've rebased it on master and this was not trivial:/ So, now I don't know what to do with this.
But of course, I would be happy to reply to reviews!

@mtrmac
Copy link
Collaborator

mtrmac commented Jan 17, 2018

Sorry about that, I was unavailable for most of December and getting to this through my backlog took a while.

Here’s an in-progress review, I’m afraid I have to context switch to something else for the rest of today…

copy/copy.go Outdated
func (c *copier) copyBlobFromStream(srcStream io.Reader, srcInfo types.BlobInfo,
getOriginalLayerCopyWriter func(decompressor compression.DecompressorFunc) io.Writer,
canCompress bool) (types.BlobInfo, error) {
canCompress bool, isConfig bool) (types.BlobInfo, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The canCompress value comes from canModifyManifest in callers, and applies just as well to decompression; please rename the parameter, perhaps to canModifyBlob, and make it prevent both compression and decompression.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok.

copy/copy.go Outdated

// === Decompress the layer if it is compressed and decompression is desired
// This is currently only used by docker-archive
if isCompressed && c.dest.CompressesLayers() == types.Decompress {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(This if /*complex*/ {} else if {}; if {} sequence feels like something that should be possible to express in a clearer way. I haven’t actually tried yet, though.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, the sequence can be rewritten such as:

if compression is desired and it is possible
    compress()
else if decompression is desired and it is possible
    decompress()
else
    continue

if inputInfo.Size == -1 { // Ouch, we need to stream the blob into a temporary file just to determine the size.
// Ouch, we need to stream the blob into a temporary file just to determine the size.
// When the layer is decompressed, we also have to generate the digest on uncompressed datas.
if inputInfo.Size == -1 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn’t this be inputInfo.Size == -1 || inputInfo.Digest == ""?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is possible that inputInfo.Digest == "" && inputInfo.Size != -1 is true but I add a guard in case of.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You’re right that the copy.Image code won’t have inputInfo.Digest == "" && inputInfo.Size != -1, but the documented behavior of the interface does allow it.

The added check below (Can not stream a blob with unknown digest to docker tarfile) is too late; check in this condition, and then at the location of the current a digest ill always be available.

}
if inputInfo.Digest == "" {
inputInfo.Digest = digester.Digest()
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be more consistent to keep the update of inputInfo.Digest and inputInfo.Size close together.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure to understand wht you mean... but I've tryied.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this. Thanks.

if err := d.sendFile(inputInfo.Digest.String(), inputInfo.Size, tee); err != nil {
return types.BlobInfo{}, err
// When the digest is generated on the uncompressed layer, we
// have to recheck if the layer has been already sent.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? inputInfo.Digest, if present, must match exactly the contents of the input stream. It should never happen that the stream is uncompressed but the digest value matches a compressed version and needs to be recomputed just to be correct. Is that broken somewhere?

Maybe the HasBlob check at the top should just be moved to this place.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are right.

t := make(map[string]string)
t[repoTag[1]] = rootLayerID
r := make(map[string]map[string]string)
r[repoTag[0]] = t
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of the above can be expressed as

repositories := map[string]map[string]string{
   repoTag[0]:{repoTag[1]:rootLayerID}
}

which seems much more readable, and does not even need the “This file looks like” comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

}

func createRepositoriesFile(d *Destination, rootLayerID string) error {
// We generate the repositories file
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function name already says that, no need to repeat it here. Or, if you want to, move it one line up and make it a proper Golang doc string.

func createRepositoriesFile(d *Destination, rootLayerID string) error {
// We generate the repositories file
// This file looks like '{repo : { tag : rootLayerSha }}'
repoTag := strings.Split(d.repoTag, ":")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewDestination already works with the name and tag as separate strings extracted from a well-typed reference.NamedTagged; it is awkward to concatenate them to a string and then parse them out of the string again.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

// legacyConfigFileName = "json"
// legacyVersionFileName = "VERSION"
legacyVersionFileName = "VERSION"
// legacyRepositoriesFileName = "repositories"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either use the legacyRepositoriesFileName and legacyLayerFileName constants, or remove them from here if you want the code to hard-code the names directly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use these constants.

// The legacy format requires a config file per layer
layerConfig := make(map[string]*json.RawMessage)
id := l.Digest.Hex()
idJSON := json.RawMessage("\"" + id + "\"")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awkward and potentially unsafe. Couldn't layerConfig be a map[string]interface{}, assigning some string and some *json.RawMesage values to individual entries of the map? Or actually a typed structure?

(It’s not yet clear to me whether all of this should share code with c/I/image/v1ConfigFromConfigJSON.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it's not clean but the objective is just to extract some parts of the image configuration file and use them to create layer configuration files, without really know what is inside these parts. Since contents of these parts don't seem to be relevant, I think it's not necessary to create a complex data structure just to copy/paste these JSON blobs.
I now use a map[string]interface{} where the extracted attributes are *json.RawMesage while attributes that are created are string (id and parent).

Copy link
Contributor Author

@nlewo nlewo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your comments @mtrmac.
Note, I didn't run your validation targets yet.

}
if inputInfo.Digest == "" {
inputInfo.Digest = digester.Digest()
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure to understand wht you mean... but I've tryied.

if inputInfo.Size == -1 { // Ouch, we need to stream the blob into a temporary file just to determine the size.
// Ouch, we need to stream the blob into a temporary file just to determine the size.
// When the layer is decompressed, we also have to generate the digest on uncompressed datas.
if inputInfo.Size == -1 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is possible that inputInfo.Digest == "" && inputInfo.Size != -1 is true but I add a guard in case of.

if err := d.sendFile(inputInfo.Digest.String(), inputInfo.Size, tee); err != nil {
return types.BlobInfo{}, err
// When the digest is generated on the uncompressed layer, we
// have to recheck if the layer has been already sent.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are right.

if isConfig {
buf := new(bytes.Buffer)
buf.ReadFrom(stream)
d.config = buf
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, Thanks.

func createRepositoriesFile(d *Destination, rootLayerID string) error {
// We generate the repositories file
// This file looks like '{repo : { tag : rootLayerSha }}'
repoTag := strings.Split(d.repoTag, ":")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

t := make(map[string]string)
t[repoTag[1]] = rootLayerID
r := make(map[string]map[string]string)
r[repoTag[0]] = t
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

for i, l := range man.LayersDescriptors {
layerPaths = append(layerPaths, l.Digest.Hex()+"/layer.tar")
b := []byte("1.0")
if err := d.sendFile(filepath.Join(l.Digest.Hex(), legacyVersionFileName), int64(len(b)), bytes.NewReader(b)); err != nil {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

// The legacy format requires a config file per layer
layerConfig := make(map[string]*json.RawMessage)
id := l.Digest.Hex()
idJSON := json.RawMessage("\"" + id + "\"")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it's not clean but the objective is just to extract some parts of the image configuration file and use them to create layer configuration files, without really know what is inside these parts. Since contents of these parts don't seem to be relevant, I think it's not necessary to create a complex data structure just to copy/paste these JSON blobs.
I now use a map[string]interface{} where the extracted attributes are *json.RawMesage while attributes that are created are string (id and parent).

}
}

if err := createRepositoriesFile(d, man.LayersDescriptors[len(man.LayersDescriptors)-1].Digest.Hex()); err != nil {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I add a guard to only create the repostorie file is there is at least one layer.

// legacyConfigFileName = "json"
// legacyVersionFileName = "VERSION"
legacyVersionFileName = "VERSION"
// legacyRepositoriesFileName = "repositories"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use these constants.

@nlewo nlewo force-pushed the pr/docker-archive branch 2 times, most recently from bbff322 to 50a38b3 Compare January 18, 2018 21:24
copy/copy.go Outdated
if canModifyBlob && c.dest.CompressesLayers() == types.Compress && !isCompressed {
logrus.Debugf("Compressing blob on the fly")
pipeReader, pipeWriter := io.Pipe()

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: please drop this line, to keep the defer immediately after creating the object.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

compress := dest.ShouldCompressLayers()
assert.False(t, compress)
info, err := dest.PutBlob(bytes.NewReader(blob), types.BlobInfo{Digest: digest.Digest("sha256:digest-test"), Size: int64(9)})
assert.Equal(t, dest.CompressesLayers(), types.PreserveOriginal)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order of arguments should be assert.Equal(t, expectedValue, valueBeingTested)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

// a hostname-qualified reference.
// See https://github.com/containers/image/issues/72 for a more detailed
// analysis and explanation.
refString := fmt.Sprintf("%s:%s", ref.Name(), ref.Tag())
Copy link
Collaborator

@mtrmac mtrmac Jan 19, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move the big comment above this line to PutManifest as well, along with the refString variable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

tee := io.TeeReader(stream, digester.Hash())
if err := d.sendFile(inputInfo.Digest.String(), inputInfo.Size, tee); err != nil {
return types.BlobInfo{}, err
if inputInfo.Digest.String() == "" {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should always be false at this point, at least after the previous if is updated (and the code below already depends on inputInfo.Digest being valid).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I'm fixing it... Let me know if you meant something else.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, at this point the code can unconditionally assume inputInfo.Digest is valid.

The start of PutBlob could, I think, be

func (d *Destination) PutBlob(stream io.Reader, inputInfo types.BlobInfo, isConfig bool) (types.BlobInfo, error) {
	// Ouch, we need to stream the blob into a temporary file just to determine the size or digest.
	if inputInfo.Size == -1 || inputInfo.Digest.String() == "" {
 		logrus.Debugf("docker tarfile: input with unknown size or digest, streaming to disk first ...")

and transparently handle the hypothetical case of known size && unknown digest instead of failing.

if err := d.sendFile(filepath.Join(inputInfo.Digest.Hex(), "layer.tar"), inputInfo.Size, stream); err != nil {
return types.BlobInfo{}, err
}
d.blobs[inputInfo.Digest] = types.BlobInfo{Digest: inputInfo.Digest, Size: inputInfo.Size}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

d.blobs can be updated in the isConfig case as well. HasBlob is, strictly speaking, not restricted to layer blobs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yes, exactly!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, why not move the d.blobs[inputInfo.Digest = … line one line lower, out of the if?

types/types.go Outdated
// ShouldCompressLayers returns true iff it is desirable to compress layer blobs written to this destination.
ShouldCompressLayers() bool
// CompressesLayers is used to know what kind of compression should be applied on layers
CompressesLayers() LayerCompression
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CompressesLayers, e.g. when compared with SupportsSignatures above it, reads as if it answers the question ”does the destination compress layers”, which is not the case. Name it perhaps something like DesiredLayerCompression?

copy/copy.go Outdated
originalLayerReader = destStream
}

// === Compress the layer if it is uncompressed and compression is desired
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment now does not apply to the whole section, it is more of an alternative to the new two // == indented comments instead of a higher-level description to the two.

The original idea was that the // == comments delineate separate stages of the pipeline; so, maybe rewrite this one as something like // == Deal with layer compression/decompression if necessary,
and drop the two indented // == ones.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


// All layers expect the root one have a parent
if i != 0 {
layerConfig["parent"] = man.LayersDescriptors[i-1].Digest.Hex()
Copy link
Collaborator

@mtrmac mtrmac Jan 19, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An image may in principle use exactly the same layers several times, in a pathological case even in sequence; in that case this would create multiple versions of files with the same name in a single tar archive, and a loop in the “parent” links with some of the versions.

Instead, the IDs used in here need to be distinct even if the same l.Digest.Hex() value is repeated, see ChainID in https://gist.github.com/aaronlehmann/b42a2eaf633fc949f93b or the implementation in moby/moby/image/tarexport.

Copy link
Contributor Author

@nlewo nlewo Jan 25, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could be implemented but I don't have any image to test this logic. Do you know how could I produce this kind of image or where I could find one?
cc @gilligan @kuznero

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess, take any existing image (e.g. skopeo copy docker://whatever dir:tmpdir to have an easy way to edit it), edits its manifest to repeat one item of the layers array, edit its config.json to correspondingly list the layer twice in rootfs.diff_ids, and update the manifest with the updated manifest digest.

(To be honest, this would probably be the first time anyone is actually testing this corner case, so something else might turn out to be broken.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thx. I think I successfully built an image such like that. Currently, when I docker-load this image (after copying it with skopeo docker-archive), Docker sees 5 layers while there are 6 layers in the rootfs array.
I will see how we could handle this kind of images.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mtrmac I've modified an image in order to reuse a layer by using the skopeo transport dir. I've tryied to copy this image to docker-daemon and it prints:

Skipping fetch of repeat blob sha256:6fbc4a21b806838b63b774b338c6ad19d696a9e655f50b4e358cc4006c3baa79

In fact, the same behavior is observed if we use the proposed docker-archive implementation: the duplicated layer is ignored.

To be honest, I don't really know if the generated image reproduces well this use case and I'm not sure what is the expected behavior when this kind of images are loaded by Docker... It would be easier by having a real image.

Moreover, in the Docker implementation, they use the chainID to generate the path of the layer in the tar stream. This is hard for us to do this because the layer is sent to the tar stream by the PutBlob method which is designed to handle each layer independently from other ones: we can not easily use previous layer sha to generate the current layer path.

Finally, it seems this kind of image would probably never been used!
So instead of adding complexity to the code for this corner case, we could print a message that says "Repeated layer is ignored".

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping fetch of repeat blob sha256:…

That part is an optional optimization to PutBlob and more or less known to work; I am more worried about the correctness of the generated metadata (in this case, primarily the parent links).

(I need to test this scenario out myself, maybe I am talking nonsense; hopefully tomorrow. For now just this quick note.)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, tested this. TL;DR: the legacy metadata in the generated tar file crashes the Docker daemon on docker load, so this really needs to be fixed.

(To be fair, the unmodified tar file which includes the new metadata as well works fine with docker load; but generating of the legacy format is the whole port of this PR, so the legacy format should be correct.)

# Build skopeo with this PR, updating layers.go as necessary
$ ./skopeo --override-os linux --policy default-policy.json copy docker://busybox dir:edits
$ python -mjson.tool < edits/5b0d59026729b68570d99bc4f3f7c31a2e4f2a5736435641565d93e7c25bd2c3.tar > _
# To duplicate the `rootfs.diff_ids` array entry
$ vi _
$ shasum --algorithm 256 _ | cut -d ' ' -f 1
e2f0703c8b534d097a822e895b0852ceedccb83a7a122aa25f92757b0ee1fcc3
$ ls -l _
$ mv _ edits/$(shasum --algorithm 256 _ | cut -d ' ' -f 1).tar
# To update the size and digest of the config, and duplicate the layer entry
$ vi edits/manifest.json
# Test that the result is minimally consistent
$ ./skopeo --override-os linux --policy default-policy.json copy dir:edits dir:t
$ rm -rf t
$ ./skopeo --override-os linux --policy default-policy.json copy dir:edits docker-archive:archive0.tar:busybox:v0
$ mkdir archive0
$ cd archive0/
$ tar xf ../archive0.tar 
$ ls
4febd3792a1fb2153108b4fa50161c6ee5e3d16aa483a63215f936a113a88e9a	manifest.json
e2f0703c8b534d097a822e895b0852ceedccb83a7a122aa25f92757b0ee1fcc3.json	repositories
# The layer refers to itself as a parent!
$ python -mjson.tool 4febd3792a1fb2153108b4fa50161c6ee5e3d16aa483a63215f936a113a88e9a/json

    "id": "4febd3792a1fb2153108b4fa50161c6ee5e3d16aa483a63215f936a113a88e9a",

    "parent": "4febd3792a1fb2153108b4fa50161c6ee5e3d16aa483a63215f936a113a88e9a"

# Make sure the legacy loader is used
$ rm manifest.json
override r--r--r--  mitr/staff for manifest.json? y
$ tar cf ../archive1.tar .
$ cd ..
# Verify that skopeo can’t use a legacy-only archive
$ ./skopeo --policy default-policy.json copy docker-archive:archive1.tar:busybox:v0 dir:t
WARN[0000] docker-archive: references are not supported for sources (ignoring) 
FATA[0000] Error determining manifest MIME type for docker-archive:archive1.tar:docker.io/library/busybox:v0: Error loading tar component manifest.json: file does not exist 
# Try using the file with docker. This hangs for a while, eventually failing with
# docker load < ~mitr/archive1.tar
error during connect: Post http://%2Fvar%2Frun%2Fdocker.sock/v1.26/images/load?quiet=0: EOF

and the system logs show the daemon aborting with a stack overflow due to an infinite recursion in github.com/docker/docker/image/tarexport.(*tarexporter).legacyLoadImage().

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moreover, in the Docker implementation, they use the chainID to generate the path of the layer in the tar stream. This is hard for us to do this because the layer is sent to the tar stream by the PutBlob method which is designed to handle each layer independently from other ones: we can not easily use previous layer sha to generate the current layer path.

The metadata is created not in PutBlob, but in writeLegacyLayerMetadata, at which point the full ordered set of layers is available as layerDescriptors.

return false
// CompressesLayers indicates if layers must be compressed, decompressed or preserved
func (d *Destination) CompressesLayers() types.LayerCompression {
return types.Decompress
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move this out of docker/tarfile.Destination into the users, docker/daemon.daemonImageDestination and docker/archive.archiveImageDestination, and return Decompress only in the docker/archive implementation; for docker/daemon, let’s keep the current PreserveOriginal behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

if isCompressed && ic.dest.ShouldDecompressLayers() {
logrus.Debugf("Blob will be decompressed")
destStream, err = decompressor(destStream)
if err != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

schema1 is still around (and old images will keep using it), but hopefully its will diminish over time… just like using the repositories archive format :)

I really don’t like decompressing the possibly gigabytes of data twice, but you’re right that the code is tricky and I don’t quite have the extra time necessary to get this right, so I guess let’s live with the two separate decompressions.

Copy link
Collaborator

@mtrmac mtrmac left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

This is a complete review, unlike the previous in-progress/partial ones.

The one critical blocker is the layer ID computation; otherwise various smaller things. From earlier, only the handling of inputInfo.Digest() == "" is outstanding (and the double decompression of schema1 images, though that is non-blocking).

@nlewo
Copy link
Contributor Author

nlewo commented Jan 25, 2018

Thanks for your review. I've addressed all of your comments, excepted the one related to the ChainID which is still in discussion.

}

// WriteLegacyLayerMetadata writes legacy VERSION and configuration files for all layers
func (d *Destination) WriteLegacyLayerMetadata(layerDescriptors []manifest.Schema2Descriptor) (layerPaths []string, err error) {
Copy link
Collaborator

@mtrmac mtrmac Jan 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: please make this method private (lowercase writeLegacy…)

tee := io.TeeReader(stream, digester.Hash())
if err := d.sendFile(inputInfo.Digest.String(), inputInfo.Size, tee); err != nil {
return types.BlobInfo{}, err
if inputInfo.Digest.String() == "" {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, at this point the code can unconditionally assume inputInfo.Digest is valid.

The start of PutBlob could, I think, be

func (d *Destination) PutBlob(stream io.Reader, inputInfo types.BlobInfo, isConfig bool) (types.BlobInfo, error) {
	// Ouch, we need to stream the blob into a temporary file just to determine the size or digest.
	if inputInfo.Size == -1 || inputInfo.Digest.String() == "" {
 		logrus.Debugf("docker tarfile: input with unknown size or digest, streaming to disk first ...")

and transparently handle the hypothetical case of known size && unknown digest instead of failing.

@mtrmac
Copy link
Collaborator

mtrmac commented Jan 26, 2018

The FAIL - does not have a valid DCO test failures: Please sign your commits (git commit --amend -s)

@nlewo nlewo force-pushed the pr/docker-archive branch from 20a6720 to 8bb449d Compare January 30, 2018 17:46
@nlewo
Copy link
Contributor Author

nlewo commented Feb 4, 2018

@gilligan yes, it should work with and without this patch (i'm already using skopeo to push images that have been produced with dockerTools.buildImage).

@gilligan
Copy link

gilligan commented Feb 4, 2018

huh? Last I tried that did not work since skopeo was complaining about a missing manifest file I believe

@nlewo
Copy link
Contributor Author

nlewo commented Feb 5, 2018

@gilligan I'm pushing to a registry several images generated by dockerTools.buildImage without any troubles. Can you share your image or provide more details?
I think it's not the right place here to discuss about this. Can you open an issue or ping me on the irc nixos channel (my nick is lewo).

@nlewo
Copy link
Contributor Author

nlewo commented Feb 8, 2018

@mtrmac Thanks for your how to "build crazy images" :) When I had triyed, I didn't remove the manifest.json file. This explains why it was working fine for me.

I pushed a commit that avoids the loop by skipping repeated layers. A warning is printed to inform the user.

@nlewo nlewo force-pushed the pr/docker-archive branch from 7106151 to e107475 Compare February 8, 2018 14:51
@mtrmac
Copy link
Collaborator

mtrmac commented Feb 8, 2018

I pushed a commit that avoids the loop by skipping repeated layers.

That changes the content of the image; the duplicates matter (see below for an example). Please either compute a ChainID as discussed above, of generate completely new IDs which are unique within the tarball and unrelated to the layer DiffID values.


Actually, there is a far simpler way to produce such an image: Create an empty empty file, and Dockerfile with

FROM fedora

ADD empty /
RUN rm /empty
ADD empty /

This results in the following RootFS.Layers:

                "sha256:d32459d9ce237564fb93573b85cbc707600d43fbe5e46e8eeef22cad914bb516",
                "sha256:4b5c23136932e4a87cecc06f674ff3f66ca21c8a61653b97aea095b5822ded60",
                "sha256:46c039e328386fe951548c428c030f15bf6c764aa3222fedb9d114d2b3270bb8",
                "sha256:4b5c23136932e4a87cecc06f674ff3f66ca21c8a61653b97aea095b5822ded60"

i.e. the two ADD empty / directives cause two exactly identical layers to be created — and merely removing the last one changes the image, from an image which contains /empty to one which doesn’t.

@nlewo nlewo force-pushed the pr/docker-archive branch from ca67b84 to 0dac8ec Compare February 12, 2018 20:05
@mtrmac
Copy link
Collaborator

mtrmac commented Feb 14, 2018

Would https://github.com/mtrmac/image/tree/docker-archive-chainid (testable via https://github.com/mtrmac/skopeo/tree/docker-archive-chainid ) work for you? At least docker load accepts both the new and old metadata from the generated tarball, with a

FROM fedora

ADD a /
ADD a /
ADD b /
ADD c /
ADD b /
ADD c /
ADD a /

(Note how the layers end up in the root of the tarball again; the legacy paths all become symlinks.)

@nlewo
Copy link
Contributor Author

nlewo commented Feb 18, 2018

@mtrmac thanks for your patches and the skopeo branch:)
Your patches work well on my docker images.

@nlewo nlewo force-pushed the pr/docker-archive branch from 0dac8ec to a490e69 Compare February 28, 2018 16:55
@nlewo
Copy link
Contributor Author

nlewo commented Feb 28, 2018

@mtrmac I've integrated your patches and rebased this PR on master.
Are there still some blocking points?

@mtrmac
Copy link
Collaborator

mtrmac commented Mar 3, 2018

👍 Thanks!

@runcom PTAL

Approved with PullApprove

@mtrmac
Copy link
Collaborator

mtrmac commented Mar 3, 2018

Failing make test-skopeo in this PR should be fixed through containers/skopeo#480 .

@nlewo
Copy link
Contributor Author

nlewo commented Mar 13, 2018

@runcom Could you please have a look?

@nlewo
Copy link
Contributor Author

nlewo commented Mar 27, 2018

@mtrmac it would be really nice to get this PR merged since I'm waiting for it to improve our Docker tooling. How could we move forward?

@mtrmac
Copy link
Collaborator

mtrmac commented Mar 27, 2018

@runcom ping again, this has been waiting for you for 24 days now.

@runcom
Copy link
Member

runcom commented Mar 27, 2018

@nlewo this needs one last rebase - I'm reviewing it

nlewo and others added 4 commits March 27, 2018 17:34
docker save generates image compatible with the legacy format, ie,
layers are tar, they have a configuration file and a repositories file
is created.

There are some external tools that still relie on this old format such
as mesos [1] and nixos [2].

[1] https://github.com/apache/mesos/blob//7ca46e24b3339ba27e88b99ea95362c956ef03c1/src/slave/containerizer/mesos/provisioner/docker/local_puller.cpp#L168
[2] https://github.com/NixOS/nixpkgs/blob/5c6dc717a66b6555e5757f92376527da7a553fec/pkgs/build-support/docker/default.nix#L143

Signed-off-by: Antoine Eiche <lewo@abesis.fr>
A new layer id is generated by using the current layer id and its
parent layer id.

Signed-off-by: Antoine Eiche <lewo@abesis.fr>
Duplicate IDs would otherwise result in duplicate files in the tarball,
with some of the versions possibly causing an import loop.

Duplicate layer IDs can happen
- using layer-at-a-time editing tools
- naively by accident, e.g.
  > FROM fedora
  >
  > ADD empty /
  > RUN rm /empty
  > ADD empty /
  creates two identical layers
- by a malicious actor

Signed-off-by: Miloslav Trmač <mitr@redhat.com>
Instead, keep all layers in the root of the tarbale, and create
subdirectories only in the legacy metadata writer.

Signed-off-by: Miloslav Trmač <mitr@redhat.com>
@runcom
Copy link
Member

runcom commented Mar 27, 2018

LGTM

Approved with PullApprove

@nlewo nlewo force-pushed the pr/docker-archive branch from e992967 to 7e96dbf Compare March 28, 2018 10:25
@nlewo
Copy link
Contributor Author

nlewo commented Mar 28, 2018

I've rebased and made some tests on my side.
Thanks @mtrmac @runcom

@runcom runcom merged commit fbc14df into containers:master Mar 28, 2018
nlewo added a commit to nlewo/skopeo that referenced this pull request Mar 28, 2018
Signed-off-by: Antoine Eiche <lewo@abesis.fr>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants