├── tests ├── __init__.py ├── api │ ├── __init__.py │ └── test_images.py ├── views │ ├── __init__.py │ ├── test_v2.py │ ├── test_users.py │ ├── test_ping.py │ ├── test_cdn.py │ ├── base.py │ ├── test_search.py │ ├── test_images.py │ ├── test_path.py │ └── test_repositories.py ├── search │ ├── __init__.py │ ├── test_init.py │ ├── test_gsa.py │ └── test_base.py ├── data │ ├── published_good │ │ ├── v1 │ │ │ └── foo │ │ │ │ └── abc123 │ │ │ │ ├── json │ │ │ │ ├── layer │ │ │ │ └── ancestry │ │ └── v2 │ │ │ └── zoo │ │ │ ├── manifests │ │ │ ├── 1 │ │ │ │ └── sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf │ │ │ ├── 2 │ │ │ │ └── sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf │ │ │ └── list │ │ │ │ └── latest │ │ │ └── blobs │ │ │ └── sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf │ ├── gsa │ │ ├── crane.conf │ │ ├── rhel70_no_desc.xml │ │ └── rhel70.xml │ ├── solr │ │ └── crane.conf │ ├── serve_content │ │ ├── crane_no_path.conf │ │ └── crane.conf │ ├── demo_config.conf │ ├── metadata_good │ │ ├── foo_v2.json │ │ ├── bar_v2.json │ │ ├── protected.json │ │ ├── registry.json │ │ ├── foo_v3.json │ │ ├── nop.json │ │ ├── bar.json │ │ ├── baz.json │ │ ├── qux.json │ │ ├── foo.json │ │ └── zoo_v4.json │ ├── v2 │ │ ├── metadata_good │ │ │ └── bar.json │ │ ├── metadata_good_v3 │ │ │ └── foo_v3.json │ │ ├── metadata_bad │ │ │ └── wrong_version.json │ │ └── metadata_good_v4 │ │ │ └── zoo_v4.json │ ├── metadata_bad │ │ └── wrong_version.json │ ├── test_no_entitlement.pem │ └── test_entitlement.pem ├── test_wsgi.py ├── demo_data.py ├── test_app.py └── test_config.py ├── crane ├── api │ ├── __init__.py │ ├── images.py │ └── repository.py ├── views │ ├── __init__.py │ ├── crane.py │ ├── v1.py │ └── v2.py ├── __init__.py ├── wsgi.py ├── static │ ├── img │ │ └── crane.png │ ├── fonts │ │ ├── FontAwesome.otf │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.ttf │ │ ├── fontawesome-webfont.woff │ │ ├── OpenSans-Bold-webfont.eot │ │ ├── OpenSans-Bold-webfont.ttf │ │ ├── OpenSans-Bold-webfont.woff │ │ ├── OpenSans-Italic-webfont.eot │ │ ├── OpenSans-Italic-webfont.ttf │ │ ├── OpenSans-Light-webfont.eot │ │ ├── OpenSans-Light-webfont.ttf │ │ ├── OpenSans-Light-webfont.woff │ │ ├── PatternFlyIcons-webfont.eot │ │ ├── PatternFlyIcons-webfont.ttf │ │ ├── OpenSans-Italic-webfont.woff │ │ ├── OpenSans-Regular-webfont.eot │ │ ├── OpenSans-Regular-webfont.ttf │ │ ├── OpenSans-Regular-webfont.woff │ │ ├── OpenSans-Semibold-webfont.eot │ │ ├── OpenSans-Semibold-webfont.ttf │ │ ├── PatternFlyIcons-webfont.woff │ │ ├── OpenSans-BoldItalic-webfont.eot │ │ ├── OpenSans-BoldItalic-webfont.ttf │ │ ├── OpenSans-BoldItalic-webfont.woff │ │ ├── OpenSans-ExtraBold-webfont.eot │ │ ├── OpenSans-ExtraBold-webfont.ttf │ │ ├── OpenSans-ExtraBold-webfont.woff │ │ ├── OpenSans-LightItalic-webfont.eot │ │ ├── OpenSans-LightItalic-webfont.ttf │ │ ├── OpenSans-Semibold-webfont.woff │ │ ├── OpenSans-LightItalic-webfont.woff │ │ ├── OpenSans-ExtraBoldItalic-webfont.eot │ │ ├── OpenSans-ExtraBoldItalic-webfont.ttf │ │ ├── OpenSans-ExtraBoldItalic-webfont.woff │ │ ├── OpenSans-SemiboldItalic-webfont.eot │ │ ├── OpenSans-SemiboldItalic-webfont.ttf │ │ └── OpenSans-SemiboldItalic-webfont.woff │ ├── css │ │ └── bootstrap-treeview.min.css │ └── js │ │ ├── patternfly.min.js │ │ └── bootstrap-treeview.min.js ├── data │ └── default_config.conf ├── exceptions.py ├── search │ ├── __init__.py │ ├── solr.py │ ├── gsa.py │ └── base.py ├── templates │ ├── repositories.html │ └── layout.html ├── app.py ├── config.py ├── data.py └── app_util.py ├── deployment ├── crane.wsgi ├── crane_el6.wsgi ├── apache24.conf └── apache22.conf ├── requirements.txt ├── .coveragerc ├── run.py ├── test-requirements.txt ├── .travis ├── requirements.txt └── install_m2crypto.sh ├── setup.cfg ├── AUTHORS ├── COPYRIGHT ├── .gitignore ├── .travis.yml ├── setup.py ├── Dockerfile ├── README.rst ├── COMMITMENT └── docs ├── Makefile └── conf.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crane/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crane/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/search/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crane/__init__.py: -------------------------------------------------------------------------------- 1 | version = '3.3a1' 2 | -------------------------------------------------------------------------------- /tests/data/published_good/v1/foo/abc123/json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /tests/data/published_good/v1/foo/abc123/layer: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /tests/data/published_good/v1/foo/abc123/ancestry: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /deployment/crane.wsgi: -------------------------------------------------------------------------------- 1 | from crane.wsgi import application 2 | -------------------------------------------------------------------------------- /tests/data/gsa/crane.conf: -------------------------------------------------------------------------------- 1 | [gsa] 2 | url: http://foo/bar 3 | -------------------------------------------------------------------------------- /tests/data/published_good/v2/zoo/manifests/list/latest: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /tests/data/solr/crane.conf: -------------------------------------------------------------------------------- 1 | [solr] 2 | url: http://foo/bar 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask>=0.9 2 | # for pkg_resources 3 | setuptools 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = crane 3 | 4 | [report] 5 | show_missing = True 6 | -------------------------------------------------------------------------------- /crane/wsgi.py: -------------------------------------------------------------------------------- 1 | from .app import create_app 2 | 3 | application = create_app() 4 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from crane.app import create_app 2 | 3 | create_app().run(port=5001) 4 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | flake8 3 | mock<1.1 4 | nose 5 | unittest2 6 | -------------------------------------------------------------------------------- /crane/static/img/crane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/img/crane.png -------------------------------------------------------------------------------- /tests/data/serve_content/crane_no_path.conf: -------------------------------------------------------------------------------- 1 | [serve_content] 2 | enable: true 3 | content_dir_v1: 4 | -------------------------------------------------------------------------------- /tests/data/demo_config.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | debug: true 3 | 4 | [gsa] 5 | url: http://pulpproject.org/search 6 | -------------------------------------------------------------------------------- /crane/static/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /tests/data/published_good/v2/zoo/blobs/sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /tests/data/published_good/v2/zoo/manifests/1/sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /tests/data/published_good/v2/zoo/manifests/2/sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /crane/static/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /crane/static/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /crane/static/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /.travis/requirements.txt: -------------------------------------------------------------------------------- 1 | # Requirements that are specific to the travis build environment 2 | python-dateutil>=1.5 3 | iniparse>=0.3.1 4 | -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Bold-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Bold-webfont.eot -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Bold-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Bold-webfont.ttf -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Bold-webfont.woff -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Italic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Italic-webfont.eot -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Italic-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Italic-webfont.ttf -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Light-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Light-webfont.eot -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Light-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Light-webfont.ttf -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Light-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Light-webfont.woff -------------------------------------------------------------------------------- /crane/static/fonts/PatternFlyIcons-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/PatternFlyIcons-webfont.eot -------------------------------------------------------------------------------- /crane/static/fonts/PatternFlyIcons-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/PatternFlyIcons-webfont.ttf -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = ./docs/conf.py 3 | # E401: multiple imports on one line 4 | ignore = E401 5 | max-line-length = 100 6 | -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Italic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Italic-webfont.woff -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Regular-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Regular-webfont.eot -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Regular-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Regular-webfont.ttf -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Regular-webfont.woff -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Semibold-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Semibold-webfont.eot -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Semibold-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Semibold-webfont.ttf -------------------------------------------------------------------------------- /crane/static/fonts/PatternFlyIcons-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/PatternFlyIcons-webfont.woff -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-BoldItalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-BoldItalic-webfont.eot -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-BoldItalic-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-BoldItalic-webfont.ttf -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-BoldItalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-BoldItalic-webfont.woff -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-ExtraBold-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-ExtraBold-webfont.eot -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-ExtraBold-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-ExtraBold-webfont.ttf -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-ExtraBold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-ExtraBold-webfont.woff -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-LightItalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-LightItalic-webfont.eot -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-LightItalic-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-LightItalic-webfont.ttf -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-Semibold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-Semibold-webfont.woff -------------------------------------------------------------------------------- /crane/static/css/bootstrap-treeview.min.css: -------------------------------------------------------------------------------- 1 | .list-group-item{cursor:pointer}span.indent{margin-left:10px;margin-right:10px}span.icon{margin-right:5px} -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-LightItalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-LightItalic-webfont.woff -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-ExtraBoldItalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-ExtraBoldItalic-webfont.eot -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-ExtraBoldItalic-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-ExtraBoldItalic-webfont.ttf -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-ExtraBoldItalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-ExtraBoldItalic-webfont.woff -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-SemiboldItalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-SemiboldItalic-webfont.eot -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-SemiboldItalic-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-SemiboldItalic-webfont.ttf -------------------------------------------------------------------------------- /crane/static/fonts/OpenSans-SemiboldItalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulp/crane/HEAD/crane/static/fonts/OpenSans-SemiboldItalic-webfont.woff -------------------------------------------------------------------------------- /tests/data/serve_content/crane.conf: -------------------------------------------------------------------------------- 1 | [serve_content] 2 | enable: true 3 | content_dir_v1: /the/content/dir/web/v1/ 4 | content_dir_v2: /the/content/dir/web/v2/ 5 | -------------------------------------------------------------------------------- /tests/data/metadata_good/foo_v2.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "foo", 3 | "repo-registry-id": "redhat/foo", 4 | "url": "http://cdn.redhat.com/foo/bar", 5 | "version": 2 6 | } 7 | -------------------------------------------------------------------------------- /tests/data/metadata_good/bar_v2.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "v2bar", 3 | "repo-registry-id": "v2/bar", 4 | "url": "http://cdn.redhat.com/bar/baz/images/", 5 | "version": 2 6 | } 7 | -------------------------------------------------------------------------------- /tests/data/v2/metadata_good/bar.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "bar", 3 | "repo-registry-id": "bar", 4 | "url": "http://cdn.redhat.com/bar/baz/images", 5 | "version": 2 6 | } 7 | -------------------------------------------------------------------------------- /tests/data/metadata_good/protected.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "protected", 3 | "repo-registry-id": "protected", 4 | "url": "http://cdn.redhat.com/bar/baz/images", 5 | "version": 2, 6 | "protected": true 7 | } -------------------------------------------------------------------------------- /tests/data/metadata_good/registry.json: -------------------------------------------------------------------------------- 1 | {"repo-registry-id": "registry", "repository": "registry", "url": 2 | "https://oh-hey-beav-whats-up.os1.phx2.redhat.com/pulp/docker/v2/registry/", 3 | "version": 2, "protected": false, "type": "pulp-docker-redirect"} -------------------------------------------------------------------------------- /deployment/crane_el6.wsgi: -------------------------------------------------------------------------------- 1 | # see /usr/share/doc/python-jinja2-26-2.6/README.Fedora for details on why 2 | # this unpleasantry is necessary. 3 | import sys 4 | sys.path.insert(0, '/usr/lib/python2.6/site-packages/Jinja2-2.6-py2.6.egg') 5 | 6 | from crane.wsgi import application 7 | -------------------------------------------------------------------------------- /tests/data/metadata_good/foo_v3.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "foo", 3 | "repo-registry-id": "redhat/foo", 4 | "url": "http://cdn.redhat.com/foo/bar", 5 | "version": 3, 6 | "schema2_data": ["sha256:a1d963a97357110bdbfc70767a495c8df6ddfa9bda4da3183165ca73c3b990d2", 7 | "1.25.1-musl", "1.25.0-glibc"] 8 | } 9 | -------------------------------------------------------------------------------- /tests/data/metadata_good/nop.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "id": "def4561" 5 | } 6 | ], 7 | "repository": "nop", 8 | "repo-registry-id": "nop", 9 | "tags": { 10 | "ab": "def456" 11 | }, 12 | "url": "http://cdn.redhat.com/nop/baz/images", 13 | "version": 1 14 | } 15 | -------------------------------------------------------------------------------- /tests/data/v2/metadata_good_v3/foo_v3.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "foo", 3 | "repo-registry-id": "redhat/foo", 4 | "url": "http://cdn.redhat.com/foo/bar", 5 | "version": 3, 6 | "schema2_data": ["sha256:a1d963a97357110bdbfc70767a495c8df6ddfa9bda4da3183165ca73c3b990d2", 7 | "1.25.1-musl", "1.25.0-glibc"] 8 | } 9 | -------------------------------------------------------------------------------- /tests/test_wsgi.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from flask import Flask 4 | import mock 5 | 6 | 7 | @mock.patch('crane.app.init_logging') 8 | class TestWSGI(unittest.TestCase): 9 | def test_application_exists(self, mock_init_logging): 10 | from crane import wsgi 11 | self.assertTrue(isinstance(wsgi.application, Flask)) 12 | -------------------------------------------------------------------------------- /tests/data/metadata_bad/wrong_version.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "id": "abc123" 5 | }, 6 | { 7 | "id": "xyz789" 8 | } 9 | ], 10 | "repository": "foo", 11 | "repo-registry-id": "redhat/foo", 12 | "url": "http://cdn.redhat.com/foo/bar/images/", 13 | "version": -1 14 | } 15 | -------------------------------------------------------------------------------- /tests/data/metadata_good/bar.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "id": "def456" 5 | } 6 | ], 7 | "repository": "bar", 8 | "repo-registry-id": "bar", 9 | "tags": { 10 | "latest": "def456", 11 | "test": "ghi789" 12 | }, 13 | "url": "http://cdn.redhat.com/bar/baz/images", 14 | "version": 1 15 | } 16 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Barnaby Court (bcourt@redhat.com) 2 | Michael Hrivnak (mhrivnak@redhat.com) 3 | Sayli Karmarkar (skarmark@redhat.com) 4 | Bryan Kearney (bryan.kearney@gmail.com) 5 | Dennis Kliban (dkliban@redhat.com) 6 | Ina Panova (ipanova@redhat.com) 7 | Aaron Weitekamp (aweiteka@redhat.com) 8 | A.P. Rajshekhar (randalap@redhat.com) 9 | Mihai Ibanescu (mihai.ibanescu@gmail.com) 10 | -------------------------------------------------------------------------------- /tests/data/metadata_good/baz.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "id": "baz123" 5 | } 6 | ], 7 | "repository": "baz", 8 | "repo-registry-id": "baz", 9 | "protected": true, 10 | "tags": { 11 | "latest": "baz123" 12 | }, 13 | "url": "http://cdn.redhat.com/foo/path/bar/baz/images", 14 | "version": 1 15 | } 16 | -------------------------------------------------------------------------------- /tests/data/metadata_good/qux.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "id": "qux123" 5 | } 6 | ], 7 | "repository": "qux", 8 | "repo-registry-id": "qux", 9 | "protected": true, 10 | "tags": { 11 | "latest": "qux123" 12 | }, 13 | "url": "http://cdn.redhat.com/some/invalid/path/qux/images", 14 | "version": 1 15 | } 16 | -------------------------------------------------------------------------------- /tests/data/v2/metadata_bad/wrong_version.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "id": "abc123" 5 | }, 6 | { 7 | "id": "xyz789" 8 | } 9 | ], 10 | "repository": "foo", 11 | "repo-registry-id": "redhat/foo", 12 | "tags": { 13 | "latest": "abc123" 14 | }, 15 | "url": "http://cdn.redhat.com/foo/bar/images/", 16 | "version": -1 17 | } 18 | -------------------------------------------------------------------------------- /tests/data/metadata_good/foo.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "id": "abc123" 5 | }, 6 | { 7 | "id": "xyz789" 8 | } 9 | ], 10 | "repository": "foo", 11 | "repo-registry-id": "redhat/foo", 12 | "tags": { 13 | "latest": "abc123", 14 | "test": "def234" 15 | }, 16 | "url": "http://cdn.redhat.com/foo/bar/images/", 17 | "version": 1 18 | } -------------------------------------------------------------------------------- /crane/data/default_config.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | debug: false 3 | data_dir: /var/lib/crane/metadata/ 4 | data_dir_polling_interval: 60 5 | endpoint: 6 | 7 | [cdn] 8 | url_match: 9 | url_replace: 10 | url_auth_secret: 11 | url_auth_ttl: 300 12 | url_auth_param: _auth_ 13 | url_auth_algo: sha256 14 | 15 | [serve_content] 16 | enable: false 17 | content_dir_v1: /var/www/pub/docker/v1/web/ 18 | content_dir_v2: /var/www/pub/docker/v2/web/ 19 | use_x_sendfile: true 20 | 21 | [gsa] 22 | url: 23 | 24 | [solr] 25 | url: 26 | -------------------------------------------------------------------------------- /COPYRIGHT: -------------------------------------------------------------------------------- 1 | Copyright © 2014 Pulp Project developers. 2 | 3 | This software is licensed to you under the GNU General Public 4 | License as published by the Free Software Foundation; either version 5 | 2 of the License (GPLv2) or (at your option) any later version. 6 | There is NO WARRANTY for this software, express or implied, 7 | including the implied warranties of MERCHANTABILITY, 8 | NON-INFRINGEMENT, or FITNESS FOR A PARTICULAR PURPOSE. You should 9 | have received a copy of GPLv2 along with this software; if not, see 10 | http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. 11 | -------------------------------------------------------------------------------- /tests/views/test_v2.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import unittest2 3 | 4 | from crane.views import v2 5 | 6 | 7 | class UtilTest(unittest2.TestCase): 8 | def test_get_accept_headers(self): 9 | tests = [ 10 | (dict(), set()), 11 | (dict(Accept="a"), set(["a"])), 12 | (dict(Accept="a, b"), set(["a", "b"])), 13 | (dict(Accept="a,b"), set(["a", "b"])), 14 | (dict(Accept=" a , b "), set(["a", "b"])), 15 | (dict(Accept="a; q=1, b"), set(["a", "b"])), 16 | ] 17 | req = mock.MagicMock() 18 | for headers, expected in tests: 19 | req.headers = headers 20 | self.assertEquals(expected, v2.get_accept_headers(req)) 21 | -------------------------------------------------------------------------------- /crane/exceptions.py: -------------------------------------------------------------------------------- 1 | class HTTPError(Exception): 2 | def __init__(self, status_code, message=''): 3 | """ 4 | Raise this exception to return an http response indicating an error. 5 | 6 | :param status_code: HTTP status code. It's a good idea to get this straight 7 | from httplib, such as httplib.NOT_FOUND 8 | :type status_code: int 9 | :param message: optional error message to be put in the response 10 | body. If not supplied, the default message for the 11 | status code will be used. 12 | """ 13 | super(HTTPError, self).__init__() 14 | self.message = message 15 | self.status_code = status_code 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # pycharm 56 | .idea/ 57 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "pypy" 5 | before_install: 6 | - sudo apt-get update -qq 7 | - sudo apt-get install -y build-essential python-dev openssl libevent-dev python-pip liblzma-dev libssl-dev python-m2crypto swig 8 | # Create a fake RPM package so that the python-rhsm certificate class will load properly 9 | - mkdir rpm 10 | - touch rpm/__init__.py 11 | - .travis/install_m2crypto.sh 12 | install: 13 | - "pip install ." 14 | - "pip install -r test-requirements.txt" 15 | - "pip install -r .travis/requirements.txt" 16 | - "pip install coveralls" 17 | - "pip install git+https://github.com/barnabycourt/python-rhsm.git" 18 | before_script: 19 | - flake8 20 | script: nosetests --with-coverage --cover-package crane --cover-min-percentage 95 --cover-erase 21 | after_success: coveralls 22 | -------------------------------------------------------------------------------- /tests/data/metadata_good/zoo_v4.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "zoo", 3 | "repo-registry-id": "redhat/zoo", 4 | "url": "http://cdn.redhat.com/zoo/bar", 5 | "version": 4, 6 | "schema2_data": ["sha256:a1d963a97357110bdbfc70767a495c8df6ddfa9bda4da3183165ca73c3b990d2", 7 | "1.25.1-musl", "1.25.0-glibc"], 8 | "manifest_list_amd64_tags": {"bar": ["sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf", 2], 9 | "latest": ["sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf", 1]}, 10 | "protected": false, 11 | "type": "pulp-docker-redirect", 12 | "manifest_list_data": ["bar", "sha256:3e0e8f106ef40081ed9977a432dcf1720e4a6af101b243686b3dc548540e7ca8", 13 | "sha256:a90b7a658d44eadc569a296d45217115e61add1a7ae0958f084841c5f3ce7956", "latest"] 14 | } 15 | -------------------------------------------------------------------------------- /tests/data/v2/metadata_good_v4/zoo_v4.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "zoo", 3 | "repo-registry-id": "redhat/zoo", 4 | "url": "http://cdn.redhat.com/zoo/bar", 5 | "version": 4, 6 | "schema2_data": ["sha256:a1d963a97357110bdbfc70767a495c8df6ddfa9bda4da3183165ca73c3b990d2", 7 | "1.25.1-musl", "1.25.0-glibc"], 8 | "manifest_list_amd64_tags": {"bar": ["sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf", 2], 9 | "latest": ["sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf", 1]}, 10 | "protected": false, 11 | "type": "pulp-docker-redirect", 12 | "manifest_list_data": ["bar", "sha256:3e0e8f106ef40081ed9977a432dcf1720e4a6af101b243686b3dc548540e7ca8", 13 | "sha256:a90b7a658d44eadc569a296d45217115e61add1a7ae0958f084841c5f3ce7956", "latest"] 14 | } 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | import crane 5 | 6 | 7 | _abs_dir = os.path.dirname(os.path.abspath(__file__)) 8 | _req_path = os.path.join(_abs_dir, 'requirements.txt') 9 | requirements = open(_req_path).read() 10 | _test_req_path = os.path.join(_abs_dir, 'test-requirements.txt') 11 | test_requirements = open(_test_req_path).read() 12 | 13 | 14 | setup( 15 | name='crane', 16 | version=crane.version, 17 | packages=find_packages(exclude=['tests', 'tests.*']), 18 | url='http://www.pulpproject.org', 19 | license='GPLv2+', 20 | author='Pulp Team', 21 | author_email='pulp-list@redhat.com', 22 | description='docker-registry-like API with redirection, as a wsgi app', 23 | install_requires=requirements, 24 | tests_require=test_requirements, 25 | test_suite='unittest2.collector', 26 | package_data={ 27 | 'crane': ['data/*.conf', 'templates/*.html', 'static/css/*', 'static/js/*', 28 | 'static/fonts/*', 'static/img/*'] 29 | }, 30 | ) 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This runs crane (http://github.com/pulp/crane) on centos7 2 | # 3 | # Example usage: 4 | # $ sudo docker run -p 5000:80 -v /home/you/cranedata:/var/lib/crane/metadata pulp/crane 5 | 6 | FROM centos:centos7 7 | MAINTAINER Pulp Team 8 | 9 | RUN yum -y install epel-release 10 | 11 | RUN yum update -y 12 | 13 | RUN yum install -y python-flask python-pip httpd mod_wsgi python-rhsm 14 | 15 | RUN mkdir -p /var/lib/crane/metadata/ 16 | 17 | ADD deployment/apache24.conf /etc/httpd/conf.d/crane.conf 18 | ADD deployment/crane.wsgi /usr/share/crane/crane.wsgi 19 | 20 | ADD crane /usr/local/src/crane/crane 21 | ADD setup.py /usr/local/src/crane/ 22 | ADD setup.cfg /usr/local/src/crane/ 23 | ADD requirements.txt /usr/local/src/crane/ 24 | ADD test-requirements.txt /usr/local/src/crane/ 25 | 26 | ADD LICENSE /usr/share/doc/python-crane/ 27 | ADD COPYRIGHT /usr/share/doc/python-crane/ 28 | ADD README.rst /usr/share/doc/python-crane/ 29 | 30 | RUN pip install /usr/local/src/crane/ 31 | 32 | ENV APACHE_RUN_USER apache 33 | ENV APACHE_RUN_GROUP apache 34 | 35 | EXPOSE 80 36 | 37 | ENTRYPOINT ["/usr/sbin/httpd"] 38 | CMD ["-D", "FOREGROUND"] 39 | -------------------------------------------------------------------------------- /crane/search/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .. import config 4 | from .base import SearchBackend 5 | from .gsa import GSA 6 | from .solr import Solr 7 | 8 | 9 | _logger = logging.getLogger(__name__) 10 | 11 | 12 | # default to a backend that will always return 404 13 | backend = SearchBackend() 14 | 15 | 16 | def load_config(app): 17 | """ 18 | parse the search config and instantiate a search backend if one is 19 | configured. sets the global "backend" value to be the new backend. 20 | 21 | :param app: flask application 22 | :type app: flask.Flask 23 | """ 24 | global backend 25 | 26 | gsa_url = app.config.get(config.SECTION_GSA, {}).get(config.KEY_URL) 27 | if gsa_url: 28 | backend = GSA(gsa_url) 29 | _logger.info('using GSA search backend') 30 | return 31 | solr_url = app.config.get(config.SECTION_SOLR, {}).get(config.KEY_URL) 32 | if solr_url: 33 | backend = Solr(solr_url) 34 | _logger.info('using solr search backend') 35 | return 36 | 37 | # reset to default if the config previously had one configured, but changed. 38 | _logger.info('no search backend configured') 39 | backend = SearchBackend() 40 | -------------------------------------------------------------------------------- /.travis/install_m2crypto.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -xe 2 | 3 | # This is based on a suggestion in a github comment: 4 | # https://github.com/travis-ci/travis-ci/issues/721#issuecomment-17197098 5 | 6 | # openssl 1.0 does not have sslv2, which is not disabled in m2crypto 7 | # therefore this workaround is required 8 | 9 | PATCH=" 10 | --- SWIG/_ssl.i 2015-11-03 15:54:40.942000000 -0500 11 | +++ SWIG/_ssl.i 2015-11-03 15:55:17.810000000 -0500 12 | @@ -48,8 +48,10 @@ 13 | %rename(ssl_get_alert_desc_v) SSL_alert_desc_string_long; 14 | extern const char *SSL_alert_desc_string_long(int); 15 | 16 | +#ifndef OPENSSL_NO_SSL2 17 | %rename(sslv2_method) SSLv2_method; 18 | extern SSL_METHOD *SSLv2_method(void); 19 | +#endif 20 | %rename(sslv3_method) SSLv3_method; 21 | extern SSL_METHOD *SSLv3_method(void); 22 | %rename(sslv23_method) SSLv23_method;" 23 | 24 | sudo ln -s /usr/include/x86_64-linux-gnu/openssl/opensslconf.h /usr/include/openssl/opensslconf.h 25 | 26 | pip install --download="." m2crypto==0.21.1 27 | tar -xf M2Crypto-*.tar.gz 28 | rm M2Crypto-*.tar.gz 29 | cd M2Crypto-* 30 | echo "$PATCH" | patch -p0 31 | python setup.py install 32 | cd .. 33 | # We need to clean up so that flake8 doesn't get upset. 34 | rm -rf M2Crypto-* 35 | -------------------------------------------------------------------------------- /tests/views/test_users.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | import mock 5 | 6 | import crane.app 7 | 8 | 9 | class TestUsers(unittest.TestCase): 10 | def setUp(self): 11 | with mock.patch('crane.app.init_logging'): 12 | self.app = crane.app.create_app().test_client() 13 | 14 | def test_post(self): 15 | response = self.app.post('/v1/users/') 16 | 17 | self.assertEqual(response.status_code, 201) 18 | self.assertEqual(response.headers['Content-Type'], 'application/json') 19 | self.assertEqual(response.headers['X-Docker-Registry-Config'], 'common') 20 | self.assertEqual(response.headers['X-Docker-Registry-Version'], '0.6.6') 21 | 22 | self.assertEqual(json.loads(response.data), 'User Created') 23 | 24 | def test_get(self): 25 | response = self.app.get('/v1/users/') 26 | 27 | self.assertEqual(response.status_code, 200) 28 | self.assertEqual(response.headers['Content-Type'], 'application/json') 29 | self.assertEqual(response.headers['X-Docker-Registry-Config'], 'common') 30 | self.assertEqual(response.headers['X-Docker-Registry-Version'], '0.6.6') 31 | 32 | self.assertEqual(json.loads(response.data), 'OK') 33 | -------------------------------------------------------------------------------- /tests/views/test_ping.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | import mock 5 | 6 | import crane.app 7 | 8 | 9 | class TestPing(unittest.TestCase): 10 | def setUp(self): 11 | with mock.patch('crane.app.init_logging'): 12 | self.app = crane.app.create_app().test_client() 13 | 14 | def test_response(self): 15 | response = self.app.get('/v1/_ping') 16 | 17 | self.assertEqual(response.status_code, 200) 18 | self.assertEqual(response.headers['Content-Type'], 'application/json') 19 | self.assertEqual(response.headers['X-Docker-Registry-Config'], 'common') 20 | self.assertEqual(response.headers['X-Docker-Registry-Standalone'], 'True') 21 | self.assertEqual(response.headers['X-Docker-Registry-Version'], '0.6.6') 22 | 23 | # the real docker-registry has only "True" as the body 24 | self.assertEqual(json.loads(response.data), True) 25 | 26 | def test_response_for_v2(self): 27 | response = self.app.get('/v2/') 28 | 29 | self.assertEqual(response.status_code, 200) 30 | self.assertEqual(response.headers['Content-Type'], 'application/json') 31 | self.assertEqual(response.headers['Docker-Distribution-API-Version'], 'registry/2.0') 32 | 33 | # the real docker-registry has only "{}" (empty json body) as the body 34 | self.assertEqual(json.loads(response.data), {}) 35 | -------------------------------------------------------------------------------- /tests/views/test_cdn.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | 4 | from crane import config 5 | from tests.views import base 6 | import mock 7 | 8 | mock_time = mock.Mock() 9 | mock_time.return_value = time.mktime(datetime(2020, 7, 4).timetuple()) 10 | 11 | 12 | class TestCDN(base.BaseCraneAPITest): 13 | def test_url_rewrite(self): 14 | self.app.config[config.SECTION_CDN][config.KEY_URL_MATCH] = 'cdn.redhat.com' 15 | self.app.config[config.SECTION_CDN][config.KEY_URL_REPLACE] = 'cdn.fedora.com' 16 | response = self.test_client.get('/v2/redhat/zoo/manifests/latest') 17 | 18 | self.assertEqual(response.status_code, 302) 19 | self.assertTrue(response.headers['Location'].startswith('http://cdn.fedora.com/zoo/bar')) 20 | 21 | @mock.patch('time.time', mock_time) 22 | def test_cdn_auth(self): 23 | self.app.config[config.SECTION_CDN][config.KEY_URL_AUTH_PARAM] = '_auth_' 24 | self.app.config[config.SECTION_CDN][config.KEY_URL_AUTH_TTL] = 600 25 | self.app.config[config.SECTION_CDN][config.KEY_URL_AUTH_SECRET] = 'abc123' 26 | response = self.test_client.get('/v2/redhat/zoo/manifests/latest') 27 | 28 | expected_time = int(time.time()) + 600 29 | self.assertEqual(response.status_code, 302) 30 | self.assertIn('?_auth_=', response.headers['Location']) 31 | self.assertIn('exp=%s~' % expected_time, response.headers['Location']) 32 | -------------------------------------------------------------------------------- /tests/demo_data.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | metadata_good_path = os.path.join(os.path.dirname(__file__), 'data/metadata_good/') 4 | metadata_bad_path = os.path.join(os.path.dirname(__file__), 'data/metadata_good/') 5 | metadata_good_path_v2 = os.path.join(os.path.dirname(__file__), 'data/v2/metadata_good/') 6 | metadata_good_path_v3 = os.path.join(os.path.dirname(__file__), 'data/v2/metadata_good_v3/') 7 | metadata_good_path_v4 = os.path.join(os.path.dirname(__file__), 'data/v2/metadata_good_v4/') 8 | metadata_bad_path_v2 = os.path.join(os.path.dirname(__file__), 'data/v2/metadata_bad/') 9 | 10 | demo_config_path = os.path.join(os.path.dirname(__file__), 'data/demo_config.conf') 11 | foo_metadata_path = os.path.join(os.path.dirname(__file__), 'data/metadata_good/foo.json') 12 | foo_v2_metadata_path = os.path.join(os.path.dirname(__file__), 'data/metadata_good/foo_v2.json') 13 | foo_v3_metadata_path = os.path.join(os.path.dirname(__file__), 'data/metadata_good/foo_v3.json') 14 | foo_v4_metadata_path = os.path.join(os.path.dirname(__file__), 'data/metadata_good/zoo_v4.json') 15 | wrong_version_path = os.path.join(os.path.dirname(__file__), 16 | 'data/metadata_bad/wrong_version.json') 17 | 18 | demo_entitlement_cert_path = os.path.join(os.path.dirname(__file__), 'data/test_entitlement.pem') 19 | demo_no_entitlement_cert_path = os.path.join(os.path.dirname(__file__), 20 | 'data/test_no_entitlement.pem') 21 | -------------------------------------------------------------------------------- /tests/search/test_init.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import unittest2 3 | 4 | import mock 5 | 6 | from crane import config, search 7 | from crane.search import SearchBackend, GSA, Solr 8 | 9 | 10 | class TestLoadConfig(unittest2.TestCase): 11 | def test_default(self): 12 | mock_app = mock.MagicMock() 13 | mock_app.config = {} 14 | 15 | search.load_config(mock_app) 16 | 17 | # make sure the default backend is used when no settings are present 18 | # in the config 19 | self.assertIsInstance(search.backend, SearchBackend) 20 | # make sure it is not a subclass of SearchBackend 21 | self.assertIs(inspect.getmro(search.backend.__class__)[0], SearchBackend) 22 | 23 | def test_gsa(self): 24 | mock_app = mock.MagicMock() 25 | fake_url = 'http://pulpproject.org/search' 26 | mock_app.config = { 27 | config.SECTION_GSA: {config.KEY_URL: fake_url}, 28 | } 29 | 30 | search.load_config(mock_app) 31 | 32 | self.assertIsInstance(search.backend, GSA) 33 | self.assertEqual(search.backend.url, fake_url) 34 | 35 | def test_solr(self): 36 | mock_app = mock.MagicMock() 37 | fake_url = 'http://pulpproject.org/search' 38 | mock_app.config = { 39 | config.SECTION_SOLR: {config.KEY_URL: fake_url}, 40 | } 41 | 42 | search.load_config(mock_app) 43 | 44 | self.assertIsInstance(search.backend, Solr) 45 | self.assertEqual(search.backend.url_template, fake_url) 46 | -------------------------------------------------------------------------------- /deployment/apache24.conf: -------------------------------------------------------------------------------- 1 | # Use this config with Apache 2.4 or later 2 | 3 | WSGIScriptAlias / /usr/share/crane/crane.wsgi 4 | 5 | Require host localhost 6 | 7 | # Uncomment this when using 'serve_content' 8 | # 9 | # Require all granted 10 | # XSendFile on 11 | # XSendFilePath /var/lib/crane/repos/ 12 | # 13 | # 14 | # Require all granted 15 | # XSendFile on 16 | # XSendFilePath /var/lib/crane/repos/ 17 | # 18 | 19 | Require all granted 20 | 21 | 22 | 23 | # 24 | # SSLEngine on 25 | # SSLCertificateFile /etc/pki/your_cert_here.crt 26 | # SSLCertificateKeyFile /etc/pki/your_cert_key_here.key 27 | # WSGIScriptAlias / /usr/share/crane/crane.wsgi 28 | # 29 | # Require host localhost 30 | # 31 | # Uncomment this when using 'serve_content' 32 | # 33 | # Require all granted 34 | # XSendFile on 35 | # XSendFilePath /var/lib/crane/repos/ 36 | # 37 | # 38 | # Require all granted 39 | # XSendFile on 40 | # XSendFilePath /var/lib/crane/repos/ 41 | # 42 | # 43 | # SSLVerifyClient optional_no_ca 44 | # SSLVerifyDepth 2 45 | # SSLOptions +StdEnvVars +ExportCertData +FakeBasicAuth 46 | # Require all granted 47 | # 48 | # 49 | -------------------------------------------------------------------------------- /crane/templates/repositories.html: -------------------------------------------------------------------------------- 1 | {# _crane/templates/repositories.html_ #} 2 | {% extends "layout.html" %} 3 | {% block title %} {{repo_type}} Repositories {% endblock %} 4 | {% block body %} 5 |
6 |
7 |
8 | {% if repos_json is defined %} 9 | {% for repo_name, repo_info in repos_json.iteritems() %} 10 |
11 |

Repository id: {{ repo_name }}

12 |

Protected: {{ repo_info.protected }}

13 |
14 | {% if repo_info.tags is defined %} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for tag, image_id in repo_info.tags.iteritems() %} 24 | 25 | 28 | 31 | 32 | {% endfor %} 33 | 34 |
TagImage Id
26 | {{ tag }} 27 | 29 | {{ image_id }} 30 |
35 | {% endif %} 36 |
37 | {% endfor %} 38 | {% endif %} 39 |
40 |
41 |
42 | 50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | :warning: ⛔️ Pulp2 is EOL as of November 30 2022, for more info visit this link https://pulpproject.org/2022/09/19/pulp-2-eol/. ⛔️ 2 | 3 | crane 4 | ===== 5 | 6 | .. image:: https://travis-ci.org/pulp/crane.svg?branch=master 7 | :target: https://travis-ci.org/pulp/crane 8 | 9 | .. image:: https://coveralls.io/repos/pulp/crane/badge.png?branch=master 10 | :target: https://coveralls.io/r/pulp/crane?branch=master 11 | 12 | What is Crane? 13 | -------------- 14 | 15 | Crane is a small read-only web application that provides enough of the docker 16 | registry API to support "docker pull". Crane supports two modes of operation: 17 | 18 | 1. Serve 302 redirects to some other location where files are 19 | being served. A base file location URL can be specified per-repository. 20 | This is the default mode. 21 | 2. Local content delivery. In this mode, Crane provides "X-Sendfile" headers 22 | to the Apache web server. Apache will deliver the static files including 23 | all its optimizations. 24 | 25 | Crane loads its data from json files stored on disk. It does not have a 26 | database or use any other services. The json files can be generated with pulp 27 | by publishing a docker repository. 28 | 29 | Crane is a flask app written in Python. It is very easy to deploy and has a 30 | small footprint, so it is a great way to provide a read-only "docker pull" API 31 | that redirects to a static file service. 32 | 33 | Advanced users can configure a search appliance to support "docker search" and 34 | can setup repository protection using SSL certificates. 35 | 36 | See the `current development documentation `_ 37 | for more information. 38 | -------------------------------------------------------------------------------- /crane/views/crane.py: -------------------------------------------------------------------------------- 1 | """ 2 | Non-public view for use by admins to see a list of repositories served by crane. 3 | """ 4 | from flask import Blueprint, current_app, json, render_template, request 5 | 6 | from .. import app_util 7 | 8 | 9 | section = Blueprint('crane', __name__, url_prefix='/crane') 10 | 11 | 12 | @section.route('/repositories') 13 | @section.route('/repositories/v1') 14 | def repositories(): 15 | """ 16 | Returns a json document containing a dictionary of v1 repositories served by crane 17 | and keyed by the repo-registry-id which is unique for each repository. 18 | 19 | :return: json string containing a list of docker repositories 20 | :rtype: basestring 21 | """ 22 | repos_json = app_util.get_repositories() 23 | if 'Accept' in request.headers and request.headers['Accept'] == 'application/json': 24 | response = current_app.make_response(json.dumps(repos_json)) 25 | response.headers['Content-Type'] = 'application/json' 26 | return response 27 | return render_template("repositories.html", repos_json=repos_json, repo_type='v1') 28 | 29 | 30 | @section.route('/repositories/v2') 31 | def repositories_v2(): 32 | """ 33 | Returns a json document containing a dictionary of v2 repositories served by crane 34 | and keyed by the repo-registry-id which is unique for each repository. 35 | 36 | :return: json string containing a list of docker repositories 37 | :rtype: basestring 38 | """ 39 | repos_json = app_util.get_v2_repositories() 40 | if 'Accept' in request.headers and request.headers['Accept'] == 'application/json': 41 | response = current_app.make_response(json.dumps(repos_json)) 42 | response.headers['Content-Type'] = 'application/json' 43 | return response 44 | return render_template("repositories.html", repos_json=repos_json, repo_type='v2') 45 | -------------------------------------------------------------------------------- /deployment/apache22.conf: -------------------------------------------------------------------------------- 1 | # Use this config with Apache 2.2 or earlier 2 | 3 | WSGIScriptAlias / /usr/share/crane/crane.wsgi 4 | 5 | Order Deny,Allow 6 | Deny from all 7 | Allow from localhost 8 | 9 | # Uncomment this when using 'serve_content' 10 | # 11 | # Order Allow,Deny 12 | # Allow from all 13 | # XSendFile on 14 | # XSendFilePath /var/lib/crane/repos/ 15 | # 16 | # 17 | # Order Allow,Deny 18 | # Allow from all 19 | # XSendFile on 20 | # XSendFilePath /var/lib/crane/repos/ 21 | # 22 | 23 | Order allow,deny 24 | Allow from all 25 | 26 | 27 | 28 | # 29 | # SSLEngine on 30 | # SSLCertificateFile /etc/pki/your_cert_here.crt 31 | # SSLCertificateKeyFile /etc/pki/your_cert_key_here.key 32 | # WSGIScriptAlias / /usr/share/crane/crane.wsgi 33 | # 34 | # Order Deny,Allow 35 | # Deny from all 36 | # Allow from localhost 37 | # 38 | # Uncomment this when using 'serve_content' 39 | # 40 | # Order Allow,Deny 41 | # Allow from all 42 | # XSendFile on 43 | # XSendFilePath /var/lib/crane/repos/ 44 | # 45 | # 46 | # Order Allow,Deny 47 | # Allow from all 48 | # XSendFile on 49 | # XSendFilePath /var/lib/crane/repos/ 50 | # 51 | # 52 | # SSLVerifyClient optional_no_ca 53 | # SSLVerifyDepth 2 54 | # SSLOptions +StdEnvVars +ExportCertData +FakeBasicAuth 55 | # Order allow,deny 56 | # Allow from all 57 | # 58 | # 59 | -------------------------------------------------------------------------------- /tests/views/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import mock 4 | import unittest2 5 | 6 | from crane import app, config, data 7 | 8 | 9 | metadata_good_path = os.path.join(os.path.dirname(__file__), '../data/metadata_good/') 10 | 11 | 12 | class BaseCraneAPITest(unittest2.TestCase): 13 | def setUp(self): 14 | with mock.patch('crane.app.init_logging'): 15 | self.app = app.create_app() 16 | self.app.config[config.KEY_DATA_DIR] = metadata_good_path 17 | self.app.config[config.KEY_ENDPOINT] = 'localhost:5000' 18 | self.app.config['DEBUG'] = True 19 | data.load_all(self.app) 20 | self.test_client = self.app.test_client() 21 | 22 | def tearDown(self): 23 | """ 24 | reset response data 25 | """ 26 | data.response_data = { 27 | 'repos': {}, 28 | 'images': {}, 29 | } 30 | 31 | 32 | published_good_path = os.path.join(os.path.dirname(__file__), '../data/published_good/') 33 | 34 | 35 | class BaseCraneAPITestServeContent(BaseCraneAPITest): 36 | 37 | def setUp(self): 38 | super(BaseCraneAPITestServeContent, self).setUp() 39 | self.app.config[config.KEY_SC_ENABLE] = True 40 | self.app.config[config.KEY_SC_CONTENT_DIR_V1] = os.path.join(published_good_path, 'v1') 41 | self.app.config[config.KEY_SC_CONTENT_DIR_V2] = os.path.join(published_good_path, 'v2') 42 | 43 | def verify_200(self, response, rel_path, content_type, v1=False): 44 | self.assertEqual(response.status_code, 200) 45 | self.assertEqual(response.headers['Content-Type'], content_type) 46 | if v1: 47 | base_path = self.app.config[config.KEY_SC_CONTENT_DIR_V1] 48 | else: 49 | base_path = self.app.config[config.KEY_SC_CONTENT_DIR_V2] 50 | self.assertEqual(response.headers['Docker-Distribution-API-Version'], 'registry/2.0') 51 | self.assertEqual(response.headers['X-Sendfile'], os.path.join(base_path, rel_path)) 52 | -------------------------------------------------------------------------------- /crane/templates/layout.html: -------------------------------------------------------------------------------- 1 | {# _crane/templates/layout.html_ #} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% block title %}{% endblock %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 39 | {% block body %} 40 |

This heading is defined in the parent.

41 | {% endblock %} 42 | 43 | 44 | -------------------------------------------------------------------------------- /tests/views/test_search.py: -------------------------------------------------------------------------------- 1 | import httplib 2 | import json 3 | 4 | import mock 5 | 6 | from crane.search.base import SearchResult, SearchBackend 7 | import base 8 | 9 | 10 | class TestSearch(base.BaseCraneAPITest): 11 | def test_no_query(self): 12 | response = self.test_client.get('/v1/search') 13 | 14 | self.assertEqual(response.status_code, httplib.BAD_REQUEST) 15 | 16 | def test_empty_query(self): 17 | response = self.test_client.get('/v1/search?q=') 18 | 19 | self.assertEqual(response.status_code, httplib.BAD_REQUEST) 20 | 21 | @mock.patch('crane.search.backend.search', spec_set=True) 22 | def test_with_results(self, mock_search): 23 | mock_search.return_value = [ 24 | SearchBackend._format_result(SearchResult('rhel', 'Red Hat Enterprise Linux', 25 | **SearchResult.result_defaults)), 26 | ] 27 | 28 | response = self.test_client.get('/v1/search?q=rhel') 29 | data = json.loads(response.data) 30 | 31 | self.assertDictEqual(data, { 32 | 'query': 'rhel', 33 | 'num_results': 1, 34 | 'results': mock_search.return_value 35 | }) 36 | 37 | @mock.patch('crane.search.backend.search', spec_set=True) 38 | def test_num_results(self, mock_search): 39 | mock_search.return_value = [ 40 | SearchBackend._format_result(SearchResult('rhel', 'Red Hat Enterprise Linux', 41 | **SearchResult.result_defaults)), 42 | SearchBackend._format_result(SearchResult('foo', 'Foo', 43 | **SearchResult.result_defaults)), 44 | SearchBackend._format_result(SearchResult('bar', 'Bar', 45 | **SearchResult.result_defaults)), 46 | ] 47 | 48 | response = self.test_client.get('/v1/search?q=rhel') 49 | data = json.loads(response.data) 50 | 51 | self.assertEqual(data['num_results'], 3) 52 | -------------------------------------------------------------------------------- /COMMITMENT: -------------------------------------------------------------------------------- 1 | GPL Cooperation Commitment, version 1.0 2 | 3 | Before filing or continuing to prosecute any legal proceeding or claim 4 | (other than a Defensive Action) arising from termination of a Covered 5 | License, we commit to extend to the person or entity ('you') accused 6 | of violating the Covered License the following provisions regarding 7 | cure and reinstatement, taken from GPL version 3. As used here, the 8 | term 'this License' refers to the specific Covered License being 9 | enforced. 10 | 11 | However, if you cease all violation of this License, then your 12 | license from a particular copyright holder is reinstated (a) 13 | provisionally, unless and until the copyright holder explicitly 14 | and finally terminates your license, and (b) permanently, if the 15 | copyright holder fails to notify you of the violation by some 16 | reasonable means prior to 60 days after the cessation. 17 | 18 | Moreover, your license from a particular copyright holder is 19 | reinstated permanently if the copyright holder notifies you of the 20 | violation by some reasonable means, this is the first time you 21 | have received notice of violation of this License (for any work) 22 | from that copyright holder, and you cure the violation prior to 30 23 | days after your receipt of the notice. 24 | 25 | We intend this Commitment to be irrevocable, and binding and 26 | enforceable against us and assignees of or successors to our 27 | copyrights. 28 | 29 | Definitions 30 | 31 | 'Covered License' means the GNU General Public License, version 2 32 | (GPLv2), the GNU Lesser General Public License, version 2.1 33 | (LGPLv2.1), or the GNU Library General Public License, version 2 34 | (LGPLv2), all as published by the Free Software Foundation. 35 | 36 | 'Defensive Action' means a legal proceeding or claim that We bring 37 | against you in response to a prior proceeding or claim initiated by 38 | you or your affiliate. 39 | 40 | 'We' means each contributor to this repository as of the date of 41 | inclusion of this file, including subsidiaries of a corporate 42 | contributor. 43 | 44 | This work is available under a [Creative Commons Attribution-ShareAlike 4.0 International license] 45 | (https://creativecommons.org/licenses/by-sa/4.0/). 46 | -------------------------------------------------------------------------------- /crane/api/images.py: -------------------------------------------------------------------------------- 1 | import httplib 2 | import urlparse 3 | import os 4 | 5 | from flask import current_app 6 | from crane import exceptions, config 7 | from crane.app_util import authorize_image_id 8 | 9 | VALID_IMAGE_FILES = frozenset(['ancestry', 'json', 'layer']) 10 | 11 | 12 | @authorize_image_id 13 | def get_image_file_url(image_id, repo_info, filename): 14 | """ 15 | Return the url for a file in an image 16 | 17 | :param image_id: The identifier for the image 18 | :type image_id: basestring 19 | :param repo_info: The tuple containing the information about the repository 20 | :type repo_info: crane.data.Repo 21 | :param filename: The identifier for the file belonging to the image 22 | :type filename: basestring 23 | :returns: url for a file inside an image 24 | :rtype: str 25 | 26 | :raises NotFoundException: if the file specified is not known 27 | """ 28 | if filename not in VALID_IMAGE_FILES: 29 | raise exceptions.HTTPError(httplib.NOT_FOUND) 30 | 31 | base_url = repo_info.url 32 | 33 | if not base_url.endswith('/'): 34 | base_url += '/' 35 | 36 | return urlparse.urljoin(base_url, '/'.join((image_id, filename))) 37 | 38 | 39 | @authorize_image_id 40 | def get_image_file_path(image_id, repo_info, filename): 41 | """ 42 | Return the file path for a file in an image 43 | 44 | :param image_id: The identifier for the image 45 | :type image_id: basestring 46 | :param repo_info: The tuple containing the information about the repository 47 | :type repo_info: crane.data.Repo 48 | :param filename: The identifier for the file belonging to the image 49 | :type filename: basestring 50 | :returns: file path for a file inside an image 51 | :rtype: tuple 52 | 53 | :raises NotFoundException: if the file specified is not known 54 | """ 55 | 56 | if filename not in VALID_IMAGE_FILES: 57 | raise exceptions.HTTPError(httplib.NOT_FOUND) 58 | 59 | if filename == 'layer': 60 | mediatype = 'application/octet-stream' 61 | else: 62 | mediatype = 'application/json' 63 | 64 | base_path = current_app.config.get(config.KEY_SC_CONTENT_DIR_V1) 65 | 66 | result = os.path.join(base_path, repo_info.repository, image_id, filename) 67 | 68 | return result, mediatype 69 | -------------------------------------------------------------------------------- /crane/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import os.path 4 | 5 | from flask import Flask 6 | 7 | from crane.views import crane, v1, v2 8 | from crane import config 9 | from crane import data 10 | from crane import exceptions 11 | from crane import app_util 12 | from crane import search 13 | 14 | 15 | class CraneFlask(Flask): 16 | def get_send_file_max_age(self, filepath): 17 | # Shorten the cache timeout if the file is not content adressable 18 | if os.path.basename(filepath).startswith('sha256:'): 19 | return super(CraneFlask, self).get_send_file_max_age(filepath) 20 | return self.config[config.KEY_DATA_POLLING_INTERVAL] 21 | 22 | 23 | def create_app(): 24 | """ 25 | Creates the flask app, loading blueprints and the configuration. 26 | 27 | :return: flask app 28 | :rtype: Flask 29 | """ 30 | init_logging() 31 | 32 | app = CraneFlask(__name__) 33 | app.register_blueprint(v1.section) 34 | app.register_blueprint(v2.section) 35 | app.register_blueprint(crane.section) 36 | app.register_error_handler(exceptions.HTTPError, app_util.http_error_handler) 37 | 38 | config.load(app) 39 | 40 | # in case the config says that debug mode is on, we need to adjust the 41 | # log level 42 | set_log_level(app) 43 | data.start_monitoring_data_dir(app) 44 | search.load_config(app) 45 | 46 | logging.getLogger(__name__).info('application initialized') 47 | return app 48 | 49 | 50 | def init_logging(): 51 | """ 52 | setup up logging to use sys.stderr, which will go into the web server's logs. 53 | """ 54 | logger = logging.getLogger('crane') 55 | logger.setLevel(logging.INFO) 56 | 57 | handler = logging.StreamHandler(sys.stderr) 58 | handler.setLevel(logging.DEBUG) 59 | formatter = logging.Formatter('[%(levelname)s] %(name)s: %(message)s') 60 | handler.setFormatter(formatter) 61 | 62 | logger.addHandler(handler) 63 | 64 | 65 | def set_log_level(app): 66 | """ 67 | In case the config file says we should be in debug mode, set the log level 68 | to debug. 69 | 70 | :param app: flask app 71 | :type app: flask.Flask 72 | """ 73 | if app.config['DEBUG']: 74 | logger = logging.getLogger('crane') 75 | logger.setLevel(logging.DEBUG) 76 | logger.debug('debug log level enabled') 77 | -------------------------------------------------------------------------------- /tests/api/test_images.py: -------------------------------------------------------------------------------- 1 | import httplib 2 | import os 3 | 4 | from crane import exceptions, config 5 | from crane.api import images 6 | 7 | from ..test_app_util import FlaskContextBase 8 | 9 | 10 | class TestGetImageFileUrl(FlaskContextBase): 11 | 12 | def test_invalid_filename(self): 13 | with self.assertRaises(exceptions.HTTPError) as assertion: 14 | images.get_image_file_url('def456', 'foo') 15 | self.assertEqual(assertion.exception.status_code, httplib.NOT_FOUND) 16 | 17 | def test_invalid_image_id(self): 18 | with self.assertRaises(exceptions.HTTPError) as assertion: 19 | images.get_image_file_url('bad_image_id', 'ancestry') 20 | self.assertEqual(assertion.exception.status_code, httplib.NOT_FOUND) 21 | 22 | def test_repo_url_missing_trailing_slash(self): 23 | result = images.get_image_file_url('def456', 'ancestry') 24 | expected = 'http://cdn.redhat.com/bar/baz/images/def456/ancestry' 25 | self.assertEquals(result, expected) 26 | 27 | def test_repo_url_with_trailing_slash(self): 28 | result = images.get_image_file_url('abc123', 'ancestry') 29 | expected = 'http://cdn.redhat.com/foo/bar/images/abc123/ancestry' 30 | self.assertEquals(result, expected) 31 | 32 | 33 | class TestGetImageFilePath(FlaskContextBase): 34 | 35 | def test_invalid_filename(self): 36 | with self.assertRaises(exceptions.HTTPError) as assertion: 37 | images.get_image_file_path('def456', 'foo') 38 | self.assertEqual(assertion.exception.status_code, httplib.NOT_FOUND) 39 | 40 | def test_invalid_image_id(self): 41 | with self.assertRaises(exceptions.HTTPError) as assertion: 42 | images.get_image_file_path('bad_image_id', 'ancestry') 43 | self.assertEqual(assertion.exception.status_code, httplib.NOT_FOUND) 44 | 45 | def dir_v1(self, rel_path): 46 | return os.path.join(self.app.config[config.KEY_SC_CONTENT_DIR_V1], rel_path) 47 | 48 | def test_repo_path_ancestry(self): 49 | result = images.get_image_file_path('abc123', 'ancestry') 50 | expected = (self.dir_v1('foo/abc123/ancestry'), 'application/json') 51 | self.assertEquals(result, expected) 52 | 53 | def test_repo_path_layer(self): 54 | result = images.get_image_file_path('abc123', 'layer') 55 | expected = (self.dir_v1('foo/abc123/layer'), 'application/octet-stream') 56 | self.assertEquals(result, expected) 57 | -------------------------------------------------------------------------------- /tests/data/gsa/rhel70_no_desc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 0.031378rhel7.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 2 27 | 28 | https://downloads-docker.usersys.redhat.com/gsa/feed/docker-repo?id=redhat-rhel7.0https://downloads-docker.usersys.redhat.com/gsa/feed/docker-repo%3Fid%3Dredhat-rhel7.0https://downloads-docker.usersys.redhat.com/gsa/feed/docker-repo?id=redhat-rhel7.0redhat-<b>rhel7</b>.<b>0</b> 53989a68c68b912f88929c98 Red Hat Enterprise Linux 7 base image813 Jul 2014T4-C48LVNLMKA663redhat-<b>rhel7</b>.<b>0</b> 53989a68c68b912f88929c98 Red<br> Hat Enterprise Linux 7 base image en 29 | 0 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /tests/data/test_no_entitlement.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC4jCCAcoCCQCoRWnjXk1+9zANBgkqhkiG9w0BAQUFADAzMSIwIAYDVQQDDBli 3 | Y291cnQudXNlcnN5cy5yZWRoYXQuY29tMQ0wCwYDVQQKDARQVUxQMB4XDTE0MDQy 4 | MjIwNDUyM1oXDTMzMDcyNjIwNDUyM1owMzEiMCAGA1UEAwwZYmNvdXJ0LnVzZXJz 5 | eXMucmVkaGF0LmNvbTENMAsGA1UECgwEUFVMUDCCASIwDQYJKoZIhvcNAQEBBQAD 6 | ggEPADCCAQoCggEBANQAw9/jOwyO3by2X2Egkq6ZdkOTIvaE9CVRD3dTH5dCt/Sa 7 | 5jjr5O5lHwXZ+keDf9szObWDHzRkCk+92QR323Z0+airC2NigBu2gLHYxdnirEKK 8 | VedamrPi0F3aNaGmwnj3nE5vfM5DS/O7/cxmBeLcl152etGb5mAiqWWB8dXa6jo2 9 | HrH6z0q1JC0cPfR+DvbiXAsvg30r06I3K4u1sfW7N8+wO0gMdLslw2EQ+mqQaPP6 10 | o1D8LdQ3X6VEW7iLzP525zli+vI2zddvtuV/KD/u/t8HoOdqlcrwBtKJWDbYhfJH 11 | WPvJiNh9LI/jfzeUEC7w22x7RNTvOCQmali3DwsCAwEAATANBgkqhkiG9w0BAQUF 12 | AAOCAQEAuE9C/wlBmpoyHo9H47wJxRqwuSd1OSmnkKbHvlEngCOcp15gzWbLhYAm 13 | qCuELLCsAtyu+OPCUx1pZozcx8roLmOaDSvMKFxS4ohfAVPr091pkpYBIKoejmjp 14 | ll+tsd8It+X07fNCXW6UrQCiRQqsrMyBollBb1PBlDqxJBKbS4uo9f66NPMLsqwY 15 | nAHVQapzHOtCOmqmvmCHxcS5sd842BKdDM9s0aV++4o3LkWsKr83Yc9TwXGrJ83b 16 | llCEO82en04leQqzdk4VRtdiaRU8F/PQUb0CHU2O9H404zaRKu174Jxz9alvt/Nn 17 | xZLjGWSHbEaFMi3Wfwdk2/kK9tjIKA== 18 | -----END CERTIFICATE----- 19 | -----BEGIN RSA PRIVATE KEY----- 20 | MIIEowIBAAKCAQEA1ADD3+M7DI7dvLZfYSCSrpl2Q5Mi9oT0JVEPd1Mfl0K39Jrm 21 | OOvk7mUfBdn6R4N/2zM5tYMfNGQKT73ZBHfbdnT5qKsLY2KAG7aAsdjF2eKsQopV 22 | 51qas+LQXdo1oabCePecTm98zkNL87v9zGYF4tyXXnZ60ZvmYCKpZYHx1drqOjYe 23 | sfrPSrUkLRw99H4O9uJcCy+DfSvTojcri7Wx9bs3z7A7SAx0uyXDYRD6apBo8/qj 24 | UPwt1DdfpURbuIvM/nbnOWL68jbN12+25X8oP+7+3weg52qVyvAG0olYNtiF8kdY 25 | +8mI2H0sj+N/N5QQLvDbbHtE1O84JCZqWLcPCwIDAQABAoIBACEM8Xxw517A0w69 26 | e8cfld6EbCyCvruh5JIAviGDIvSo4RbQIz4SgIEt4JU/80W82Wzp4oBKrc20Hutp 27 | lHCe4ubnu5gw+jiHPaOUYyHWwpmCPgqtmFDWDjInFQbcounNnpnPOF3+AX4Cfc/E 28 | qf94lEhWzX5biDAvVs37+V6q0HHfQyYCVEuQO5JVoAkkvMGy0WwozFdgSIRak/lx 29 | OLJ8/EPA8PEYBNf+wm418KdqTNJ+Mx8LKTB5v3I/dSJgEWE3ZSTT5C+0nAp9ysAx 30 | VzzZVsA+deIOqi61gC2DJzPf/fbul7p5fkh71ZJGbY3xCaM3HyQEsEYiTITir413 31 | oWOt0kECgYEA/fwu4t+90Vh/3JRKK+3e2+rguM4sEeOApzuMN6QaAtiWlqGpL3pp 32 | bh9zyBlqeJaELRf6iorhUo2aDl08BgrFlWYdIXix2vCdS/DeqveN1yZt3DbLn6i7 33 | unzOUHOlgm5J3oTDBG3XP4YC9KrG9VzmxY/+pWoqL5Wol2sxqDanYysCgYEA1a9S 34 | Hoh8AVCD2M3LlVW8NieJp9glcqL8uTEXsYm0hWBp/jZ/XjAeMRq4pmFb+9uKAD6Q 35 | kMCegRLGhoe4dh6bvN0nglyFojigngoLQqXKiinJJNlhjudYQ9b0qxg59SF3z6qf 36 | pdAEU1eJ7tJUWXBrVZ35JOzUQuUksM4GGdKAk6ECgYACaxRHNLop9B3Di+Xo5Srq 37 | DSJ2n//ra68d6IM9RGK7BDTq2j8yJIg8dDA2B4fr/gLkKwZHq2rZzl9ZI6oepJud 38 | AIvmda+71e6penGkTxcapVMGdb8alhCzhdyxB2jcBRDO/ZSdxi32fOAqVDXwwCGy 39 | X9yc1pXwPgyM5IhkgaC2YQKBgQDNwViAYAmPtniOTXMHo5tsRdvty1obrUdOBzB6 40 | Wk+B0lmdV9qC1jBsNf+w7bSFqlqSa6wGRgMZ4/tXVN5Qlp59WDIP+4kNsBswshkv 41 | nraNHTk6izq+QJMkF5pWGSjNmeFlDDVXkxpnKbZ+SPbBfFdOBKP/Yy/sDfqraGZF 42 | fQUjoQKBgCyRj7l7RHjXtfMwtsE/YYy5QrVKO98D/HVBYi/j6tP+6LWba+IswhRY 43 | SI8+TeCRqJo5o3F+fIjoStDXRnfP52v3N4V4fYw0s5xoe5786InYc774GzgZOHpl 44 | ChRUBoCbayokFJjyKunHusy3R+qV0dCLtFnVmq1yAExaJkPxNJeS 45 | -----END RSA PRIVATE KEY----- 46 | -------------------------------------------------------------------------------- /crane/api/repository.py: -------------------------------------------------------------------------------- 1 | from crane.app_util import authorize_repo_id, authorize_name, get_data, get_v2_data 2 | 3 | 4 | @authorize_repo_id 5 | def get_images_for_repo(repo_id): 6 | """ 7 | Return the images details json structure for a repository 8 | 9 | :param repo_id: The identifier for the repository 10 | :type repo_id: basestring 11 | :returns: json structure of image details 12 | :rtype: dict 13 | """ 14 | # Validation that the repo exists is taken care of by the decorator 15 | return get_data()['repos'][repo_id].images_json 16 | 17 | 18 | @authorize_repo_id 19 | def get_tags_for_repo(repo_id): 20 | """ 21 | Return the tag details for a repository 22 | 23 | :param repo_id: The identifier for the repository 24 | :type repo_id: basestring 25 | :returns: json structure of repo tags 26 | :rtype: dict 27 | """ 28 | # Validation that the repo exists is taken care of by the decorator 29 | return get_data()['repos'][repo_id].tags_json 30 | 31 | 32 | @authorize_name 33 | def get_path_for_repo(repo_id): 34 | """ 35 | Return the URL for the repository. 36 | 37 | :param repo_id: The identifier/name for the repository 38 | :type repo_id: basestring 39 | :returns: URL for the repository 40 | :rtype: basestring 41 | """ 42 | return get_v2_data()['repos'][repo_id].url 43 | 44 | 45 | @authorize_name 46 | def get_schema2_data_for_repo(repo_id): 47 | """ 48 | Return the schema2 data for the repository. 49 | 50 | :param repo_id: The identifier/name for the repository 51 | :type repo_id: basestring 52 | :returns: schema2 data for the repo 53 | :rtype: list 54 | """ 55 | repo = get_v2_data()['repos'][repo_id] 56 | try: 57 | schema2_data = repo.schema2_data 58 | except AttributeError: 59 | schema2_data = None 60 | return schema2_data 61 | 62 | 63 | @authorize_name 64 | def get_manifest_list_data_for_repo(repo_id): 65 | """ 66 | Return the manifest list data for the repository. 67 | 68 | :param repo_id: The identifier/name for the repository 69 | :type repo_id: basestring 70 | :returns: manifest list data for the repo 71 | :rtype: list 72 | """ 73 | repo = get_v2_data()['repos'][repo_id] 74 | try: 75 | manifest_list_data = repo.manifest_list_data 76 | except AttributeError: 77 | manifest_list_data = [] 78 | return manifest_list_data 79 | 80 | 81 | def get_pulp_repository_name(repo_id): 82 | """ 83 | Return the name used in pulp for the repository. 84 | 85 | :param repo_id: The identifier/name for the repository 86 | :type repo_id: basestring 87 | :returns: the pulp repository name 88 | :rtype: string 89 | """ 90 | return get_v2_data()['repos'][repo_id].repository 91 | 92 | 93 | @authorize_name 94 | def get_manifest_list_amd64_for_repo(repo_id): 95 | """ 96 | Return the manifest list amd64 tags for the repository. 97 | 98 | :param repo_id: The identifier/name for the repository 99 | :type repo_id: basestring 100 | :returns: manifest list amd64 tags for the repo 101 | :rtype: dict 102 | """ 103 | repo = get_v2_data()['repos'][repo_id] 104 | try: 105 | manifest_list_amd64_tags = repo.manifest_list_amd64_tags 106 | except AttributeError: 107 | manifest_list_amd64_tags = {} 108 | return manifest_list_amd64_tags 109 | -------------------------------------------------------------------------------- /tests/data/gsa/rhel70.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 0.031378rhel7.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 2 27 | 28 | https://downloads-docker.usersys.redhat.com/gsa/feed/docker-repo?id=redhat-rhel7.0https://downloads-docker.usersys.redhat.com/gsa/feed/docker-repo%3Fid%3Dredhat-rhel7.0https://downloads-docker.usersys.redhat.com/gsa/feed/docker-repo?id=redhat-rhel7.0redhat-<b>rhel7</b>.<b>0</b> 53989a68c68b912f88929c98 Red Hat Enterprise Linux 7 base image813 Jul 2014T4-C48LVNLMKA663redhat-<b>rhel7</b>.<b>0</b> 53989a68c68b912f88929c98 Red<br> Hat Enterprise Linux 7 base image enhttps://downloads-docker.usersys.redhat.com/gsa/feed/docker-repo?id=redhat-slash-rhel7.0https://downloads-docker.usersys.redhat.com/gsa/feed/docker-repo%3Fid%3Dredhat-slash-rhel7.0https://downloads-docker.usersys.redhat.com/gsa/feed/docker-repo?id=redhat-slash-rhel7.0redhat-slash-<b>rhel7</b>.<b>0</b> 53989b14c68b912f8a8a4a0a Red Hat Enterprise Linux 7 base image813 Jul 2014T4-C48LVNLMKA663redhat-slash-<b>rhel7</b>.<b>0</b> 53989b14c68b912f8a8a4a0a<br> Red Hat Enterprise Linux 7 base image en 29 | 0 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /tests/views/test_images.py: -------------------------------------------------------------------------------- 1 | from . import base 2 | 3 | 4 | class TestImagesRedirect(base.BaseCraneAPITest): 5 | def test_invalid_file_name(self): 6 | response = self.test_client.get('/v1/images/abc123/notvalid') 7 | 8 | self.assertEqual(response.status_code, 404) 9 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 10 | 11 | def test_ancestry(self): 12 | response = self.test_client.get('/v1/images/abc123/ancestry') 13 | 14 | self.assertEqual(response.status_code, 302) 15 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 16 | self.assertEqual(response.headers['Location'], 17 | 'http://cdn.redhat.com/foo/bar/images/abc123/ancestry') 18 | 19 | def test_json(self): 20 | response = self.test_client.get('/v1/images/abc123/json') 21 | 22 | self.assertEqual(response.status_code, 302) 23 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 24 | self.assertEqual(response.headers['Location'], 25 | 'http://cdn.redhat.com/foo/bar/images/abc123/json') 26 | 27 | def test_layer(self): 28 | response = self.test_client.get('/v1/images/abc123/layer') 29 | 30 | self.assertEqual(response.status_code, 302) 31 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 32 | self.assertEqual(response.headers['Location'], 33 | 'http://cdn.redhat.com/foo/bar/images/abc123/layer') 34 | 35 | def test_image_does_not_exist(self): 36 | response = self.test_client.get('/v1/images/idontexist/layer') 37 | 38 | self.assertEqual(response.status_code, 404) 39 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 40 | 41 | def test_image_url_without_trailing_slash(self): 42 | """ 43 | Make sure that if the metadata has a url without a trailing slash, 44 | everything still works. 45 | """ 46 | response = self.test_client.get('/v1/images/def456/layer') 47 | 48 | self.assertEqual(response.status_code, 302) 49 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 50 | self.assertEqual(response.headers['Location'], 51 | 'http://cdn.redhat.com/bar/baz/images/def456/layer') 52 | 53 | 54 | class TestImagesServeContent(base.BaseCraneAPITestServeContent): 55 | def test_ancestry(self): 56 | response = self.test_client.get('/v1/images/abc123/ancestry') 57 | self.verify_200(response, 'foo/abc123/ancestry', 'application/json', v1=True) 58 | 59 | def test_json(self): 60 | response = self.test_client.get('/v1/images/abc123/json') 61 | self.verify_200(response, 'foo/abc123/json', 'application/json', v1=True) 62 | 63 | def test_layer(self): 64 | response = self.test_client.get('/v1/images/abc123/layer') 65 | self.verify_200(response, 'foo/abc123/layer', 'application/octet-stream', v1=True) 66 | 67 | def test_json_does_not_exist(self): 68 | response = self.test_client.get('/v1/images/def456/json') 69 | 70 | self.assertEqual(response.status_code, 404) 71 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 72 | 73 | def test_image_does_not_exist(self): 74 | response = self.test_client.get('/v1/images/idontexist/layer') 75 | 76 | self.assertEqual(response.status_code, 404) 77 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 78 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import Flask 4 | import mock 5 | import unittest2 6 | 7 | from crane import app, config, app_util, exceptions, search 8 | from crane.search import GSA 9 | from crane.views import crane, v1 10 | from . import demo_data 11 | 12 | 13 | @mock.patch('os.environ.get', spec_set=True, return_value=demo_data.demo_config_path) 14 | class TestCreateApp(unittest2.TestCase): 15 | def setUp(self): 16 | super(TestCreateApp, self).setUp() 17 | with mock.patch('crane.app.init_logging') as mock_init_logging: 18 | self.app = app.create_app() 19 | # hold this so one of the tests can inspect it 20 | self.mock_init_logging = mock_init_logging 21 | 22 | def test_returns_app(self, mock_environ_get): 23 | self.assertIsInstance(self.app, Flask) 24 | 25 | def test_loads_config(self, mock_environ_get): 26 | self.assertTrue(config.KEY_DATA_DIR in self.app.config) 27 | 28 | def test_blueprints_loaded(self, mock_environ_get): 29 | self.assertTrue(v1.section.name in self.app.blueprints) 30 | self.assertTrue(crane.section.name in self.app.blueprints) 31 | 32 | def test_handlers_added(self, mock_environ_get): 33 | handlers = self.app.error_handler_spec[None][None] 34 | self.assertEquals(handlers, {exceptions.HTTPError: 35 | app_util.http_error_handler}) 36 | 37 | def test_calls_init_logging(self, mock_environ_get): 38 | self.mock_init_logging.assert_called_once_with() 39 | 40 | def test_calls_search(self, mock_environ_get): 41 | # reset to the default state 42 | search.backend = search.SearchBackend() 43 | 44 | # run the "create_app", which because of the mock_environ_get, will load 45 | # our demo config. That config has GSA info. 46 | with mock.patch('crane.app.init_logging'): 47 | app.create_app() 48 | 49 | # this will only be true if the search config was parsed 50 | self.assertIsInstance(search.backend, GSA) 51 | 52 | 53 | @mock.patch('logging.Logger.addHandler', spec_set=True) 54 | class TestInitLogging(unittest2.TestCase): 55 | def test_adds_handler(self, mock_add_handler): 56 | app.create_app() 57 | # make sure it was called 58 | self.assertEqual(mock_add_handler.call_count, 1) 59 | # make sure the first argument is the right type 60 | self.assertIsInstance(mock_add_handler.call_args[0][0], logging.Handler) 61 | # make sure the first argument was the only argument 62 | mock_add_handler.assert_called_once_with(mock_add_handler.call_args[0][0]) 63 | 64 | 65 | @mock.patch('crane.app.init_logging') 66 | @mock.patch('logging.Logger.setLevel', spec_set=True) 67 | class TestSetLogLevel(unittest2.TestCase): 68 | def setUp(self): 69 | super(TestSetLogLevel, self).setUp() 70 | self.app = app.create_app() 71 | 72 | def test_debug(self, mock_set_level, mock_init_logging): 73 | self.app.config['DEBUG'] = True 74 | 75 | app.set_log_level(self.app) 76 | 77 | # make sure it set the level to debug 78 | mock_set_level.assert_called_once_with(logging.DEBUG) 79 | 80 | def test_not_debug(self, mock_set_level, mock_init_logging): 81 | self.app.config['DEBUG'] = False 82 | 83 | app.set_log_level(self.app) 84 | 85 | # make sure it did not change the log level 86 | self.assertEqual(mock_set_level.call_count, 0) 87 | 88 | 89 | class TestSendFileCacheTimeout(unittest2.TestCase): 90 | def setUp(self): 91 | super(TestSendFileCacheTimeout, self).setUp() 92 | self.app = app.create_app() 93 | 94 | def test_short_cache_timeout(self): 95 | max_age = self.app.get_send_file_max_age('/var/www/pub/docker/v2/web/repo/tags/list') 96 | self.assertEqual(max_age, self.app.config[config.KEY_DATA_POLLING_INTERVAL]) 97 | 98 | @mock.patch.object(Flask, 'get_send_file_max_age', return_value=-1) 99 | def test_long_cache_timeout(self, mock_flask_max_age): 100 | self.app.get_send_file_max_age('/var/www/pub/docker/v2/web/repo/manifests/2/sha256:d59969bd6f4eb748dab218f5914993d3f6621a95bd4515982f5311d2a63fd3a0') # noqa 101 | self.assertEqual(mock_flask_max_age.call_count, 1) 102 | -------------------------------------------------------------------------------- /tests/data/test_entitlement.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDhjCCAu+gAwIBAgIITJyzu+0bOP4wDQYJKoZIhvcNAQEFBQAwQzEiMCAGA1UE 3 | AwwZYXJraGFtLnVzZXJzeXMucmVkaGF0LmNvbTELMAkGA1UEBhMCVVMxEDAOBgNV 4 | BAcMB1JhbGVpZ2gwHhcNMTQwMjEzMDAwMDAwWhcNMzAwMjEzMDA1NzU4WjArMSkw 5 | JwYDVQQDEyA4YThkMDFlOTQ0MmNmZTc3MDE0NDJkMDIyN2I4MTcwYTCCASIwDQYJ 6 | KoZIhvcNAQEBBQADggEPADCCAQoCggEBAMaNwp6rQghEV+f/gj0+91XSJQFNPi9D 7 | 3wI3BrTQaS0AxDIUr8HuCWacvCER+kXOl3s63PqkOKR9w9kgrdb1VSBzATPEOpch 8 | FzoerrpkKTPvsbuFrDA4JYVOc+k+qTQNaTHUNpG8njB4qlVn/4+W66xH/Y/kcpXy 9 | BRHDeLLilGaFfAdYsgFF879RqaUn4KVfZ1w7FUGIxSiDLGDSkkWMAYVehPMs0G0I 10 | n8VYkkCuDU7X45oE9o4h4lTOc2N0eNloZO7x2LIGCa30qeDQHriWpzMqV3vIHDgN 11 | rDgg7Li7M2wluHj+/a44iesjG4hlr+U8jju/ECf7GMs6WlekqDXUqQMCAwEAAaOC 12 | ARUwggERMBEGCWCGSAGG+EIBAQQEAwIFoDALBgNVHQ8EBAMCBLAwcwYDVR0jBGww 13 | aoAUoOSTpMacx9aJQZutJUj8ItBo9J2hR6RFMEMxIjAgBgNVBAMMGWFya2hhbS51 14 | c2Vyc3lzLnJlZGhhdC5jb20xCzAJBgNVBAYTAlVTMRAwDgYDVQQHDAdSYWxlaWdo 15 | ggkAxEssvCG0EFwwHQYDVR0OBBYEFIjaKEG0+B8UXW/eFUkDuf2OryX6MBMGA1Ud 16 | JQQMMAoGCCsGAQUFBwMCMBIGCSsGAQQBkggJBgQFDAMzLjIwMgYJKwYBBAGSCAkH 17 | BCUEI3jay0stSy1iSMvPZ0jMKU+sLGYoSCzJYAAAW40HowR3LwngMA0GCSqGSIb3 18 | DQEBBQUAA4GBAGz3f6DYUjTRe8NhAWRRcH1tImtvpLL/zxq+qUZmXFSk3Ya6itnC 19 | HZyNdE1GsHJhdWMcmnNvxAc3ViIDYNJU29TMZIGAuCYVTyX/28/gqRhpLuzwyN85 20 | jtFcJj46GNnHudj3RciKiw3KvVjc+kSV7Fn6Vz0I4W4TNyryBeL74vzl 21 | -----END CERTIFICATE----- 22 | -----BEGIN ENTITLEMENT DATA----- 23 | eJzFVVtvmzAU/ivIT5sUNxhISHhrJ20vmfqQPq2qIgdOKCrY1Jd2UZT/vmOTlN6S 24 | ddOiISSwz+X7zncsnw3JpdC2AUUywli6Wk5XKc2jJKbJtJjSyXga03gFxSSBKEl4 25 | Sgbk3nJhKrMmGRsQbZc6V1VrKilItiH6zmIm/ghaNiA11aAeQNGIapnfgaHaFJhC 26 | 8AbQ7bxzCy7nwdz7BRdWFDUUwacomPsAPQjmhouCqyKY27aVynzGBI9ciUqUJItD 27 | 5NB5kiwakIYLXkIDwpDMKAtoxcxVDo5cDQ9QI+4+IyYy69YxmTE6i8l2OyBSFU6M 28 | DRG2WXpZ/A4qkY7icPqyfgduuEIsEoUsoWFEWXwVhpl/f6AziKIzjt4xovZG8dyF 29 | M1zyPJdW+FUUx4zF+GEEObVKFjZ3FV5vSOUSxmk4HvVCfqmtNqBQkeCiQr8BQTG1 30 | bwlhZ6HLrfLbykBurAKXh5zPZuSmo+DFusbFUqEui50kCPwMLDzatdeY4zP2Icwd 31 | QBSP+lasbdNj7VwpNK1Zl22JlpovfRP3Jr/uHGjn8YCiS9c5A9rQ3QpV5OYWN4cr 32 | KYf+33WHL/G4kWzFaw0d4xdUMeHCKgeH3g0YXnDDF/CzrRTSC58kCg/wF+BO/w6G 33 | 7gvvizhk/2AJQx//Z4W8Ch52mr2pbRz21UVx8pv+CHmsOUL+v84wfI6TP0T7VHz7 34 | BuDe8Dj76AB1w8vy3QP1xvDRk8TrR77Wf1tJF707SwruLdaBNwkvfeDV+TfXA/xE 35 | 5Gb7+qK5nL+4atJnV81M8iK44DUX+QnvtskzQK5KCL5WNeynzalA0x50fstRLZxz 36 | UuHsOhXgtAf8/jQk/yUY7rRS1m52etAJnxQhg2mSRPkK0hQHpP9bhcmIRThKt9tf 37 | HQexUg== 38 | -----END ENTITLEMENT DATA----- 39 | -----BEGIN RSA SIGNATURE----- 40 | TxC90NaS5+vNc1MW+DVzF1Db2gG78ATXcWvDkINf1ok3x11eSawZnmxM4TAKQk0S 41 | 7ADQeF12jEJ3w5Pcw7QmfRTogc21+3Y82S5XKdghzak0VguJB9CpyIYvhdiGe7gL 42 | fFmYXSIpRFpbb2rQ3N7vtKnjA6/l6MDMs7VU5ymV1Rw= 43 | -----END RSA SIGNATURE----- 44 | -----BEGIN RSA PRIVATE KEY----- 45 | MIIEogIBAAKCAQEAxo3CnqtCCERX5/+CPT73VdIlAU0+L0PfAjcGtNBpLQDEMhSv 46 | we4JZpy8IRH6Rc6Xezrc+qQ4pH3D2SCt1vVVIHMBM8Q6lyEXOh6uumQpM++xu4Ws 47 | MDglhU5z6T6pNA1pMdQ2kbyeMHiqVWf/j5brrEf9j+RylfIFEcN4suKUZoV8B1iy 48 | AUXzv1GppSfgpV9nXDsVQYjFKIMsYNKSRYwBhV6E8yzQbQifxViSQK4NTtfjmgT2 49 | jiHiVM5zY3R42Whk7vHYsgYJrfSp4NAeuJanMypXe8gcOA2sOCDsuLszbCW4eP79 50 | rjiJ6yMbiGWv5TyOO78QJ/sYyzpaV6SoNdSpAwIDAQABAoIBADDtwiYDofQTk24S 51 | Yu/g0maNsJzPgiF3oj8TfE2WhjSW2cJVorIxjJOC0EF+AqStMlsluErzqRmv/FFH 52 | Yk90iZPDg2pUUvPnLage44P/JsArsyq42CPR0j01hg7WaC/xlhi3aPMk/f6H3cHK 53 | LQofcNg1rWCGNU6KI4GrH9VFTLiugP45+qLse83FU3LquKo9L4cLHiFoWnmDGWZR 54 | E/8vlFhsVPLxfy1GlYPXbsBs0QqjsjNrDHUpmNb8DJPRy9kHGrOLKPPo9S4/MWjA 55 | 5F8pjSLJSUWeOq1A7UBJo6amlS1Lsi4k5vanHKbQKumPqkxE42mj6jO5MZWL0GUv 56 | s9Jbb/ECgYEA73cAxNA+i287KlHoHz060t8EWUFdArnC0M4bpiYvWxynziFXYdW6 57 | ZmGiwwCSXTuZJiHqb52FpsjBLDTvZaks89Oat11dh4dvVRHeKnU8LoDtQkfGI8wq 58 | sLIfHAwpQ+yyHV8kXraaHIjTxYDYY1rZO7MTZgogyTINDdyeIUHXeO8CgYEA1EOT 59 | WwcBRv07CAYU7oEwwfKA/tJiar6uTesgRAe35ejgmn0mBMFAjDjdXYDJ5y7o8GXa 60 | znVA/fB44l5y0cXCV2rSSaPCE0RIqFiYBveFhOV+zUFUraSLuntXQU87vQdpNzVC 61 | flIItL+I6aRYQzB1x0lvJXpW4dTiAWyZY+OMCS0CgYAitkFqRjWKyMjuzYi0Xo/U 62 | WXer6ImvB3ZyBi6D2y0qK/E3NCYSjITEqoqJcd/FnAoLOQdevprNHnTCD7pUJj3Z 63 | 6maXBfW027ELx9dImE3s/8tkvDrAnaviiS9xRWIF8p2vHEeNYzLbyAnKq26ymEy9 64 | FqxNMlKmV5cE12IkuAKgCQKBgBU4jErVcHNAbd5nfUA6+jzpmavgLKavWY3ArAEh 65 | JGl/1rSbPmnLSEC8jqUCcy0Bq5gdFrr9TfoVSICZILGIK93BeDSUgpqagQkTkl9f 66 | 76FRsFOk7GpPwQhrvFVzLm2/h+1VDcjDOsuXOoDhRVGyTpQV61671GPPfKjXyp4J 67 | 6b4hAoGATvnmi9ryFH8n1dM7mnbKzdZwlpu7chijbuMyVYexXtvF8Cn29oQHq5Nt 68 | wL2MJEcnxVuQup2Tma/fzmEin5v6uC73N9NlRMD9KxIsaAyTiO9xeawYadyuiPhC 69 | GmAun2zttH/td+wfXno6enQSu259u6e4avUbdlaWwDJmkuH22sU= 70 | -----END RSA PRIVATE KEY----- 71 | -------------------------------------------------------------------------------- /crane/search/solr.py: -------------------------------------------------------------------------------- 1 | import httplib 2 | import itertools 3 | import json 4 | import logging 5 | import urllib 6 | 7 | from .. import exceptions 8 | from .base import HTTPBackend, SearchResult 9 | 10 | 11 | _logger = logging.getLogger(__name__) 12 | 13 | 14 | class Solr(HTTPBackend): 15 | def __init__(self, url_template): 16 | """ 17 | :param url_template: PEP3101 string that is a URL that will accept a 18 | single argument to its .format() method, which 19 | is the url-encoded search string. 20 | :type url_template: str 21 | """ 22 | self.url_template = url_template 23 | 24 | def search(self, query): 25 | """ 26 | Searches a Solr search backend based on a given query parameter. 27 | 28 | :param query: a string representing the search input from a user that 29 | should be passed through to the solr backend 30 | :type query: basestring 31 | 32 | :return: a collection of search results as a generator of 33 | SearchResult instances. These results have been filtered 34 | to exclude any repositories that are not being served by 35 | this deployment of this app. 36 | :rtype: generator 37 | """ 38 | quoted_query = urllib.quote(query) 39 | url = self.url_template.format(quoted_query) 40 | _logger.debug('searching with URL: %s' % url) 41 | 42 | body = self._get_data(url) 43 | 44 | results = self._parse(body) 45 | filtered_results = itertools.ifilter(self._filter_result, results) 46 | return itertools.imap(self._format_result, filtered_results) 47 | 48 | def _parse(self, body): 49 | """ 50 | Processes the raw response body into search results 51 | 52 | :param body: body from the web response object 53 | :type body: str 54 | 55 | :return: generator of SearchResult instances 56 | :rtype: generator 57 | """ 58 | try: 59 | data = json.loads(body) 60 | for item in data['response']['docs']: 61 | description = item.get('ir_description', item.get('publishedAbstract')) 62 | trusted = item.get('ir_automated', SearchResult.result_defaults['is_trusted']) 63 | automated = item.get('ir_official', SearchResult.result_defaults['is_official']) 64 | stars = item.get('ir_stars', SearchResult.result_defaults['star_count']) 65 | 66 | if item.get('documentKind') == 'CertifiedSoftware' and 'c_pull_command' not in item: 67 | continue 68 | elif item.get('documentKind') == 'CertifiedSoftware': 69 | for value in item.get('c_pull_command'): 70 | name = value.replace('docker pull', '', 1).strip() 71 | should_filter = False 72 | yield SearchResult(name, description, trusted, automated, 73 | stars, should_filter) 74 | else: 75 | name = item.get('allTitle') 76 | should_filter = True if item.get('documentKind') == 'ImageRepository' \ 77 | else False 78 | yield SearchResult(name, description, trusted, automated, 79 | stars, should_filter) 80 | except Exception, e: 81 | _logger.error('could not parse response body: %s' % e) 82 | _logger.exception('could not parse response') 83 | raise exceptions.HTTPError(httplib.BAD_GATEWAY, 84 | 'error communicating with backend search service') 85 | 86 | def _filter_result(self, result): 87 | """ 88 | Overrides _filter_result of HTTPBackend. If the result object does not represent an ISV 89 | repository, authorize it otherwise skip authorization 90 | 91 | :param result: one search result 92 | :type result: SearchResult 93 | :return: True if either the repository is known and the user is authorized or 94 | if authorization can be skipped else False 95 | :rtype: bool 96 | """ 97 | if result.should_filter: 98 | return super(Solr, self)._filter_result(result) 99 | else: 100 | return True 101 | -------------------------------------------------------------------------------- /crane/static/js/patternfly.min.js: -------------------------------------------------------------------------------- 1 | var PatternFly=PatternFly||{};!function($){sidebar=function(){var documentHeight=0,navbarpfHeight=0,colHeight=0;$(".navbar-pf .navbar-toggle").is(":hidden")&&(documentHeight=$(document).height(),navbarpfHeight=$(".navbar-pf").outerHeight(),colHeight=documentHeight-navbarpfHeight),$(".sidebar-pf").parent(".row").children('[class*="col-"]').css({"min-height":colHeight})},$(document).ready(function(){$(".sidebar-pf").length>0&&0==$(".datatable").length&&sidebar()}),$(window).resize(function(){$(".sidebar-pf").length>0&&sidebar()})}(jQuery),function($){PatternFly.popovers=function(selector){var allpopovers=$(selector);allpopovers.popover(),allpopovers.filter("[data-close=true]").each(function(index,element){var $this=$(element),title=$this.attr("data-original-title")+'';$this.attr("data-original-title",title)}),allpopovers.on("click",function(e){var $this=$(this);$title=$this.next(".popover").find(".popover-title"),$title.find(".close").parent(".popover-title").addClass("closable"),$title.find(".close").on("click",function(){$this.popover("toggle")}),e.preventDefault()})}}(jQuery),function($){$.fn.dataTableExt&&($.extend(!0,$.fn.dataTable.defaults,{bDestroy:!0,bAutoWidth:!1,iDisplayLength:20,sDom:"<'dataTables_header' f i r ><'table-responsive' t ><'dataTables_footer' p >",oLanguage:{sInfo:"Showing _START_ to _END_ of _TOTAL_ Items",sInfoFiltered:"(of _MAX_)",sInfoEmpty:"Showing 0 Results",sZeroRecords:"

Suggestions

",sSearch:""},sPaginationType:"bootstrap_input"}),$.extend($.fn.dataTableExt.oStdClasses,{sWrapper:"dataTables_wrapper"}),$.fn.dataTableExt.oApi.fnPagingInfo=function(oSettings){return{iStart:oSettings._iDisplayStart,iEnd:oSettings.fnDisplayEnd(),iLength:oSettings._iDisplayLength,iTotal:oSettings.fnRecordsTotal(),iFilteredTotal:oSettings.fnRecordsDisplay(),iPage:-1===oSettings._iDisplayLength?0:Math.ceil(oSettings._iDisplayStart/oSettings._iDisplayLength),iTotalPages:-1===oSettings._iDisplayLength?0:Math.ceil(oSettings.fnRecordsDisplay()/oSettings._iDisplayLength)}},$.extend($.fn.dataTableExt.oPagination,{bootstrap_input:{fnInit:function(oSettings,nPaging,fnDraw){var fnClickHandler=(oSettings.oLanguage.oPaginate,function(e){e.preventDefault(),oSettings.oApi._fnPageChange(oSettings,e.data.action)&&fnDraw(oSettings)});$(nPaging).append('
of 3
');var els=$("li",nPaging);$(els[0]).bind("click.DT",{action:"first"},fnClickHandler),$(els[1]).bind("click.DT",{action:"previous"},fnClickHandler),$(els[2]).bind("click.DT",{action:"next"},fnClickHandler),$(els[3]).bind("click.DT",{action:"last"},fnClickHandler);var nInput=$("input",nPaging);$(nInput).keyup(function(e){if(38==e.which||39==e.which?this.value++:(37==e.which||40==e.which)&&this.value>1&&this.value--,""!=this.value&&!this.value.match(/[^0-9]/)){var iNewStart=oSettings._iDisplayLength*(this.value-1);if(iNewStart>oSettings.fnRecordsDisplay())return oSettings._iDisplayStart=(Math.ceil((oSettings.fnRecordsDisplay()-1)/oSettings._iDisplayLength)-1)*oSettings._iDisplayLength,void fnDraw(oSettings);oSettings._iDisplayStart=iNewStart,fnDraw(oSettings)}})},fnUpdate:function(oSettings){var i,ien,oPaging=oSettings.oInstance.fnPagingInfo(),an=oSettings.aanFeatures.p,iPages=Math.ceil(oSettings.fnRecordsDisplay()/oSettings._iDisplayLength),iCurrentPage=Math.ceil(oSettings._iDisplayStart/oSettings._iDisplayLength)+1;for(i=0,ien=an.length;ien>i;i++)$(".paginate_input").val(iCurrentPage),$(".paginate_of b").html(iPages),0===oPaging.iPage?($("li.first",an[i]).addClass("disabled"),$("li.prev",an[i]).addClass("disabled")):($("li.first",an[i]).removeClass("disabled"),$("li.prev",an[i]).removeClass("disabled")),oPaging.iPage===oPaging.iTotalPages-1||0===oPaging.iTotalPages?($("li.next",an[i]).addClass("disabled"),$("li.last",an[i]).addClass("disabled")):($("li.next",an[i]).removeClass("disabled"),$("li.last",an[i]).removeClass("disabled"))}}}))}(jQuery); -------------------------------------------------------------------------------- /crane/search/gsa.py: -------------------------------------------------------------------------------- 1 | import httplib 2 | import itertools 3 | import logging 4 | import urllib 5 | import urlparse 6 | import xml.etree.cElementTree as ET 7 | 8 | from .. import exceptions 9 | from .base import HTTPBackend, SearchResult 10 | 11 | 12 | _logger = logging.getLogger(__name__) 13 | 14 | 15 | class GSA(HTTPBackend): 16 | """ 17 | This backend works with a Google Search Appliance. It was developed against 18 | version 3.2 of the Google Search Protocol. 19 | 20 | This expects to find two meta tag (MT) elements for each result element in 21 | the returned XML: one with the name "portal_name", and one with the name 22 | "portal_short_description". 23 | """ 24 | def __init__(self, url): 25 | """ 26 | :param url: full URL to a Google Search Appliance that will allow a 27 | search operation. The query parameter "q" will be added to 28 | the URL, and the result will be used in a GET request to 29 | retrieve search results. 30 | :type url: basestring 31 | """ 32 | self.url = url 33 | # expand the url into its components once, and then we can re-assemble 34 | # the pieces (with the added query parameter) for each request. 35 | self.url_parts = urlparse.urlparse(url) 36 | self.params = urlparse.parse_qs(self.url_parts.query) 37 | 38 | def search(self, query): 39 | """ 40 | Searches a Google Search Appliance based on a given query parameter. 41 | 42 | :param query: a string representing the search input from a user that 43 | should be passed through to the GSA 44 | :type query: basestring 45 | 46 | :return: a collection of search results as a generator of 47 | SearchResult instances. These results have been filtered 48 | to exclude any repositories that are not being served by 49 | this deployment of this app. 50 | :rtype: generator 51 | """ 52 | url = self._form_url(query) 53 | data = self._get_data(url) 54 | result_generator = self._parse_xml(data) 55 | filtered_results = itertools.ifilter(self._filter_result, result_generator) 56 | return itertools.imap(self._format_result, filtered_results) 57 | 58 | def _form_url(self, query): 59 | """ 60 | Creates a full URL that can be used in a GET request to retrieve results 61 | from the GSA. This takes the url specified in a config file and adds 62 | the 'q' parameter with the query as its value. 63 | 64 | :param query: a string representing the search input from a user that 65 | should be passed through to the GSA 66 | :type query: basestring 67 | 68 | :return: a full URL that can be used in a GET request 69 | :rtype: basestring 70 | """ 71 | params = self.params.copy() 72 | params['q'] = [query] 73 | parts = list(self.url_parts) 74 | parts[4] = urllib.urlencode(params, doseq=True) 75 | return urlparse.urlunparse(parts) 76 | 77 | @staticmethod 78 | def _parse_xml(data): 79 | """ 80 | Parses the XML returned by a GSA and turns it into a generator of result 81 | instances. 82 | 83 | :param data: XML data returned by a GET request to a Google Search 84 | Appliance 85 | :type data: basestring 86 | 87 | :return: a collection of search results as a generator of 88 | SearchResult instances 89 | :rtype: generator 90 | 91 | :raises exceptions.HTTPError: if there is an error parsing the XML 92 | """ 93 | try: 94 | tree = ET.fromstring(data) 95 | # each result is in an element of type "R" 96 | for repo in tree.findall('./RES/R'): 97 | name = None 98 | description = '' 99 | # each attribute of the repo is returned in an element of type MT 100 | # that looks like: 101 | for mt in repo.findall('./MT'): 102 | if mt.attrib.get('N') == 'portal_name': 103 | name = mt.attrib['V'] 104 | elif mt.attrib.get('N') == 'portal_short_description': 105 | description = mt.attrib['V'] 106 | 107 | if name is not None: 108 | yield SearchResult(name, description, **SearchResult.result_defaults) 109 | 110 | except Exception: 111 | _logger.exception('could not parse xml') 112 | raise exceptions.HTTPError(httplib.BAD_GATEWAY, 113 | 'error communicating with backend search service') 114 | -------------------------------------------------------------------------------- /tests/search/test_gsa.py: -------------------------------------------------------------------------------- 1 | import httplib 2 | import inspect 3 | import os 4 | import urlparse 5 | import unittest2 6 | import mock 7 | 8 | from crane import exceptions 9 | from crane.search.base import SearchResult 10 | from crane.search.gsa import GSA 11 | 12 | 13 | basepath = os.path.dirname(__file__) 14 | 15 | rhel70_xml_path = os.path.join(basepath, '../data/gsa/rhel70.xml') 16 | with open(rhel70_xml_path) as f: 17 | rhel70_xml = f.read() 18 | 19 | rhel70_no_desc_xml_path = os.path.join(basepath, '../data/gsa/rhel70_no_desc.xml') 20 | with open(rhel70_no_desc_xml_path) as f: 21 | rhel70_no_desc_xml = f.read() 22 | 23 | 24 | class BaseGSATest(unittest2.TestCase): 25 | def setUp(self): 26 | super(BaseGSATest, self).setUp() 27 | self.url = 'http://pulpproject.org/search' 28 | self.gsa = GSA(self.url) 29 | 30 | 31 | class TestInit(BaseGSATest): 32 | def test_stores_data(self): 33 | self.assertEqual(self.gsa.url_parts.hostname, 'pulpproject.org') 34 | self.assertEqual(self.gsa.url_parts.path, '/search') 35 | self.assertEqual(self.gsa.url_parts.scheme, 'http') 36 | self.assertDictEqual(self.gsa.params, {}) 37 | 38 | 39 | class TestSearch(BaseGSATest): 40 | @mock.patch('crane.search.gsa.GSA._filter_result', spec_set=True, return_value=True) 41 | @mock.patch('crane.search.gsa.GSA._get_data', spec_set=True) 42 | @mock.patch('crane.search.gsa.GSA._parse_xml') 43 | def test_workflow_filter_true(self, mock_parse_xml, mock_get_data, mock_filter): 44 | mock_parse_xml.return_value = [SearchResult('rhel', 'Red Hat Enterprise Linux', 45 | **SearchResult.result_defaults)] 46 | 47 | ret = self.gsa.search('foo') 48 | # should_filter is part of default 49 | mock_get_data.assert_called_once_with('http://pulpproject.org/search?q=foo') 50 | self.assertDictEqual(list(ret)[0], { 51 | 'name': 'rhel', 52 | 'description': 'Red Hat Enterprise Linux', 53 | 'star_count': 0, 54 | 'is_trusted': False, 55 | 'is_official': False, 56 | 'should_filter': True, 57 | }) 58 | 59 | @mock.patch('crane.search.gsa.GSA._filter_result', spec_set=True, return_value=False) 60 | @mock.patch('crane.search.gsa.GSA._get_data', spec_set=True) 61 | @mock.patch('crane.search.gsa.GSA._parse_xml') 62 | def test_workflow_filter_false(self, mock_parse_xml, mock_get_data, mock_filter): 63 | mock_parse_xml.return_value = [SearchResult('rhel', 'Red Hat Enterprise Linux', 64 | **SearchResult.result_defaults)] 65 | 66 | ret = self.gsa.search('foo') 67 | 68 | mock_get_data.assert_called_once_with('http://pulpproject.org/search?q=foo') 69 | self.assertEqual(len(list(ret)), 0) 70 | 71 | 72 | class TestFormURL(unittest2.TestCase): 73 | def test_adds_query_param(self): 74 | gsa = GSA('http://pulpproject.org/search') 75 | 76 | url = gsa._form_url('foo') 77 | 78 | self.assertEqual(url, 'http://pulpproject.org/search?q=foo') 79 | 80 | def test_appends_query_param(self): 81 | gsa = GSA('https://pulpproject.org/search?x=1&y=2') 82 | 83 | url = gsa._form_url('foo') 84 | 85 | # verify that all of the pieces are correct. 86 | parts = urlparse.urlparse(url) 87 | self.assertEqual(parts.hostname, 'pulpproject.org') 88 | self.assertEqual(parts.scheme, 'https') 89 | self.assertEqual(parts.path, '/search') 90 | params = urlparse.parse_qs(parts.query) 91 | self.assertDictEqual(params, {'q': ['foo'], 'x': ['1'], 'y': ['2']}) 92 | 93 | 94 | class TestParseXML(BaseGSATest): 95 | def test_success(self): 96 | generator = self.gsa._parse_xml(rhel70_xml) 97 | 98 | self.assertTrue(inspect.isgenerator(generator)) 99 | 100 | items = list(generator) 101 | 102 | self.assertListEqual(items, [ 103 | SearchResult('rhel7.0', 'Red Hat Enterprise Linux 7 base image', 104 | **SearchResult.result_defaults), 105 | SearchResult('redhat/rhel7.0', 'Red Hat Enterprise Linux 7 base image', 106 | **SearchResult.result_defaults), 107 | ]) 108 | 109 | def test_no_description(self): 110 | """test when the description is missing from the XML""" 111 | generator = self.gsa._parse_xml(rhel70_no_desc_xml) 112 | items = list(generator) 113 | 114 | self.assertListEqual(items, [ 115 | SearchResult('rhel7.0', '', **SearchResult.result_defaults), 116 | ]) 117 | 118 | def test_handle_exception(self): 119 | with self.assertRaises(exceptions.HTTPError) as assertion: 120 | list(self.gsa._parse_xml('this is not xml')) 121 | 122 | self.assertEqual(assertion.exception.status_code, httplib.BAD_GATEWAY) 123 | -------------------------------------------------------------------------------- /crane/search/base.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import httplib 3 | import logging 4 | import socket 5 | import urllib2 6 | 7 | from .. import app_util 8 | from .. import exceptions 9 | 10 | 11 | _logger = logging.getLogger(__name__) 12 | 13 | 14 | class SearchBackend(object): 15 | """ 16 | Base class that all search backends should inherit from. This defines the 17 | search() method signature and provides other functionality that may be 18 | useful across different search implementations. 19 | """ 20 | def search(self, query): 21 | """ 22 | Searches a backend service based on a given query parameter. 23 | 24 | :param query: a string representing the search input from a user that 25 | should be passed through to a search service 26 | :type query: basestring 27 | 28 | :return: a collection of search results as a generator of 29 | SearchResult instances 30 | :rtype: generator 31 | """ 32 | raise exceptions.HTTPError(httplib.NOT_FOUND) 33 | 34 | @staticmethod 35 | def _format_result(result): 36 | """ 37 | Given an individual SearchResult, this will return a dictionary in the 38 | structure that "docker search" expects. 39 | 40 | :param result: one result of a search 41 | :type result: SearchResult 42 | 43 | :return: dictionary containing the search result data in the form that 44 | docker expects to receive. 45 | :rtype: dict 46 | """ 47 | return dict(result._asdict()) 48 | 49 | def _filter_result(self, result): 50 | """ 51 | Determines if a given result object, which represents a repository, is 52 | both known by this app (aka we have it in the app data), and if the user 53 | is authorized to access it. 54 | 55 | :param result: one search result 56 | :type result: SearchResult 57 | :return: True iff the repository is known and the user is authorized, 58 | else False 59 | :rtype: bool 60 | """ 61 | try: 62 | # check against repositories in V2 data dict 63 | app_util.name_is_authorized(result.name) 64 | 65 | except exceptions.HTTPError: 66 | try: 67 | # check against repositories in v1 data dict 68 | app_util.repo_is_authorized(result.name) 69 | except exceptions.HTTPError: 70 | return False 71 | return True 72 | 73 | 74 | class HTTPBackend(SearchBackend): 75 | """ 76 | This provides functionality that may be useful across different search 77 | implementations that use HTTP to communicate with a backend service. 78 | """ 79 | @staticmethod 80 | def _get_data(url): 81 | """ 82 | Gets data from a URL and handles various HTTP-related error conditions. 83 | Any search implementation that uses an HTTP-based backend service should 84 | be able to use this method for GET requests. 85 | 86 | :param url: a complete URL that will be used for a GET request 87 | :type url: basestring 88 | 89 | :return: the content of the response body 90 | :rtype: basestring 91 | 92 | :raises exceptions.HTTPError: if there is a problem performing the 93 | GET request. 94 | 502: if the response is not 200 95 | 503: if urllib2 raises an exception while 96 | performing the request 97 | 504: if the backend takes too long 98 | """ 99 | try: 100 | # one second timeout 101 | response = urllib2.urlopen(url, timeout=1) 102 | except socket.timeout: 103 | _logger.error('timeout communicating with backend search service') 104 | raise exceptions.HTTPError(httplib.GATEWAY_TIMEOUT) 105 | except urllib2.URLError, e: 106 | _logger.error('error communicating with backend search service: %s' % e.reason) 107 | raise exceptions.HTTPError(httplib.SERVICE_UNAVAILABLE) 108 | if response.getcode() != httplib.OK: 109 | _logger.error('received http response code %s from backend search service' % 110 | response.getcode()) 111 | raise exceptions.HTTPError(httplib.BAD_GATEWAY, url) 112 | 113 | return response.read() 114 | 115 | 116 | # this data structure should be used to return search results in a uniform 117 | # and well-defined way. 118 | class SearchResult(namedtuple('SearchResult', ['name', 'description', 'is_trusted', 119 | 'is_official', 'star_count', 'should_filter'])): 120 | result_defaults = {'is_trusted': False, 'is_official': False, 'star_count': 0, 121 | 'should_filter': True} 122 | -------------------------------------------------------------------------------- /crane/static/js/bootstrap-treeview.min.js: -------------------------------------------------------------------------------- 1 | !function(a,b,c,d){"use strict";var e="treeview",f=function(b,c){this.$element=a(b),this._element=b,this._elementId=this._element.id,this._styleId=this._elementId+"-style",this.tree=[],this.nodes=[],this.selectedNode={},this._init(c)};f.defaults={injectStyle:!0,levels:2,expandIcon:"glyphicon glyphicon-plus",collapseIcon:"glyphicon glyphicon-minus",nodeIcon:"glyphicon glyphicon-stop",color:d,backColor:d,borderColor:d,onhoverColor:"#F5F5F5",selectedColor:"#FFFFFF",selectedBackColor:"#428bca",enableLinks:!1,highlightSelected:!0,showBorder:!0,showTags:!1,onNodeSelected:d},f.prototype={remove:function(){this._destroy(),a.removeData(this,"plugin_"+e),a("#"+this._styleId).remove()},_destroy:function(){this.initialized&&(this.$wrapper.remove(),this.$wrapper=null,this._unsubscribeEvents()),this.initialized=!1},_init:function(b){b.data&&("string"==typeof b.data&&(b.data=a.parseJSON(b.data)),this.tree=a.extend(!0,[],b.data),delete b.data),this.options=a.extend({},f.defaults,b),this._setInitialLevels(this.tree,0),this._destroy(),this._subscribeEvents(),this._render()},_unsubscribeEvents:function(){this.$element.off("click")},_subscribeEvents:function(){this._unsubscribeEvents(),this.$element.on("click",a.proxy(this._clickHandler,this)),"function"==typeof this.options.onNodeSelected&&this.$element.on("nodeSelected",this.options.onNodeSelected)},_clickHandler:function(b){this.options.enableLinks||b.preventDefault();var c=a(b.target),d=c.attr("class")?c.attr("class").split(" "):[],e=this._findNode(c);-1!=d.indexOf("click-expand")||-1!=d.indexOf("click-collapse")?(this._toggleNodes(e),this._render()):e&&this._setSelectedNode(e)},_findNode:function(a){var b=a.closest("li.list-group-item").attr("data-nodeid"),c=this.nodes[b];return c||console.log("Error: node does not exist"),c},_triggerNodeSelectedEvent:function(b){this.$element.trigger("nodeSelected",[a.extend(!0,{},b)])},_setSelectedNode:function(a){a&&(a===this.selectedNode?this.selectedNode={}:this._triggerNodeSelectedEvent(this.selectedNode=a),this._render())},_setInitialLevels:function(b,c){if(b){c+=1;var e=this;a.each(b,function(a,b){c>=e.options.levels&&e._toggleNodes(b);var f=b.nodes?b.nodes:b._nodes?b._nodes:d;return f?e._setInitialLevels(f,c):void 0})}},_toggleNodes:function(a){(a.nodes||a._nodes)&&(a.nodes?(a._nodes=a.nodes,delete a.nodes):(a.nodes=a._nodes,delete a._nodes))},_render:function(){var b=this;b.initialized||(b.$element.addClass(e),b.$wrapper=a(b._template.list),b._injectStyle(),b.initialized=!0),b.$element.empty().append(b.$wrapper.empty()),b.nodes=[],b._buildTree(b.tree,0)},_buildTree:function(b,c){if(b){c+=1;var d=this;a.each(b,function(b,e){e.nodeId=d.nodes.length,d.nodes.push(e);for(var f=a(d._template.item).addClass("node-"+d._elementId).addClass(e===d.selectedNode?"node-selected":"").attr("data-nodeid",e.nodeId).attr("style",d._buildStyleOverride(e)),g=0;c-1>g;g++)f.append(d._template.indent);return e._nodes?f.append(a(d._template.iconWrapper).append(a(d._template.icon).addClass("click-expand").addClass(d.options.expandIcon))):e.nodes?f.append(a(d._template.iconWrapper).append(a(d._template.icon).addClass("click-collapse").addClass(d.options.collapseIcon))):f.append(a(d._template.iconWrapper).append(a(d._template.icon).addClass("glyphicon"))),f.append(a(d._template.iconWrapper).append(a(d._template.icon).addClass(e.icon?e.icon:d.options.nodeIcon))),d.options.enableLinks?f.append(a(d._template.link).attr("href",e.href).append(e.text)):f.append(e.text),d.options.showTags&&e.tags&&a.each(e.tags,function(b,c){f.append(a(d._template.badge).append(c))}),d.$wrapper.append(f),e.nodes?d._buildTree(e.nodes,c):void 0})}},_buildStyleOverride:function(a){var b="";return this.options.highlightSelected&&a===this.selectedNode?b+="color:"+this.options.selectedColor+";":a.color&&(b+="color:"+a.color+";"),this.options.highlightSelected&&a===this.selectedNode?b+="background-color:"+this.options.selectedBackColor+";":a.backColor&&(b+="background-color:"+a.backColor+";"),b},_injectStyle:function(){this.options.injectStyle&&!c.getElementById(this._styleId)&&a('").appendTo("head")},_buildStyle:function(){var a=".node-"+this._elementId+"{";return this.options.color&&(a+="color:"+this.options.color+";"),this.options.backColor&&(a+="background-color:"+this.options.backColor+";"),this.options.showBorder?this.options.borderColor&&(a+="border:1px solid "+this.options.borderColor+";"):a+="border:none;",a+="}",this.options.onhoverColor&&(a+=".node-"+this._elementId+":hover{background-color:"+this.options.onhoverColor+";}"),this._css+a},_template:{list:'
    ',item:'
  • ',indent:'',iconWrapper:'',icon:"",link:'',badge:''},_css:".list-group-item{cursor:pointer;}span.indent{margin-left:10px;margin-right:10px}span.icon{margin-right:5px}"};var g=function(a){b.console&&b.console.error(a)};a.fn[e]=function(b,c){return this.each(function(){var d=a.data(this,"plugin_"+e);"string"==typeof b?d?a.isFunction(d[b])&&"_"!==b.charAt(0)?("string"==typeof c&&(c=[c]),d[b].apply(d,c)):g("No such method : "+b):g("Not initialized, can not call method : "+b):d?d._init(b):a.data(this,"plugin_"+e,new f(this,a.extend(!0,{},b)))})}}(jQuery,window,document); -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = -W -n 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Crane.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Crane.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Crane" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Crane" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /tests/search/test_base.py: -------------------------------------------------------------------------------- 1 | import httplib 2 | import socket 3 | import urllib2 4 | import unittest2 5 | 6 | import mock 7 | 8 | from crane import exceptions 9 | from crane.search import base 10 | 11 | 12 | class TestSearchBackend(unittest2.TestCase): 13 | def setUp(self): 14 | super(TestSearchBackend, self).setUp() 15 | self.backend = base.SearchBackend() 16 | 17 | def test_search(self): 18 | with self.assertRaises(exceptions.HTTPError) as assertion: 19 | self.backend.search('foo') 20 | 21 | # by default, the base backend does not implement search, and will 22 | # cause a 404 to be returned for every call. 23 | self.assertEqual(assertion.exception.status_code, httplib.NOT_FOUND) 24 | 25 | def test_format_result(self): 26 | result = base.SearchResult('rhel', 'Red Hat Enterprise Linux', 27 | **base.SearchResult.result_defaults) 28 | 29 | ret = self.backend._format_result(result) 30 | 31 | self.assertDictEqual(ret, { 32 | 'name': 'rhel', 33 | 'description': 'Red Hat Enterprise Linux', 34 | 'is_trusted': False, 35 | 'is_official': False, 36 | 'star_count': 0, 37 | 'should_filter': True, 38 | }) 39 | 40 | def test_non_defaults(self): 41 | result = base.SearchResult('rhel', 'Red Hat Enterprise Linux', 42 | True, True, 8, False) 43 | 44 | ret = self.backend._format_result(result) 45 | 46 | self.assertDictEqual(ret, { 47 | 'name': 'rhel', 48 | 'description': 'Red Hat Enterprise Linux', 49 | 'is_trusted': True, 50 | 'is_official': True, 51 | 'star_count': 8, 52 | 'should_filter': False, 53 | }) 54 | 55 | @mock.patch('crane.app_util.repo_is_authorized', spec_set=True) 56 | @mock.patch('crane.app_util.name_is_authorized', spec_set=True) 57 | def test_filter_authorized_result(self, mock_name_is_authorized, mock_is_authorized): 58 | result = base.SearchResult('rhel', 'Red Hat Enterprise Linux', 59 | **base.SearchResult.result_defaults) 60 | mock_name_is_authorized.side_effect = exceptions.HTTPError( 61 | mock.Mock(status=404), 'not found') 62 | ret = self.backend._filter_result(result) 63 | 64 | self.assertIs(ret, True) 65 | mock_is_authorized.assert_called_once_with(result.name) 66 | 67 | @mock.patch('crane.app_util.repo_is_authorized', spec_set=True) 68 | @mock.patch('crane.app_util.name_is_authorized', spec_set=True) 69 | def test_filter_authorized_result_v2(self, mock_v2_is_authorized, mock_v1_authorized): 70 | result = base.SearchResult('rhel', 'Red Hat Enterprise Linux', 71 | True, True, 8, True) 72 | mock_v1_authorized.side_effect = exceptions.HTTPError(mock.Mock(status=404), 'not found') 73 | ret = self.backend._filter_result(result) 74 | 75 | self.assertIs(ret, True) 76 | mock_v2_is_authorized.assert_called_once_with(result.name) 77 | 78 | @mock.patch('crane.app_util.repo_is_authorized', spec_set=True) 79 | @mock.patch('crane.app_util.name_is_authorized', spec_set=True) 80 | def test_filter_non_authorized_result_v2(self, mock_v2_is_authorized, mock_v1_authorized): 81 | result = base.SearchResult('rhel', 'Red Hat Enterprise Linux', 82 | True, True, 8, True) 83 | mock_v1_authorized.side_effect = exceptions.HTTPError(mock.Mock(status=404), 'not found') 84 | mock_v2_is_authorized.side_effect = exceptions.HTTPError(mock.Mock(status=404), 'not found') 85 | ret = self.backend._filter_result(result) 86 | 87 | self.assertIs(ret, False) 88 | mock_v2_is_authorized.assert_called_once_with(result.name) 89 | 90 | @mock.patch('crane.app_util.repo_is_authorized', spec_set=True) 91 | @mock.patch('crane.app_util.name_is_authorized', spec_set=True) 92 | def test_filter_nonauthorized_result(self, mock_name_authorized, mock_is_authorized): 93 | result = base.SearchResult('rhel', 'Red Hat Enterprise Linux', 94 | **base.SearchResult.result_defaults) 95 | mock_is_authorized.side_effect = exceptions.HTTPError(httplib.NOT_FOUND) 96 | mock_name_authorized.side_effect = exceptions.HTTPError(httplib.NOT_FOUND) 97 | ret = self.backend._filter_result(result) 98 | 99 | self.assertIs(ret, False) 100 | mock_is_authorized.assert_called_once_with(result.name) 101 | 102 | 103 | @mock.patch('urllib2.urlopen', spec_set=True) 104 | class TestHTTPBackend(unittest2.TestCase): 105 | def setUp(self): 106 | super(TestHTTPBackend, self).setUp() 107 | self.backend = base.HTTPBackend() 108 | self.url = 'http://pulpproject.org/search' 109 | 110 | def test_returns_urlopen_response(self, mock_urlopen): 111 | response = mock.MagicMock() 112 | response.getcode.return_value = httplib.OK 113 | mock_urlopen.return_value = response 114 | 115 | ret = self.backend._get_data(self.url) 116 | 117 | # make sure the correct args were passed through 118 | mock_urlopen.assert_called_once_with(self.url, timeout=1) 119 | # make sure the response body is returned 120 | self.assertIs(ret, mock_urlopen.return_value.read.return_value) 121 | 122 | def test_non_200(self, mock_urlopen): 123 | response = mock.MagicMock() 124 | response.getcode.return_value = httplib.NOT_FOUND 125 | mock_urlopen.return_value = response 126 | 127 | with self.assertRaises(exceptions.HTTPError) as assertion: 128 | self.backend._get_data(self.url) 129 | 130 | # bad gateway is the correct response if the backend service returns a 131 | # non-200 response code. 132 | self.assertEqual(assertion.exception.status_code, httplib.BAD_GATEWAY) 133 | 134 | def test_timeout(self, mock_urlopen): 135 | mock_urlopen.side_effect = socket.timeout 136 | 137 | with self.assertRaises(exceptions.HTTPError) as assertion: 138 | self.backend._get_data(self.url) 139 | 140 | # make sure that a timeout talking to the backend results in the 141 | # timeout error code 142 | self.assertEqual(assertion.exception.status_code, httplib.GATEWAY_TIMEOUT) 143 | 144 | def test_urlerror(self, mock_urlopen): 145 | mock_urlopen.side_effect = urllib2.URLError(reason='?') 146 | 147 | with self.assertRaises(exceptions.HTTPError) as assertion: 148 | self.backend._get_data(self.url) 149 | 150 | # make sure that a failure to connect results in the service unavailable 151 | # error code 152 | self.assertEqual(assertion.exception.status_code, httplib.SERVICE_UNAVAILABLE) 153 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | import mock 5 | 6 | from crane import config 7 | import demo_data 8 | 9 | 10 | basepath = os.path.dirname(__file__) 11 | 12 | gsa_config_path = os.path.join(basepath, 'data/gsa/crane.conf') 13 | solr_config_path = os.path.join(basepath, 'data/solr/crane.conf') 14 | serve_content_path = os.path.join(basepath, 'data/serve_content/') 15 | 16 | 17 | class TestLoad(unittest.TestCase): 18 | def setUp(self): 19 | self.app = mock.MagicMock() 20 | self.app.config = {} 21 | 22 | @mock.patch('os.environ.get', new={config.CONFIG_ENV_NAME: '/dev/null'}.get, 23 | spec_set=True) 24 | def test_defaults(self): 25 | """ 26 | test that when no config options are specified, default values get used. 27 | """ 28 | config.load(self.app) 29 | 30 | self.assertTrue(self.app.config.get('DEBUG') is False) 31 | self.assertEqual(self.app.config.get(config.KEY_DATA_DIR), '/var/lib/crane/metadata/') 32 | self.assertEqual(self.app.config.get(config.KEY_ENDPOINT), '') 33 | self.assertTrue(self.app.config.get(config.KEY_SC_ENABLE) is False) 34 | self.assertEqual(self.app.config.get(config.KEY_SC_CONTENT_DIR_V1), 35 | '/var/www/pub/docker/v1/web/') 36 | self.assertEqual(self.app.config.get(config.KEY_SC_CONTENT_DIR_V2), 37 | '/var/www/pub/docker/v2/web/') 38 | self.assertEqual(self.app.config.get(config.KEY_DATA_POLLING_INTERVAL), 60) 39 | configured_gsa_url = self.app.config.get(config.SECTION_GSA, {}).get(config.KEY_URL) 40 | self.assertEqual(configured_gsa_url, '') 41 | configured_solr_url = self.app.config.get(config.SECTION_SOLR, {}).get(config.KEY_URL) 42 | self.assertEqual(configured_solr_url, '') 43 | 44 | @mock.patch('os.environ.get', new={config.CONFIG_ENV_NAME: solr_config_path}.get, 45 | spec_set=True) 46 | def test_solr_url(self): 47 | config.load(self.app) 48 | 49 | self.assertEqual(self.app.config.get(config.SECTION_SOLR, {}).get(config.KEY_URL), 50 | 'http://foo/bar') 51 | 52 | @mock.patch('os.environ.get', new={config.CONFIG_ENV_NAME: gsa_config_path}.get, 53 | spec_set=True) 54 | def test_gsa_url(self): 55 | config.load(self.app) 56 | 57 | self.assertEqual(self.app.config.get(config.SECTION_GSA, {}).get(config.KEY_URL), 58 | 'http://foo/bar') 59 | 60 | @mock.patch('os.environ.get', 61 | new={config.CONFIG_ENV_NAME: os.path.join(serve_content_path, 'crane.conf')}.get, 62 | spec_set=True) 63 | @mock.patch('os.path.exists', return_value=True, spec_set=True) 64 | @mock.patch.object(config._logger, 'error', spec_set=True) 65 | def test_serve_content(self, mock_error, mock_path): 66 | config.load(self.app) 67 | self.assertEqual(mock_error.call_count, 0) 68 | self.assertTrue(self.app.config.get(config.KEY_SC_ENABLE) is True) 69 | 70 | @mock.patch('os.environ.get', 71 | new={config.CONFIG_ENV_NAME: os.path.join(serve_content_path, 72 | 'crane_no_path.conf')}.get, 73 | spec_set=True) 74 | @mock.patch('os.path.exists', return_value=True, spec_set=True) 75 | @mock.patch.object(config._logger, 'error', spec_set=True) 76 | def test_serve_content_no_content_path(self, mock_error, mock_path): 77 | config.load(self.app) 78 | # make sure an error was logged and serve content is disabled 79 | self.assertEqual(mock_error.call_count, 1) 80 | self.assertTrue(self.app.config.get(config.KEY_SC_ENABLE) is False) 81 | 82 | @mock.patch('os.environ.get', 83 | new={config.CONFIG_ENV_NAME: os.path.join(serve_content_path, 'crane.conf')}.get, 84 | spec_set=True) 85 | @mock.patch('os.path.exists', return_value=False, spec_set=True) 86 | @mock.patch.object(config._logger, 'error', spec_set=True) 87 | def test_serve_content_invalid_content_path(self, mock_error, mock_path): 88 | config.load(self.app) 89 | # make sure an error was logged and serve content is disabled 90 | self.assertEqual(mock_error.call_count, 1) 91 | self.assertTrue(self.app.config.get(config.KEY_SC_ENABLE) is False) 92 | 93 | @mock.patch('pkg_resources.resource_stream', side_effect=IOError, spec_set=True) 94 | def test_defaults_not_found(self, mock_resource_stream): 95 | self.assertRaises(IOError, config.load, self.app) 96 | 97 | @mock.patch('os.environ.get', return_value='/a/b/c/idontexist', spec_set=True) 98 | def test_file_not_found(self, mock_get): 99 | self.assertRaises(IOError, config.load, self.app) 100 | 101 | @mock.patch('crane.config.CONFIG_PATH', new='/a/b/c/idontexist') 102 | def test_default_config_path_doesnt_exist(self): 103 | config.load(self.app) 104 | 105 | @mock.patch('os.environ.get', new={config.CONFIG_ENV_NAME: demo_data.demo_config_path}.get, 106 | spec_set=True) 107 | def test_demo_config(self): 108 | config.load(self.app) 109 | 110 | self.assertTrue(self.app.config.get('DEBUG') is True) 111 | 112 | configured_gsa_url = self.app.config.get(config.SECTION_GSA, {}).get(config.KEY_URL) 113 | self.assertEqual(configured_gsa_url, 'http://pulpproject.org/search') 114 | 115 | @mock.patch('crane.config.CONFIG_PATH', new='/a/b/c/idontexist') 116 | @mock.patch('os.environ.get', new={config.DEBUG_ENV_NAME: 'true'}.get, spec_set=True) 117 | def test_debug_env_variable_true(self): 118 | config.load(self.app) 119 | 120 | self.assertTrue(self.app.config.get('DEBUG') is True) 121 | 122 | @mock.patch('crane.config.CONFIG_PATH', new='/a/b/c/idontexist') 123 | @mock.patch('os.environ.get', new={config.DEBUG_ENV_NAME: 'False'}.get, spec_set=True) 124 | def test_debug_env_variable_false(self): 125 | config.load(self.app) 126 | 127 | self.assertTrue(self.app.config.get('DEBUG') is False) 128 | 129 | @mock.patch('crane.config.CONFIG_PATH', new='/a/b/c/idontexist') 130 | @mock.patch('os.environ.get', new={config.DEBUG_ENV_NAME: 'True'}.get, spec_set=True) 131 | def test_debug_env_variable_wrong_case(self): 132 | config.load(self.app) 133 | 134 | self.assertTrue(self.app.config.get('DEBUG') is True) 135 | 136 | 137 | class TestSupress(unittest.TestCase): 138 | def test_supression(self): 139 | with config.supress(ValueError): 140 | raise ValueError 141 | 142 | def test_does_not_over_supress(self): 143 | with config.supress(TypeError): 144 | self.assertRaises(ValueError, int, 'notanint') 145 | 146 | def test_supress_multiple(self): 147 | with config.supress(TypeError, ValueError): 148 | raise ValueError 149 | -------------------------------------------------------------------------------- /tests/views/test_path.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from tests.views import base 4 | 5 | 6 | class Common404CasesMixin(object): 7 | def test_invalid_repo_name(self): 8 | response = self.test_client.get('/v2/no/name/test') 9 | parsed_response_data = json.loads(response.data) 10 | 11 | self.assertEqual(response.status_code, 404) 12 | self.assertTrue(response.headers['Content-Type'].startswith('application/json')) 13 | self.assertEqual(parsed_response_data['errors'][0]['code'], '404') 14 | self.assertEqual(parsed_response_data['errors'][0]['message'], 'Not Found') 15 | 16 | def test_repo_name_without_manifest_or_tags_or_blobs(self): 17 | response = self.test_client.get('/v2/foo/test') 18 | parsed_response_data = json.loads(response.data) 19 | 20 | self.assertEqual(response.status_code, 404) 21 | self.assertTrue(response.headers['Content-Type'].startswith('application/json')) 22 | self.assertEqual(parsed_response_data['errors'][0]['code'], '404') 23 | self.assertEqual(parsed_response_data['errors'][0]['message'], 'Not Found') 24 | 25 | 26 | class TestPathRedirect(base.BaseCraneAPITest, Common404CasesMixin): 27 | 28 | def test_valid_repo_name_for_manifest(self): 29 | # #3303: verify multi-valued headers too 30 | # manifest lists are evaluated first, so pass a longer media type that 31 | # matches the manifest list as a prefix 32 | headers = {'Accept': 'application/vnd.docker.distribution.manifest.list.v2+jsonjunk,application/vnd.docker.distribution.manifest.v2+json'} # noqa 33 | response = self.test_client.get('/v2/redhat/zoo/manifests/1.25.1-musl', headers=headers) 34 | 35 | self.assertEqual(response.status_code, 302) 36 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 37 | self.assertTrue('zoo/bar/manifests/2/1.25.1-musl' in response.headers['Location']) 38 | 39 | def test_valid_repo_name_for_manifest_list(self): 40 | headers = {'Accept': 'application/vnd.docker.distribution.manifest.list.v2+json'} 41 | response = self.test_client.get('/v2/redhat/zoo/manifests/latest', headers=headers) 42 | 43 | self.assertEqual(response.status_code, 302) 44 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 45 | self.assertTrue('zoo/bar/manifests/list' in response.headers['Location']) 46 | 47 | def test_valid_repo_name_for_manifest_digest(self): 48 | headers = {'Accept': 'application/vnd.docker.distribution.manifest.v2+json'} 49 | response = self.test_client.get('/v2/redhat/foo/manifests/123456789', headers=headers) 50 | 51 | self.assertEqual(response.status_code, 302) 52 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 53 | self.assertTrue('foo/bar/manifests/1' in response.headers['Location']) 54 | 55 | def test_valid_repo_name_for_manifest_list_digest(self): 56 | headers = {'Accept': 'application/vnd.docker.distribution.manifest.list.v2+json'} 57 | response = self.test_client.get('/v2/redhat/foo/manifests/123456789', headers=headers) 58 | 59 | self.assertEqual(response.status_code, 302) 60 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 61 | self.assertTrue('foo/bar/manifests/1' in response.headers['Location']) 62 | 63 | def test_valid_repo_name_for_tags(self): 64 | response = self.test_client.get('/v2/redhat/foo/tags/latest') 65 | 66 | self.assertEqual(response.status_code, 302) 67 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 68 | self.assertTrue('foo/bar/tags/latest' in response.headers['Location']) 69 | 70 | def test_valid_repo_name_for_blobs(self): 71 | response = self.test_client.get('/v2/redhat/foo/blobs/123') 72 | 73 | self.assertEqual(response.status_code, 302) 74 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 75 | self.assertTrue('foo/bar/blobs/123' in response.headers['Location']) 76 | 77 | def test_valid_repo_name_without_trailing_slash(self): 78 | response = self.test_client.get('/v2/redhat/foo/blobs/123') 79 | 80 | self.assertEqual(response.status_code, 302) 81 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 82 | self.assertTrue('/bar/blobs/123' in response.headers['Location']) 83 | 84 | def test_valid_repo_name_when_name_has_no_slashes(self): 85 | response = self.test_client.get('/v2/registry/blobs/123') 86 | 87 | self.assertEqual(response.status_code, 302) 88 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 89 | self.assertTrue('/registry/blobs/123' in response.headers['Location']) 90 | 91 | 92 | class TestPathServeContent(base.BaseCraneAPITestServeContent, Common404CasesMixin): 93 | 94 | def test_valid_repo_name_for_manifest_list(self): 95 | content_type = 'application/vnd.docker.distribution.manifest.list.v2+json' 96 | headers = {'Accept': content_type} 97 | response = self.test_client.get('/v2/redhat/zoo/manifests/latest', headers=headers) 98 | self.verify_200(response, 'zoo/manifests/list/latest', content_type) 99 | 100 | def test_valid_repo_name_for_manifest_digest_schema_1(self): 101 | content_type = 'application/vnd.docker.distribution.manifest.v2+json' 102 | headers = {'Accept': content_type} 103 | response = self.test_client.get('/v2/redhat/zoo/manifests/latest', headers=headers) 104 | self.verify_200(response, 105 | 'zoo/manifests/1/sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf', # noqa 106 | 'application/json') 107 | 108 | def test_valid_repo_name_for_manifest_digest_schema_2(self): 109 | content_type = 'application/vnd.docker.distribution.manifest.v2+json' 110 | headers = {'Accept': content_type} 111 | response = self.test_client.get('/v2/redhat/zoo/manifests/bar', headers=headers) 112 | self.verify_200(response, 113 | 'zoo/manifests/2/sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf', # noqa 114 | content_type) 115 | 116 | def test_valid_repo_name_for_blobs(self): 117 | response = self.test_client.get('/v2/redhat/zoo/blobs/sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf') # noqa 118 | self.verify_200(response, 119 | 'zoo/blobs/sha256:c55544de64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf', # noqa 120 | 'application/octet-stream') 121 | 122 | def test_valid_repo_name_but_invalid_blob(self): 123 | response = self.test_client.get('/v2/redhat/zoo/blobs/sha256:deadbeef64a01e157b9d931f5db7a16554a14be19c367f91c9a8cdc46db086bf') # noqa 124 | self.assertEqual(response.status_code, 404) 125 | self.assertTrue(response.headers['Content-Type'].startswith('application/json')) 126 | parsed_response_data = json.loads(response.data) 127 | self.assertEqual(parsed_response_data['errors'][0]['code'], '404') 128 | self.assertEqual(parsed_response_data['errors'][0]['message'], 'Not Found') 129 | -------------------------------------------------------------------------------- /crane/views/v1.py: -------------------------------------------------------------------------------- 1 | import httplib 2 | 3 | from flask import Blueprint, json, current_app, redirect, request, send_file 4 | 5 | from .. import app_util 6 | from .. import config 7 | from .. import exceptions 8 | from ..api import repository, images 9 | from .. import search as search_package 10 | 11 | section = Blueprint('v1', __name__, url_prefix='/v1') 12 | 13 | 14 | @section.after_request 15 | def add_common_headers(response): 16 | """ 17 | Add headers to a response. 18 | 19 | All 200 responses get a content type of 'application/json' if no other content type was set, 20 | and all others retain their default. 21 | 22 | Headers are added to make this app look like the actual docker-registry. 23 | 24 | :param response: flask response object for a request 25 | :type response: flask.Response 26 | 27 | :return: a response object that has the correct headers 28 | :rtype: flask.Response 29 | """ 30 | # Set default content type to 'application/json' if response code is 200 and 31 | # content type isn't already set explicitly 32 | content_type = response.headers.get('Content-Type', '') 33 | if response.status_code == 200 and not content_type.startswith('application/'): 34 | response.headers['Content-Type'] = 'application/json' 35 | # current stable release of docker-registry 36 | response.headers['X-Docker-Registry-Version'] = '0.6.6' 37 | # "common" is documented by docker-registry as a valid config, but I am 38 | # just guessing that it will work in our case. 39 | response.headers['X-Docker-Registry-Config'] = 'common' 40 | 41 | return response 42 | 43 | 44 | @section.route('/_ping') 45 | def ping(): 46 | # "True" is what the real docker-registry puts in the response body 47 | response = current_app.make_response(json.dumps(True)) 48 | response.headers['X-Docker-Registry-Standalone'] = True 49 | return response 50 | 51 | 52 | @section.route('/users', methods=['GET', 'POST']) 53 | @section.route('/users/', methods=['GET', 'POST']) 54 | def users(): 55 | """ 56 | Undocumented API path required for docker login when apache configured for auth 57 | See get_post_users() 58 | https://github.com/docker/docker-registry/blob/master/docker_registry/index.py 59 | """ 60 | if request.method == 'GET': 61 | response = current_app.make_response(json.dumps('OK')) 62 | response.headers['X-Docker-Registry-Standalone'] = True 63 | return response 64 | response = current_app.make_response((json.dumps('User Created'), 201)) 65 | response.headers['Content-Type'] = 'application/json' 66 | response.headers['X-Docker-Registry-Standalone'] = True 67 | return response 68 | 69 | 70 | @section.route('/repositories//images') 71 | def repo_images(repo_id): 72 | """ 73 | Returns a json document containing a list of image IDs that are in the 74 | repository with the given repo_id. 75 | 76 | Adds the "X-Docker-Endpoints" header. 77 | 78 | :param repo_id: unique ID for the repository. May contain 0 or 1 of the "/" 79 | character. 80 | :type repo_id: basestring 81 | 82 | :return: json string containing a list of image IDs 83 | :rtype: basestring 84 | """ 85 | repo_id = app_util.validate_and_transform_repoid(repo_id) 86 | 87 | images_in_repo = repository.get_images_for_repo(repo_id) 88 | response = current_app.make_response(images_in_repo) 89 | # use the configured endpoint if any, otherwise default to the host of 90 | # the current request. 91 | configured_endpoint = current_app.config.get(config.KEY_ENDPOINT) 92 | response.headers['X-Docker-Endpoints'] = configured_endpoint or request.host 93 | return response 94 | 95 | 96 | @section.route('/repositories//tags') 97 | def repo_tags(repo_id): 98 | """ 99 | Returns a json document containing an object that maps tag names to image 100 | IDs. 101 | 102 | :param repo_id: unique ID for the repository. May contain 0 or 1 of the "/" 103 | character. For repo IDs that do not contain a slash, the 104 | docker client currently prepends "library/" when making 105 | this call. This function strips that off. 106 | :type repo_id: basestring 107 | 108 | :return: json string containing an object mapping tag names to image IDs 109 | :rtype: basestring 110 | """ 111 | repo_id = app_util.validate_and_transform_repoid(repo_id) 112 | 113 | return repository.get_tags_for_repo(repo_id) 114 | 115 | 116 | @section.route('/repositories//tags/') 117 | def repo_tags_get_tag(repo_id, tag_name): 118 | """ 119 | Returns a json containing an object that has image id associated with the tag 120 | name. 121 | 122 | :param repo_id: unique ID for the repository. May contain 0 or 1 of the "/" 123 | character. For repo IDs that do not contain a slash, the 124 | docker client currently prepends "library/" when making 125 | this call. This function strips that off. 126 | :type repo_id: basestring 127 | 128 | :param tag_name: name of the tag whose associated image id has to be returned 129 | :type tag_name: basestring 130 | 131 | :return: json string containing an object having image id associated with tag name 132 | :rtype: basestring 133 | """ 134 | repo_id = app_util.validate_and_transform_repoid(repo_id) 135 | 136 | tags = repository.get_tags_for_repo(repo_id) 137 | image_id = json.loads(tags).get(tag_name) 138 | if image_id is None: 139 | raise exceptions.HTTPError(httplib.NOT_FOUND) 140 | return json.dumps(image_id) 141 | 142 | 143 | @section.route('/search') 144 | def search(): 145 | """ 146 | Returns a json document containing search results in the format expected 147 | from the docker index API. 148 | 149 | :return: json structure containing search results 150 | :rtype: basestring 151 | 152 | :raises exceptions.HTTPError: if "q" is missing in the url's parameters, 153 | raises with 400 response coce 154 | """ 155 | query = request.args.get('q', '') 156 | 157 | if not query: 158 | raise exceptions.HTTPError(httplib.BAD_REQUEST, message='parameter "q" is required') 159 | 160 | data = list(search_package.backend.search(query)) 161 | response = { 162 | 'query': query, 163 | 'num_results': len(data), 164 | 'results': data, 165 | } 166 | return json.dumps(response) 167 | 168 | 169 | @section.route('/images//') 170 | def images_serve_or_redirect(image_id, filename): 171 | """ 172 | Redirects (302) the client to a path where it can access the requested file. 173 | If 'serve_content' is set to true use send_file to provide the requested file directly, 174 | taking into account the 'content_dir_v1' parameter. 175 | 176 | :param image_id: the full unique ID of a docker image 177 | :type image_id: basestring 178 | :param filename: one of "ancestry", "json", or "layer". 179 | :type filename: basestring 180 | 181 | :return: 302 redirect response 182 | :rtype: flask.Response 183 | """ 184 | serve_content = current_app.config.get(config.KEY_SC_ENABLE) 185 | 186 | if serve_content: 187 | image, mimetype = images.get_image_file_path(image_id, filename) 188 | try: 189 | return send_file(image, mimetype=mimetype) 190 | except OSError: 191 | raise exceptions.HTTPError(httplib.NOT_FOUND) 192 | else: 193 | image_url = images.get_image_file_url(image_id, filename) 194 | return redirect(image_url) 195 | -------------------------------------------------------------------------------- /crane/config.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | from ConfigParser import ConfigParser, NoSectionError, NoOptionError 3 | from contextlib import contextmanager 4 | import logging 5 | import os 6 | 7 | import pkg_resources 8 | 9 | 10 | _logger = logging.getLogger(__name__) 11 | 12 | 13 | # the default location for a user-defined config file 14 | CONFIG_PATH = '/etc/crane.conf' 15 | # the environment variable whose value can override the CONFIG_PATH 16 | CONFIG_ENV_NAME = 'CRANE_CONFIG_PATH' 17 | # the environment variable whose value can override the debug setting 18 | DEBUG_ENV_NAME = 'CRANE_DEBUG' 19 | # the resource path for the config file containing default values 20 | DEFAULT_CONFIG_RESOURCE = 'data/default_config.conf' 21 | 22 | # general app settings 23 | SECTION_GENERAL = 'general' 24 | KEY_DEBUG = 'debug' 25 | KEY_DATA_DIR = 'data_dir' 26 | KEY_DATA_POLLING_INTERVAL = 'data_dir_polling_interval' 27 | KEY_ENDPOINT = 'endpoint' 28 | 29 | # cdn rewrite settings 30 | SECTION_CDN = 'cdn' 31 | KEY_URL_MATCH = 'url_match' 32 | KEY_URL_REPLACE = 'url_replace' 33 | KEY_URL_AUTH_SECRET = 'url_auth_secret' 34 | KEY_URL_AUTH_PARAM = 'url_auth_param' 35 | KEY_URL_AUTH_TTL = 'url_auth_ttl' 36 | KEY_URL_AUTH_ALGO = 'url_auth_algo' 37 | VALID_AUTH_ALGO = ["sha256", "sha1", "md5"] 38 | 39 | # serve content settings 40 | SECTION_SERVE_CONTENT = 'serve_content' 41 | KEY_SC_ENABLE = 'enable' 42 | KEY_SC_CONTENT_DIR_V1 = 'content_dir_v1' 43 | KEY_SC_CONTENT_DIR_V2 = 'content_dir_v2' 44 | KEY_SC_USE_X_SENDFILE = 'use_x_sendfile' 45 | 46 | # google search appliance settings 47 | SECTION_GSA = 'gsa' 48 | SECTION_SOLR = 'solr' 49 | KEY_URL = 'url' 50 | 51 | 52 | def load(app): 53 | """ 54 | Load the configuration and apply it to the app. 55 | 56 | :param app: a flask app 57 | :type app: Flask 58 | 59 | :raises IOError: iff a non-default config path is specified but does not exist 60 | """ 61 | # load default values from the included default config file 62 | try: 63 | with pkg_resources.resource_stream(__package__, DEFAULT_CONFIG_RESOURCE) as default_config: 64 | parser = ConfigParser() 65 | parser.readfp(default_config) 66 | read_config(app, parser) 67 | except IOError: 68 | _logger.error('could not open default config file') 69 | raise 70 | 71 | # load user-specified config values 72 | config_path = os.environ.get(CONFIG_ENV_NAME) or CONFIG_PATH 73 | try: 74 | with open(config_path) as config_file: 75 | parser = ConfigParser() 76 | parser.readfp(config_file) 77 | read_config(app, parser) 78 | _logger.info('config loaded from %s' % config_path) 79 | except IOError: 80 | if config_path != CONFIG_PATH: 81 | _logger.error('config file not found at path %s' % config_path) 82 | raise 83 | # if the user did not specify a config path and there is not a file 84 | # at the default path, just use the default settings. 85 | _logger.info('no config specified or found, so using defaults') 86 | 87 | 88 | def read_config(app, parser): 89 | """ 90 | Read the configuration from the parser, and apply it to the app 91 | 92 | :param app: a flask app 93 | :type app: Flask 94 | :param parser: a ConfigParser that has a file already loaded 95 | :type parser: ConfigParser 96 | """ 97 | # "general" section settings 98 | with supress(NoSectionError): 99 | app.config['DEBUG'] = parser.getboolean(SECTION_GENERAL, KEY_DEBUG) 100 | # parse other "general" section values 101 | for key in (KEY_DATA_DIR, KEY_ENDPOINT): 102 | with supress(NoOptionError): 103 | app.config[key] = parser.get(SECTION_GENERAL, key) 104 | # parse "general" section values as integers 105 | for key in (KEY_DATA_POLLING_INTERVAL, ): 106 | with supress(NoOptionError): 107 | app.config[key] = int(parser.get(SECTION_GENERAL, key)) 108 | 109 | app.config['DEBUG'] = app.config.get('DEBUG') or \ 110 | os.environ.get(DEBUG_ENV_NAME, '').lower() == 'true' 111 | 112 | # "cdn" support for URL rewriting and token authorization 113 | with supress(NoSectionError): 114 | section = app.config.setdefault(SECTION_CDN, {}) 115 | 116 | # parse general values 117 | for key in (KEY_URL_MATCH, KEY_URL_REPLACE, KEY_URL_AUTH_PARAM,): 118 | with supress(NoOptionError): 119 | section[key] = parser.get(SECTION_CDN, key) 120 | 121 | # parse values as integers 122 | for key in (KEY_URL_AUTH_TTL,): 123 | with supress(NoOptionError): 124 | section[key] = int(parser.get(SECTION_CDN, key)) 125 | 126 | # parse secret and assign only if valid hex ascii string 127 | with supress(NoOptionError): 128 | secret = parser.get(SECTION_CDN, KEY_URL_AUTH_SECRET) 129 | if secret: 130 | try: 131 | binascii.a2b_hex(secret) 132 | section[KEY_URL_AUTH_SECRET] = secret 133 | except TypeError: 134 | _logger.error('skipping config option %s because it is not a valid hex ' 135 | 'string' % KEY_URL_AUTH_SECRET) 136 | 137 | # parse secret and assign only if valid hex ascii string 138 | with supress(NoOptionError): 139 | algo = parser.get(SECTION_CDN, KEY_URL_AUTH_ALGO) 140 | if algo: 141 | if algo in VALID_AUTH_ALGO: 142 | section[KEY_URL_AUTH_ALGO] = algo 143 | else: 144 | _logger.error('value for config option %s is not a valid choice. falling back ' 145 | 'to default' % KEY_URL_AUTH_ALGO) 146 | 147 | # "serve_content" section settings 148 | with supress(NoSectionError): 149 | with supress(NoOptionError): 150 | app.config[KEY_SC_ENABLE] = parser.getboolean(SECTION_SERVE_CONTENT, KEY_SC_ENABLE) 151 | with supress(NoOptionError): 152 | app.config['USE_X_SENDFILE'] = parser.getboolean(SECTION_SERVE_CONTENT, 153 | KEY_SC_USE_X_SENDFILE) 154 | # local content dir only required if crane should serve content 155 | for key_local_content_dir in (KEY_SC_CONTENT_DIR_V1, KEY_SC_CONTENT_DIR_V2): 156 | with supress(NoOptionError): 157 | app.config[key_local_content_dir] = parser.get(SECTION_SERVE_CONTENT, 158 | key_local_content_dir) 159 | if app.config[KEY_SC_ENABLE]: 160 | if not app.config[key_local_content_dir]: 161 | _logger.error('"serve_content" enabled in config, but no "%s" given, disabling the serve content feature!' % key_local_content_dir) # noqa 162 | app.config[KEY_SC_ENABLE] = False 163 | elif not os.path.exists(app.config[key_local_content_dir]): 164 | _logger.error('The directory specified by "%s" does not exist: "%s". Disabling the serve content feature!' % (key_local_content_dir, app.config[key_local_content_dir])) # noqa 165 | app.config[KEY_SC_ENABLE] = False 166 | 167 | # "gsa" (Google Search Appliance) section settings 168 | with supress(NoSectionError): 169 | section = app.config.setdefault(SECTION_GSA, {}) 170 | 171 | for key in (KEY_URL,): 172 | with supress(NoOptionError): 173 | section[key] = parser.get(SECTION_GSA, key) 174 | 175 | # "solr" section settings 176 | with supress(NoSectionError): 177 | section = app.config.setdefault(SECTION_SOLR, {}) 178 | 179 | for key in (KEY_URL,): 180 | with supress(NoOptionError): 181 | section[key] = parser.get(SECTION_SOLR, key) 182 | 183 | 184 | @contextmanager 185 | def supress(*exceptions): 186 | """ 187 | backported from python 3.4, because it's simple and awesome 188 | 189 | https://docs.python.org/3.4/library/contextlib.html#contextlib.suppress 190 | 191 | :param exceptions: list of Exception or subclasses 192 | :type exceptions: list 193 | """ 194 | try: 195 | yield 196 | except exceptions: 197 | pass 198 | -------------------------------------------------------------------------------- /crane/data.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import logging 3 | import os 4 | import threading 5 | import time 6 | import urlparse 7 | import fnmatch 8 | from flask import json 9 | 10 | from . import config 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | v1_response_data = { 16 | 'repos': {}, 17 | 'images': {}, 18 | } 19 | 20 | v2_response_data = { 21 | 'repos': {} 22 | } 23 | 24 | V1Repo = namedtuple('V1Repo', ['url', 'repository', 'images_json', 'tags_json', 25 | 'url_path', 'protected']) 26 | V2Repo = namedtuple('V2Repo', ['url', 'repository', 'url_path', 'protected']) 27 | V3Repo = namedtuple('V3Repo', ['url', 'repository', 'url_path', 'schema2_data', 'protected']) 28 | V4Repo = namedtuple('V4Repo', ['url', 'repository', 'url_path', 'schema2_data', 29 | 'manifest_list_data', 'manifest_list_amd64_tags', 'protected']) 30 | 31 | 32 | def load_from_file(path): 33 | """ 34 | Load one specific repository's metadata from a json file 35 | 36 | :param path: full path to the json file 37 | :type path: basestring 38 | 39 | :return: tuple of repo_id (str), repo_tuple (Repo), image_ids (list) 40 | if the metadata corresponds to v1 registry. If metadata is for 41 | v2 registry, image_ids will be None. 42 | :rtype: tuple 43 | 44 | :raises ValueError: if the "version" value in the metadata is not a supported 45 | metadata schema version 46 | """ 47 | with open(path) as json_file: 48 | repo_data = json.load(json_file) 49 | 50 | if repo_data['version'] not in (1, 2, 3, 4): 51 | raise ValueError('metadata version %d not supported' % repo_data['version']) 52 | 53 | repo_id = repo_data['repo-registry-id'] 54 | url_path = urlparse.urlparse(repo_data['url']).path 55 | repository = repo_data['repository'] 56 | 57 | if repo_data['version'] == 1: 58 | image_ids = [image['id'] for image in repo_data['images']] 59 | repo_tuple = V1Repo(repo_data['url'], 60 | repository, 61 | json.dumps(repo_data['images']), 62 | json.dumps(repo_data['tags']), 63 | url_path, repo_data.get('protected', False)) 64 | return repo_id, repo_tuple, image_ids 65 | elif repo_data['version'] == 2: 66 | repo_tuple = V2Repo(repo_data['url'], 67 | repository, 68 | url_path, repo_data.get('protected', False)) 69 | return repo_id, repo_tuple, None 70 | elif repo_data['version'] == 3: 71 | repo_tuple = V3Repo(repo_data['url'], 72 | repository, 73 | url_path, 74 | json.dumps(repo_data['schema2_data']), 75 | repo_data.get('protected', False)) 76 | return repo_id, repo_tuple, None 77 | elif repo_data['version'] == 4: 78 | repo_tuple = V4Repo(repo_data['url'], 79 | repository, 80 | url_path, 81 | json.dumps(repo_data['schema2_data']), 82 | json.dumps(repo_data['manifest_list_data']), 83 | json.dumps(repo_data['manifest_list_amd64_tags']), 84 | repo_data.get('protected', False)) 85 | return repo_id, repo_tuple, None 86 | 87 | 88 | def monitor_data_dir(app, last_modified=0): 89 | """ 90 | Loop forever monitoring the data directory for changes and reload the data if any changes occur 91 | This checks for updates at the interval defined in the config file (Defaults to 60 seconds) 92 | 93 | :param app: the flask application 94 | :type app: flask.Flask 95 | :param last_modified: seconds since the epoch; if the data on disk is modified 96 | after this time, it must be re-loaded. 97 | :type last_modified: int or float 98 | """ 99 | data_dir = app.config[config.KEY_DATA_DIR] 100 | polling_interval = app.config[config.KEY_DATA_POLLING_INTERVAL] 101 | if not os.path.exists(data_dir): 102 | logger.error('The data directory specified does not exist: %s' % data_dir) 103 | 104 | while True: 105 | # Find all the subdirectories 106 | dirs = [dirpath for (dirpath, _, _) in os.walk(data_dir, 107 | followlinks=True)] 108 | 109 | # Find the most recent mtime 110 | if dirs and not last_modified: 111 | try: 112 | last_modified = max(os.stat(dir).st_mtime for dir in dirs) 113 | except OSError: 114 | # One of the directories no longer exists since the 115 | # os.walk() call above. Load everything, sleep, and 116 | # try again. 117 | pass 118 | 119 | # Load everything 120 | load_all(app) 121 | 122 | # Wait for something to change 123 | while True: 124 | time.sleep(polling_interval) 125 | if not dirs: 126 | # The data directory didn't exist before so re-check 127 | break 128 | 129 | # Check if the modified time on any directory is newer 130 | # than the most recent change we saw 131 | try: 132 | logger.debug('Checking for new metadata files') 133 | most_recent = max(os.stat(dir).st_mtime for dir in dirs) 134 | if most_recent > last_modified: 135 | last_modified = most_recent 136 | break 137 | except OSError: 138 | # One of the directories no longer exists 139 | break 140 | 141 | 142 | def start_monitoring_data_dir(app): 143 | """ 144 | Spin off a daemon thread that monitors the data dir for changes and updates the app config 145 | if any changes occur. This will guarantee a refresh of the app data the first time it is run 146 | 147 | :param app: the flask application 148 | :type app: flask.Flask 149 | """ 150 | now = time.time() 151 | # load the data once in a blocking fashion 152 | load_all(app) 153 | thread = threading.Thread(target=monitor_data_dir, args=(app, now)) 154 | thread.setDaemon(True) 155 | thread.start() 156 | 157 | 158 | def load_all(app): 159 | """ 160 | Load all metadata files and replace the "response_data" value in this 161 | module. 162 | 163 | :param app: the flask application 164 | :type app: flask.Flask 165 | """ 166 | global v2_response_data 167 | v2_repos = {} 168 | 169 | global v1_response_data 170 | v1_repos = {} 171 | images = {} 172 | 173 | try: 174 | data_dir = app.config[config.KEY_DATA_DIR] 175 | logger.info('loading metadata from %s' % data_dir) 176 | # scan data dir recursively and pick json files 177 | paths = [os.path.join(dirpath, f) 178 | for dirpath, dirnames, files in os.walk(data_dir, 179 | followlinks=True) 180 | for f in fnmatch.filter(files, '*.json')] 181 | # load data from each file 182 | for metadata_file_path in paths: 183 | try: 184 | logger.debug('loading: %s' % metadata_file_path) 185 | repo_id, repo_tuple, image_ids = load_from_file(metadata_file_path) 186 | except Exception, e: 187 | logger.error('skipping current metadata load: %s' % str(e)) 188 | continue 189 | 190 | if isinstance(repo_tuple, V1Repo): 191 | v1_repos[repo_id] = repo_tuple 192 | for image_id in image_ids: 193 | images.setdefault(image_id, set()).add(repo_id) 194 | else: 195 | v2_repos[repo_id] = repo_tuple 196 | # make each set immutable 197 | for image_id in images.keys(): 198 | images[image_id] = frozenset(images[image_id]) 199 | # replace old data structure with new 200 | v1_response_data = { 201 | 'repos': v1_repos, 202 | 'images': images, 203 | } 204 | v2_response_data = { 205 | 'repos': v2_repos 206 | } 207 | logger.info('finished loading metadata') 208 | except Exception, e: 209 | logger.error('aborting metadata load: %s' % str(e)) 210 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Crane documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Nov 27 11:03:47 2012. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.intersphinx', 'sphinx.ext.extlinks'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'Crane' 44 | copyright = u'2014-2016, Pulp Team' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = '3.3a1' 52 | # The full version, including alpha/beta/rc tags. 53 | release = '3.3a1' 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | #language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | #today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | #today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = ['_build'] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | html_theme = 'default' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | # html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_domain_indices = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 153 | #html_show_sphinx = True 154 | 155 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 156 | #html_show_copyright = True 157 | 158 | # If true, an OpenSearch description file will be output, and all pages will 159 | # contain a tag referring to it. The value of this option must be the 160 | # base URL from which the finished HTML is served. 161 | #html_use_opensearch = '' 162 | 163 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 164 | #html_file_suffix = None 165 | 166 | # Output file base name for HTML help builder. 167 | htmlhelp_basename = 'Cranedoc' 168 | 169 | 170 | # -- Options for LaTeX output -------------------------------------------------- 171 | 172 | latex_elements = { 173 | # The paper size ('letterpaper' or 'a4paper'). 174 | #'papersize': 'letterpaper', 175 | 176 | # The font size ('10pt', '11pt' or '12pt'). 177 | #'pointsize': '10pt', 178 | 179 | # Additional stuff for the LaTeX preamble. 180 | #'preamble': '', 181 | } 182 | 183 | # Grouping the document tree into LaTeX files. List of tuples 184 | # (source start file, target name, title, author, documentclass [howto/manual]). 185 | latex_documents = [ 186 | ('index', 'Crane.tex', u'Crane Documentation', 187 | u'Pulp Team', 'manual'), 188 | ] 189 | 190 | # The name of an image file (relative to this directory) to place at the top of 191 | # the title page. 192 | #latex_logo = None 193 | 194 | # For "manual" documents, if this is true, then toplevel headings are parts, 195 | # not chapters. 196 | #latex_use_parts = False 197 | 198 | # If true, show page references after internal links. 199 | #latex_show_pagerefs = False 200 | 201 | # If true, show URL addresses after external links. 202 | #latex_show_urls = False 203 | 204 | # Documents to append as an appendix to all manuals. 205 | #latex_appendices = [] 206 | 207 | # If false, no module index is generated. 208 | #latex_domain_indices = True 209 | 210 | 211 | # -- Options for manual page output -------------------------------------------- 212 | 213 | # One entry per manual page. List of tuples 214 | # (source start file, name, description, authors, manual section). 215 | man_pages = [ 216 | ('index', 'crane', u'Crane Documentation', 217 | [u'Pulp Team'], 1) 218 | ] 219 | 220 | # If true, show URL addresses after external links. 221 | #man_show_urls = False 222 | 223 | 224 | # -- Options for Texinfo output ------------------------------------------------ 225 | 226 | # Grouping the document tree into Texinfo files. List of tuples 227 | # (source start file, target name, title, author, 228 | # dir menu entry, description, category) 229 | texinfo_documents = [ 230 | ('index', 'Crane', u'Crane Documentation', 231 | u'Pulp Team', 'Crane', 'One line description of project.', 232 | 'Miscellaneous'), 233 | ] 234 | 235 | # Documents to append as an appendix to all manuals. 236 | #texinfo_appendices = [] 237 | 238 | # If false, no module index is generated. 239 | #texinfo_domain_indices = True 240 | 241 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 242 | #texinfo_show_urls = 'footnote' 243 | 244 | # the "platform" URL needs to point to the correct version of platform docs for 245 | # this branch of the plugin. It is currently set to "latest" but may change as 246 | # code is branched and new RTD builders are created for platform. 247 | 248 | intersphinx_mapping = {'pylang': ('http://docs.python.org/2.7/', None), 249 | 'platform': ("http://pulp.readthedocs.org/en/latest/", None)} 250 | 251 | extlinks = { 252 | 'redmine': ('https://pulp.plan.io/issues/%s', '#'), 253 | 'fixedbugs': ('https://pulp.plan.io/projects/crane/issues?c%%5B%%5D=tracker&c%%5B%%5D=status&' 254 | 'c%%5B%%5D=priority&c%%5B%%5D=cf_5&c%%5B%%5D=subject&c%%5B%%5D=author&c%%5B%%5D' 255 | '=assigned_to&c%%5B%%5D=cf_3&f%%5B%%5D=cf_13&f%%5B%%5D=tracker_id&f%%5B%%5D=&gr' 256 | 'oup_by=&op%%5Bcf_13%%5D=%%3D&op%%5Btracker_id%%5D=%%3D&set_filter=1&sort=prior' 257 | 'ity%%3Adesc%%2Ccf_5%%3Adesc&utf8=%%E2%%9C%%93&v%%5Bcf_13%%5D%%5B%%5D=%s&v%%5Bt' 258 | 'racker_id%%5D%%5B%%5D=1', 'bugs fixed in ') 259 | } 260 | 261 | -------------------------------------------------------------------------------- /crane/views/v2.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import httplib 3 | import logging 4 | import os 5 | import urlparse 6 | import time 7 | from flask import Blueprint, json, current_app, redirect, request, send_file 8 | 9 | from crane import app_util, exceptions, config 10 | from crane.api import repository 11 | 12 | log = logging.getLogger(__name__) 13 | section = Blueprint('v2', __name__, url_prefix='/v2') 14 | 15 | 16 | @section.after_request 17 | def add_common_headers(response): 18 | """ 19 | Add headers to a response. 20 | 21 | All 200 responses get a content type of 'application/json' if no other content type was set, 22 | and all others retain their default. 23 | 24 | Headers are added to make this app look like the actual docker-registry. 25 | 26 | :param response: flask response object for a request 27 | :type response: flask.Response 28 | 29 | :return: a response object that has the correct headers 30 | :rtype: flask.Response 31 | """ 32 | if response.status_code == 200: 33 | # Set default content type to 'application/json' if response code is 200 and 34 | # content type isn't already set explicitly 35 | content_type = response.headers.get('Content-Type', '') 36 | if not content_type.startswith('application/'): 37 | response.headers['Content-Type'] = 'application/json' 38 | response.headers['Docker-Distribution-API-Version'] = 'registry/2.0' 39 | return response 40 | 41 | 42 | @section.route('/') 43 | def v2(): 44 | """ 45 | Provides version support information for /v2 requests. 46 | 47 | :return: Empty JSON document in the response body 48 | :rtype: flask.response 49 | """ 50 | # "{}" is what the real docker-registry puts in the response body 51 | response = current_app.make_response(json.dumps({})) 52 | return response 53 | 54 | 55 | @section.route('/') 56 | def name_serve_or_redirect(relative_path): 57 | """ 58 | Redirects the client to the path from where the file can be accessed. 59 | If 'serve_content' is set to true use send_file to provide the requested file directly, 60 | taking into account the 'content_dir_v2' parameter. 61 | 62 | :param relative_path: the relative path after /v2/. 63 | :type relative_path: basestring 64 | 65 | :return: 302 redirect response 66 | :rtype: flask.Response 67 | """ 68 | components = app_util.validate_and_transform_repo_name(relative_path) 69 | name_component, path_component, component_type = components 70 | base_url = repository.get_path_for_repo(name_component) 71 | if not base_url.endswith('/'): 72 | base_url += '/' 73 | schema2_data = repository.get_schema2_data_for_repo(name_component) 74 | used_mediatype = 'application/json' if component_type != 'blobs' else 'application/octet-stream' 75 | 76 | if component_type == 'manifests' and schema2_data is not None: 77 | manifest_list_data = repository.get_manifest_list_data_for_repo(name_component) 78 | manifest_list_amd64_tags = repository.get_manifest_list_amd64_for_repo(name_component) 79 | if schema2_data: 80 | schema2_data = json.loads(schema2_data) 81 | if manifest_list_data: 82 | manifest_list_data = json.loads(manifest_list_data) 83 | if manifest_list_amd64_tags: 84 | manifest_list_amd64_tags = json.loads(manifest_list_amd64_tags) 85 | manifest, identifier = path_component.split('/') 86 | if schema2_data or manifest_list_data: 87 | # if it is a newer docker client it sets accept headers to manifest schema 1, 2 and list 88 | # if it is an older docker client, he doesnot set any of accept headers 89 | accept_headers = get_accept_headers(request) 90 | schema2_mediatype = 'application/vnd.docker.distribution.manifest.v2+json' 91 | manifest_list_mediatype = 'application/vnd.docker.distribution.manifest.list.v2+json' 92 | # check first manifest list type 93 | if manifest_list_mediatype in accept_headers and identifier in manifest_list_data: 94 | path_component = os.path.join(manifest, 'list', identifier) 95 | used_mediatype = manifest_list_mediatype 96 | # this is needed for older clients which do not understand manifest list 97 | elif identifier in manifest_list_amd64_tags.keys(): 98 | if schema2_mediatype in accept_headers: 99 | schema_version = manifest_list_amd64_tags[identifier][1] 100 | if schema_version == 2: 101 | used_mediatype = schema2_mediatype 102 | path_component = os.path.join( 103 | manifest, str(schema_version), 104 | manifest_list_amd64_tags[identifier][0]) 105 | elif manifest_list_amd64_tags[identifier][1] == 1: 106 | path_component = os.path.join( 107 | manifest, '1', manifest_list_amd64_tags[identifier][0]) 108 | # this is needed in case when there is no amd64 image manifest, but there are within 109 | # one repo manifest list and image manifest with the same tag 110 | else: 111 | path_component = os.path.join(manifest, '1', identifier) 112 | elif schema2_mediatype in accept_headers and identifier in schema2_data: 113 | path_component = os.path.join(manifest, '2', identifier) 114 | used_mediatype = schema2_mediatype 115 | else: 116 | path_component = os.path.join(manifest, '1', identifier) 117 | # this is needed for V3Repo which do not have schema2 manifests 118 | else: 119 | path_component = os.path.join(manifest, '1', identifier) 120 | 121 | serve_content = current_app.config.get(config.KEY_SC_ENABLE) 122 | if serve_content: 123 | base_path = current_app.config.get(config.KEY_SC_CONTENT_DIR_V2) 124 | repo_name = repository.get_pulp_repository_name(name_component) 125 | result = os.path.join(base_path, repo_name, path_component) 126 | 127 | try: 128 | return send_file(result, mimetype=used_mediatype, 129 | add_etags=False) 130 | except OSError: 131 | raise exceptions.HTTPError(httplib.NOT_FOUND) 132 | else: 133 | url = base_url + path_component 134 | 135 | # perform CDN rewrites and auth 136 | url = cdn_rewrite_redirect_url(url) 137 | url = cdn_auth_token_url(url) 138 | return redirect(url) 139 | 140 | 141 | @section.errorhandler(exceptions.HTTPError) 142 | def handle_error(error): 143 | """ 144 | Creates a v2 compatible error response. 145 | 146 | :param error: exception raised to indicate that an HTTP error response 147 | should be generated and returned. 148 | :type error: crane.exceptions.HTTPError 149 | 150 | :return: error details in json within response body. 151 | :rtype: flask.response 152 | """ 153 | data = {"errors": [dict(code=str(error.status_code), 154 | message=error.message or httplib.responses[error.status_code])]} 155 | response = current_app.make_response(json.dumps(data)) 156 | response.headers['Content-Type'] = 'application/json' 157 | response.status_code = error.status_code 158 | return response 159 | 160 | 161 | def get_accept_headers(request): 162 | """ 163 | Parse the Accept: request header and return a set of media types. 164 | 165 | WSGI will turn multiple Accept: headers into a comma-separated string, 166 | which is expected according to HTTP standards. 167 | 168 | https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html 169 | 170 | :param request: flask request object for a request 171 | :type request: flask.Request 172 | 173 | :return: set of Accept: headers 174 | :rtype: set 175 | """ 176 | accept_headers = request.headers.get('Accept') 177 | log.debug("Accept headers from client: %s", accept_headers) 178 | if not accept_headers: 179 | return set() 180 | accept_headers = accept_headers.split(',') 181 | # Accept headers may contain additional quality parameters after ; 182 | # We will simply discard that for now 183 | return set(x.partition(';')[0].strip() for x in accept_headers) 184 | 185 | 186 | def cdn_rewrite_redirect_url(url): 187 | """ 188 | Rewrites the redirect URL by performing a simple match and replace. 189 | 190 | Returns the unmodified URL if both the url_match and url_replace options 191 | are not specified. 192 | 193 | :param url: URL for redirect 194 | :type url: string 195 | 196 | :return: rewritten URL (if configured) 197 | :rtype: string 198 | """ 199 | url_match = current_app.config.get(config.SECTION_CDN, {}).get(config.KEY_URL_MATCH) 200 | url_replace = current_app.config.get(config.SECTION_CDN, {}).get(config.KEY_URL_REPLACE) 201 | if url_match and url_replace: 202 | return url.replace(url_match, url_replace) 203 | return url 204 | 205 | 206 | def cdn_auth_token_url(url): 207 | """ 208 | Adds the token auth param to the redirect URL following Akamai's Auth Token 209 | 2.0 Specification. 210 | 211 | Returns the unmodified URL if the auth_secret configuration option is not 212 | specified. 213 | 214 | :param url: URL for redirect 215 | :type url: string 216 | 217 | :return: URL with authorization token (if configured) 218 | :rtype: string 219 | """ 220 | auth_secret = current_app.config.get(config.SECTION_CDN, {}).get(config.KEY_URL_AUTH_SECRET) 221 | if auth_secret: 222 | cdn_path = urlparse.urlparse(url).path 223 | auth_param = current_app.config.get(config.SECTION_CDN, {}).get(config.KEY_URL_AUTH_PARAM) 224 | auth_ttl = current_app.config.get(config.SECTION_CDN, {}).get(config.KEY_URL_AUTH_TTL) 225 | auth_exp = int(time.time()) + auth_ttl 226 | auth_algo = current_app.config.get(config.SECTION_CDN, {}).\ 227 | get(config.KEY_URL_AUTH_ALGO).lower() 228 | 229 | auth_token = app_util.generate_cdn_url_token(cdn_path, auth_secret, auth_exp, auth_algo) 230 | auth_qs = '?%s=%s' % (auth_param, auth_token) 231 | return url + auth_qs 232 | return url 233 | -------------------------------------------------------------------------------- /crane/app_util.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import hashlib 3 | import hmac 4 | import httplib 5 | import logging 6 | from functools import wraps 7 | 8 | from flask import json, request 9 | from rhsm import certificate 10 | from rhsm import certificate2 11 | 12 | from crane import exceptions 13 | from crane import data 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | def http_error_handler(error): 20 | """ 21 | handle HTTPError exceptions and return the associated error message and HTTP 22 | response code. This uses the response code specified on the exception. It 23 | will use a message defined on the response object if present, or else it 24 | will default to the standard message associated with the error code. 25 | 26 | :param error: exception raised to indicate that an HTTP error response 27 | should be generated and returned 28 | :type error: crane.exceptions.HTTPError 29 | 30 | :return: message and HTTP response code that should be returned in an 31 | HTTP response. 32 | :rtype: basestring, int 33 | """ 34 | message = error.message or httplib.responses[error.status_code] 35 | return message, error.status_code 36 | 37 | 38 | def authorize_repo_id(func): 39 | """ 40 | Authorize that a particular certificate has access to any directory 41 | containing the repository identified by repo_id 42 | 43 | :param repo_id: The identifier for the repository 44 | :type repo_id: str 45 | :rtype: function 46 | """ 47 | 48 | @wraps(func) 49 | def wrapper(repo_id, *args, **kwargs): 50 | # will raise an appropriate exception if not found or not authorized 51 | repo_is_authorized(repo_id) 52 | return func(repo_id, *args, **kwargs) 53 | 54 | return wrapper 55 | 56 | 57 | def repo_is_authorized(repo_id): 58 | """ 59 | determines if the current request is authorized to read the given repo ID. 60 | 61 | :param repo_id: name of the repository being accessed 62 | :type repo_id: basestring 63 | 64 | :raises exceptions.HTTPError: if authorization fails 65 | 403: if the user is not authorized 66 | 404: if the repo does not exist in this app 67 | """ 68 | response_data = get_data() 69 | repo_tuple = response_data['repos'].get(repo_id) 70 | 71 | # if this deployment of this app does not know about the requested repo 72 | if repo_tuple is None: 73 | raise exceptions.HTTPError(httplib.NOT_FOUND) 74 | 75 | if repo_tuple.protected: 76 | cert = _get_certificate() 77 | if not cert or not cert.check_path(repo_tuple.url_path): 78 | # return 404 so we don't reveal the existence of repos that the user 79 | # is not authorized for 80 | raise exceptions.HTTPError(httplib.NOT_FOUND) 81 | 82 | 83 | def authorize_image_id(func): 84 | """ 85 | Authorize that a particular certificate has access to any repo 86 | containing the specified image id 87 | 88 | :param image_id: The identifier of an image being served by Crane 89 | :type image_id: str 90 | :rtype: function 91 | """ 92 | 93 | @wraps(func) 94 | def wrapper(image_id, *args, **kwargs): 95 | response_data = get_data() 96 | image_repos = response_data['images'].get(image_id) 97 | if image_repos is None: 98 | raise exceptions.HTTPError(httplib.NOT_FOUND) 99 | found_match = False 100 | 101 | # Check if an uprotected repo matches the request 102 | 103 | # Check if a protected repo matches the request 104 | cert = _get_certificate() 105 | repo_tuple = None 106 | 107 | for repo_id in image_repos: 108 | repo_tuple = response_data['repos'].get(repo_id) 109 | # if the repo is unprotected or the path is supported 110 | if not repo_tuple.protected: 111 | found_match = True 112 | break 113 | elif cert and cert.check_path(repo_tuple.url_path): 114 | found_match = True 115 | break 116 | 117 | if not found_match: 118 | # return 404 so we don't reveal the existence of images that the user 119 | # is not authorized for 120 | raise exceptions.HTTPError(httplib.NOT_FOUND) 121 | 122 | return func(image_id, repo_tuple, *args, **kwargs) 123 | 124 | return wrapper 125 | 126 | 127 | def _get_certificate(): 128 | """ 129 | Get the parsed certificate from the environment 130 | 131 | :rtype: rhsm.certificate2.EntitlementCertificate, or None 132 | """ 133 | env = request.environ 134 | pem_str = env.get('SSL_CLIENT_CERT', '') 135 | if not pem_str: 136 | return None 137 | cert = certificate.create_from_pem(pem_str) 138 | # The certificate may not be an entitlement certificate in which case we also return None 139 | if not isinstance(cert, certificate2.EntitlementCertificate): 140 | return None 141 | return cert 142 | 143 | 144 | def get_data(): 145 | """ 146 | Get the current data used for processing requests from 147 | the flask request context. This is used so the same 148 | set of data will be used for the entirety of a single request 149 | 150 | :returns: response_data dictionary as defined in crane.data 151 | :rtype: dict 152 | """ 153 | if not hasattr(request, 'crane_data'): 154 | request.crane_data = data.v1_response_data 155 | 156 | return request.crane_data 157 | 158 | 159 | def get_v2_data(): 160 | """ 161 | Get the current data used for processing requests from 162 | the flask request context. This is used so the same 163 | set of data will be used for the entirety of a single request. 164 | 165 | :returns: response_data dictionary as defined in crane.data 166 | :rtype: dict 167 | """ 168 | if not hasattr(request, 'crane_data_v2'): 169 | request.crane_data_v2 = data.v2_response_data 170 | 171 | return request.crane_data_v2 172 | 173 | 174 | def get_repositories(): 175 | """ 176 | Get the current data used for processing requests from the flask request context 177 | and format it to display basic information about image ids and tags associated 178 | with each v1 repository. 179 | 180 | Value corresponding to each key(repo-registry-id) is a dictionary itself 181 | with the following format: 182 | {'image-ids': [, , ...], 183 | 'tags': {: , : , ...} 184 | 'protected': true/false} 185 | 186 | :return: dictionary keyed by repo-registry-ids 187 | :rtype: dict 188 | """ 189 | all_repo_data = get_data().get('repos', {}) 190 | relevant_repo_data = {} 191 | for repo_registry_id, repo in all_repo_data.items(): 192 | image_ids = [image_json['id'] for image_json in json.loads(repo.images_json)] 193 | 194 | relevant_repo_data[repo_registry_id] = {'image_ids': image_ids, 195 | 'tags': json.loads(repo.tags_json), 196 | 'protected': repo.protected} 197 | 198 | return relevant_repo_data 199 | 200 | 201 | def get_v2_repositories(): 202 | """ 203 | Get the current data used for processing requests from the flask request context 204 | and format it to display basic information about v2 repository. 205 | 206 | Value corresponding to each key(repo-registry-id) is a dictionary itself 207 | with the following format: 208 | {'protected': true/false} 209 | 210 | :return: dictionary keyed by repo-registry-ids 211 | :rtype: dict 212 | """ 213 | all_repo_data_v2 = get_v2_data().get('repos', {}) 214 | relevant_repo_data = {} 215 | for repo_registry_id, repo in all_repo_data_v2.items(): 216 | relevant_repo_data[repo_registry_id] = {'protected': repo.protected} 217 | 218 | return relevant_repo_data 219 | 220 | 221 | def validate_and_transform_repoid(repo_id): 222 | """ 223 | Validates that the repo ID does not contain more than one slash, and removes 224 | the default "library" namespace if present. 225 | 226 | :param repo_id: unique ID for the repository. May contain 0 or 1 of the "/" 227 | character. For repo IDs that do not contain a slash, the 228 | docker client currently prepends "library/" when making 229 | this call. This function strips that off. 230 | :type repo_id: basestring 231 | 232 | :return: repo ID without the "library" namespace 233 | :rtype: basestring 234 | """ 235 | # a valid repository ID will have zero or one slash 236 | if len(repo_id.split('/')) > 2: 237 | raise exceptions.HTTPError(httplib.NOT_FOUND) 238 | 239 | # for repositories that do not have a "/" in the name, docker will add 240 | # "library/" to the beginning of the repository path. 241 | if repo_id.startswith('library/'): 242 | return repo_id[len('library/'):] 243 | return repo_id 244 | 245 | 246 | def name_is_authorized(name): 247 | """ 248 | Determines if the current request is authorized to read the given repo name. 249 | 250 | :param name: name of the repository being accessed 251 | :type name: basestring 252 | 253 | :raises exceptions.HTTPError: if authorization fails 254 | 403: if the user is not authorized 255 | 404: if the repo does not exist in this app 256 | """ 257 | v2_response_data = get_v2_data() 258 | v2_repo_tuple = v2_response_data['repos'].get(name) 259 | 260 | # if this deployment of this app does not know about the requested repo 261 | if v2_repo_tuple is None: 262 | raise exceptions.HTTPError(httplib.NOT_FOUND) 263 | 264 | if v2_repo_tuple.protected: 265 | cert = _get_certificate() 266 | if not cert or not cert.check_path(v2_repo_tuple.url_path): 267 | # return 404 so we don't reveal the existence of repos that the user 268 | # is not authorized for 269 | raise exceptions.HTTPError(httplib.NOT_FOUND) 270 | 271 | 272 | def authorize_name(func): 273 | """ 274 | Authorize that a particular certificate has access to any directory 275 | containing the repository identified by repo_name. 276 | 277 | :param repo_id: The identifier for the repository 278 | :type repo_id: str 279 | :rtype: function 280 | """ 281 | 282 | @wraps(func) 283 | def wrapper(repo_id, *args, **kwargs): 284 | # will raise an appropriate exception if not found or not authorized 285 | name_is_authorized(repo_id) 286 | return func(repo_id, *args, **kwargs) 287 | 288 | return wrapper 289 | 290 | 291 | def validate_and_transform_repo_name(path): 292 | """ 293 | Checks and extracts a repo registry id from the path parameter. The 294 | repo name is considered to be the substring left of any of the [tags, manifests, 295 | blobs]. 296 | 297 | :param path: value for full path component containing both repo name and file path 298 | :type path: basestring 299 | 300 | :return: tuple containing extracted name, path components and the identified component type 301 | :rtype: tuple 302 | """ 303 | 304 | component_types = ['tags', 'manifests', 'blobs'] 305 | 306 | components = path.split('/') 307 | 308 | for rindex, component in enumerate(reversed(components)): 309 | if component in component_types: 310 | split_index = len(components) - 1 - rindex 311 | name_component = '/'.join(components[:split_index]) 312 | path_component = '/'.join(components[split_index:]) 313 | return name_component, path_component, component 314 | 315 | raise exceptions.HTTPError(httplib.NOT_FOUND) 316 | 317 | 318 | def generate_cdn_url_token(path, secret, expiration, algorithm): 319 | """ 320 | Generates a query string token to be validated at the CDN in order to 321 | provide authorization of resources 322 | 323 | :param path: path to be secured 324 | :type path: string 325 | :param secret: secret used for generating hmac token 326 | :type secret: string 327 | :param expiration: unix timestamp for token expiration 328 | :type expiration: int 329 | :param algorithm: hashing algorithm (one of: sha256, sha1, md5) 330 | :type algorithm: string 331 | 332 | :return: string containing query string token 333 | :rtype: string 334 | """ 335 | 336 | field_delimiter = '~' 337 | new_token = 'exp=%d%c' % (expiration, field_delimiter) 338 | 339 | hash_source = '%surl=%s' % (new_token, path) 340 | token_hmac = hmac.new( 341 | binascii.a2b_hex(secret), 342 | hash_source, 343 | getattr(hashlib, algorithm)).hexdigest() 344 | 345 | new_token += 'hmac=%s' % token_hmac 346 | return new_token 347 | -------------------------------------------------------------------------------- /tests/views/test_repositories.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import base 4 | from crane import config 5 | 6 | 7 | class TestRepository(base.BaseCraneAPITest): 8 | def test_repositories_json(self): 9 | response = self.test_client.get('/crane/repositories', 10 | headers={'Accept': 'application/json'}) 11 | self.assertEqual(response.status_code, 200) 12 | self.assertEqual(response.headers['Content-Type'], 'application/json') 13 | 14 | response_data = json.loads(response.data) 15 | expected_data = {'baz': {'protected': True, 16 | 'tags': {'latest': 'baz123'}, 17 | 'image_ids': ['baz123']}, 18 | 'bar': {'protected': False, 19 | 'tags': {'latest': 'def456', 'test': 'ghi789'}, 20 | 'image_ids': ['def456']}, 21 | 'qux': {'protected': True, 22 | 'tags': {'latest': 'qux123'}, 23 | 'image_ids': ['qux123']}, 24 | 'redhat/foo': {'protected': False, 25 | 'tags': {'latest': 'abc123', 'test': 'def234'}, 26 | 'image_ids': ['abc123', 'xyz789']}} 27 | 28 | self.assertEqual(response_data['baz'], expected_data['baz']) 29 | self.assertEqual(response_data['bar'], expected_data['bar']) 30 | self.assertEqual(response_data['qux'], expected_data['qux']) 31 | self.assertEqual(response_data['redhat/foo'], expected_data['redhat/foo']) 32 | 33 | def test_repositories_v2_json(self): 34 | response = self.test_client.get('/crane/repositories/v2', 35 | headers={'Accept': 'application/json'}) 36 | self.assertEqual(response.status_code, 200) 37 | self.assertEqual(response.headers['Content-Type'], 'application/json') 38 | 39 | response_data = json.loads(response.data) 40 | expected_data = {'registry': {'protected': False}, 41 | 'v2/bar': {'protected': False}, 42 | 'redhat/foo': {'protected': False}} 43 | 44 | self.assertEqual(response_data['registry'], expected_data['registry']) 45 | self.assertEqual(response_data['v2/bar'], expected_data['v2/bar']) 46 | self.assertEqual(response_data['redhat/foo'], expected_data['redhat/foo']) 47 | 48 | def test_repositories_v2_html(self): 49 | response = self.test_client.get('/crane/repositories/v2') 50 | self.assertEqual(response.status_code, 200) 51 | self.assertEqual(response.headers['Content-Type'], 'text/html; charset=utf-8') 52 | expected_data = {'registry': {'protected': False}, 53 | 'v2/bar': {'protected': False}, 54 | 'redhat/foo': {'protected': False}} 55 | 56 | # Assert that all repo ids in json are present in the HTML 57 | for repo_id, repo_info in expected_data.iteritems(): 58 | self.assertTrue(response.data.find(repo_id)) 59 | 60 | def test_repositories_html(self): 61 | response = self.test_client.get('/crane/repositories') 62 | self.assertEqual(response.status_code, 200) 63 | self.assertEqual(response.headers['Content-Type'], 'text/html; charset=utf-8') 64 | expected_data = {'baz': {'protected': True, 65 | 'tags': {'latest': 'baz123'}, 66 | 'image_ids': ['baz123']}, 67 | 'bar': {'protected': False, 68 | 'tags': {'latest': 'def456', 'test': 'ghi789'}, 69 | 'image_ids': ['def456']}, 70 | 'qux': {'protected': True, 71 | 'tags': {'latest': 'qux123'}, 72 | 'image_ids': ['qux123']}, 73 | 'redhat/foo': {'protected': False, 74 | 'tags': {'latest': 'abc123', 'test': 'def234'}, 75 | 'image_ids': ['abc123', 'xyz789']}} 76 | # Assert that all repo ids in json are present in the HTML 77 | for repo_id, repo_info in expected_data.iteritems(): 78 | self.assertTrue(response.data.find(repo_id)) 79 | # Assert all tagged images are present 80 | for tag, image_id in repo_info['tags'].iteritems(): 81 | self.assertTrue(response.data.find(tag)) 82 | self.assertTrue(response.data.find(image_id)) 83 | # Assert all the image ids for a repo are present 84 | for image_id in repo_info['image_ids']: 85 | self.assertTrue(response.data.find(image_id)) 86 | 87 | def test_images(self): 88 | response = self.test_client.get('/v1/repositories/redhat/foo/images') 89 | 90 | self.assertEqual(response.status_code, 200) 91 | self.assertEqual(response.headers['Content-Type'], 'application/json') 92 | self.assertEqual(response.headers['X-Docker-Registry-Config'], 'common') 93 | self.assertEqual(response.headers['X-Docker-Registry-Version'], '0.6.6') 94 | self.assertEqual(response.headers['X-Docker-Endpoints'], 'localhost:5000') 95 | 96 | response_data = json.loads(response.data) 97 | self.assertTrue({'id': 'abc123'} in response_data) 98 | self.assertTrue({'id': 'xyz789'} in response_data) 99 | 100 | def test_detect_endpoint(self): 101 | """ 102 | Set the configured endpoint to None, forcing crane to detect what 103 | host and port are being accessed by the request. 104 | """ 105 | self.app.config[config.KEY_ENDPOINT] = '' 106 | 107 | response = self.test_client.get('/v1/repositories/redhat/foo/images') 108 | 109 | self.assertEqual(response.status_code, 200) 110 | # 'localhost' is apparently what flask's test client produces 111 | self.assertEqual(response.headers['X-Docker-Endpoints'], 'localhost') 112 | 113 | def test_images_no_namespace(self): 114 | """ 115 | The "bar" repository ID does not have a namespace 116 | """ 117 | response = self.test_client.get('/v1/repositories/bar/images') 118 | 119 | self.assertEqual(response.status_code, 200) 120 | self.assertEqual(response.headers['Content-Type'], 'application/json') 121 | self.assertEqual(response.headers['X-Docker-Registry-Config'], 'common') 122 | self.assertEqual(response.headers['X-Docker-Registry-Version'], '0.6.6') 123 | self.assertEqual(response.headers['X-Docker-Endpoints'], 'localhost:5000') 124 | 125 | response_data = json.loads(response.data) 126 | self.assertTrue({'id': 'def456'} in response_data) 127 | 128 | def test_images_no_namespace_docker_1_3_plus(self): 129 | """ 130 | The "bar" repository ID does not have a namespace 131 | """ 132 | response = self.test_client.get('/v1/repositories/library/bar/images') 133 | 134 | self.assertEqual(response.status_code, 200) 135 | self.assertEqual(response.headers['Content-Type'], 'application/json') 136 | self.assertEqual(response.headers['X-Docker-Registry-Config'], 'common') 137 | self.assertEqual(response.headers['X-Docker-Registry-Version'], '0.6.6') 138 | self.assertEqual(response.headers['X-Docker-Endpoints'], 'localhost:5000') 139 | 140 | response_data = json.loads(response.data) 141 | self.assertTrue({'id': 'def456'} in response_data) 142 | 143 | def test_images_404(self): 144 | response = self.test_client.get('/v1/repositories/idontexist/images') 145 | 146 | self.assertEqual(response.status_code, 404) 147 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 148 | 149 | def test_images_too_many_slashes(self): 150 | """ 151 | The repo_id may have at most one slash. Here we have 3, which should 152 | cause a 404 153 | """ 154 | response = self.test_client.get('/v1/repositories/a/b/c/d/images') 155 | 156 | self.assertEqual(response.status_code, 404) 157 | 158 | def test_tags(self): 159 | response = self.test_client.get('/v1/repositories/redhat/foo/tags') 160 | 161 | self.assertEqual(response.status_code, 200) 162 | self.assertEqual(response.headers['Content-Type'], 'application/json') 163 | self.assertEqual(response.headers['X-Docker-Registry-Config'], 'common') 164 | self.assertEqual(response.headers['X-Docker-Registry-Version'], '0.6.6') 165 | 166 | self.assertEqual(json.loads(response.data), {'test': 'def234', 'latest': 'abc123'}) 167 | 168 | def test_tags_no_namespace(self): 169 | """ 170 | The "bar" repository ID does not have a namespace 171 | """ 172 | # the docker client adds "library" as the default namespace in this case. 173 | response = self.test_client.get('/v1/repositories/library/bar/tags') 174 | 175 | self.assertEqual(response.status_code, 200) 176 | self.assertEqual(response.headers['Content-Type'], 'application/json') 177 | self.assertEqual(response.headers['X-Docker-Registry-Config'], 'common') 178 | self.assertEqual(response.headers['X-Docker-Registry-Version'], '0.6.6') 179 | 180 | self.assertEqual(json.loads(response.data), {'latest': 'def456', 'test': 'ghi789'}) 181 | 182 | def test_tags_404(self): 183 | response = self.test_client.get('/v1/repositories/redhat/idontexist/tags') 184 | 185 | self.assertEqual(response.status_code, 404) 186 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 187 | 188 | def test_tags_too_many_slashes(self): 189 | """ 190 | The repo_id may have at most one slash. Here we have 3, which should 191 | cause a 404 192 | """ 193 | response = self.test_client.get('/v1/repositories/a/b/c/d/tags') 194 | 195 | self.assertEqual(response.status_code, 404) 196 | 197 | def test_tag_get_tag(self): 198 | response = self.test_client.get('/v1/repositories/redhat/foo/tags/test') 199 | 200 | self.assertEqual(response.status_code, 200) 201 | self.assertEqual(response.headers['Content-Type'], 'application/json') 202 | self.assertEqual(response.headers['X-Docker-Registry-Config'], 'common') 203 | self.assertEqual(response.headers['X-Docker-Registry-Version'], '0.6.6') 204 | 205 | self.assertEqual(json.loads(response.data), 'def234') 206 | 207 | def test_tag_get_tag_no_namespace(self): 208 | """ 209 | The "bar" repository ID does not have a namespace 210 | """ 211 | # the docker client adds "library" as the default namespace in this case. 212 | response = self.test_client.get('/v1/repositories/library/bar/tags/test') 213 | 214 | self.assertEqual(response.status_code, 200) 215 | self.assertEqual(response.headers['Content-Type'], 'application/json') 216 | self.assertEqual(response.headers['X-Docker-Registry-Config'], 'common') 217 | self.assertEqual(response.headers['X-Docker-Registry-Version'], '0.6.6') 218 | 219 | def test_tag_get_tag_404(self): 220 | response = self.test_client.get('/v1/repositories/redhat/idontexist/tag/test') 221 | 222 | self.assertEqual(response.status_code, 404) 223 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 224 | 225 | def test_tag_get_tag_too_many_slashes(self): 226 | """ 227 | The repo_id may have at most one slash. Here we have 3, which should 228 | cause a 404 229 | """ 230 | response = self.test_client.get('/v1/repositories/a/b/c/d/tags/test') 231 | 232 | self.assertEqual(response.status_code, 404) 233 | 234 | def test_tag_get_tag_not_found(self): 235 | """ 236 | The tag may not exist. Here, nop repo does not have a test tag, which 237 | should result in a 404 238 | """ 239 | response = self.test_client.get('/v1/repositories/library/nop/tags/test') 240 | 241 | self.assertEqual(response.status_code, 404) 242 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 243 | 244 | def test_tag_latest(self): 245 | response = self.test_client.get('/v1/repositories/redhat/foo/tags/latest') 246 | 247 | self.assertEqual(response.status_code, 200) 248 | self.assertEqual(response.headers['Content-Type'], 'application/json') 249 | self.assertEqual(response.headers['X-Docker-Registry-Config'], 'common') 250 | self.assertEqual(response.headers['X-Docker-Registry-Version'], '0.6.6') 251 | 252 | self.assertEqual(json.loads(response.data), 'abc123') 253 | 254 | def test_tag_latest_no_namespace(self): 255 | """ 256 | The "bar" repository ID does not have a namespace 257 | """ 258 | # the docker client adds "library" as the default namespace in this case. 259 | response = self.test_client.get('/v1/repositories/library/bar/tags/latest') 260 | 261 | self.assertEqual(response.status_code, 200) 262 | self.assertEqual(response.headers['Content-Type'], 'application/json') 263 | self.assertEqual(response.headers['X-Docker-Registry-Config'], 'common') 264 | self.assertEqual(response.headers['X-Docker-Registry-Version'], '0.6.6') 265 | 266 | def test_tag_latest_404(self): 267 | response = self.test_client.get('/v1/repositories/redhat/idontexist/tag/latest') 268 | 269 | self.assertEqual(response.status_code, 404) 270 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 271 | 272 | def test_tag_latest_too_many_slashes(self): 273 | """ 274 | The repo_id may have at most one slash. Here we have 3, which should 275 | cause a 404 276 | """ 277 | response = self.test_client.get('/v1/repositories/a/b/c/d/tags/latest') 278 | 279 | self.assertEqual(response.status_code, 404) 280 | 281 | def test_tag_latest_not_found(self): 282 | """ 283 | The latest tag may not exist. Here, nop repo does not have a latest tag, which 284 | should result in a 404 285 | """ 286 | response = self.test_client.get('/v1/repositories/library/nop/tags/latest') 287 | 288 | self.assertEqual(response.status_code, 404) 289 | self.assertTrue(response.headers['Content-Type'].startswith('text/html')) 290 | --------------------------------------------------------------------------------