From 4bd70eab3c8b9196435965be9d3651f08e52338a Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 16 Apr 2018 12:32:35 +0300 Subject: [PATCH 1/3] Add basic tests for schema1 --- image/docker/schema1.py | 2 +- image/docker/test/test_schema1.py | 82 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 image/docker/test/test_schema1.py diff --git a/image/docker/schema1.py b/image/docker/schema1.py index 462554960..38edc4616 100644 --- a/image/docker/schema1.py +++ b/image/docker/schema1.py @@ -114,7 +114,7 @@ class DockerSchema1Manifest(object): }, }, 'required': [DOCKER_SCHEMA1_PROTECTED_KEY, DOCKER_SCHEMA1_HEADER_KEY, - DOCKER_SCHEMA1_SIGNATURE_KEY], + DOCKER_SCHEMA1_SIGNATURE_KEY], }, }, DOCKER_SCHEMA1_REPO_TAG_KEY: { diff --git a/image/docker/test/test_schema1.py b/image/docker/test/test_schema1.py new file mode 100644 index 000000000..509709318 --- /dev/null +++ b/image/docker/test/test_schema1.py @@ -0,0 +1,82 @@ +import json +import pytest + +from image.docker.schema1 import MalformedSchema1Manifest, DockerSchema1Manifest + +@pytest.mark.parametrize('json_data', [ + '', + '{}', + """ + { + "unknown": "key" + } + """, +]) +def test_malformed_manifests(json_data): + with pytest.raises(MalformedSchema1Manifest): + DockerSchema1Manifest(json_data) + + +@pytest.mark.parametrize('namespace', [ + '', + 'somenamespace', +]) +def test_valid_manifest(namespace): + manifest_bytes = json.dumps({ + "name": namespace + "/hello-world" if namespace else 'hello-world', + "tag": "latest", + "architecture": "amd64", + "fsLayers": [ + { + "blobSum": "sha256:cc8567d70002e957612902a8e985ea129d831ebe04057d88fb644857caa45d11" + }, + { + "blobSum": "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef" + } + ], + "history": [ + { + "v1Compatibility": "{\"id\":\"someid\", \"parent\": \"anotherid\"}" + }, + { + "v1Compatibility": "{\"id\":\"anotherid\"}" + }, + ], + "schemaVersion": 1, + "signatures": [ + { + "header": { + "jwk": { + "crv": "P-256", + "kid": "OD6I:6DRK:JXEJ:KBM4:255X:NSAA:MUSF:E4VM:ZI6W:CUN2:L4Z6:LSF4", + "kty": "EC", + "x": "3gAwX48IQ5oaYQAYSxor6rYYc_6yjuLCjtQ9LUakg4A", + "y": "t72ge6kIA1XOjqjVoEOiPPAURltJFBMGDSQvEGVB010" + }, + "alg": "ES256" + }, + "signature": "XREm0L8WNn27Ga_iE_vRnTxVMhhYY0Zst_FfkKopg6gWSoTOZTuW4rK0fg_IqnKkEKlbD83tD46LKEGi5aIVFg", + "protected": "eyJmb3JtYXRMZW5ndGgiOjY2MjgsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAxNS0wNC0wOFQxODo1Mjo1OVoifQ" + } + ] + }) + + manifest = DockerSchema1Manifest(manifest_bytes, validate=False) + assert len(manifest.signatures) == 1 + assert manifest.namespace == namespace + assert manifest.repo_name == 'hello-world' + assert manifest.tag == 'latest' + assert manifest.image_ids == {'someid', 'anotherid'} + assert manifest.parent_image_ids == {'anotherid'} + + assert len(manifest.layers) == 2 + + assert manifest.layers[0].v1_metadata.image_id == 'anotherid' + assert manifest.layers[0].v1_metadata.parent_image_id is None + + assert manifest.layers[1].v1_metadata.image_id == 'someid' + assert manifest.layers[1].v1_metadata.parent_image_id == 'anotherid' + + assert manifest.leaf_layer == manifest.layers[1] + + assert manifest.digest From 52b12131f7614755ca3be9df4c581013388e1e2f Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 16 Apr 2018 15:38:13 +0300 Subject: [PATCH 2/3] Add schema2 manifest and schema2 config, along with tests --- .../{schema2.py => schema2/__init__.py} | 5 + image/docker/schema2/config.py | 216 ++++++++++++++++++ image/docker/schema2/manifest.py | 205 +++++++++++++++++ image/docker/schema2/test/__init__.py | 0 image/docker/schema2/test/test_config.py | 129 +++++++++++ image/docker/schema2/test/test_manifest.py | 87 +++++++ 6 files changed, 642 insertions(+) rename image/docker/{schema2.py => schema2/__init__.py} (73%) create mode 100644 image/docker/schema2/config.py create mode 100644 image/docker/schema2/manifest.py create mode 100644 image/docker/schema2/test/__init__.py create mode 100644 image/docker/schema2/test/test_config.py create mode 100644 image/docker/schema2/test/test_manifest.py diff --git a/image/docker/schema2.py b/image/docker/schema2/__init__.py similarity index 73% rename from image/docker/schema2.py rename to image/docker/schema2/__init__.py index 2fa40a87c..6d3392c41 100644 --- a/image/docker/schema2.py +++ b/image/docker/schema2/__init__.py @@ -8,6 +8,11 @@ https://github.com/docker/distribution/blob/master/docs/spec/manifest-v2-2.md DOCKER_SCHEMA2_MANIFEST_CONTENT_TYPE = 'application/vnd.docker.distribution.manifest.v2+json' DOCKER_SCHEMA2_MANIFESTLIST_CONTENT_TYPE = 'application/vnd.docker.distribution.manifest.list.v2+json' +DOCKER_SCHEMA2_LAYER_CONTENT_TYPE = 'application/vnd.docker.image.rootfs.diff.tar.gzip' +DOCKER_SCHEMA2_REMOTE_LAYER_CONTENT_TYPE = 'application/vnd.docker.image.rootfs.foreign.diff.tar.gzip' + +DOCKER_SCHEMA2_CONFIG_CONTENT_TYPE = 'application/vnd.docker.container.image.v1+json' + OCI_MANIFEST_CONTENT_TYPE = 'application/vnd.oci.image.manifest.v1+json' OCI_MANIFESTLIST_CONTENT_TYPE = 'application/vnd.oci.image.index.v1+json' diff --git a/image/docker/schema2/config.py b/image/docker/schema2/config.py new file mode 100644 index 000000000..faf09c26a --- /dev/null +++ b/image/docker/schema2/config.py @@ -0,0 +1,216 @@ +""" +Implements validation and conversion for the Schema2 config JSON. + +Example: +{ + "architecture": "amd64", + "config": { + "Hostname": "", + "Domainname": "", + "User": "", + "AttachStdin": false, + "AttachStdout": false, + "AttachStderr": false, + "Tty": false, + "OpenStdin": false, + "StdinOnce": false, + "Env": [ + "HTTP_PROXY=http:\/\/localhost:8080", + "http_proxy=http:\/\/localhost:8080", + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" + ], + "Cmd": [ + "sh" + ], + "Image": "", + "Volumes": null, + "WorkingDir": "", + "Entrypoint": null, + "OnBuild": null, + "Labels": { + + } + }, + "container": "b7a43694b435c8e9932615643f61f975a9213e453b15cd6c2a386f144a2d2de9", + "container_config": { + "Hostname": "b7a43694b435", + "Domainname": "", + "User": "", + "AttachStdin": true, + "AttachStdout": true, + "AttachStderr": true, + "Tty": true, + "OpenStdin": true, + "StdinOnce": true, + "Env": [ + "HTTP_PROXY=http:\/\/localhost:8080", + "http_proxy=http:\/\/localhost:8080", + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" + ], + "Cmd": [ + "sh" + ], + "Image": "somenamespace\/somerepo", + "Volumes": null, + "WorkingDir": "", + "Entrypoint": null, + "OnBuild": null, + "Labels": { + + } + }, + "created": "2018-04-16T10:41:19.079522722Z", + "docker_version": "17.09.0-ce", + "history": [ + { + "created": "2018-04-03T18:37:09.284840891Z", + "created_by": "\/bin\/sh -c #(nop) ADD file:9e4ca21cbd24dc05b454b6be21c7c639216ae66559b21ba24af0d665c62620dc in \/ " + }, + { + "created": "2018-04-03T18:37:09.613317719Z", + "created_by": "\/bin\/sh -c #(nop) CMD [\"sh\"]", + "empty_layer": true + }, + { + "created": "2018-04-16T10:37:44.418262777Z", + "created_by": "sh" + }, + { + "created": "2018-04-16T10:41:19.079522722Z", + "created_by": "sh" + } + ], + "os": "linux", + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:3e596351c689c8827a3c9635bc1083cff17fa4a174f84f0584bd0ae6f384195b", + "sha256:4552be273c71275a88de0b8c8853dcac18cb74d5790f5383d9b38d4ac55062d5", + "sha256:1319c76152ca37fbeb7fb71e0ffa7239bc19ffbe3b95c00417ece39d89d06e6e" + ] + } +} +""" + +import copy +import json + +from collections import namedtuple +from jsonschema import validate as validate_schema, ValidationError +from dateutil.parser import parse as parse_date + +DOCKER_SCHEMA2_CONFIG_HISTORY_KEY = "history" +DOCKER_SCHEMA2_CONFIG_ROOTFS_KEY = "rootfs" +DOCKER_SCHEMA2_CONFIG_CREATED_KEY = "created" +DOCKER_SCHEMA2_CONFIG_CREATED_BY_KEY = "created_by" +DOCKER_SCHEMA2_CONFIG_EMPTY_LAYER_KEY = "empty_layer" +DOCKER_SCHEMA2_CONFIG_TYPE_KEY = "type" + + +LayerHistory = namedtuple('LayerHistory', ['created', 'created_datetime', 'command', 'is_empty']) + + +class MalformedSchema2Config(Exception): + """ + Raised when a config fails an assertion that should be true according to the Docker Manifest + v2.2 Config Specification. + """ + pass + + +class DockerSchema2Config(object): + METASCHEMA = { + 'type': 'object', + 'description': 'The container configuration found in a schema 2 manifest', + 'required': [DOCKER_SCHEMA2_CONFIG_HISTORY_KEY, DOCKER_SCHEMA2_CONFIG_ROOTFS_KEY], + 'properties': { + DOCKER_SCHEMA2_CONFIG_HISTORY_KEY: { + 'type': 'array', + 'description': 'The history used to create the container image', + 'items': { + 'type': 'object', + 'properties': { + DOCKER_SCHEMA2_CONFIG_EMPTY_LAYER_KEY: { + 'type': 'boolean', + 'description': 'If present, this layer is empty', + }, + DOCKER_SCHEMA2_CONFIG_CREATED_KEY: { + 'type': 'string', + 'description': 'The date/time that the layer was created', + 'format': 'date-time', + 'x-example': '2018-04-03T18:37:09.284840891Z', + }, + DOCKER_SCHEMA2_CONFIG_CREATED_BY_KEY: { + 'type': 'string', + 'description': 'The command used to create the layer', + 'x-example': '\/bin\/sh -c #(nop) ADD file:somesha in /', + }, + }, + 'required': [DOCKER_SCHEMA2_CONFIG_CREATED_KEY, DOCKER_SCHEMA2_CONFIG_CREATED_BY_KEY], + 'additionalProperties': True, + }, + }, + DOCKER_SCHEMA2_CONFIG_ROOTFS_KEY: { + 'type': 'object', + 'description': 'Describes the root filesystem for this image', + 'properties': { + DOCKER_SCHEMA2_CONFIG_TYPE_KEY: { + 'type': 'string', + 'description': 'The type of the root file system entries', + }, + }, + 'required': [DOCKER_SCHEMA2_CONFIG_TYPE_KEY], + 'additionalProperties': True, + }, + }, + 'additionalProperties': True, + } + + def __init__(self, config_bytes): + try: + self._parsed = json.loads(config_bytes) + except ValueError as ve: + raise MalformedSchema2Config('malformed config data: %s' % ve) + + try: + validate_schema(self._parsed, DockerSchema2Config.METASCHEMA) + except ValidationError as ve: + raise MalformedSchema2Config('config data does not match schema: %s' % ve) + + @property + def history(self): + """ Returns the history of the image, started at the base layer. """ + for history_entry in self._parsed[DOCKER_SCHEMA2_CONFIG_HISTORY_KEY]: + created_datetime = parse_date(history_entry[DOCKER_SCHEMA2_CONFIG_CREATED_KEY]) + yield LayerHistory(created_datetime=created_datetime, + created=history_entry[DOCKER_SCHEMA2_CONFIG_CREATED_KEY], + command=history_entry[DOCKER_SCHEMA2_CONFIG_CREATED_BY_KEY], + is_empty=history_entry.get(DOCKER_SCHEMA2_CONFIG_EMPTY_LAYER_KEY, False)) + + def build_v1_compatibility(self, layer_index, v1_id, v1_parent_id): + """ Builds the V1 compatibility block for the given layer. + + Note that the layer_index is 0-indexed, with the *base* layer being 0, and the leaf + layer being last. + """ + history = list(self.history) + + # If the layer is the leaf, it gets the full config (minus 2 fields). Otherwise, it gets only + # IDs. + v1_compatibility = copy.deepcopy(self._parsed) if layer_index == len(history) - 1 else {} + v1_compatibility['id'] = v1_id + if v1_parent_id is not None: + v1_compatibility['parent'] = v1_parent_id + + if 'created' not in v1_compatibility: + v1_compatibility['created'] = history[layer_index].created + + if 'container_config' not in v1_compatibility: + v1_compatibility['container_config'] = { + 'Cmd': history[layer_index].command, + } + + # The history and rootfs keys are schema2-config specific. + v1_compatibility.pop(DOCKER_SCHEMA2_CONFIG_HISTORY_KEY, None) + v1_compatibility.pop(DOCKER_SCHEMA2_CONFIG_ROOTFS_KEY, None) + return v1_compatibility diff --git a/image/docker/schema2/manifest.py b/image/docker/schema2/manifest.py new file mode 100644 index 000000000..f316bcdbd --- /dev/null +++ b/image/docker/schema2/manifest.py @@ -0,0 +1,205 @@ +import json +import logging +import hashlib + +from collections import namedtuple + +from jsonschema import validate as validate_schema, ValidationError + +from digest import digest_tools +from image.docker import ManifestException +from image.docker.schema2 import (DOCKER_SCHEMA2_MANIFEST_CONTENT_TYPE, + DOCKER_SCHEMA2_CONFIG_CONTENT_TYPE, + DOCKER_SCHEMA2_LAYER_CONTENT_TYPE, + DOCKER_SCHEMA2_REMOTE_LAYER_CONTENT_TYPE) +from image.docker.schema2.config import DockerSchema2Config + +# Keys. +DOCKER_SCHEMA2_MANIFEST_VERSION_KEY = 'schemaVersion' +DOCKER_SCHEMA2_MANIFEST_MEDIATYPE_KEY = 'mediaType' +DOCKER_SCHEMA2_MANIFEST_CONFIG_KEY = 'config' +DOCKER_SCHEMA2_MANIFEST_SIZE_KEY = 'size' +DOCKER_SCHEMA2_MANIFEST_DIGEST_KEY = 'digest' +DOCKER_SCHEMA2_MANIFEST_LAYERS_KEY = 'layers' +DOCKER_SCHEMA2_MANIFEST_URLS_KEY = 'urls' + +# Named tuples. +DockerV2ManifestConfig = namedtuple('DockerV2ManifestConfig', ['size', 'digest']) +DockerV2ManifestLayer = namedtuple('DockerV2ManifestLayer', ['index', 'size', 'digest', + 'is_remote', 'urls']) + +LayerWithV1ID = namedtuple('LayerWithV1ID', ['layer', 'v1_id', 'v1_parent_id']) + +logger = logging.getLogger(__name__) + +class MalformedSchema2Manifest(ManifestException): + """ + Raised when a manifest fails an assertion that should be true according to the Docker Manifest + v2.2 Specification. + """ + pass + + +class DockerSchema2Manifest(object): + METASCHEMA = { + 'type': 'object', + 'properties': { + DOCKER_SCHEMA2_MANIFEST_VERSION_KEY: { + 'type': 'number', + 'description': 'The version of the schema. Must always be `2`.', + 'minimum': 2, + 'maximum': 2, + }, + DOCKER_SCHEMA2_MANIFEST_MEDIATYPE_KEY: { + 'type': 'string', + 'description': 'The media type of the schema.', + 'enum': [DOCKER_SCHEMA2_MANIFEST_CONTENT_TYPE], + }, + DOCKER_SCHEMA2_MANIFEST_CONFIG_KEY: { + 'type': 'object', + 'description': 'The config field references a configuration object for a container, ' + + 'by digest. This configuration item is a JSON blob that the runtime ' + + 'uses to set up the container.', + 'properties': { + DOCKER_SCHEMA2_MANIFEST_MEDIATYPE_KEY: { + 'type': 'string', + 'description': 'The MIME type of the referenced object. This should generally be ' + + 'application/vnd.docker.container.image.v1+json', + 'enum': [DOCKER_SCHEMA2_CONFIG_CONTENT_TYPE], + }, + DOCKER_SCHEMA2_MANIFEST_SIZE_KEY: { + 'type': 'number', + 'description': 'The size in bytes of the object. This field exists so that a ' + + 'client will have an expected size for the content before ' + + 'validating. If the length of the retrieved content does not ' + + 'match the specified length, the content should not be trusted.', + }, + DOCKER_SCHEMA2_MANIFEST_DIGEST_KEY: { + 'type': 'string', + 'description': 'The content addressable digest of the config in the blob store', + }, + }, + 'required': [DOCKER_SCHEMA2_MANIFEST_MEDIATYPE_KEY, DOCKER_SCHEMA2_MANIFEST_SIZE_KEY, + DOCKER_SCHEMA2_MANIFEST_DIGEST_KEY], + }, + DOCKER_SCHEMA2_MANIFEST_LAYERS_KEY: { + 'type': 'array', + 'description': 'The layer list is ordered starting from the base ' + + 'image (opposite order of schema1).', + 'items': { + 'type': 'object', + 'properties': { + DOCKER_SCHEMA2_MANIFEST_MEDIATYPE_KEY: { + 'type': 'string', + 'description': 'The MIME type of the referenced object. This should generally be ' + + 'application/vnd.docker.image.rootfs.diff.tar.gzip. Layers of type ' + + 'application/vnd.docker.image.rootfs.foreign.diff.tar.gzip may be ' + + 'pulled from a remote location but they should never be pushed.', + 'enum': [DOCKER_SCHEMA2_LAYER_CONTENT_TYPE, DOCKER_SCHEMA2_REMOTE_LAYER_CONTENT_TYPE], + }, + DOCKER_SCHEMA2_MANIFEST_SIZE_KEY: { + 'type': 'number', + 'description': 'The size in bytes of the object. This field exists so that a ' + + 'client will have an expected size for the content before ' + + 'validating. If the length of the retrieved content does not ' + + 'match the specified length, the content should not be trusted.', + }, + DOCKER_SCHEMA2_MANIFEST_DIGEST_KEY: { + 'type': 'string', + 'description': 'The content addressable digest of the layer in the blob store', + }, + }, + 'required': [ + DOCKER_SCHEMA2_MANIFEST_MEDIATYPE_KEY, DOCKER_SCHEMA2_MANIFEST_SIZE_KEY, + DOCKER_SCHEMA2_MANIFEST_DIGEST_KEY, + ], + }, + }, + }, + 'required': [DOCKER_SCHEMA2_MANIFEST_VERSION_KEY, DOCKER_SCHEMA2_MANIFEST_MEDIATYPE_KEY, + DOCKER_SCHEMA2_MANIFEST_CONFIG_KEY, DOCKER_SCHEMA2_MANIFEST_LAYERS_KEY], + } + + def __init__(self, manifest_bytes): + self._layers = None + + try: + self._parsed = json.loads(manifest_bytes) + except ValueError as ve: + raise MalformedSchema2Manifest('malformed manifest data: %s' % ve) + + try: + validate_schema(self._parsed, DockerSchema2Manifest.METASCHEMA) + except ValidationError as ve: + raise MalformedSchema2Manifest('manifest data does not match schema: %s' % ve) + + @property + def config(self): + config = self._parsed[DOCKER_SCHEMA2_MANIFEST_CONFIG_KEY] + return DockerV2ManifestConfig(size=config[DOCKER_SCHEMA2_MANIFEST_SIZE_KEY], + digest=config[DOCKER_SCHEMA2_MANIFEST_DIGEST_KEY]) + + @property + def layers(self): + """ Returns the layers of this manifest, from base to leaf. """ + if self._layers is None: + self._layers = list(self._generate_layers()) + return self._layers + + @property + def leaf_layer(self): + return self.layers[-1] + + def _generate_layers(self): + for index, layer in enumerate(self._parsed[DOCKER_SCHEMA2_MANIFEST_LAYERS_KEY]): + content_type = layer[DOCKER_SCHEMA2_MANIFEST_MEDIATYPE_KEY] + is_remote = content_type == DOCKER_SCHEMA2_REMOTE_LAYER_CONTENT_TYPE + + try: + digest = digest_tools.Digest.parse_digest(layer[DOCKER_SCHEMA2_MANIFEST_DIGEST_KEY]) + except digest_tools.InvalidDigestException: + raise MalformedSchema2Manifest('could not parse manifest digest: %s' % + layer[DOCKER_SCHEMA2_MANIFEST_DIGEST_KEY]) + + yield DockerV2ManifestLayer(index=index, + size=layer[DOCKER_SCHEMA2_MANIFEST_SIZE_KEY], + digest=digest, + is_remote=is_remote, + urls=layer.get(DOCKER_SCHEMA2_MANIFEST_URLS_KEY)) + + @property + def layers_with_v1_ids(self): + digest_history = hashlib.sha256() + v1_layer_parent_id = None + v1_layer_id = None + + for layer in self.layers: + v1_layer_parent_id = v1_layer_id + + # Create a new synthesized V1 ID for the layer by adding its digest and index to the + # existing digest history hash builder. This will ensure unique V1s across *all* schemas in + # a repository. + digest_history.update(str(layer.digest)) + digest_history.update("#") + digest_history.update(str(layer.index)) + digest_history.update("|") + v1_layer_id = digest_history.hexdigest() + yield LayerWithV1ID(layer=layer, v1_id=v1_layer_id, v1_parent_id=v1_layer_parent_id) + + def populate_schema1_builder(self, v1_builder, lookup_config_fn): + """ Populates a DockerSchema1ManifestBuilder with the layers and config from + this schema. The `lookup_config_fn` is a function that, when given the config + digest SHA, returns the associated configuration JSON bytes for this schema. + """ + config_bytes = lookup_config_fn(self.config.digest) + schema2_config = DockerSchema2Config(config_bytes) + + # Build the V1 IDs for the layers. + layers = list(self.layers_with_v1_ids) + for layer_with_ids in reversed(layers): # Schema1 has layers in reverse order + v1_compatibility = schema2_config.build_v1_compatibility(layer_with_ids.layer.index, + layer_with_ids.v1_id, + layer_with_ids.v1_parent_id) + v1_builder.add_layer(str(layer_with_ids.layer.digest), json.dumps(v1_compatibility)) + + return v1_builder diff --git a/image/docker/schema2/test/__init__.py b/image/docker/schema2/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/image/docker/schema2/test/test_config.py b/image/docker/schema2/test/test_config.py new file mode 100644 index 000000000..345e63fd6 --- /dev/null +++ b/image/docker/schema2/test/test_config.py @@ -0,0 +1,129 @@ +import json +import pytest + +from image.docker.schema2.config import MalformedSchema2Config, DockerSchema2Config + +@pytest.mark.parametrize('json_data', [ + '', + '{}', + """ + { + "unknown": "key" + } + """, +]) +def test_malformed_configs(json_data): + with pytest.raises(MalformedSchema2Config): + DockerSchema2Config(json_data) + +CONFIG_BYTES = json.dumps({ + "architecture": "amd64", + "config": { + "Hostname": "", + "Domainname": "", + "User": "", + "AttachStdin": False, + "AttachStdout": False, + "AttachStderr": False, + "Tty": False, + "OpenStdin": False, + "StdinOnce": False, + "Env": [ + "HTTP_PROXY=http:\/\/localhost:8080", + "http_proxy=http:\/\/localhost:8080", + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" + ], + "Cmd": [ + "sh" + ], + "Image": "", + "Volumes": None, + "WorkingDir": "", + "Entrypoint": None, + "OnBuild": None, + "Labels": { + + } + }, + "container": "b7a43694b435c8e9932615643f61f975a9213e453b15cd6c2a386f144a2d2de9", + "container_config": { + "Hostname": "b7a43694b435", + "Domainname": "", + "User": "", + "AttachStdin": True, + "AttachStdout": True, + "AttachStderr": True, + "Tty": True, + "OpenStdin": True, + "StdinOnce": True, + "Env": [ + "HTTP_PROXY=http:\/\/localhost:8080", + "http_proxy=http:\/\/localhost:8080", + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" + ], + "Cmd": [ + "sh" + ], + "Image": "jschorr\/somerepo", + "Volumes": None, + "WorkingDir": "", + "Entrypoint": None, + "OnBuild": None, + "Labels": { + + } + }, + "created": "2018-04-16T10:41:19.079522722Z", + "docker_version": "17.09.0-ce", + "history": [ + { + "created": "2018-04-03T18:37:09.284840891Z", + "created_by": "\/bin\/sh -c #(nop) ADD file:9e4ca21cbd24dc05b454b6be21c7c639216ae66559b21ba24af0d665c62620dc in \/ " + }, + { + "created": "2018-04-03T18:37:09.613317719Z", + "created_by": "/bin/sh -c #(nop) CMD [\"sh\"]", + "empty_layer": True + }, + { + "created": "2018-04-16T10:37:44.418262777Z", + "created_by": "sh" + }, + { + "created": "2018-04-16T10:41:19.079522722Z", + "created_by": "sh" + } + ], + "os": "linux", + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:3e596351c689c8827a3c9635bc1083cff17fa4a174f84f0584bd0ae6f384195b", + "sha256:4552be273c71275a88de0b8c8853dcac18cb74d5790f5383d9b38d4ac55062d5", + "sha256:1319c76152ca37fbeb7fb71e0ffa7239bc19ffbe3b95c00417ece39d89d06e6e" + ] + } +}) + +def test_valid_config(): + config = DockerSchema2Config(CONFIG_BYTES) + history = list(config.history) + assert len(history) == 4 + + assert not history[0].is_empty + assert history[1].is_empty + + assert history[0].created_datetime.year == 2018 + assert history[1].command == '/bin/sh -c #(nop) CMD ["sh"]' + assert history[2].command == 'sh' + + for index, history_entry in enumerate(history): + v1_compat = config.build_v1_compatibility(index, 'somev1id', 'someparentid') + assert v1_compat['id'] == 'somev1id' + assert v1_compat['parent'] == 'someparentid' + + if index == 3: + assert v1_compat['container_config'] == config._parsed['container_config'] + else: + assert 'Hostname' not in v1_compat['container_config'] + assert v1_compat['container_config']['Cmd'] == history_entry.command diff --git a/image/docker/schema2/test/test_manifest.py b/image/docker/schema2/test/test_manifest.py new file mode 100644 index 000000000..89ad35e29 --- /dev/null +++ b/image/docker/schema2/test/test_manifest.py @@ -0,0 +1,87 @@ +import json +import pytest + +from app import docker_v2_signing_key +from image.docker.schema1 import DockerSchema1ManifestBuilder +from image.docker.schema2.manifest import MalformedSchema2Manifest, DockerSchema2Manifest +from image.docker.schema2.test.test_config import CONFIG_BYTES + +@pytest.mark.parametrize('json_data', [ + '', + '{}', + """ + { + "unknown": "key" + } + """, +]) +def test_malformed_manifests(json_data): + with pytest.raises(MalformedSchema2Manifest): + DockerSchema2Manifest(json_data) + + +MANIFEST_BYTES = json.dumps({ + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": { + "mediaType": "application/vnd.docker.container.image.v1+json", + "size": 7023, + "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7" + }, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip", + "size": 1234, + "digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736", + "urls": ['http://some/url'], + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 32654, + "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 16724, + "digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 73109, + "digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736" + }, + ], +}) + +def test_valid_manifest(): + manifest = DockerSchema2Manifest(MANIFEST_BYTES) + assert manifest.config.size == 7023 + assert str(manifest.config.digest) == 'sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7' + + assert len(manifest.layers) == 4 + assert manifest.layers[0].is_remote + assert manifest.layers[0].size == 1234 + assert str(manifest.layers[0].digest) == 'sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736' + assert manifest.layers[0].urls + + assert manifest.leaf_layer == manifest.layers[3] + assert not manifest.leaf_layer.is_remote + assert manifest.leaf_layer.size == 73109 + + +def test_build_schema1(): + manifest = DockerSchema2Manifest(MANIFEST_BYTES) + + builder = DockerSchema1ManifestBuilder('somenamespace', 'somename', 'sometag') + manifest.populate_schema1_builder(builder, lambda digest: CONFIG_BYTES) + schema1 = builder.build(docker_v2_signing_key) + + assert len(schema1.layers) == len(manifest.layers) + assert set(schema1.image_ids) == set([l.v1_id for l in manifest.layers_with_v1_ids]) + assert set(schema1.parent_image_ids) == set([l.v1_parent_id for l in manifest.layers_with_v1_ids if l.v1_parent_id]) + + manifest_layers = list(manifest.layers_with_v1_ids) + for index, layer in enumerate(schema1.layers): + assert layer.digest == manifest_layers[index].layer.digest + assert layer.v1_metadata.image_id == manifest_layers[index].v1_id + assert layer.v1_metadata.parent_image_id == manifest_layers[index].v1_parent_id From a73cd9170abe3cc97e74927f5575009123e0b345 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 16 Apr 2018 17:22:25 +0300 Subject: [PATCH 3/3] Add schema2 list support --- image/docker/schema1.py | 4 + image/docker/schema2/list.py | 194 +++++++++++++++++++++ image/docker/schema2/manifest.py | 11 +- image/docker/schema2/test/test_list.py | 70 ++++++++ image/docker/schema2/test/test_manifest.py | 4 +- image/docker/test/__init__.py | 0 image/docker/test/test_schema1.py | 85 +++++---- 7 files changed, 320 insertions(+), 48 deletions(-) create mode 100644 image/docker/schema2/list.py create mode 100644 image/docker/schema2/test/test_list.py create mode 100644 image/docker/test/__init__.py diff --git a/image/docker/schema1.py b/image/docker/schema1.py index 38edc4616..9b11470e1 100644 --- a/image/docker/schema1.py +++ b/image/docker/schema1.py @@ -195,6 +195,10 @@ class DockerSchema1Manifest(object): if not verified: raise InvalidSchema1Signature() + @property + def schema_version(self): + return 1 + @property def content_type(self): return DOCKER_SCHEMA1_SIGNED_MANIFEST_CONTENT_TYPE diff --git a/image/docker/schema2/list.py b/image/docker/schema2/list.py new file mode 100644 index 000000000..24092de41 --- /dev/null +++ b/image/docker/schema2/list.py @@ -0,0 +1,194 @@ +import json + +from cachetools import lru_cache +from jsonschema import validate as validate_schema, ValidationError + +from image.docker.schema1 import DOCKER_SCHEMA1_MANIFEST_CONTENT_TYPE +from image.docker.schema1 import DockerSchema1Manifest +from image.docker.schema2 import (DOCKER_SCHEMA2_MANIFESTLIST_CONTENT_TYPE, + DOCKER_SCHEMA2_MANIFEST_CONTENT_TYPE) +from image.docker.schema2.manifest import DockerSchema2Manifest + +# Keys. +DOCKER_SCHEMA2_MANIFESTLIST_VERSION_KEY = 'schemaVersion' +DOCKER_SCHEMA2_MANIFESTLIST_MEDIATYPE_KEY = 'mediaType' +DOCKER_SCHEMA2_MANIFESTLIST_SIZE_KEY = 'size' +DOCKER_SCHEMA2_MANIFESTLIST_DIGEST_KEY = 'digest' +DOCKER_SCHEMA2_MANIFESTLIST_MANIFESTS_KEY = 'manifests' +DOCKER_SCHEMA2_MANIFESTLIST_PLATFORM_KEY = 'platform' +DOCKER_SCHEMA2_MANIFESTLIST_ARCHITECTURE_KEY = 'architecture' +DOCKER_SCHEMA2_MANIFESTLIST_OS_KEY = 'os' +DOCKER_SCHEMA2_MANIFESTLIST_OS_VERSION_KEY = 'os.version' +DOCKER_SCHEMA2_MANIFESTLIST_OS_FEATURES_KEY = 'os.features' +DOCKER_SCHEMA2_MANIFESTLIST_FEATURES_KEY = 'features' +DOCKER_SCHEMA2_MANIFESTLIST_VARIANT_KEY = 'variant' + + +class MalformedSchema2ManifestList(Exception): + """ + Raised when a manifest list fails an assertion that should be true according to the + Docker Manifest v2.2 Specification. + """ + pass + + +class LazyManifestLoader(object): + def __init__(self, manifest_data, lookup_manifest_fn): + self._manifest_data = manifest_data + self._lookup_manifest_fn = lookup_manifest_fn + self._loaded_manifest = None + + @property + def manifest_obj(self): + if self._loaded_manifest is not None: + return self._loaded_manifest + + self._loaded_manifest = self._load_manifest() + return self._loaded_manifest + + def _load_manifest(self): + digest = self._manifest_data[DOCKER_SCHEMA2_MANIFESTLIST_DIGEST_KEY] + size = self._manifest_data[DOCKER_SCHEMA2_MANIFESTLIST_SIZE_KEY] + manifest_bytes = self._lookup_manifest_fn(digest) + if len(manifest_bytes) != size: + raise MalformedSchema2ManifestList('Size of manifest does not match that retrieved: %s vs %s', + len(manifest_bytes), size) + + content_type = self._manifest_data[DOCKER_SCHEMA2_MANIFESTLIST_MEDIATYPE_KEY] + if content_type == DOCKER_SCHEMA2_MANIFEST_CONTENT_TYPE: + return DockerSchema2Manifest(manifest_bytes) + + if content_type == DOCKER_SCHEMA1_MANIFEST_CONTENT_TYPE: + return DockerSchema1Manifest(manifest_bytes, validate=False) + + raise MalformedSchema2ManifestList('Unknown manifest content type') + + +class DockerSchema2ManifestList(object): + METASCHEMA = { + 'type': 'object', + 'properties': { + DOCKER_SCHEMA2_MANIFESTLIST_VERSION_KEY: { + 'type': 'number', + 'description': 'The version of the manifest list. Must always be `2`.', + 'minimum': 2, + 'maximum': 2, + }, + DOCKER_SCHEMA2_MANIFESTLIST_MEDIATYPE_KEY: { + 'type': 'string', + 'description': 'The media type of the manifest list.', + 'enum': [DOCKER_SCHEMA2_MANIFESTLIST_CONTENT_TYPE], + }, + DOCKER_SCHEMA2_MANIFESTLIST_MANIFESTS_KEY: { + 'type': 'array', + 'description': 'The manifests field contains a list of manifests for specific platforms', + 'items': { + 'type': 'object', + 'properties': { + DOCKER_SCHEMA2_MANIFESTLIST_MEDIATYPE_KEY: { + 'type': 'string', + 'description': 'The MIME type of the referenced object. This will generally be ' + + 'application/vnd.docker.distribution.manifest.v2+json, but it ' + + 'could also be application/vnd.docker.distribution.manifest.v1+json ' + + 'if the manifest list references a legacy schema-1 manifest.', + 'enum': [DOCKER_SCHEMA2_MANIFEST_CONTENT_TYPE, DOCKER_SCHEMA1_MANIFEST_CONTENT_TYPE], + }, + DOCKER_SCHEMA2_MANIFESTLIST_SIZE_KEY: { + 'type': 'number', + 'description': 'The size in bytes of the object. This field exists so that a ' + + 'client will have an expected size for the content before ' + + 'validating. If the length of the retrieved content does not ' + + 'match the specified length, the content should not be trusted.', + }, + DOCKER_SCHEMA2_MANIFESTLIST_DIGEST_KEY: { + 'type': 'string', + 'description': 'The content addressable digest of the manifest in the blob store', + }, + DOCKER_SCHEMA2_MANIFESTLIST_PLATFORM_KEY: { + 'type': 'object', + 'description': 'The platform object describes the platform which the image in ' + + 'the manifest runs on', + 'properties': { + DOCKER_SCHEMA2_MANIFESTLIST_ARCHITECTURE_KEY: { + 'type': 'string', + 'description': 'Specifies the CPU architecture, for example amd64 or ppc64le.', + }, + DOCKER_SCHEMA2_MANIFESTLIST_OS_KEY: { + 'type': 'string', + 'description': 'Specifies the operating system, for example linux or windows', + }, + DOCKER_SCHEMA2_MANIFESTLIST_OS_VERSION_KEY: { + 'type': 'string', + 'description': 'Specifies the operating system version, for example 10.0.10586', + }, + DOCKER_SCHEMA2_MANIFESTLIST_OS_FEATURES_KEY: { + 'type': 'array', + 'description': 'specifies an array of strings, each listing a required OS ' + + 'feature (for example on Windows win32k)', + 'items': { + 'type': 'string', + }, + }, + DOCKER_SCHEMA2_MANIFESTLIST_VARIANT_KEY: { + 'type': 'string', + 'description': 'Specifies a variant of the CPU, for example armv6l to specify ' + + 'a particular CPU variant of the ARM CPU', + }, + DOCKER_SCHEMA2_MANIFESTLIST_FEATURES_KEY: { + 'type': 'array', + 'description': 'specifies an array of strings, each listing a required CPU ' + + 'feature (for example sse4 or aes).', + 'items': { + 'type': 'string', + }, + }, + }, + 'required': [DOCKER_SCHEMA2_MANIFESTLIST_ARCHITECTURE_KEY, + DOCKER_SCHEMA2_MANIFESTLIST_OS_KEY], + }, + }, + 'required': [DOCKER_SCHEMA2_MANIFESTLIST_MEDIATYPE_KEY, + DOCKER_SCHEMA2_MANIFESTLIST_SIZE_KEY, + DOCKER_SCHEMA2_MANIFESTLIST_DIGEST_KEY, + DOCKER_SCHEMA2_MANIFESTLIST_PLATFORM_KEY], + }, + }, + }, + 'required': [DOCKER_SCHEMA2_MANIFESTLIST_VERSION_KEY, + DOCKER_SCHEMA2_MANIFESTLIST_MEDIATYPE_KEY, + DOCKER_SCHEMA2_MANIFESTLIST_MANIFESTS_KEY], + } + + def __init__(self, manifest_bytes): + self._layers = None + + try: + self._parsed = json.loads(manifest_bytes) + except ValueError as ve: + raise MalformedSchema2ManifestList('malformed manifest data: %s' % ve) + + try: + validate_schema(self._parsed, DockerSchema2ManifestList.METASCHEMA) + except ValidationError as ve: + raise MalformedSchema2ManifestList('manifest data does not match schema: %s' % ve) + + @lru_cache(maxsize=1) + def manifests(self, lookup_manifest_fn): + """ Returns the manifests in the list. The `lookup_manifest_fn` is a function + that returns the manifest bytes for the specified digest. + """ + manifests = self._parsed[DOCKER_SCHEMA2_MANIFESTLIST_MANIFESTS_KEY] + return [LazyManifestLoader(m, lookup_manifest_fn) for m in manifests] + + def get_v1_compatible_manifest(self, lookup_manifest_fn): + """ Returns the manifest that is compatible with V1, by virtue of being `amd64` and `linux`. + If none, returns None. + """ + for manifest in self.manifests(lookup_manifest_fn): + platform = manifest._manifest_data[DOCKER_SCHEMA2_MANIFESTLIST_PLATFORM_KEY] + architecture = platform[DOCKER_SCHEMA2_MANIFESTLIST_ARCHITECTURE_KEY] + os = platform[DOCKER_SCHEMA2_MANIFESTLIST_OS_KEY] + if architecture == 'amd64' and os == 'linux': + return manifest + + return None diff --git a/image/docker/schema2/manifest.py b/image/docker/schema2/manifest.py index f316bcdbd..12b00956a 100644 --- a/image/docker/schema2/manifest.py +++ b/image/docker/schema2/manifest.py @@ -3,7 +3,6 @@ import logging import hashlib from collections import namedtuple - from jsonschema import validate as validate_schema, ValidationError from digest import digest_tools @@ -133,6 +132,10 @@ class DockerSchema2Manifest(object): except ValidationError as ve: raise MalformedSchema2Manifest('manifest data does not match schema: %s' % ve) + @property + def schema_version(self): + return 2 + @property def config(self): config = self._parsed[DOCKER_SCHEMA2_MANIFEST_CONFIG_KEY] @@ -160,7 +163,7 @@ class DockerSchema2Manifest(object): except digest_tools.InvalidDigestException: raise MalformedSchema2Manifest('could not parse manifest digest: %s' % layer[DOCKER_SCHEMA2_MANIFEST_DIGEST_KEY]) - + yield DockerV2ManifestLayer(index=index, size=layer[DOCKER_SCHEMA2_MANIFEST_SIZE_KEY], digest=digest, @@ -192,6 +195,10 @@ class DockerSchema2Manifest(object): digest SHA, returns the associated configuration JSON bytes for this schema. """ config_bytes = lookup_config_fn(self.config.digest) + if len(config_bytes) != self.config.size: + raise MalformedSchema2Manifest('Size of config does not match that retrieved: %s vs %s', + len(config_bytes), self.config.size) + schema2_config = DockerSchema2Config(config_bytes) # Build the V1 IDs for the layers. diff --git a/image/docker/schema2/test/test_list.py b/image/docker/schema2/test/test_list.py new file mode 100644 index 000000000..c2f98ff7d --- /dev/null +++ b/image/docker/schema2/test/test_list.py @@ -0,0 +1,70 @@ +import json +import pytest + +from image.docker.schema1 import DockerSchema1Manifest +from image.docker.schema2.manifest import DockerSchema2Manifest +from image.docker.schema2.list import MalformedSchema2ManifestList, DockerSchema2ManifestList +from image.docker.schema2.test.test_manifest import MANIFEST_BYTES as v22_bytes +from image.docker.test.test_schema1 import MANIFEST_BYTES as v21_bytes + +@pytest.mark.parametrize('json_data', [ + '', + '{}', + """ + { + "unknown": "key" + } + """, +]) +def test_malformed_manifest_lists(json_data): + with pytest.raises(MalformedSchema2ManifestList): + DockerSchema2ManifestList(json_data) + + +MANIFESTLIST_BYTES = json.dumps({ + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json", + "manifests": [ + { + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "size": 983, + "digest": "sha256:e6", + "platform": { + "architecture": "ppc64le", + "os": "linux", + } + }, + { + "mediaType": "application/vnd.docker.distribution.manifest.v1+json", + "size": 878, + "digest": "sha256:5b", + "platform": { + "architecture": "amd64", + "os": "linux", + "features": [ + "sse4" + ] + } + } + ] +}) + +def test_valid_manifestlist(): + def _get_manifest(digest): + if digest == 'sha256:e6': + return v22_bytes + else: + return v21_bytes + + manifestlist = DockerSchema2ManifestList(MANIFESTLIST_BYTES) + assert len(manifestlist.manifests(_get_manifest)) == 2 + + for index, manifest in enumerate(manifestlist.manifests(_get_manifest)): + if index == 0: + assert isinstance(manifest.manifest_obj, DockerSchema2Manifest) + assert manifest.manifest_obj.schema_version == 2 + else: + assert isinstance(manifest.manifest_obj, DockerSchema1Manifest) + assert manifest.manifest_obj.schema_version == 1 + + assert manifestlist.get_v1_compatible_manifest(_get_manifest).manifest_obj.schema_version == 1 diff --git a/image/docker/schema2/test/test_manifest.py b/image/docker/schema2/test/test_manifest.py index 89ad35e29..b365ab5b3 100644 --- a/image/docker/schema2/test/test_manifest.py +++ b/image/docker/schema2/test/test_manifest.py @@ -25,7 +25,7 @@ MANIFEST_BYTES = json.dumps({ "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "config": { "mediaType": "application/vnd.docker.container.image.v1+json", - "size": 7023, + "size": 1885, "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7" }, "layers": [ @@ -55,7 +55,7 @@ MANIFEST_BYTES = json.dumps({ def test_valid_manifest(): manifest = DockerSchema2Manifest(MANIFEST_BYTES) - assert manifest.config.size == 7023 + assert manifest.config.size == 1885 assert str(manifest.config.digest) == 'sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7' assert len(manifest.layers) == 4 diff --git a/image/docker/test/__init__.py b/image/docker/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/image/docker/test/test_schema1.py b/image/docker/test/test_schema1.py index 509709318..96ae2d12b 100644 --- a/image/docker/test/test_schema1.py +++ b/image/docker/test/test_schema1.py @@ -17,53 +17,50 @@ def test_malformed_manifests(json_data): DockerSchema1Manifest(json_data) -@pytest.mark.parametrize('namespace', [ - '', - 'somenamespace', -]) -def test_valid_manifest(namespace): - manifest_bytes = json.dumps({ - "name": namespace + "/hello-world" if namespace else 'hello-world', - "tag": "latest", - "architecture": "amd64", - "fsLayers": [ - { - "blobSum": "sha256:cc8567d70002e957612902a8e985ea129d831ebe04057d88fb644857caa45d11" - }, - { - "blobSum": "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef" - } - ], - "history": [ - { - "v1Compatibility": "{\"id\":\"someid\", \"parent\": \"anotherid\"}" - }, - { - "v1Compatibility": "{\"id\":\"anotherid\"}" - }, - ], - "schemaVersion": 1, - "signatures": [ - { - "header": { - "jwk": { - "crv": "P-256", - "kid": "OD6I:6DRK:JXEJ:KBM4:255X:NSAA:MUSF:E4VM:ZI6W:CUN2:L4Z6:LSF4", - "kty": "EC", - "x": "3gAwX48IQ5oaYQAYSxor6rYYc_6yjuLCjtQ9LUakg4A", - "y": "t72ge6kIA1XOjqjVoEOiPPAURltJFBMGDSQvEGVB010" - }, - "alg": "ES256" +MANIFEST_BYTES = json.dumps({ + "name": 'hello-world', + "tag": "latest", + "architecture": "amd64", + "fsLayers": [ + { + "blobSum": "sha256:cc8567d70002e957612902a8e985ea129d831ebe04057d88fb644857caa45d11" + }, + { + "blobSum": "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef" + } + ], + "history": [ + { + "v1Compatibility": "{\"id\":\"someid\", \"parent\": \"anotherid\"}" + }, + { + "v1Compatibility": "{\"id\":\"anotherid\"}" + }, + ], + "schemaVersion": 1, + "signatures": [ + { + "header": { + "jwk": { + "crv": "P-256", + "kid": "OD6I:6DRK:JXEJ:KBM4:255X:NSAA:MUSF:E4VM:ZI6W:CUN2:L4Z6:LSF4", + "kty": "EC", + "x": "3gAwX48IQ5oaYQAYSxor6rYYc_6yjuLCjtQ9LUakg4A", + "y": "t72ge6kIA1XOjqjVoEOiPPAURltJFBMGDSQvEGVB010" }, - "signature": "XREm0L8WNn27Ga_iE_vRnTxVMhhYY0Zst_FfkKopg6gWSoTOZTuW4rK0fg_IqnKkEKlbD83tD46LKEGi5aIVFg", - "protected": "eyJmb3JtYXRMZW5ndGgiOjY2MjgsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAxNS0wNC0wOFQxODo1Mjo1OVoifQ" - } - ] - }) + "alg": "ES256" + }, + "signature": "XREm0L8WNn27Ga_iE_vRnTxVMhhYY0Zst_FfkKopg6gWSoTOZTuW4rK0fg_IqnKkEKlbD83tD46LKEGi5aIVFg", + "protected": "eyJmb3JtYXRMZW5ndGgiOjY2MjgsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAxNS0wNC0wOFQxODo1Mjo1OVoifQ" + } + ] +}) - manifest = DockerSchema1Manifest(manifest_bytes, validate=False) + +def test_valid_manifest(): + manifest = DockerSchema1Manifest(MANIFEST_BYTES, validate=False) assert len(manifest.signatures) == 1 - assert manifest.namespace == namespace + assert manifest.namespace == '' assert manifest.repo_name == 'hello-world' assert manifest.tag == 'latest' assert manifest.image_ids == {'someid', 'anotherid'}