├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── dev-requirements.txt ├── stale.yml └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.md ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── django_oci ├── __init__.py ├── admin.py ├── apps.py ├── auth.py ├── files.py ├── models.py ├── settings.py ├── signals.py ├── storage.py ├── urls.py ├── utils.py └── views │ ├── __init__.py │ ├── auth.py │ ├── base.py │ ├── blobs.py │ ├── image.py │ └── parsers.py ├── docs ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── VERSION ├── _config.yml ├── _data │ └── toc.yml ├── _docs │ ├── design.md │ ├── faq.md │ ├── getting-started │ │ ├── auth.md │ │ ├── example.md │ │ ├── index.md │ │ ├── options.md │ │ ├── reggie.md │ │ └── testing.md │ ├── introduction.md │ └── storage │ │ └── index.md ├── _includes │ ├── alert.html │ ├── doc.html │ ├── editable.html │ ├── footer.html │ ├── google-analytics.html │ ├── head.html │ ├── headers.html │ ├── navigation.html │ ├── scrolltop.html │ ├── sidebar.html │ ├── social.html │ ├── tags.html │ └── toc.html ├── _layouts │ ├── default.html │ ├── page.html │ └── post.html ├── _posts │ └── 2020-10-12-hello-world.md ├── assets │ ├── css │ │ ├── extra.css │ │ ├── font-awesome.css │ │ ├── main.css │ │ ├── palette.css │ │ ├── style.css │ │ └── style1.css │ ├── img │ │ ├── django-oci.png │ │ └── logo.png │ └── js │ │ ├── application.a59e2a89.js │ │ ├── application.js │ │ ├── modernizr.1aa3b519.js │ │ └── modernizr.74668098.js ├── conformance │ ├── index.html │ └── junit.xml ├── favicon.ico ├── index.md ├── pages │ ├── about.md │ ├── archive.md │ ├── feed.xml │ ├── news.md │ ├── sitemap.xml │ └── usage-agreement.md └── search │ └── search_index.json ├── examples └── singularity │ ├── busybox_latest.sif │ └── config.json ├── manage.py ├── pyproject.toml ├── requirements.txt ├── runtests.sh ├── setup.cfg ├── setup.py └── tests ├── README.md ├── __init__.py ├── requirements.txt ├── settings.py ├── test_api.py ├── test_conformance.py ├── urls.py └── wsgi.py /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every 4 | little bit helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | ## Types of Contributions 9 | 10 | ### Report Bugs 11 | 12 | Report bugs at https://github.com/vsoch/django-oci/issues. 13 | 14 | If you are reporting a bug, please include: 15 | 16 | * Your operating system name and version. 17 | * Any details about your local setup that might be helpful in troubleshooting. 18 | * Detailed steps to reproduce the bug. 19 | 20 | ### Fix Bugs 21 | Look through the GitHub issues for bugs. Anything tagged with "bug" 22 | is open to whoever wants to implement it. 23 | 24 | ### Implement Features 25 | 26 | Look through the GitHub issues for features. Anything tagged with "feature" 27 | is open to whoever wants to implement it. 28 | 29 | ### Write Documentation 30 | 31 | django-oci could always use more documentation, whether as part of the 32 | official django-oci docs, in docstrings, or even on the web in blog posts, 33 | articles, and such. 34 | 35 | ### Submit Feedback 36 | 37 | The best way to send feedback is to file an issue at https://github.com/vsoch/django-oci/issues. 38 | 39 | If you are proposing a feature: 40 | 41 | * Explain in detail how it would work. 42 | * Keep the scope as narrow as possible, to make it easier to implement. 43 | * Remember that this is a volunteer-driven project, and that contributions 44 | are welcome :) 45 | 46 | ## Pull Request Guidelines 47 | 48 | Before you submit a pull request, check that it meets these guidelines: 49 | 50 | 1. The pull request should include tests. 51 | 2. If the pull request adds functionality, the docs should be updated. Put 52 | your new functionality into a function with a docstring, and add the 53 | feature to the list in README.rst. 54 | 3. The pull request should work for minimally Python 3.5 and for PyPy. 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * django-oci version: 2 | * Django version: 3 | * Python version: 4 | * Operating System: 5 | 6 | ### Description 7 | 8 | Describe what you were trying to get done. 9 | Tell us what happened, what went wrong, and what you expected to happen. 10 | 11 | ### What I Did 12 | 13 | ``` 14 | Paste the command(s) you ran and the output. 15 | If there was a crash, please include the traceback here. 16 | ``` 17 | -------------------------------------------------------------------------------- /.github/dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pre-commit 2 | black 3 | isort 4 | flake8 5 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches_ignore: [] 9 | 10 | jobs: 11 | formatting: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Setup black linter 17 | run: conda create --quiet --name black pyflakes 18 | 19 | - name: Check Spelling 20 | uses: crate-ci/typos@7ad296c72fa8265059cc03d1eda562fbdfcd6df2 # v1.9.0 21 | with: 22 | files: ./docs/_docs/*.md ./docs/_docs/*/*.md 23 | 24 | - name: Lint and format Python code 25 | run: | 26 | export PATH="/usr/share/miniconda/bin:$PATH" 27 | source activate black 28 | pip install -r .github/dev-requirements.txt 29 | pre-commit run --all-files 30 | 31 | testing: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v2 35 | - name: Setup testing environment 36 | run: conda create --quiet --name testing pytest 37 | 38 | - name: Download opencontainers/distribution-spec 39 | uses: actions/checkout@v2 40 | with: 41 | repository: opencontainers/distribution-spec 42 | ref: v1.0.1 43 | path: dist-spec 44 | 45 | - name: Set up Go 1.16 46 | uses: actions/setup-go@v1 47 | with: 48 | go-version: 1.16 49 | 50 | - name: Set GOPATH 51 | run: | 52 | # temporary fix for https://github.com/actions/setup-go/issues/14 53 | echo "GOPATH=$(dirname $GITHUB_WORKSPACE)" >> $GITHUB_ENV 54 | echo "$(dirname $GITHUB_WORKSPACE)/bin" >> $GITHUB_PATH 55 | 56 | - name: Compile conformance.test binary 57 | run: | 58 | cd dist-spec/conformance 59 | go mod vendor 60 | CGO_ENABLED=0 go test -c -o ../../conformance.test 61 | cd ../../ 62 | 63 | - name: Django Tests 64 | run: | 65 | export PATH="/usr/share/miniconda/bin:$PATH" 66 | source activate testing 67 | pip install -r tests/requirements.txt 68 | python manage.py makemigrations django_oci 69 | python manage.py makemigrations 70 | python manage.py migrate 71 | python manage.py migrate django_oci 72 | echo ::group::tests.test_api 73 | python manage.py test tests.test_api 74 | echo ::endgroup::tests.test_api 75 | rm db-test.sqlite3 76 | 77 | - name: Conformance Tests 78 | run: | 79 | export PATH="/usr/share/miniconda/bin:$PATH" 80 | source activate testing 81 | python manage.py makemigrations django_oci 82 | python manage.py makemigrations 83 | python manage.py migrate 84 | python manage.py migrate django_oci 85 | echo ::group::tests.test_conformance 86 | DISABLE_AUTHENTICATION=yes python manage.py test tests.test_conformance 87 | echo ::endgroup::tests.test_conformance 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # Django development 5 | env 6 | migrations 7 | images 8 | _site 9 | Gemfile.lock 10 | conformance.test 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Packages 16 | *.egg 17 | *.egg-info 18 | dist 19 | build 20 | eggs 21 | parts 22 | bin 23 | var 24 | sdist 25 | develop-eggs 26 | .installed.cfg 27 | lib 28 | lib64 29 | 30 | # Installer logs 31 | pip-log.txt 32 | 33 | # Unit test / coverage reports 34 | .coverage 35 | .tox 36 | nosetests.xml 37 | htmlcov 38 | 39 | # Translations 40 | *.mo 41 | 42 | # Mr Developer 43 | .mr.developer.cfg 44 | .project 45 | .pydevproject 46 | 47 | # Pycharm/Intellij 48 | .idea 49 | 50 | # Complexity 51 | output/*.html 52 | output/*/index.html 53 | 54 | # Sphinx 55 | docs/_build 56 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-case-conflict 7 | - id: check-docstring-first 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | - id: mixed-line-ending 11 | 12 | - repo: local 13 | hooks: 14 | - id: black 15 | name: black 16 | language: python 17 | types: [python] 18 | entry: black 19 | 20 | - id: isort 21 | name: isort 22 | args: [--filter-files] 23 | language: python 24 | types: [python] 25 | entry: isort 26 | 27 | - id: flake8 28 | name: flake8 29 | language: python 30 | types: [python] 31 | entry: flake8 32 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | ## Development Lead 4 | 5 | * Vanessa Sochat 6 | 7 | ## Contributors 8 | 9 | None yet. Why not be the first? 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This is a manually generated log to track changes to the repository for each release. 4 | Each section should include general headers such as **Implemented enhancements** 5 | and **Merged pull requests**. All closed issued and bug fixes should be 6 | represented by the pull requests that fixed them. 7 | Critical items to know are: 8 | 9 | - renamed commands 10 | - deprecated / removed commands 11 | - changed defaults 12 | - backward incompatible changes 13 | - migration guidance 14 | - changed behaviour 15 | 16 | ## [master](https://github.com/vsoch/django-oci/tree/master) 17 | - unpinning pyjwt version (0.0.17) 18 | - updating license headers 19 | - support for Django 4.0+ 20 | - Better tweak MANIFEST for upload to not include pycache (0.0.16) 21 | - Bug with filesystem save (saving without full image path) (0.0.15) 22 | - Adding mount and HEAD new endpoints for distribution spec (0.0.14) 23 | - View specific permission (pull,push) required (0.0.13) 24 | - Adding Django ratelimit to protect views (0.0.12) 25 | - Added authentication (0.0.11) 26 | - Django OCI core release without authentication (0.0.1) 27 | - skeleton release (0.0.0) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache Software License 2.0 3 | 4 | Copyright (c) 2020-2023, Vanessa Sochat 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md AUTHORS.md LICENSE 2 | 3 | graft django_oci 4 | 5 | global-exclude __pycache__ 6 | global-exclude *.py[co] 7 | global-exclude migrations 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-oci 2 | 3 | [![PyPI version](https://badge.fury.io/py/django-oci.svg)](https://badge.fury.io/py/django-oci) 4 | ![docs/assets/img/django-oci.png](docs/assets/img/django-oci.png) 5 | 6 | Open Containers distribution API for Django. 7 | 8 | This repository serves a Django app that can be used to provide an opencontainers 9 | distribution (OCI) endpoint to push and pull containers. An [example](tests) 10 | application is provided in `tests` that can be interacted with here. 11 | 12 | 13 | ## Quickstart 14 | 15 | Install django-oci:: 16 | 17 | ```bash 18 | pip install django-oci 19 | ``` 20 | 21 | Add it to your `INSTALLED_APPS` along with `rest_framework` 22 | 23 | ```python 24 | 25 | INSTALLED_APPS = ( 26 | ... 27 | 'django_oci', 28 | 'rest_framework', 29 | ... 30 | ) 31 | ``` 32 | 33 | Add django-oci's URL patterns: 34 | 35 | ```python 36 | 37 | from django_oci import urls as django_oci_urls 38 | urlpatterns = [ 39 | ... 40 | url(r'^', include(django_oci.urls)), 41 | ... 42 | ] 43 | ``` 44 | 45 | See the [documentation](https://vsoch.github.io/django-oci/) or [getting started guide](https://vsoch.github.io/django-oci/docs/getting-started/) for more details about setup, and testing. An [example application](tests) is provided 46 | and described in the getting started guide as well. The latest [conformance testing](https://vsoch.github.io/django-oci/conformance/) is provided as well. 47 | 48 | ## Many Thanks 49 | 50 | * [cookiecutter-djangopackage](https://github.com/pydanny/cookiecutter-djangopackage) 51 | -------------------------------------------------------------------------------- /django_oci/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.17" 2 | default_app_config = "django_oci.apps.DjangoOciConfig" 3 | -------------------------------------------------------------------------------- /django_oci/admin.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | 19 | from django.contrib import admin 20 | 21 | from .models import Image, Repository 22 | 23 | 24 | @admin.register(Repository) 25 | class RepositoryAdmin(admin.ModelAdmin): 26 | pass 27 | 28 | 29 | @admin.register(Image) 30 | class ImageAdmin(admin.ModelAdmin): 31 | pass 32 | -------------------------------------------------------------------------------- /django_oci/apps.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | 19 | from django.apps import AppConfig 20 | 21 | 22 | class DjangoOciConfig(AppConfig): 23 | name = "django_oci" 24 | verbose_name = "Opencontainer Distribution specification for Django" 25 | 26 | def ready(self): 27 | import django_oci.signals 28 | 29 | assert django_oci.signals 30 | -------------------------------------------------------------------------------- /django_oci/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | import base64 19 | import re 20 | import time 21 | import uuid 22 | from datetime import datetime 23 | 24 | import jwt 25 | from django.contrib.auth.models import User 26 | from django.middleware import cache 27 | from django.urls import resolve 28 | from rest_framework.authtoken.models import Token 29 | from rest_framework.response import Response 30 | 31 | from django_oci import settings 32 | from django_oci.models import Repository 33 | from django_oci.utils import get_server 34 | 35 | 36 | def is_authenticated( 37 | request, repository=None, must_be_owner=True, repository_exists=True, scopes=None 38 | ): 39 | """ 40 | Function to check if a request is authenticated, a repository and the request is required. 41 | Returns a boolean to indicate if the user is authenticated, and a response with 42 | the challenge if not. If allow_if_private is True, we only allow access to users 43 | that are owners or contributors, regardless of having a valid token. 44 | 45 | Arguments: 46 | ========== 47 | request (requests.Request) : the Request object to inspect 48 | repository (str or Repository): the name of a repository or instance 49 | must_be_owner (bool) : if must be owner is true, requires push 50 | reposity_exists (bool) : flag to indicate that the repository exists. 51 | """ 52 | # Scopes default to push and pull, more conservative 53 | scopes = scopes or ["push", "pull"] 54 | 55 | # Derive the view name from the request PATH_INFO 56 | func, two, three = resolve(request.META["PATH_INFO"]) 57 | view_name = "%s.%s" % (func.__module__, func.__name__) 58 | 59 | # If authentication is disabled, return the original view 60 | if settings.DISABLE_AUTHENTICATION or view_name not in settings.AUTHENTICATED_VIEWS: 61 | print(f"{settings.DISABLE_AUTHENTICATION}") 62 | print( 63 | f"{view_name} is not in authenticated views: {settings.AUTHENTICATED_VIEWS}" 64 | ) 65 | return True, None, None 66 | 67 | # Ensure repository is valid, only if provided 68 | name = repository 69 | if repository is not None and repository_exists and isinstance(repository, str): 70 | try: 71 | repository = Repository.objects.get(name=repository) 72 | name = repository.name 73 | except Repository.DoesNotExist: 74 | return False, Response(status=404), None 75 | 76 | # Case 2: Already has a jwt valid token 77 | is_valid, user = validate_jwt(request, repository, must_be_owner) 78 | if is_valid: 79 | return True, None, user 80 | 81 | # Case 3: False and response will return request for auth 82 | user = get_user(request) 83 | if not user: 84 | headers = {"Www-Authenticate": get_challenge(request, name, scopes=scopes)} 85 | return False, Response(status=401, headers=headers), user 86 | 87 | # Denied for any other reason 88 | return False, Response(status=403), user 89 | 90 | 91 | def generate_jwt(username, scope, realm, repository): 92 | """Given a username, scope, realm, repository, and service, generate a jwt 93 | token to return to the user with a default expiration of 10 minutes. 94 | 95 | Arguments: 96 | ========== 97 | username (str) : the user's username to add under "sub" 98 | scope (list) : a list of scopes to require (e.g., ['push, pull']) 99 | realm (str) : the authentication realm, typically /auth 100 | repository (str): the repository name 101 | """ 102 | # The jti expires after TOKEN_EXPIRES_SECONDS 103 | issued_at = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") 104 | filecache = cache.caches["django_oci_upload"] 105 | jti = str(uuid.uuid4()) 106 | filecache.set(jti, "good", timeout=settings.TOKEN_EXPIRES_SECONDS) 107 | now = int(time.time()) 108 | expires_at = now + settings.TOKEN_EXPIRES_SECONDS 109 | 110 | # import jwt and generate token 111 | # https://tools.ietf.org/html/rfc7519#section-4.1.5 112 | payload = { 113 | "iss": realm, # auth endpoint 114 | "sub": username, 115 | "exp": expires_at, 116 | "nbf": now, 117 | "iat": now, 118 | "jti": jti, 119 | "access": [{"type": "repository", "name": repository, "actions": scope}], 120 | } 121 | token = jwt.encode(payload, settings.JWT_SERVER_SECRET, algorithm="HS256") 122 | if isinstance(token, bytes): 123 | token = token.decode("utf-8") 124 | return { 125 | "token": token, 126 | "expires_in": settings.TOKEN_EXPIRES_SECONDS, 127 | "issued_at": issued_at, 128 | } 129 | 130 | 131 | def validate_jwt(request, repository, must_be_owner): 132 | """Given a jwt token, decode and validate 133 | 134 | Arguments: 135 | ========== 136 | request (requests.Request) : the Request object to inspect 137 | repository (models.Repository): the repository instance 138 | must_be_owner (bool) : if True, requires additional push scope 139 | """ 140 | header = request.META.get("HTTP_AUTHORIZATION", "") 141 | if re.search("bearer", header, re.IGNORECASE): 142 | encoded = re.sub("bearer", "", header, flags=re.IGNORECASE).strip() 143 | 144 | # Any reason not valid will issue an error here 145 | try: 146 | decoded = jwt.decode( 147 | encoded, settings.JWT_SERVER_SECRET, algorithms=["HS256"] 148 | ) 149 | except Exception as exc: 150 | print("jwt could no be decoded, %s" % exc) 151 | return False, None 152 | 153 | # Ensure that the jti is still valid 154 | filecache = cache.caches["django_oci_upload"] 155 | if not filecache.get(decoded.get("jti")) == "good": 156 | print("Filecache with jti not found.") 157 | return False, None 158 | 159 | # The user must exist 160 | try: 161 | user = User.objects.get(username=decoded.get("sub")) 162 | except User.DoesNotExist: 163 | print("Username %s not found" % decoded.get("sub")) 164 | return False, None 165 | 166 | # If a repository exists, the user must be an owner 167 | if ( 168 | isinstance(repository, Repository) 169 | and (repository.private or must_be_owner) 170 | and user not in repository.owners.all() 171 | and user not in repository.contributors.all() 172 | ): 173 | print("Username %s not in repository owners" % decoded.get("sub")) 174 | return False, None 175 | 176 | # If repository is not defined and must be owner, no go 177 | if repository is None and must_be_owner: 178 | print("Repository is None and must be owner") 179 | return False, None 180 | 181 | # TODO: any validation needed for access type? 182 | requested_name = decoded.get("access", [{}])[0].get("name") 183 | if isinstance(repository, Repository) and repository.name != requested_name: 184 | print("Repository name is not equal to requested name.") 185 | return False, None 186 | 187 | # Do we have the correct permissions? 188 | requested_permission = decoded.get("access", [{}])[0].get("actions") 189 | if must_be_owner and "push" not in requested_permission: 190 | print("Must be owner and push not in requested permissions") 191 | return False, None 192 | return True, user 193 | 194 | return False, None 195 | 196 | 197 | def get_user(request): 198 | """Given a request, read the Authorization header to get the base64 encoded 199 | username and token (password) which is a basic auth. If we return the user 200 | object, the user is successfully authenticated. Otherwise, return None. 201 | and the calling function should return Forbidden status. 202 | 203 | Arguments: 204 | ========== 205 | request (requests.Request) : the Request object to inspect 206 | """ 207 | header = request.META.get("HTTP_AUTHORIZATION", "") 208 | 209 | if re.search("basic", header, re.IGNORECASE): 210 | encoded = re.sub("basic", "", header, flags=re.IGNORECASE).strip() 211 | decoded = base64.b64decode(encoded).decode("utf-8") 212 | username, token = decoded.split(":", 1) 213 | try: 214 | token = Token.objects.get(key=token) 215 | if token.user.username == username: 216 | return token.user 217 | except Exception: 218 | pass 219 | 220 | 221 | def get_token(request): 222 | """The same as validate_token, but return the token object to check the 223 | associated user. 224 | 225 | Arguments: 226 | ========== 227 | request (requests.Request) : the Request object to inspect 228 | """ 229 | # Coming from HTTP, look for authorization as bearer token 230 | token = request.META.get("HTTP_AUTHORIZATION") 231 | 232 | if token: 233 | try: 234 | return Token.objects.get(key=token.replace("BEARER", "").strip()) 235 | except Token.DoesNotExist: 236 | pass 237 | 238 | # Next attempt - try to get token via user session 239 | elif request.user.is_authenticated and not request.user.is_anonymous: 240 | try: 241 | return Token.objects.get(user=request.user) 242 | except Token.DoesNotExist: 243 | pass 244 | 245 | 246 | def get_challenge(request, repository, scopes=["pull", "push"]): 247 | """Given an unauthenticated request, return a challenge in 248 | the Www-Authenticate header 249 | 250 | Arguments: 251 | ========== 252 | request (requests.Request): the Request object to inspect 253 | repository (str) : the repository name 254 | scopes (list) : list of scopes to return 255 | """ 256 | DOMAIN_NAME = get_server(request) 257 | if not isinstance(scopes, list): 258 | scopes = [scopes] 259 | auth_server = settings.AUTH_SERVER or "%s/auth/token" % DOMAIN_NAME 260 | return 'realm="%s",service="%s",scope="repository:%s:%s"' % ( 261 | auth_server, 262 | DOMAIN_NAME, 263 | repository, 264 | ",".join(scopes), 265 | ) 266 | -------------------------------------------------------------------------------- /django_oci/files.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | 19 | import hashlib 20 | import os 21 | from datetime import timezone 22 | 23 | from django.core.files.base import ContentFile 24 | from django.core.files.uploadedfile import UploadedFile 25 | from django.db import models 26 | 27 | from django_oci.settings import MEDIA_ROOT 28 | 29 | 30 | class ChunkedUpload(models.Model): 31 | """We can use an abstract class to interact with a chunked upload without 32 | saving anything to the database. 33 | """ 34 | 35 | session_id = models.CharField(max_length=255) 36 | file = models.FileField( 37 | max_length=255, upload_to=os.path.join(MEDIA_ROOT, "sessions") 38 | ) 39 | offset = models.BigIntegerField(default=0) 40 | 41 | @property 42 | def filename(self): 43 | return os.path.basename(self.session_id) 44 | 45 | @property 46 | def expires_on(self): 47 | return self.created_on + 10000 48 | 49 | @property 50 | def expired(self): 51 | return self.expires_on <= timezone.now() 52 | 53 | @property 54 | def sha256(self): 55 | if getattr(self, "_md5", None) is None: 56 | hasher = hashlib.sha256() 57 | for chunk in self.file.chunks(): 58 | hasher.update(chunk) 59 | self._sha256 = hasher.hexdigest() 60 | return self._sha256 61 | 62 | def delete(self, delete_file=True, *args, **kwargs): 63 | if self.file: 64 | storage, path = self.file.storage, self.file.path 65 | super(ChunkedUpload, self).delete(*args, **kwargs) 66 | if self.file and delete_file: 67 | storage.delete(path) 68 | 69 | def __str__(self): 70 | return "<%s: session_id: %s - bytes: %s>" % ( 71 | self.filename, 72 | self.session_id, 73 | self.offset, 74 | ) 75 | 76 | def write_chunk(self, chunk, chunk_start): 77 | """Append a chunk to the file, or write the file if it doesn't exist yet. 78 | This is done to a temporary storage location in images/sessions until 79 | the blob is finalized. 80 | """ 81 | self.file.close() 82 | 83 | # If it's the first chunk, we need to instantiate the file 84 | if chunk_start == 0: 85 | self.file.save(name=self.filename, content=ContentFile(""), save=False) 86 | 87 | # We should start writing at next index, not over a previously written one 88 | if self.file.size == 0 and chunk_start != 0: 89 | return 415 90 | 91 | # If a chunk is uploaded out of order, the registry MUST respond with a 416 Requested Range Not Satisfiable code. 92 | elif chunk_start != 0 and self.file.size != chunk_start: 93 | return 416 94 | 95 | # Write chunk (mode = append+binary) 96 | with open(self.file.path, mode="ab") as fd: 97 | fd.write(chunk) 98 | 99 | # Update the offset 100 | self.offset += len(chunk) 101 | self._sha256 = None # Clear cached hash digest 102 | self.file.close() # Flush 103 | return 202 104 | 105 | def get_uploaded_file(self): 106 | self.file.close() 107 | self.file.open(mode="rb") # mode = read+binary 108 | return UploadedFile(file=self.file, name=self.filename, size=self.offset) 109 | -------------------------------------------------------------------------------- /django_oci/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | 19 | import hashlib 20 | import json 21 | import os 22 | import re 23 | 24 | from django.contrib.auth.models import User 25 | from django.core.files.storage import FileSystemStorage 26 | from django.db import models 27 | from django.middleware import cache 28 | from django.urls import reverse 29 | 30 | from django_oci import settings 31 | 32 | PRIVACY_CHOICES = ( 33 | (False, "Public (The collection will be accessible by anyone)"), 34 | (True, "Private (The collection will be not listed.)"), 35 | ) 36 | 37 | 38 | def get_privacy_default(): 39 | return settings.PRIVATE_ONLY 40 | 41 | 42 | class OverwriteStorage(FileSystemStorage): 43 | def get_available_name(self, name, **kwargs): 44 | """Define a new filesystem storage so that it's okay to overwrite an 45 | existing filename. 46 | """ 47 | if self.exists(name): 48 | os.remove(name) 49 | return name 50 | 51 | def get_valid_name(self, name, **kwargs): 52 | """The default function will replace the : in the filename (removing it) 53 | but we want to allow it. 54 | """ 55 | name = str(name).strip().replace(" ", "_") 56 | return re.sub(r"(?u)[^-\w.:]", "", name) 57 | 58 | 59 | def calculate_digest(body): 60 | """Calculate the sha256 sum for some body (bytes)""" 61 | hasher = hashlib.sha256() 62 | hasher.update(body) 63 | return hasher.hexdigest() 64 | 65 | 66 | def get_upload_folder(instance, filename): 67 | """a helper function to upload a blob to local storage""" 68 | repository_name = instance.repository.name 69 | blobs_home = os.path.join(settings.MEDIA_ROOT, "blobs", repository_name) 70 | if not os.path.exists(blobs_home): 71 | os.makedirs(blobs_home) 72 | filename = os.path.join(blobs_home, filename) 73 | return filename 74 | 75 | 76 | def get_image_by_tag(name, reference, tag, create=False, body=None): 77 | """given the name of a repository and a reference, look up the image 78 | based on the reference. By default we use the reference to look for 79 | a tag or digest. A return of None indicates that the image is not found, 80 | and the view should deal with this (e.g., create the image) or raise 81 | Http404. 82 | 83 | Parameters 84 | ========== 85 | name (str): the name of the repository to lookup 86 | reference (str): an image version string 87 | tag (str): a tag that doesn't match as a version string 88 | create (bool): if does not exist, create the image (new manifest push) 89 | body (bytes): if we need to create, we must have a digest from the body 90 | """ 91 | # Ensure the repository exists 92 | try: 93 | repository = Repository.objects.get(name=name) 94 | except Repository.DoesNotExist: 95 | return None 96 | 97 | # reference can be a tag (more likely) or digest 98 | image = None 99 | if tag: 100 | try: 101 | image = repository.image_set.get(tag__name=tag) 102 | except Image.DoesNotExist: 103 | pass 104 | 105 | elif reference: 106 | try: 107 | image = repository.image_set.get(version=reference) 108 | except Image.DoesNotExist: 109 | pass 110 | 111 | if not image and create: 112 | if not reference and body: 113 | reference = "sha256:%s" % calculate_digest(body) 114 | image, _ = Image.objects.get_or_create( 115 | repository=repository, version=reference, manifest=body 116 | ) 117 | if tag: 118 | tag, _ = Tag.objects.get_or_create(image=image, name=tag) 119 | tag.image = image 120 | tag.save() 121 | 122 | # This saves annotations and layer (blob) associations 123 | image.update_manifest(body) 124 | 125 | return image 126 | 127 | 128 | class Repository(models.Model): 129 | 130 | name = models.CharField( 131 | max_length=500, # name of repository, e.g., username/reponame 132 | unique=True, 133 | blank=False, 134 | null=False, 135 | ) 136 | 137 | add_date = models.DateTimeField("date added", auto_now_add=True) 138 | modify_date = models.DateTimeField("date modified", auto_now=True) 139 | owners = models.ManyToManyField( 140 | User, 141 | blank=True, 142 | default=None, 143 | related_name="container_collection_owners", 144 | related_query_name="owners", 145 | ) 146 | contributors = models.ManyToManyField( 147 | User, 148 | related_name="container_collection_contributors", 149 | related_query_name="contributor", 150 | blank=True, 151 | help_text="users with edit permission to the collection", 152 | verbose_name="Contributors", 153 | ) 154 | 155 | # By default, collections are public 156 | private = models.BooleanField( 157 | choices=PRIVACY_CHOICES, 158 | default=get_privacy_default, 159 | verbose_name="Accessibility", 160 | ) 161 | 162 | def has_view_permission(self, user): 163 | return user in self.owners.all() or user in self.contributors.all() 164 | 165 | def get_absolute_url(self): 166 | return reverse("repository_details", args=[str(self.id)]) 167 | 168 | def __str__(self): 169 | return self.get_uri() 170 | 171 | def __unicode__(self): 172 | return self.get_uri() 173 | 174 | def get_uri(self): 175 | return self.name 176 | 177 | def get_label(self): 178 | return "repository" 179 | 180 | class Meta: 181 | app_label = "django_oci" 182 | 183 | 184 | class Blob(models.Model): 185 | """a blob, which can be a binary or archive to be extracted.""" 186 | 187 | add_date = models.DateTimeField("date added", auto_now_add=True) 188 | modify_date = models.DateTimeField("date modified", auto_now=True) 189 | content_type = models.CharField(max_length=250, null=False) 190 | digest = models.CharField(max_length=250, null=True, blank=True) 191 | datafile = models.FileField( 192 | upload_to=get_upload_folder, max_length=255, storage=OverwriteStorage() 193 | ) 194 | remotefile = models.CharField(max_length=500, null=True, blank=True) 195 | 196 | # When a repository is deleted, so are the blobs 197 | repository = models.ForeignKey( 198 | Repository, 199 | null=False, 200 | blank=False, 201 | on_delete=models.CASCADE, 202 | ) 203 | 204 | def get_label(self): 205 | return "blob" 206 | 207 | def get_download_url(self): 208 | """Blobs are loosely associated with repositories.""" 209 | if self.remotefile is not None: 210 | return self.remotefile 211 | return reverse( 212 | "django_oci:blob_download", 213 | kwargs={"digest": self.digest, "name": self.repository.name}, 214 | ) 215 | 216 | def create_upload_session(self): 217 | """A function to create an upload session for a particular blob. 218 | The version variable will be set with a session id. 219 | """ 220 | # Get the django oci upload cache, and generate an expiring session upload id 221 | filecache = cache.caches["django_oci_upload"] 222 | 223 | # Expires in default 10 seconds 224 | filecache.set(self.session_id, 1, timeout=settings.SESSION_EXPIRES_SECONDS) 225 | return reverse("django_oci:blob_upload", kwargs={"session_id": self.session_id}) 226 | 227 | @property 228 | def session_id(self): 229 | return "put/%s/%s" % (self.id, self.digest) 230 | 231 | def get_abspath(self): 232 | return os.path.join(settings.MEDIA_ROOT, self.datafile.name) 233 | 234 | class Meta: 235 | app_label = "django_oci" 236 | unique_together = ( 237 | ( 238 | "repository", 239 | "digest", 240 | ), 241 | ) 242 | 243 | 244 | class Image(models.Model): 245 | """An image (manifest) holds a set of layers (blobs) for a repository. 246 | Blobs can be shared between manifests, and are deleted if they are 247 | no longer referenced. 248 | """ 249 | 250 | add_date = models.DateTimeField("date manifest added", auto_now=True) 251 | 252 | # When a repository is deleted, so are the manifests 253 | repository = models.ForeignKey( 254 | Repository, 255 | null=False, 256 | blank=False, 257 | on_delete=models.CASCADE, 258 | ) 259 | 260 | # Blobs are added at the creation of the manifest (can be shared based on hash) 261 | blobs = models.ManyToManyField(Blob) 262 | 263 | # The text of the manifest (added at the end) 264 | manifest = models.BinaryField(null=False, blank=False, default=b"{}") 265 | 266 | # The version (digest) of the manifest 267 | version = models.CharField(max_length=250, null=True, blank=True) 268 | 269 | # Manifest functions to get, save, and return download url 270 | def get_manifest(self): 271 | return self.manifest 272 | 273 | def add_blob(self, digest): 274 | """A helper function to lookup and add a blob to an image. If the blob 275 | is already added, no harm done. 276 | """ 277 | if digest: 278 | try: 279 | blob = Blob.objects.get(digest=digest, repository=self.repository) 280 | self.blobs.add(blob) 281 | except Blob.DoesNotExist: 282 | pass 283 | 284 | def remove_blob(self, digest): 285 | """We can only remove a blob if it is no longer linked to any images 286 | for the repository. This should be called after new blobs are parsed 287 | and added via the manifest. 288 | """ 289 | if digest: 290 | try: 291 | blob = Blob.objects.get(digest=digest, repository=self.repository) 292 | if blob.image_set.count() == 0: 293 | blob.delete() 294 | except Blob.DoesNotExist: 295 | pass 296 | 297 | def update_blob_links(self, manifest): 298 | # Keep a list of blobs that we will remove 299 | current_blobs = set() 300 | 301 | # The configuration blob, and then all blob layers 302 | config_digest = manifest.get("config", {}).get("digest") 303 | current_blobs.add(config_digest) 304 | for layer in manifest.get("layers", []): 305 | current_blobs.add(layer.get("digest")) 306 | 307 | # Remove unlinked blobs 308 | unlinked_blobs = [x for x in self.blobs.all() if x.digest not in current_blobs] 309 | 310 | # Add all current blobs not already present, remove unlinked 311 | [self.add_blob(digest) for digest in current_blobs] 312 | [self.remove_blob(x) for x in unlinked_blobs] 313 | 314 | def update_annotations(self, manifest): 315 | # Just delete all previous annotations 316 | self.annotation_set.all().delete() 317 | for key, value in manifest.get("annotations", {}).items(): 318 | annotation, created = Annotation.objects.get_or_create(key=key, image=self) 319 | annotation.value = value 320 | annotation.save() 321 | 322 | def update_manifest(self, manifest): 323 | """Loading a manifest (after save) means creating an association between blobs and 324 | annotations 325 | """ 326 | # Load a derivation to get blob links and annotations 327 | if not isinstance(manifest, str): 328 | manifest = manifest.decode("utf-8") 329 | if not isinstance(manifest, dict): 330 | manifest = json.loads(manifest) 331 | self.update_blob_links(manifest) 332 | self.update_annotations(manifest) 333 | self.save() 334 | 335 | def get_manifest_url(self): 336 | if self.version: 337 | return reverse( 338 | "django_oci:image_manifest", 339 | kwargs={"name": self.repository.name, "reference": self.version}, 340 | ) 341 | return reverse( 342 | "django_oci:image_manifest", 343 | kwargs={"name": self.repository.name, "tag": self.tag_set.first().name}, 344 | ) 345 | 346 | # A container only gets a version when fit's frozen, otherwise known by tag 347 | def get_uri(self): 348 | return self.repository.name 349 | 350 | # Return an image file path 351 | def get_image_path(self): 352 | if self.image not in [None, ""]: 353 | return self.image.datafile.path 354 | return None 355 | 356 | def get_download_url(self): 357 | if self.image not in [None, ""]: 358 | return self.image.datafile.file 359 | return None 360 | 361 | def get_label(self): 362 | return "image" 363 | 364 | def __str__(self): 365 | return self.get_uri() 366 | 367 | def __unicode__(self): 368 | return self.get_uri() 369 | 370 | class Meta: 371 | app_label = "django_oci" 372 | unique_together = ( 373 | ( 374 | "repository", 375 | "version", 376 | ), 377 | ) 378 | 379 | 380 | class Tag(models.Model): 381 | """A tag is a reference for one or more manifests""" 382 | 383 | name = models.CharField(max_length=250, null=False, blank=False) 384 | image = models.ForeignKey( 385 | Image, 386 | null=False, 387 | blank=False, 388 | # When a manifest is deleted, any associated tags are too 389 | on_delete=models.CASCADE, 390 | ) 391 | 392 | def __str__(self): 393 | return "" % self.name 394 | 395 | 396 | class Annotation(models.Model): 397 | """An annotation is a key/value pair to describe an image. 398 | We will want to parse these from an image manifest (eventually) 399 | """ 400 | 401 | key = models.CharField(max_length=250, null=False, blank=False) 402 | value = models.CharField(max_length=250, null=False, blank=False) 403 | image = models.ForeignKey(Image, on_delete=models.CASCADE) 404 | 405 | def __str__(self): 406 | return "%s:%s" % (self.key, self.value) 407 | 408 | def __unicode__(self): 409 | return "%s:%s" % (self.key, self.value) 410 | 411 | def get_label(self): 412 | return "annotation" 413 | 414 | class Meta: 415 | app_label = "django_oci" 416 | unique_together = (("key", "image"),) 417 | -------------------------------------------------------------------------------- /django_oci/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (C) 2020-2023 Vanessa Sochat. 4 | 5 | This Source Code Form is subject to the terms of the 6 | Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 7 | with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | Settings are namespaced to the DJANGO_OCI namespace. Any of the following 10 | below can be overridden in the default django settings, e.g.: 11 | 12 | DJANGO_OCI = { 13 | 'SPEC_VERSION': "v2", 14 | 'PRIVATE_ONLY': False 15 | } 16 | 17 | """ 18 | 19 | import logging 20 | import os 21 | import uuid 22 | 23 | from django.conf import settings 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | # Default views to Authenticate 28 | authenticated_views = [ 29 | "django_oci.views.blobs.BlobUpload", 30 | "django_oci.views.blobs.BlobDownload", 31 | "django_oci.views.image.ImageTags", 32 | "django_oci.views.image.ImageManifest", 33 | "django_oci.views.image.view", 34 | "django_oci.views.blobs.view", 35 | ] 36 | 37 | DEFAULTS = { 38 | # Url base prefix 39 | "URL_PREFIX": "v2", 40 | # Disable registry authentication 41 | "DISABLE_AUTHENTICATION": False, 42 | # Version of distribution spec 43 | "SPEC_VERSION": "1", 44 | # Repository permissions 45 | "PRIVATE_ONLY": False, 46 | # Allowed content types to upload as layers 47 | "CONTENT_TYPES": ["application/octet-stream"], 48 | # Image Manifest content type 49 | "IMAGE_MANIFEST_CONTENT_TYPE": "application/vnd.oci.image.manifest.v1+json", 50 | # Storage backend 51 | "STORAGE_BACKEND": "filesystem", 52 | # Domain used in templates, api prefix 53 | "DOMAIN_URL": "http://127.0.0.1:8000", 54 | # Media root (if saving images on filesystem 55 | "MEDIA_ROOT": "images", 56 | # Set a cache directory, otherwise defaults to MEDIA_ROOT + /cache 57 | "CACHE_DIR": None, 58 | # The number of seconds a session (upload request) is valid (10 minutes) 59 | "SESSION_EXPIRES_SECONDS": 600, 60 | # The number of seconds a token is valid (10 minutes) 61 | "TOKEN_EXPIRES_SECONDS": 600, 62 | # Disable deletion of an image by tag or manifest (default is not disabled) 63 | "DISABLE_TAG_MANIFEST_DELETE": False, 64 | # Default content type is application/octet-stream 65 | "DEFAULT_CONTENT_TYPE": "application/octet-stream", 66 | # Default views to put under authentication, given that DISABLE_AUTHENTICTION is False 67 | "AUTHENTICATED_VIEWS": authenticated_views, 68 | # If you have a custom authentication server to generate tokens (defaults to /registry/auth/token 69 | "AUTHENTICATION_SERVER": None, 70 | # jwt encoding secret: set server wide or generated on the fly 71 | "JWT_SERVER_SECRET": str(uuid.uuid4()), 72 | # View rate limit, defaults to 100/1day using django-ratelimit based on ipaddress 73 | "VIEW_RATE_LIMIT": "100/1d", 74 | # Given that someone goes over, are they blocked for a period? 75 | "VIEW_RATE_LIMIT_BLOCK": True, 76 | } 77 | 78 | # The user can define a section for DJANGO_OCI in settings 79 | oci = getattr(settings, "DJANGO_OCI", DEFAULTS) 80 | 81 | # Allows for import of variables above from django_oci.settings 82 | URL_PREFIX = oci.get("URL_PREFIX", DEFAULTS["URL_PREFIX"]) 83 | DISABLE_AUTHENTICATION = oci.get( 84 | "DISABLE_AUTHENTICATION", DEFAULTS["DISABLE_AUTHENTICATION"] 85 | ) 86 | AUTH_SERVER = oci.get("AUTHENTICATION_SERVER", DEFAULTS["AUTHENTICATION_SERVER"]) 87 | SPEC_VERSION = oci.get("SPEC_VERSION", DEFAULTS["SPEC_VERSION"]) 88 | PRIVATE_ONLY = oci.get("PRIVATE_ONLY", DEFAULTS["PRIVATE_ONLY"]) 89 | CONTENT_TYPES = oci.get("CONTENT_TYPES", DEFAULTS["CONTENT_TYPES"]) 90 | JWT_SERVER_SECRET = oci.get("JWT_SERVER_SECRET", DEFAULTS["JWT_SERVER_SECRET"]) 91 | STORAGE_BACKEND = oci.get("STORAGE_BACKEND", DEFAULTS["STORAGE_BACKEND"]) 92 | DOMAIN_URL = oci.get("DOMAIN_URL", DEFAULTS["DOMAIN_URL"]) 93 | MEDIA_ROOT = oci.get("MEDIA_ROOT", DEFAULTS["MEDIA_ROOT"]) 94 | CACHE_DIR = oci.get("CACHE_DIR", DEFAULTS["CACHE_DIR"]) 95 | DEFAULT_CONTENT_TYPE = oci.get("DEFAULT_CONTENT_TYPE", DEFAULTS["DEFAULT_CONTENT_TYPE"]) 96 | IMAGE_MANIFEST_CONTENT_TYPE = oci.get( 97 | "IMAGE_MANIFEST_CONTENT_TYPE", DEFAULTS["IMAGE_MANIFEST_CONTENT_TYPE"] 98 | ) 99 | DISABLE_TAG_MANIFEST_DELETE = oci.get( 100 | "DISABLE_TAG_MANIFEST_DELETE", DEFAULTS["DISABLE_TAG_MANIFEST_DELETE"] 101 | ) 102 | TOKEN_EXPIRES_SECONDS = oci.get( 103 | "TOKEN_EXPIRES_SECONDS", DEFAULTS["TOKEN_EXPIRES_SECONDS"] 104 | ) 105 | SESSION_EXPIRES_SECONDS = oci.get( 106 | "SESSION_EXPIRES_SECONDS", DEFAULTS["SESSION_EXPIRES_SECONDS"] 107 | ) 108 | AUTHENTICATED_VIEWS = oci.get("AUTHENTICATED_VIEWS", DEFAULTS["AUTHENTICATED_VIEWS"]) 109 | 110 | # Rate Limits 111 | VIEW_RATE_LIMIT = oci.get("VIEW_RATE_LIMIT", DEFAULTS["VIEW_RATE_LIMIT"]) 112 | VIEW_RATE_LIMIT_BLOCK = oci.get( 113 | "VIEW_RATE_LIMIT_BLOCK", DEFAULTS["VIEW_RATE_LIMIT_BLOCK"] 114 | ) 115 | 116 | 117 | # Set filesystem cache, also adding to middleware 118 | CACHES = getattr(settings, "CACHES", {}) 119 | MIDDLEWARE = getattr(settings, "MIDDLEWARE", []) 120 | 121 | for entry in [ 122 | "django.middleware.cache.UpdateCacheMiddleware", 123 | "django.middleware.common.CommonMiddleware", 124 | "django.middleware.cache.FetchFromCacheMiddleware", 125 | ]: 126 | if entry not in MIDDLEWARE: 127 | MIDDLEWARE.append(entry) 128 | 129 | # Default auto field 130 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 131 | 132 | # Create a filesystem cache for temporary upload sessions 133 | cache = CACHE_DIR or os.path.join(MEDIA_ROOT, "cache") 134 | if not os.path.exists(cache): 135 | logger.debug(f"Creating cache directory {cache}") 136 | os.makedirs(cache) 137 | 138 | CACHES.update( 139 | { 140 | "django_oci_upload": { 141 | "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", 142 | "LOCATION": os.path.abspath(cache), 143 | } 144 | } 145 | ) 146 | -------------------------------------------------------------------------------- /django_oci/signals.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | from django.contrib.auth import get_user_model 19 | from django.db.models.signals import post_delete, post_save 20 | from django.dispatch import receiver 21 | from rest_framework.authtoken.models import Token 22 | 23 | from .models import Image 24 | 25 | UserModel = get_user_model() 26 | 27 | 28 | @receiver(post_delete, sender=Image) 29 | def delete_blobs(sender, instance, **kwargs): 30 | print("Delete image signal running.") 31 | 32 | for blob in instance.blobs.all(): 33 | if hasattr(blob, "datafile"): 34 | if blob.image_set.count() == 0: 35 | print("Deleting %s, no longer used." % blob.datafile) 36 | blob.datafile.delete() 37 | blob.delete() 38 | 39 | 40 | @receiver(post_save, sender=UserModel) 41 | def create_auth_token(sender, instance=None, created=False, **kwargs): 42 | """Create a token for the user when the user is created (with oAuth2) 43 | 44 | 1. Assign user a token 45 | 2. Assign user to default group 46 | 47 | Create a Profile instance for all newly created User instances. We only 48 | run on user creation to avoid having to check for existence on each call 49 | to User.save. 50 | """ 51 | if created: 52 | Token.objects.create(user=instance) 53 | -------------------------------------------------------------------------------- /django_oci/storage.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | 19 | import hashlib 20 | import logging 21 | import os 22 | import shutil 23 | import uuid 24 | 25 | from django.core.files.uploadedfile import SimpleUploadedFile 26 | from django.http.response import Http404, HttpResponse 27 | from django.urls import reverse 28 | from rest_framework.response import Response 29 | 30 | from django_oci import settings 31 | from django_oci.files import ChunkedUpload 32 | from django_oci.models import Blob 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | 37 | def get_storage(): 38 | """Return the correct storage handler based on the key obtained from 39 | settings 40 | """ 41 | storage = settings.STORAGE_BACKEND 42 | lookup = {"filesystem": FileSystemStorage} 43 | if storage not in lookup: 44 | logger.warning( 45 | f"{storage} not supported as a storage backend, defaulting to filesystem." 46 | ) 47 | storage = "filesystem" 48 | return lookup[storage]() 49 | 50 | 51 | # Storage backends for django_oci 52 | # each should have default set of functions for: 53 | # monolithic upload 54 | # chunked upload 55 | # etc. 56 | 57 | 58 | class StorageBase: 59 | """A storage base provides shared functions for a storage type""" 60 | 61 | def calculate_digest(self, body): 62 | """Calculate the sha256 sum for some body (bytes)""" 63 | hasher = hashlib.sha256() 64 | hasher.update(body) 65 | return hasher.hexdigest() 66 | 67 | 68 | class FileSystemStorage(StorageBase): 69 | def create_blob_request(self, repository): 70 | """A create blob request is intended to be done first with a name, 71 | and content type, and we do all steps of the creation 72 | except for the actual upload of the file, which is handled 73 | by a second PUT request to a session id. 74 | """ 75 | # Generate blob upload session to return to the user 76 | version = "session-%s" % uuid.uuid4() 77 | blob = Blob.objects.create(digest=version, repository=repository) 78 | blob.save() 79 | 80 | # Upon success, the response MUST have a code of 202 Accepted with a location header 81 | return Response(status=202, headers={"Location": blob.create_upload_session()}) 82 | 83 | def finish_blob( 84 | self, 85 | blob, 86 | digest, 87 | ): 88 | """Finish a blob, meaning finalizing the digest and returning a download 89 | url relative to the name provided. 90 | """ 91 | 92 | # In the case of a blob created from upload session, need to rename to be digest 93 | if blob.datafile.name != digest: 94 | final_path = os.path.join( 95 | settings.MEDIA_ROOT, "blobs", blob.repository.name, digest 96 | ) 97 | if not os.path.exists(final_path): 98 | dirname = os.path.dirname(final_path) 99 | if not os.path.exists(dirname): 100 | os.makedirs(dirname) 101 | shutil.move(blob.datafile.path, final_path) 102 | else: 103 | os.remove(blob.datafile.name) 104 | blob.datafile.name = final_path 105 | 106 | # Delete the blob if it already existed 107 | try: 108 | existing_blob = Blob.objects.get(repository=blob.repository, digest=digest) 109 | existing_blob.delete() 110 | except Exception: 111 | pass 112 | 113 | blob.digest = digest 114 | blob.save() 115 | 116 | # Location header must have being a pullable blob URL. 117 | return Response(status=201, headers={"Location": blob.get_download_url()}) 118 | 119 | def create_blob(self, digest, body, content_type, blob=None, repository=None): 120 | """Create an image blob from a monolithic post. We get the repository 121 | name along with the body for the blob and the digest. 122 | 123 | Parameters 124 | ========== 125 | body (bytes): the request body to write the container 126 | digest (str): the computed digest of the blob 127 | content_type (str): the blob content type 128 | blob (models.Blob): a blob object (if already created) 129 | """ 130 | # the MUST match the blob's digest (how to calculate) 131 | calculated_digest = self.calculate_digest(body) 132 | 133 | # If there is an algorithm prefix, add it 134 | if ":" in digest: 135 | calculated_digest = "%s:%s" % (digest.split(":")[0], calculated_digest) 136 | 137 | if calculated_digest != digest: 138 | return Response(status=400) 139 | 140 | # If we don't have the blob object yet 141 | created = False 142 | if not blob: 143 | blob, created = Blob.objects.get_or_create( 144 | digest=calculated_digest, repository=repository 145 | ) 146 | 147 | # Delete the blob if it already existed 148 | try: 149 | existing_blob = Blob.objects.get(repository=blob.repository, digest=digest) 150 | existing_blob.delete() 151 | except Exception: 152 | pass 153 | 154 | if not blob.datafile: 155 | datafile = SimpleUploadedFile( 156 | calculated_digest, body, content_type=content_type 157 | ) 158 | blob.datafile = datafile 159 | 160 | # The digest is updated here if it was previously a session id 161 | blob.content_type = content_type 162 | blob.digest = digest 163 | blob.save() 164 | 165 | # If it's already existing, return Accepted header, otherwise alert created 166 | # NOTE: this is set to 201 currently because the conformance test only allows that 167 | # status_code = 202 168 | # if created: 169 | status_code = 201 170 | 171 | # Location header must have being a pullable blob URL. 172 | return Response( 173 | status=status_code, headers={"Location": blob.get_download_url()} 174 | ) 175 | 176 | def upload_blob_chunk( 177 | self, 178 | blob, 179 | body, 180 | content_start, 181 | content_end, 182 | content_length, 183 | ): 184 | """Upload a chunk of a blob 185 | 186 | Parameters 187 | ========== 188 | blob (Blob): the blob to upload to 189 | body (bytes): the request body to write to the blob 190 | content_type (str): the blob content type 191 | content_start (int): the content starting index 192 | content_end (int): the content ending index 193 | content_length (int): the content length 194 | """ 195 | status_code = self.write_chunk( 196 | blob=blob, content_start=content_start, content_end=content_end, body=body 197 | ) 198 | 199 | # If it's already existing, return Accepted header, otherwise alert created 200 | if status_code not in [201, 202]: 201 | return Response(status=status_code) 202 | 203 | # Generate the same upload 204 | location = reverse( 205 | "django_oci:blob_upload", kwargs={"session_id": blob.session_id} 206 | ) 207 | return Response(status=status_code, headers={"Location": location}) 208 | 209 | def write_chunk(self, blob, content_start, content_end, body): 210 | """Write a chunk to a blob. During a chunked upload, the digest corresponds 211 | to the session_id, and is saved temporarily. It's named on upload finish. 212 | """ 213 | # Ensure the size is correct (we add 1 to include the start index) 214 | if len(body) != content_end - content_start + 1: 215 | return 416 216 | 217 | # If we don't yet have a blob.datafile, create a new one, assert that upload_range starts at 0 218 | if not blob.datafile: 219 | 220 | # The first request must start at 0 221 | if content_start != 0: 222 | return 416 223 | 224 | # Create an empty data file 225 | datafile = ChunkedUpload(session_id=blob.digest) 226 | 227 | # Uploading another chunk for existing file 228 | else: 229 | datafile = ChunkedUpload(session_id=blob.digest, file=blob.datafile.file) 230 | 231 | # Update the chunk, get back the status code 232 | status_code = datafile.write_chunk(body, content_start) 233 | blob.datafile.name = datafile.file.name 234 | blob.save() 235 | return status_code 236 | 237 | def blob_exists(self, name, digest): 238 | """Given a blob repository name and digest, return a 200 response 239 | with the digest of the uploaded blob in the header Docker-Content-Digest. 240 | """ 241 | try: 242 | blob = Blob.objects.get(digest=digest, repository__name=name) 243 | except Blob.DoesNotExist: 244 | raise Http404 245 | headers = {"Docker-Content-Digest": blob.digest} 246 | return Response(status=200, headers=headers) 247 | 248 | def download_blob(self, name, digest): 249 | """Given a blob repository name and digest, return response to stream download. 250 | The repository name is associated to the blob via the image. 251 | """ 252 | try: 253 | blob = Blob.objects.get(digest=digest, repository__name=name) 254 | except Blob.DoesNotExist: 255 | try: 256 | # Try getting a cross mounted blob with matching digest (any name) 257 | blob = Blob.objects.filter(digest=digest).first() 258 | except Blob.DoesNotExist: 259 | raise Http404 260 | 261 | # If we don't have a blob, no go. 262 | if not blob: 263 | raise Http404 264 | 265 | if os.path.exists(blob.datafile.name): 266 | with open(blob.datafile.name, "rb") as fh: 267 | response = HttpResponse(fh.read(), content_type=blob.content_type) 268 | response[ 269 | "Content-Disposition" 270 | ] = "inline; filename=" + os.path.basename(blob.datafile.name) 271 | return response 272 | 273 | # If we get here, file doesn't exist 274 | raise Http404 275 | 276 | def delete_blob(self, name, digest): 277 | """Given a blob repository name and digest, delete and return success (202).""" 278 | try: 279 | blob = Blob.objects.get(digest=digest, repository__name=name) 280 | except Blob.DoesNotExist: 281 | raise Http404 282 | 283 | # Delete the blob, will eventually need to check permissions 284 | blob.delete() 285 | return Response(status=202) 286 | 287 | 288 | # Load storage on application init 289 | storage = get_storage() 290 | -------------------------------------------------------------------------------- /django_oci/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | 19 | from django.urls import path, re_path 20 | 21 | from django_oci import settings, views 22 | 23 | app_name = "django_oci" 24 | 25 | 26 | urlpatterns = [ 27 | path( 28 | "auth/token/", 29 | views.GetAuthToken.as_view(), 30 | name="get_auth_token", 31 | ), 32 | # https://github.com/opencontainers/distribution-spec/blob/master/spec.md#api-version-check 33 | re_path( 34 | r"^%s/?$" % settings.URL_PREFIX, 35 | views.APIVersionCheck.as_view(), 36 | name="api_version_check", 37 | ), 38 | re_path( 39 | r"^%s/(?P[a-z0-9\/-_]+(?:[._-][a-z0-9]+)*)/tags/list/?$" 40 | % settings.URL_PREFIX, 41 | views.ImageTags.as_view(), 42 | name="image_tags", 43 | ), 44 | # This is for a full digest reference 45 | # https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pulling-an-image-manifest 46 | re_path( 47 | r"^%s/(?P[a-z0-9\/-_]+(?:[._-][a-z0-9]+)*)/manifests/(?P[A-Za-z0-9_+.-]+:[A-Fa-f0-9]+)/?$" 48 | % settings.URL_PREFIX, 49 | views.ImageManifest.as_view(), 50 | name="image_manifest", 51 | ), 52 | # This is for a tag reference 53 | re_path( 54 | r"^%s/(?P[a-z0-9\/-_]+(?:[._-][a-z0-9]+)*)/manifests/(?P[A-Za-z0-9_+.-]+)/?$" 55 | % settings.URL_PREFIX, 56 | views.ImageManifest.as_view(), 57 | name="image_manifest", 58 | ), 59 | re_path( 60 | r"^%s/(?P[a-z0-9\/-_]+(?:[._-][a-z0-9]+)*)/blobs/uploads/?$" 61 | % settings.URL_PREFIX, 62 | views.BlobUpload.as_view(), 63 | name="blob_upload", 64 | ), 65 | # Listed twice, once with and once without trailing slash 66 | path( 67 | "%s/blobs/uploads//" % settings.URL_PREFIX, 68 | views.BlobUpload.as_view(), 69 | name="blob_upload", 70 | ), 71 | path( 72 | "%s/blobs/uploads/" % settings.URL_PREFIX, 73 | views.BlobUpload.as_view(), 74 | name="blob_upload", 75 | ), 76 | # Also listed twice, once with and once without trailing slash 77 | path( 78 | "%s//blobs/" % settings.URL_PREFIX, 79 | views.BlobDownload.as_view(), 80 | name="blob_download", 81 | ), 82 | path( 83 | "%s//blobs//" % settings.URL_PREFIX, 84 | views.BlobDownload.as_view(), 85 | name="blob_download", 86 | ), 87 | ] 88 | -------------------------------------------------------------------------------- /django_oci/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | 19 | import logging 20 | import re 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | # Regular expressions to parse registry, collection, repo, tag and version 26 | _docker_uri = re.compile( 27 | "(?:(?P[^/@]+[.:][^/@]*)/)?" 28 | "(?P(?:[^:@/]+/)+)?" 29 | "(?P[^:@/]+)" 30 | "(?::(?P[^:@]+))?" 31 | "(?:@(?P.+))?" 32 | "$" 33 | ) 34 | 35 | # Reduced to match registry:port/repo or registry.com/repo 36 | _reduced_uri = re.compile( 37 | "(?:(?P[^/@]+[.:][^/@]*)/)?" 38 | "(?P[^:@/]+)" 39 | "(?::(?P[^:@]+))?" 40 | "(?:@(?P.+))?" 41 | "$" 42 | "(?P.)?" 43 | ) 44 | 45 | # Default 46 | _default_uri = re.compile( 47 | "(?:(?P[^/@]+)/)?" 48 | "(?P(?:[^:@/]+/)+)" 49 | "(?P[^:@/]+)" 50 | "(?::(?P[^:@]+))?" 51 | "(?:@(?P.+))?" 52 | "$" 53 | ) 54 | 55 | 56 | def set_default(item, default, use_default): 57 | """if an item provided is None and boolean use_default is set to True, 58 | return the default. Otherwise, return the item. 59 | """ 60 | if item is None and use_default: 61 | return default 62 | return item 63 | 64 | 65 | def get_server(request): 66 | """Given a request, parse it to determine the server name and using http/https""" 67 | scheme = request.is_secure() and "https" or "http" 68 | return f"{scheme}://{request.get_host()}" 69 | 70 | 71 | def parse_content_range(content_range): 72 | """Given a content range, match based on regular expression and return 73 | parsed start, end (both int) 74 | """ 75 | # Ensure range matches regular expression 76 | if not re.search("^[0-9]+-[0-9]+$", content_range): 77 | raise ValueError 78 | 79 | # Parse the content range into numbers 80 | return [int(x.strip()) for x in content_range.strip().split("-")] 81 | 82 | 83 | def parse_image_name( 84 | image_name, 85 | tag=None, 86 | version=None, 87 | defaults=True, 88 | ext="sif", 89 | default_collection="library", 90 | default_tag="latest", 91 | base=None, 92 | lowercase=True, 93 | ): 94 | 95 | """return a collection and repo name and tag 96 | for an image file. 97 | 98 | Parameters 99 | ========= 100 | image_name: a user provided string indicating a collection, 101 | image, and optionally a tag. 102 | tag: optionally specify tag as its own argument 103 | over-rides parsed image tag 104 | defaults: use defaults "latest" for tag and "library" 105 | for collection. 106 | base: if defined, remove from image_name, appropriate if the 107 | user gave a registry url base that isn't part of namespace. 108 | lowercase: turn entire URI to lowercase (default is True) 109 | """ 110 | 111 | # Save the original string 112 | original = image_name 113 | 114 | if base is not None: 115 | image_name = image_name.replace(base, "").strip("/") 116 | 117 | # If a file is provided, remove extension 118 | image_name = re.sub("[.](img|simg|sif)", "", image_name) 119 | 120 | # Parse the provided name 121 | uri_regexes = [_reduced_uri, _default_uri, _docker_uri] 122 | 123 | for r in uri_regexes: 124 | match = r.match(image_name) 125 | if match: 126 | break 127 | 128 | if not match: 129 | logger.warning('Could not parse image "%s"! Exiting.' % image_name) 130 | return 131 | 132 | # Get matches 133 | registry = match.group("registry") 134 | collection = match.group("collection") 135 | repo_name = match.group("repo") 136 | repo_tag = tag or match.group("tag") 137 | version = version or match.group("version") 138 | 139 | # A repo_name is required 140 | assert repo_name 141 | 142 | # If a collection isn't provided 143 | collection = set_default(collection, default_collection, defaults) 144 | repo_tag = set_default(repo_tag, default_tag, defaults) 145 | 146 | # The collection, name must be all lowercase 147 | if lowercase: 148 | collection = collection.lower().rstrip("/") 149 | repo_name = repo_name.lower() 150 | repo_tag = repo_tag.lower() 151 | else: 152 | collection = collection.rstrip("/") 153 | 154 | if version is not None: 155 | version = version.lower() 156 | 157 | # Piece together the uri base 158 | if registry is None: 159 | uri = "%s/%s" % (collection, repo_name) 160 | else: 161 | uri = "%s/%s/%s" % (registry, collection, repo_name) 162 | 163 | url = uri 164 | 165 | # Tag is defined 166 | if repo_tag is not None: 167 | uri = "%s:%s" % (url, repo_tag) 168 | 169 | # Version is defined 170 | storage_version = None 171 | if version is not None: 172 | uri = "%s@%s" % (uri, version) 173 | storage_version = "%s.%s" % (uri, ext) 174 | 175 | # A second storage URI honors the tag (:) separator 176 | 177 | storage = "%s.%s" % (uri, ext) 178 | return { 179 | "collection": collection, 180 | "original": original, 181 | "registry": registry, 182 | "image": repo_name, 183 | "url": url, 184 | "tag": repo_tag, 185 | "version": version, 186 | "storage": storage_version or storage, 187 | "uri": uri, 188 | } 189 | -------------------------------------------------------------------------------- /django_oci/views/__init__.py: -------------------------------------------------------------------------------- 1 | # Load storage on application init 2 | from django_oci.storage import get_storage 3 | 4 | from .auth import GetAuthToken 5 | from .base import APIVersionCheck 6 | from .blobs import BlobDownload, BlobUpload 7 | from .image import ImageManifest, ImageTags 8 | 9 | storage = get_storage() 10 | -------------------------------------------------------------------------------- /django_oci/views/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | 19 | import re 20 | 21 | from django.http import HttpResponseForbidden 22 | from django.utils.decorators import method_decorator 23 | from django.views.decorators.cache import never_cache 24 | from rest_framework.decorators import authentication_classes, permission_classes 25 | from rest_framework.response import Response 26 | from rest_framework.views import APIView 27 | 28 | from django_oci import settings 29 | from django_oci.auth import generate_jwt, get_user 30 | from django_oci.utils import get_server 31 | 32 | 33 | @authentication_classes([]) 34 | @permission_classes([]) 35 | @method_decorator(never_cache, name="dispatch") 36 | class GetAuthToken(APIView): 37 | """ 38 | Given a GET request for a token, validate and return it. 39 | """ 40 | 41 | permission_classes = [] 42 | allowed_methods = ("GET",) 43 | 44 | @method_decorator(never_cache) 45 | def get(self, request, *args, **kwargs): 46 | """ 47 | GET /auth/token 48 | """ 49 | print("GET /auth/token") 50 | user = get_user(request) 51 | 52 | # No token provided matching a user, no go 53 | if not user: 54 | return HttpResponseForbidden() 55 | 56 | # Formalate the jwt token response, with a unique id 57 | _ = request.GET.get("service") 58 | scope = request.GET.get("scope") 59 | 60 | # The scope should include the repository name, and permissions 61 | # "repository:vanessa/container:pull,push" 62 | match = re.match("repository:(?P.+):(?P.+)", scope) 63 | repository, scope = match.groups() 64 | scope = scope.split(",") 65 | 66 | # Generate domain name for auth server 67 | DOMAIN_NAME = get_server(request) 68 | auth_server = settings.AUTH_SERVER or "%s/auth/token" % DOMAIN_NAME 69 | 70 | # Generate the token data, a dict with token, expires_in, and issued_at 71 | data = generate_jwt( 72 | username=user.username, 73 | scope=scope, 74 | realm=auth_server, 75 | repository=repository, 76 | ) 77 | return Response(status=200, data=data) 78 | -------------------------------------------------------------------------------- /django_oci/views/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | 19 | from rest_framework.response import Response 20 | from rest_framework.views import APIView 21 | 22 | 23 | class APIVersionCheck(APIView): 24 | """provide version support information based on response statuses. 25 | This endpoint should only allow GET requests. 26 | https://github.com/opencontainers/distribution-spec/blob/master/spec.md#api-version-check 27 | """ 28 | 29 | permission_classes = [] 30 | allowed_methods = ("GET",) 31 | 32 | def get(self, request, *args, **kwargs): 33 | headers = {"Docker-Distribution-API-Version": "registry/2.0"} 34 | return Response({"success": True}, headers=headers) 35 | -------------------------------------------------------------------------------- /django_oci/views/image.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | 19 | from django.http.response import Http404 20 | from django.utils.decorators import method_decorator 21 | from django.views.decorators.cache import never_cache 22 | from ratelimit.decorators import ratelimit 23 | from rest_framework.renderers import JSONRenderer 24 | from rest_framework.response import Response 25 | from rest_framework.views import APIView 26 | 27 | from django_oci import settings 28 | from django_oci.auth import is_authenticated 29 | from django_oci.models import Repository, get_image_by_tag 30 | 31 | from .parsers import ManifestRenderer 32 | 33 | 34 | @method_decorator(never_cache, name="dispatch") 35 | class ImageTags(APIView): 36 | """ 37 | Return a list of tags for an image. 38 | """ 39 | 40 | permission_classes = [] 41 | allowed_methods = ("GET",) 42 | 43 | @method_decorator( 44 | ratelimit( 45 | key="ip", 46 | rate=settings.VIEW_RATE_LIMIT, 47 | method="GET", 48 | block=settings.VIEW_RATE_LIMIT_BLOCK, 49 | ) 50 | ) 51 | @method_decorator(never_cache) 52 | def get(self, request, *args, **kwargs): 53 | """ 54 | GET /v2//tags/list. We don't require authentication to list tags, 55 | unless the repository is private. 56 | """ 57 | name = kwargs.get("name") 58 | number = request.GET.get("n") 59 | last = request.GET.get("last") 60 | 61 | try: 62 | repository = Repository.objects.get(name=name) 63 | except Repository.DoesNotExist: 64 | raise Http404 65 | 66 | # If allow_continue False, return response 67 | allow_continue, response, _ = is_authenticated( 68 | request, repository, scopes=["pull"] 69 | ) 70 | if not allow_continue: 71 | return response 72 | 73 | tags = [ 74 | x 75 | for x in list(repository.image_set.values_list("tag__name", flat=True)) 76 | if x 77 | ] 78 | 79 | # Tags must be sorted in lexical order 80 | tags.sort() 81 | 82 | # Number must be an integer if defined 83 | if number: 84 | number = int(number) 85 | 86 | # if last, not included in the results, but up to tags after will be returned. 87 | if last and number: 88 | try: 89 | start = tags.index(last) 90 | except IndexError: 91 | start = 0 92 | tags = tags[start:number] 93 | 94 | elif number: 95 | tags = tags[:number] 96 | 97 | # Ensure tags sorted in lexical order 98 | data = {"name": repository.name, "tags": sorted(tags)} 99 | return Response(status=200, data=data) 100 | 101 | 102 | @method_decorator(never_cache, name="dispatch") 103 | class ImageManifest(APIView): 104 | """ 105 | An Image Manifest holds the configuration and metadata about an image 106 | GET: is to retrieve an existing image manifest 107 | PUT: is to push a manifest 108 | HEAD: confirm that a manifest exists. 109 | """ 110 | 111 | renderer_classes = [ManifestRenderer, JSONRenderer] 112 | permission_classes = [] 113 | allowed_methods = ( 114 | "GET", 115 | "PUT", 116 | "DELETE", 117 | ) 118 | 119 | @method_decorator(never_cache) 120 | @method_decorator( 121 | ratelimit( 122 | key="ip", 123 | rate=settings.VIEW_RATE_LIMIT, 124 | method="DELETE", 125 | block=settings.VIEW_RATE_LIMIT_BLOCK, 126 | ) 127 | ) 128 | def delete(self, request, *args, **kwargs): 129 | """ 130 | DELETE /v2//manifests/ 131 | """ 132 | # A registry must globally disable or enable both 133 | if settings.DISABLE_TAG_MANIFEST_DELETE: 134 | return Response(status=405) 135 | 136 | name = kwargs.get("name") 137 | reference = kwargs.get("reference") 138 | tag = kwargs.get("tag") 139 | 140 | # If allow_continue False, return response 141 | allow_continue, response, _ = is_authenticated( 142 | request, name, must_be_owner=True 143 | ) 144 | if not allow_continue: 145 | return response 146 | 147 | # Retrieve the image, return of None indicates not found 148 | image = get_image_by_tag(name, reference=reference, tag=tag, create=False) 149 | if not image: 150 | raise Http404 151 | 152 | # Delete the image tag 153 | if tag: 154 | tag = image.tag_set.filter(name=tag) 155 | tag.delete() 156 | 157 | # Delete a manifest 158 | elif reference: 159 | image.delete() 160 | 161 | # Upon success, the registry MUST respond with a 202 Accepted code. 162 | return Response(status=202) 163 | 164 | @method_decorator(never_cache) 165 | @method_decorator( 166 | ratelimit( 167 | key="ip", 168 | rate=settings.VIEW_RATE_LIMIT, 169 | method="PUT", 170 | block=settings.VIEW_RATE_LIMIT_BLOCK, 171 | ) 172 | ) 173 | def put(self, request, *args, **kwargs): 174 | """ 175 | PUT /v2//manifests/ 176 | https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pushing-manifests 177 | """ 178 | # We likely can default to the v1 manifest, unless otherwise specified 179 | # This isn't used or checked for the time being 180 | # application/vnd.oci.image.manifest.v1+json 181 | _ = request.META.get("CONTENT_TYPE", settings.IMAGE_MANIFEST_CONTENT_TYPE) 182 | 183 | name = kwargs.get("name") 184 | reference = kwargs.get("reference") 185 | tag = kwargs.get("tag") 186 | 187 | # If allow_continue False, return response 188 | allow_continue, response, _ = is_authenticated( 189 | request, name, must_be_owner=True 190 | ) 191 | if not allow_continue: 192 | return response 193 | 194 | # Reference must be sha256 digest 195 | if reference and not reference.startswith("sha256:"): 196 | return Response(status=400) 197 | 198 | # Also provide the body in case we have a tag 199 | image = get_image_by_tag(name, reference, tag, create=True, body=request.body) 200 | 201 | # If allow_continue False, return response 202 | allow_continue, response, _ = is_authenticated( 203 | request, image.repository, must_be_owner=True 204 | ) 205 | if not allow_continue: 206 | return response 207 | 208 | return Response(status=201, headers={"Location": image.get_manifest_url()}) 209 | 210 | @method_decorator(never_cache) 211 | @method_decorator( 212 | ratelimit( 213 | key="ip", 214 | rate=settings.VIEW_RATE_LIMIT, 215 | method="GET", 216 | block=settings.VIEW_RATE_LIMIT_BLOCK, 217 | ) 218 | ) 219 | def get(self, request, *args, **kwargs): 220 | """ 221 | GET /v2//manifests/ 222 | """ 223 | 224 | name = kwargs.get("name") 225 | reference = kwargs.get("reference") 226 | tag = kwargs.get("tag") 227 | 228 | # If allow_continue False, return response 229 | allow_continue, response, _ = is_authenticated(request, name, scopes=["pull"]) 230 | if not allow_continue: 231 | return response 232 | 233 | image = get_image_by_tag(name, tag=tag, reference=reference) 234 | 235 | # If the manifest is not found in the registry, the response code MUST be 404 Not Found. 236 | if not image: 237 | raise Http404 238 | return Response(image.manifest, status=200) 239 | 240 | @method_decorator(never_cache) 241 | @method_decorator( 242 | ratelimit( 243 | key="ip", 244 | rate=settings.VIEW_RATE_LIMIT, 245 | method="HEAD", 246 | block=settings.VIEW_RATE_LIMIT_BLOCK, 247 | ) 248 | ) 249 | def head(self, request, *args, **kwargs): 250 | """ 251 | HEAD /v2//manifests/ 252 | """ 253 | name = kwargs.get("name") 254 | reference = kwargs.get("reference") 255 | tag = kwargs.get("tag") 256 | 257 | allow_continue, response, _ = is_authenticated(request, name) 258 | if not allow_continue: 259 | return response 260 | 261 | image = get_image_by_tag(name, tag=tag, reference=reference) 262 | if not image: 263 | raise Http404 264 | return Response(status=200) 265 | -------------------------------------------------------------------------------- /django_oci/views/parsers.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright (c) 2020-2023, Vanessa Sochat 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | """ 18 | 19 | from rest_framework import renderers 20 | from rest_framework.negotiation import BaseContentNegotiation 21 | 22 | 23 | class IgnoreClientContentNegotiation(BaseContentNegotiation): 24 | """Important! This class is not used, however if you want to validate 25 | (or allow) any media type (e.g., the server doesn't return 406) you 26 | can set this as the default content negotiation for any view. 27 | 28 | content_negotiation_class = IgnoreClientContentNegotiation 29 | """ 30 | 31 | def select_parser(self, request, parsers): 32 | """Return the first parser (usually application/json)""" 33 | return parsers[0] 34 | 35 | def select_renderer(self, request, renderers, format_suffix): 36 | """Return the first renderer (usually application/json)""" 37 | return (renderers[0], renderers[0].media_type) 38 | 39 | 40 | class ManifestRenderer(renderers.BaseRenderer): 41 | """A ManifestRenderer is provided to the ImageManifest views, which need 42 | to accept a content type for the image manifest. Without this 43 | renderer, Django will return 406 44 | """ 45 | 46 | media_type = "application/vnd.oci.image.manifest.v1+json" 47 | format = None 48 | charset = None 49 | render_style = "binary" 50 | 51 | def render(self, data, media_type=None, renderer_context=None): 52 | return data 53 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This is a manually generated log to track changes to the repository for each release. 4 | Each section should include general headers such as **Implemented enhancements** 5 | and **Merged pull requests**. All closed issued and bug fixes should be 6 | represented by the pull requests that fixed them. 7 | Critical items to know are: 8 | 9 | - renamed commands 10 | - deprecated / removed commands 11 | - changed defaults 12 | - backward incompatible changes 13 | - migration guidance 14 | - changed behaviour 15 | 16 | ## [master](https://github.com/vsoch/mkdocs-jekyll/tree/master) 17 | - getting search working (0.0.1) 18 | - start of theme (0.0.0) 19 | -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | ruby RUBY_VERSION 3 | 4 | # Hello! This is where you manage which Jekyll version is used to run. 5 | # When you want to use a different version, change it below, save the 6 | # file and run `bundle install`. Run Jekyll with `bundle exec`, like so: 7 | # 8 | # bundle exec jekyll serve 9 | # 10 | # This will help ensure the proper Jekyll version is running. 11 | # Happy Jekylling! 12 | # gem "jekyll", "3.2.1" 13 | 14 | # This is the default theme for new Jekyll sites. You may change this to anything you like. 15 | # gem "minima" 16 | 17 | # If you want to use GitHub Pages, remove the "gem "jekyll"" above and 18 | # uncomment the line below. To upgrade, run `bundle update github-pages`. 19 | gem "github-pages", group: :jekyll_plugins 20 | 21 | # If you have any plugins, put them here! 22 | # group :jekyll_plugins do 23 | # gem "jekyll-github-metadata", "~> 1.0" 24 | # end 25 | -------------------------------------------------------------------------------- /docs/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018-2019 Vanessa Sochat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # MkDocs Jekyll Theme 2 | 3 | [![CircleCI](https://circleci.com/gh/vsoch/mkdocs-jekyll/tree/master.svg?style=svg)](https://circleci.com/gh/vsoch/mkdocs-jekyll/tree/master) 4 | 5 | ![assets/img/mkdocs-jekyll.png](assets/img/mkdocs-jekyll.png) 6 | 7 | This is a [starter template](https://vsoch.github.com/mkdocs-jekyll/) for a mkdocs jekyll theme, based on these two 8 | previous arts: 9 | 10 | - [alexcarpenter/material-jekyll-theme](http://alexcarpenter.github.io/material-jekyll-theme) 11 | - [squidfunk/mkdocs-material](https://github.com/squidfunk/mkdocs-material) 12 | 13 | ## Usage 14 | 15 | ### 1. Get the code 16 | 17 | You can clone the repository right to where you want to host the docs: 18 | 19 | ```bash 20 | git clone https://github.com/vsoch/mkdocs-jekyll.git docs 21 | cd docs 22 | ``` 23 | 24 | ### 2. Customize 25 | 26 | To edit configuration values, customize the [_config.yml](_config.yml). 27 | To add pages, write them into the [pages](pages) folder. 28 | You define urls based on the `permalink` attribute in your pages, 29 | and then add them to the navigation by adding to the content of [_data/toc.myl](_data/toc.yml). 30 | 31 | ### 3. Options 32 | 33 | Most of the configuration values in the [_config.yml](_config.yml) are self explanatory, 34 | and for more details, see the [about page](https://vsoch.github.io/mkdocs-jekyll/about/) 35 | rendered on the site. 36 | 37 | ### 4. Serve 38 | 39 | Depending on how you installed jekyll: 40 | 41 | ```bash 42 | jekyll serve 43 | # or 44 | bundle exec jekyll serve 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/VERSION: -------------------------------------------------------------------------------- 1 | 0.0.1 2 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | # Welcome to Jekyll! 2 | # 3 | # This config file is meant for settings that affect your whole blog, values 4 | # which you are expected to set up once and rarely edit after that. If you find 5 | # yourself editing these this file very often, consider using Jekyll's data files 6 | # feature for the data you need to update frequently. 7 | # 8 | # For technical reasons, this file is *NOT* reloaded automatically when you use 9 | # 'jekyll serve'. If you change this file, please restart the server process. 10 | 11 | # Site settings 12 | # These are used to personalize your new site. If you look in the HTML files, 13 | # you will see them accessed via {{ site.title }}, {{ site.email }}, and so on. 14 | # You can create any custom variable you would like, and they will be accessible 15 | # in the templates via {{ site.myvariable }}. 16 | 17 | # Setup 18 | title: OpenContainers Django 19 | email: vsochat@stanford.edu 20 | author: "@vsoch" 21 | tagline: "Django module to create a distribution spec registry" 22 | baseurl: "/django-oci/" 23 | github: "https://www.github.com/vsoch/django-oci" 24 | 25 | markdown: kramdown 26 | 27 | 28 | description: > # this means to ignore newlines until "baseurl:" 29 | Django-OCI is an OpenContainers distribution spec verified registry for 30 | containers. We welcome your feedback, and contributions to code and documentation. 31 | 32 | baseurl: "/django-oci" # the subpath of your site, e.g. /blog 33 | 34 | # This is mostly for testing 35 | url: "" # the base hostname & protocol for your site 36 | weburl: https://vsoch.github.io/django-oci 37 | 38 | # Social (First three Required) 39 | repo: "https://github.com/vsoch/django-oci" 40 | github_user: "vsoch" 41 | github_repo: "django-oci" 42 | 43 | # Optional 44 | twitter: vsoch 45 | linkedin: vsochat 46 | # registry: https://quay.io/repository/vanessa/sregistry 47 | # google-analytics: UA-XXXXXXXXXX 48 | # Image and (square) dimension for logo (don't start with /) 49 | # If commented, will use material hat theme 50 | logo: "assets/img/logo.png" 51 | logo_pixels: 34 52 | color: "#008037" # primary color for header, buttons 53 | 54 | # If you add tags to pages, you can link them to some external search 55 | # If you want to disable this, comment the URL. 56 | # tag_search_endpoint: https://ask.cyberinfrastructure.org/search?q= 57 | tag_color: danger # danger, success, warning, primary, info, secondary 58 | 59 | accentColor: green # purple, green, etc. 60 | themeColor: green # purple, green, blue, orange, purple, grey 61 | fixedNav: 'true' # true or false 62 | 63 | permalink: /:year/:title/ 64 | markdown: kramdown 65 | exclude: [_site, CHANGELOG.md, LICENSE, README.md, vendor] 66 | 67 | # Collections 68 | collections: 69 | docs: 70 | output: true 71 | permalink: /docs/:path 72 | 73 | # Defaults 74 | defaults: 75 | - scope: 76 | path: "_docs" 77 | type: "docs" 78 | values: 79 | layout: page 80 | - 81 | scope: 82 | path: "" 83 | type: "pages" 84 | values: 85 | layout: "page" 86 | - 87 | scope: 88 | path: "posts" 89 | type: "posts" 90 | values: 91 | layout: "post" 92 | -------------------------------------------------------------------------------- /docs/_data/toc.yml: -------------------------------------------------------------------------------- 1 | - title: Introduction 2 | url: docs/introduction 3 | children: 4 | - title: About 5 | url: about 6 | - title: Background 7 | url: docs/introduction 8 | - title: Design 9 | url: docs/design 10 | - title: Frequently Asked Questions 11 | url: docs/faq 12 | - title: Policy 13 | url: policy 14 | - title: Getting Started 15 | url: docs/introduction 16 | children: 17 | - title: Install 18 | url: docs/getting-started/#install 19 | - title: Project Settings 20 | url: docs/getting-started/#project-settings 21 | - title: Customization Options 22 | url: docs/getting-started/options 23 | - title: Authentication 24 | url: docs/getting-started/auth 25 | - title: Example Project 26 | url: docs/getting-started/example 27 | - title: Example Client 28 | url: docs/getting-started/reggie 29 | - title: Testing 30 | url: docs/getting-started/testing 31 | - title: Storage 32 | url: docs/storage 33 | children: 34 | - title: Overview 35 | url: docs/storage 36 | - title: News 37 | url: news 38 | -------------------------------------------------------------------------------- /docs/_docs/design.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Design 3 | pdf: true 4 | toc: false 5 | --- 6 | 7 | # Design 8 | 9 | This document aims to answer questions about design. There is some leeway with respect 10 | to how the models are designed. 11 | 12 | ## Models 13 | 14 | In the case that we have repositories, blobs, and image manifests that connect the two, 15 | you could imagine a design that has blobs shared between images and repositories, or one 16 | that is more redundant to keep a storage namespace based on repositories, that way no two 17 | repositories can share the exact same blob. Or more specifically: 18 | 19 | - Having blobs linked 1:1 to a repository, and then referenced in Image Manifests only belonging to that repository (you wouldn't need to worry about blob sharing across repositories, and cleanup would be limited to ensuring the blob only isn't needed by the repository in question) 20 | - Having blobs linked to Image Manifests, which are linked to Repositories. 21 | - Having blobs not linked to any Image Manifest or Repository and shared amongst all blobs (and for this implementation idea you would need to ensure that blobs are not deleted that are still being used, and that there aren't any security loopholes with someone uploading a blob that would be used by another repository. 22 | 23 | While the last design is more space efficient, is does potentially introduce security and cleanup 24 | issues when sharing blobs. For this reason, we choose the second design - by way of the distribution-spec 25 | having the repository name as a parameter to most requests, we can easily link each of blobs and Image manifests 26 | to a repository directly. For more detail about the distribution-spec, we recommend that you look 27 | at the [distribution-spec repository](https://github.com/opencontainers/distribution-spec/). 28 | The remainder of this section briefly discusses models. 29 | 30 | ### Repository 31 | 32 | A repository is a namespace like `myorg/myrepo` that has one or more image manifests, 33 | or lists of blobs associated with a set of metadata. A repository will be owned by a user, 34 | and optionally have additional contributors, and finally, be optionally private. 35 | These last set of features are not yet implemented. 36 | 37 | ## Images 38 | 39 | An image model is technically referring to an image manifest. This means it points to one 40 | or more blobs, and has metadata about an image. The image manifest itself is stored in the 41 | exact same byte stream as it's provided (BinaryField), and the blobs and annotations are extracted 42 | after parsing from string to json. An image (manifest) is also directly linked (or owned) 43 | by a repository, and each manifest has a many to many relationship to point to one or more blobs 44 | 45 | ## Blobs 46 | 47 | A blob is a binary (a FileField) along with a content type that is uploaded by a client. 48 | The general workflow for a push is to provide a set of blobs associated with an image manifest 49 | and repository. For the implementation here, we link blobs off the bat with a repository (and 50 | storage honors this structure as well) and then either use POST, PUT, or PATCH to do a monolithic 51 | or chunked upload. Blobs are then referenced in image manifests and can be requested for pull 52 | by an oci compliant client. To eventually support remote file storage, blobs have both a datafile 53 | (FileField) and a remotefile that would allow for a remote address (not yet used). 54 | 55 | ## Tag 56 | 57 | A tag is typically a small string to describe a version of a manifest, e.g., "latest." 58 | Tags fall under the general description of a "digest" which can also include a sha256 sum. 59 | In the case of the implementation here, Tags are represented in their own table, and 60 | have fields for a name, and then a foreign key to a particular image. This means 61 | that one image can have more than one tag, and tags are not shared between images. 62 | 63 | ## Annotation 64 | 65 | Akin to a tag, an annotation also stores a foreign key to a particular image, but 66 | instead of just a name, we hold a key and value pair. For both these strategies, while 67 | it might be redundant to store "the same" tag or annotation for different repositories, 68 | this approach is taken to mirror the design choice to not have shared model instances 69 | between repositories. This design choice could of course change if there is compelling 70 | reason. 71 | -------------------------------------------------------------------------------- /docs/_docs/faq.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Frequently Asked Questions 3 | pdf: true 4 | toc: false 5 | --- 6 | 7 | # Frequently Asked Questions 8 | 9 | - [What is Django-OCI](#what-is-django-oci) 10 | - [What is a Linux Container?](#what-is-a-linux-container) 11 | 12 | 13 | ### What is Django OCI? 14 | 15 | Django-OCI is an open source registry that conforms to the opencontainers [distribution spec](https://github.com/opencontainers/distribution-spec). It is a Django module, meaning that Python developers that use Django can easily implement their own 16 | OCI compliant registries. 17 | 18 | ### What is a Linux Container? 19 | 20 | A container image is an encapsulated, portable environment that is created to distribute a scientific analysis or a general function. Containers help with reproducibility of such content as they nicely package software and data dependencies, along with libraries that are needed. Thus, the core of Singularity Hub are these Singularity container images, and by way of being on Singularity Hub they can be easily built, updated, referenced with a url for a publication, and shared. This small guide will help you to get started building your containers using Singularity Hub and your Github repositories. 21 | -------------------------------------------------------------------------------- /docs/_docs/getting-started/auth.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Authentication" 3 | pdf: true 4 | toc: true 5 | --- 6 | 7 | # Authentication 8 | 9 | Django oci takes a "docker style" version of OAuth 2.0. Details can be seen 10 | in [this issue discussion](https://github.com/opencontainers/distribution-spec/issues/110#issuecomment-708691114). 11 | This generally means the following: 12 | 13 | ## 1. Unauthorized Response 14 | If authentication is enabled, meaning that `DISABLE_AUTHENTICATION` is False in the django OCI settings, views that require some kind of retrieval, change, or creation of content will return a 401 "Unauthorized" [response](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401). Note that if you set `DISABLE_AUTHENTICATION` to True, this won't happen (but it's hugely discouraged to have a registry that doesn't have Authentication, unless you have some specific, strong use case). 15 | 16 | ## 2. Www-Authenticate Header 17 | The registry knows to return a `Www-Authenticate` header to the client with information about how 18 | to request a token. That might look something like: 19 | 20 | ``` 21 | realm="http://127.0.0.1/auth",service="http://127.0.0.1",scope="repository:vanessa/container:push,pull" 22 | ``` 23 | 24 | Note that realm is typically referring to the authentication server, and the service is the container 25 | registry. In the case of Django OCI they are one and the same (e.g., both on localhost) but this doesn't 26 | have to be the case. 27 | 28 | ## 3. Token Request 29 | The client then submits a request to the realm with those variables as query parameters (e.g., GET) 30 | and also provides a basic authentication header, which in the case of Django OCI, is the user's username 31 | and token associated with the account, which is generated by the Rest Framework on creation, and can 32 | be re-generated by the user. We put them together as follows: 33 | 34 | ``` 35 | "username:token" 36 | ``` 37 | 38 | And then base64 encode that, and add it to the http Authorization header. 39 | 40 | ``` 41 | {"Authorization": "Basic "} 42 | ``` 43 | 44 | That request then goes to the authorization realm, which determines if the user 45 | has permission to access resource requested, and for the scope needed. 46 | 47 | ## 4. Token Generation 48 | Given that the user account is valid, meaning that we check that the username exists, 49 | the token is correct, and the user has permission for the scopes requested for the repository, 50 | we generate a jwt token that looks like the following: 51 | 52 | ```python 53 | { 54 | "iss": "auth.docker.com", 55 | "sub": "jlhawn", 56 | "exp": 1415387315, 57 | "nbf": 1415387015, 58 | "iat": 1415387015, 59 | "jti": "tYJCO1c6cnyy7kAn0c7rKPgbV1H1bFws", 60 | "access": [ 61 | { 62 | "type": "repository", 63 | "name": "samalba/my-app", 64 | "actions": [ 65 | "push" 66 | ] 67 | } 68 | ] 69 | } 70 | ``` 71 | 72 | The "exp" field is the timestamp for when the token expires. The nbf says "This can't be used 73 | before this timestamp," and iat refers to the issued at timestamp. You can read more about 74 | [jwt here](https://tools.ietf.org/html/rfc7519). We basically use a python jwt library to 75 | encode this into a long token using a secret on the server, and return this token to the 76 | calling client. 77 | 78 | ```python 79 | {"token": "1sdjkjf....xxsdfser", "issued_at": "", "expires_in": 600} 80 | ``` 81 | 82 | ## 5. Request retry 83 | 84 | The client then retries the same request, but added the token to the Authorization header, 85 | this time with Bearer. 86 | 87 | ``` 88 | {"Authorization": "Bearer "} 89 | ``` 90 | 91 | And then hooray! The request should be successful, along with subsequent requests using the 92 | token until it expires. For more detail about Authorization, we recommend that you reference 93 | the [reggie Authentication tutorial](reggie#with-authentication). 94 | -------------------------------------------------------------------------------- /docs/_docs/getting-started/example.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Example Project" 3 | pdf: true 4 | toc: true 5 | --- 6 | 7 | # Example Application 8 | 9 | You can develop or test interactively using the example (very simple) application 10 | under [tests](https://github.com/vsoch/django-oci/tree/master/tests). 11 | First create a virtual environment with the dependencies 12 | that you need: 13 | 14 | ```bash 15 | python -m venv env 16 | source env/bin/activate 17 | pip install -r requirements.txt 18 | ``` 19 | 20 | For development you can install the `django_oci` module locally: 21 | 22 | ```bash 23 | $ pip install -e . 24 | ``` 25 | 26 | The [manage.py](https://github.com/vsoch/django-oci/blob/master/manage.py) is located in the root directory 27 | so it's easy to update your install ad then interact with your test interface. 28 | 29 | ```bash 30 | python manage.py makemigrations django_oci 31 | python manage.py migrate django_oci 32 | python manage.py runserver 33 | ``` 34 | 35 | See the [tests](https://github.com/vsoch/django-oci/tree/master/tests) folder for more details, 36 | including how to start a development server. Examples will be added. 37 | -------------------------------------------------------------------------------- /docs/_docs/getting-started/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Getting Started" 3 | pdf: true 4 | toc: true 5 | --- 6 | 7 | # Getting Started 8 | 9 | ## Install 10 | 11 | You might first want to install django-oci: 12 | 13 | ```bash 14 | pip install django-oci 15 | ``` 16 | 17 | you could also install a development version from GitHub: 18 | 19 | ```bash 20 | git clone https://github.com/vsoch/django-oci 21 | cd django-oci 22 | 23 | # To install to your python packages 24 | pip install . 25 | 26 | # To install from clone location 27 | pip install -e . 28 | ``` 29 | 30 | This should install the one dependency, Django Rest Framework. If you want a requirements.txt 31 | file to do the same, one is provided in the repository. 32 | 33 | ```bash 34 | pip install -r reqiurements.txt 35 | ``` 36 | 37 | ## Project Settings 38 | 39 | Add it to your `INSTALLED_APPS` along with `rest_framework` 40 | 41 | ```python 42 | 43 | INSTALLED_APPS = ( 44 | ... 45 | 'django_oci', 46 | 'rest_framework', 47 | 'rest_framework.authtoken', 48 | ... 49 | ) 50 | ``` 51 | 52 | Add django-oci's URL patterns: 53 | 54 | ```python 55 | 56 | from django_oci import urls as django_oci_urls 57 | urlpatterns = [ 58 | ... 59 | url(r'^', include(django_oci.urls)), 60 | ... 61 | ] 62 | 63 | ``` 64 | 65 | You should also read about other [options]({{ site.baseurl }}/docs/getting-started/options) 66 | to provide in your project settings to customize the registry, and see the [example application]({{ site.baseurl }}/docs/getting-started/example) 67 | for an example of deployment. Details about authentication can be read about [here]({{ site.baseurl }}/docs/getting-started/auth). This will generate a distribution-spec set of API endpoints to generally push, pull, 68 | and otherwise interact with a registry. What is not (yet) provided are frontend 69 | interfaces to see your containers. 70 | -------------------------------------------------------------------------------- /docs/_docs/getting-started/options.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Options" 3 | pdf: true 4 | toc: true 5 | --- 6 | 7 | # Options 8 | 9 | The following options are available for you to define under a `DJANGO_OCI` 10 | dictionary in your Django settings. For example, to change the `MEDIA_ROOT` 11 | (where we store blobs, sessions, etc. for a filesystem storage) and the cache 12 | directory you might do the following: 13 | 14 | ```python 15 | DJANGO_OCI = { 16 | "STORAGE_BACKEND": "filesystem", 17 | # Change default "images" folder to "data" 18 | "MEDIA_ROOT": "data", 19 | # Set a cache directory, otherwise defaults to MEDIA_ROOT + /cache 20 | "CACHE_DIR": "cache", 21 | } 22 | ``` 23 | 24 | ## Options Available 25 | 26 | The following options are available for you to change or configure. If there is a functionality 27 | missing that you'd like to see here, please [open an issue]({{ site.repo }}/issues). 28 | 29 | 30 | | name | description | type |default | 31 | |------|-------------|------|--------| 32 | |URL_PREFIX | Url base prefix | string | v2 | 33 | |DISABLE_AUTHENTICATION | Set to True to disable authentication | boolean | False | 34 | |SPEC_VERSION | Version of distribution spec | string | 1 | 35 | |PRIVATE_ONLY| Only allow private repositories (not implemented yet) | boolean | False | 36 | |CONTENT_TYPES | Allowed content types to upload as layers | list of strings | ["application/octet-stream"] | 37 | |IMAGE_MANIFEST_CONTENT_TYPE | Image Manifest content type | string | application/vnd.oci.image.manifest.v1+json | 38 | |STORAGE_BACKEND | what storage backend to use | string | filesystem | 39 | |DOMAIN_URL | the default domain url to use | string | http://127.0.0.1:8000 | 40 | |MEDIA_ROOT | Media root (if saving images on filesystem | string | images | 41 | |CACHE_DIR | Set a custom cache directory | string | MEDIA_ROOT + /cache | 42 | |SESSION_EXPIRES_SECONDS | The number of seconds a session (upload request) is valid (10 minutes) | integer | 600 | 43 | |TOKEN_EXPIRES_SECONDS | The number of seconds a token for a request is valid (10 minutes) | integer | 600 | 44 | |DISABLE_TAG_MANIFEST_DELETE| Don't allow deleting of manifest tags | boolean | False | 45 | |DEFAULT_CONTENT_TYPE| Default content type is application/octet-stream | string | application/octet-stream| 46 | |VIEW_RATE_LIMIT| The rate limit to set for view requests | string | 100/1d | 47 | |VIEW_RATE_LIMIT_BLOCK| Temporarily block the user that goes over | boolean | True | 48 | |AUTHENTICATED_VIEWS | A list of view names to require authentication | list | see below | 49 | 50 | For authenticated views, the default list is the following: 51 | 52 | ``` 53 | [ 54 | "django_oci.views.blobs.BlobUpload", 55 | "django_oci.views.blobs.BlobDownload", 56 | "django_oci.views.image.ImageTags", 57 | "django_oci.views.image.ImageManifest", 58 | ] 59 | ``` 60 | 61 | Some of these are not yet developed (e.g., `PRIVATE_ONLY` and others are unlikely to ever change 62 | (e.g., `DEFAULT_CONTENT_TYPE` but are provided in case you want to innovate or try something new. 63 | -------------------------------------------------------------------------------- /docs/_docs/getting-started/testing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Testing" 3 | pdf: true 4 | toc: true 5 | --- 6 | 7 | # Testing 8 | 9 | ## Python Tests 10 | 11 | Tests are located in [tests](https://github.com/vsoch/django-oci/tree/master/tests) and can be run with: 12 | 13 | ```bash 14 | source /bin/activate 15 | pip install -r requirements.txt 16 | ./runtests.sh 17 | ``` 18 | 19 | ## Conformance 20 | 21 | Conformance testing is provided by the [distribution-spec](https://github.com/opencontainers/distribution-spec) repository. 22 | This means that you would want to start the server, and then clone this repository and run the tests. Complete 23 | instructions are [here](https://github.com/opencontainers/distribution-spec/tree/master/conformance), and the results 24 | of the latest tests can be seen under [conformance](https://vsoch.github.io/django-oci/conformance), which will be updated 25 | with each release. An example is provided below: 26 | 27 | ### 1. Start the Server 28 | First run your example server as follows (cleaning up the test database): 29 | 30 | ```bash 31 | rm -rf db-test.sqlite3 32 | python manage.py makemigrations django_oci 33 | python manage.py makemigrations 34 | python manage.py migrate 35 | python manage.py migrate django_oci 36 | python manage.py runserver 37 | ``` 38 | ``` 39 | System check identified no issues (0 silenced). 40 | October 10, 2020 - 20:52:20 41 | Django version 3.1.1, using settings 'tests.settings' 42 | Starting development server at http://127.0.0.1:8000/ 43 | Quit the server with CONTROL-C. 44 | ``` 45 | 46 | This tells us that the developmment server is running on port 8000 on localhost. We will 47 | need this for next steps! 48 | 49 | ### 2. Clone and build conformance tests 50 | 51 | Somewhere else on your machine, clone the distribution-spec repository and then 52 | cd into conformance. 53 | 54 | ```bash 55 | git clone https://github.com/opencontainers/distribution-spec/ 56 | cd distribution-spec/conformance 57 | ``` 58 | You'll need to [install GoLang](https://golang.org/doc/install) and then compile the test code into `conformance.test`: 59 | 60 | ```bash 61 | go test -c 62 | ``` 63 | 64 | Then export environment variables that we need for tests: 65 | 66 | ```bash 67 | # Registry details 68 | export OCI_ROOT_URL="http://127.0.0.1:8000/" 69 | export OCI_NAMESPACE="vsoch/django-oci" 70 | #export OCI_USERNAME="myuser" 71 | #export OCI_PASSWORD="mypass" 72 | 73 | # Which workflows to run 74 | export OCI_TEST_PUSH=1 75 | export OCI_TEST_PULL=1 76 | export OCI_TEST_CONTENT_DISCOVERY=1 77 | export OCI_TEST_CONTENT_MANAGEMENT=1 78 | 79 | # Extra settings 80 | #export OCI_HIDE_SKIPPED_WORKFLOWS=0 81 | #export OCI_DEBUG=0 82 | #export OCI_DELETE_MANIFEST_BEFORE_BLOBS=0 83 | ``` 84 | 85 | And then you'll have a report (html and xml) in the test folder! This can be submit to the 86 | distribution spec repository to validate your registry. 87 | -------------------------------------------------------------------------------- /docs/_docs/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | pdf: true 4 | toc: false 5 | --- 6 | 7 | # Introduction 8 | 9 | ## Opencontainers 10 | 11 | Containers have really taken off in the first two decades of the century. They've done so well, in fact, that 12 | it quickly became apparent that many different entities would need to better work together on standards for container 13 | runtimes, interactions, and even registries. This is the rationale for the creation of the [opencontainers](https://opencontainers.org/) initiative in 2015. You can read [more about the effort here](https://opencontainers.org/about/overview/) or in their own words: 14 | 15 | > The Open Container Initiative is an open governance structure for the express purpose of creating open industry standards around container formats and runtimes. Established in June 2015 by Docker and other leaders in the container industry, the OCI currently contains two specifications: the Runtime Specification (runtime-spec) and the Image Specification (image-spec). The Runtime Specification outlines how to run a “filesystem bundle” that is unpacked on disk. At a high-level an OCI implementation would download an OCI Image then unpack that image into an OCI Runtime filesystem bundle. At this point the OCI Runtime Bundle would be run by an OCI Runtime. 16 | 17 | The relevant specification for django-oci is the [distribution spec](https://github.com/opencontainers/distribution-spec), which defines the interactions that some client should be able to have with a container registry. 18 | 19 | ## Singularity containers 20 | 21 | Also back around 2016, containers were badly needed for science. The main difference here is that most scientists would want to 22 | run containers on big shared clusters, high performance computing resources, where it was a security issue to use a root daemon, which is the way that Docker worked at the time. Additionally, Docker provided a level of isolation that made it hard to interact 23 | with drivers and traditional technologies like MPI, or even the cluster manager. 24 | 25 | This led to the creation of Singularity containers, which were first under the umbrella of Lawrence Berkeley National Lab, and now the [software is supported](https://sylabs.io/guides/3.5/user-guide/quick_start.html) by the company Sylabs. During these years, community members such as the creator of this software aimed to provide registries just for Singularity containers, namely a hosted service, [Singularity Hub](https://singularity-hub.org) and an open source derivation, [Singularity Registry Server](https://singularityhub.github.io/sregistry). Singularity Hub was supported by the Singularity software from the getgo, as the creator was one of the early developers, and Singularity Registry Server originally implemented this same API, eventually adding the Sylabs [library API](https://sylabs.io/guides/3.6/user-guide/cloud_library.html) to allow for another means to pull containers. 26 | 27 | ## The Distribution Spec 28 | Although Singularity was designed differently (one read-only binary instead of many layers, with one writable), the basic idea that we would want a registry to interact with Singularity containers still holds true. This is also true for other facets of the Singularity software, namely having support for the [runtime spec](https://sylabs.io/guides/3.6/user-guide/oci_runtime.html) as well. The want for a registry to support the [distribution spec](https://github.com/opencontainers/distribution-spec) thus started to trickle into [issue boards](https://github.com/singularityhub/sregistry/issues/285). The developer of Singularity Registry Server didn't want to hard code support into just that registry, but instead provide a flexible module for any Django developer to use. This is the rationale behind creation of this library, [django-oci](https://github.com/vsoch/django-oci). 29 | 30 |
31 | 32 | # Goals 33 | 34 | While still in early in development, django-oci aims to meet the following goals: 35 | 36 | - provide a registry that conforms to the distribution-spec, passing conformance testing 37 | - make customization easy for the developer user 38 | - provide numerous storage backends 39 | 40 | Most of these are still under development! If you'd like to help, please [let us know!]({{site.repo}}/issues). 41 | -------------------------------------------------------------------------------- /docs/_docs/storage/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Storage" 3 | pdf: true 4 | toc: true 5 | --- 6 | 7 | # Storage Options 8 | 9 | Django-OCI aims to have several storage backends as options to store containers. 10 | Currently, we start with just Filesystem support. 11 | 12 | ## Filesystem 13 | 14 | Filesystem support is the default storage option, and is intended for smaller 15 | registries that cannot use a possibly external resource like the cloud. You 16 | don't need to change any settings to use filesystem storage, as it is the default. 17 | -------------------------------------------------------------------------------- /docs/_includes/alert.html: -------------------------------------------------------------------------------- 1 |
2 |

{% if include.title %}{{ include.title }}{% else %}{{ include.type }}{% endif %}

3 |

{{ include.content }}

4 |
5 | -------------------------------------------------------------------------------- /docs/_includes/doc.html: -------------------------------------------------------------------------------- 1 | {% if include.name %}{{ include.name }}{% else %}{{ include.path }}{% endif %} 2 | -------------------------------------------------------------------------------- /docs/_includes/editable.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 34 | 60 | -------------------------------------------------------------------------------- /docs/_includes/footer.html: -------------------------------------------------------------------------------- 1 | 44 | 45 | {% assign slashes = page.url | split: "/" %} 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/_includes/google-analytics.html: -------------------------------------------------------------------------------- 1 | {% if site.google-analytics %} 2 | {% endif %} 9 | -------------------------------------------------------------------------------- /docs/_includes/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% if page.title %}{{ page.title }}{% else %}{{ site.title }}{% endif %} 9 | 10 | {% if site.author %}{% endif %} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /docs/_includes/headers.html: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /docs/_includes/navigation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Skip to content 10 |
11 | 60 |
61 | -------------------------------------------------------------------------------- /docs/_includes/scrolltop.html: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 43 | -------------------------------------------------------------------------------- /docs/_includes/sidebar.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 61 |
62 |
63 |
64 |
65 |
66 |
67 | 73 |
74 |
75 |
76 | -------------------------------------------------------------------------------- /docs/_includes/social.html: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /docs/_includes/tags.html: -------------------------------------------------------------------------------- 1 | {% if site.tag_search_endpoint %}{% if page.tags %}{% endif %}{% endif %} 3 | -------------------------------------------------------------------------------- /docs/_includes/toc.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 36 | -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% include head.html %} 4 | 5 | 6 | {% include navigation.html %} 7 | 8 |
9 |
10 |
11 | {% include sidebar.html %} 12 |
13 | {{ content }} 14 |
15 |
16 |
17 |
18 | {% include footer.html %} 19 | {% include headers.html %} 20 | {% include tags.html %} 21 | {% include scrolltop.html %} 22 | {% include google-analytics.html %} 23 | 24 | 25 | -------------------------------------------------------------------------------- /docs/_layouts/page.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 |
5 |
6 | {{ content }} 7 | {% include toc.html %} 8 | {% include editable.html %} 9 |
10 |
11 | -------------------------------------------------------------------------------- /docs/_layouts/post.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | --- 4 | 5 |

{{ page.title }}

6 | {% if page.badges %}{% for badge in page.badges %}{{ badge.tag }}{% endfor %}{% endif %} 7 | 8 | {{ content }} 9 | -------------------------------------------------------------------------------- /docs/_posts/2020-10-12-hello-world.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Django-OCI Development" 3 | date: 2020-10-12 01:51:21 -0500 4 | categories: update 5 | badges: 6 | - type: success 7 | tag: update 8 | --- 9 | 10 | This is the first post to introduce a development version of Django-OCI! 11 | Currently, the library supports only filesystem storage for an OCI compliant registry. 12 | I hope to improve this project in the coming months with the following points. 13 | 14 | 15 | 16 | ## Interfaces 17 | 18 | I'd like to provide an example registry that also has interfaces to explore 19 | containers. 20 | 21 | ## Storage Backends 22 | 23 | It will be important to have support for more storage backends than the 24 | typical filesystem. 25 | 26 | ## Authentication 27 | 28 | Currently, there is no checking or authentication to interact with the registry, 29 | which won't fly in production. This needs to be added. 30 | -------------------------------------------------------------------------------- /docs/assets/css/extra.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsoch/django-oci/e420c55eb0f4e811a466f4291245d3f449a8928e/docs/assets/css/extra.css -------------------------------------------------------------------------------- /docs/assets/img/django-oci.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsoch/django-oci/e420c55eb0f4e811a466f4291245d3f449a8928e/docs/assets/img/django-oci.png -------------------------------------------------------------------------------- /docs/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsoch/django-oci/e420c55eb0f4e811a466f4291245d3f449a8928e/docs/assets/img/logo.png -------------------------------------------------------------------------------- /docs/assets/js/modernizr.1aa3b519.js: -------------------------------------------------------------------------------- 1 | !function(e,t){for(var n in t)e[n]=t[n]}(window,function(e){function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}var n={};return t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:r})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=4)}({4:function(e,t,n){"use strict";n(5)},5:function(e,t){!function(t){!function(e,t,n){function r(e,t){return typeof e===t}function o(e){var t=_.className,n=C._config.classPrefix||"";if(T&&(t=t.baseVal),C._config.enableJSClass){var r=new RegExp("(^|\\s)"+n+"no-js(\\s|$)");t=t.replace(r,"$1"+n+"js$2")}C._config.enableClasses&&(t+=" "+n+e.join(" "+n),T?_.className.baseVal=t:_.className=t)}function i(e,t){if("object"==typeof e)for(var n in e)b(e,n)&&i(n,e[n]);else{e=e.toLowerCase();var r=e.split("."),s=C[r[0]];if(2==r.length&&(s=s[r[1]]),void 0!==s)return C;t="function"==typeof t?t():t,1==r.length?C[r[0]]=t:(!C[r[0]]||C[r[0]]instanceof Boolean||(C[r[0]]=new Boolean(C[r[0]])),C[r[0]][r[1]]=t),o([(t&&0!=t?"":"no-")+r.join("-")]),C._trigger(e,t)}return C}function s(){return"function"!=typeof t.createElement?t.createElement(arguments[0]):T?t.createElementNS.call(t,"http://www.w3.org/2000/svg",arguments[0]):t.createElement.apply(t,arguments)}function a(){var e=t.body;return e||(e=s(T?"svg":"body"),e.fake=!0),e}function u(e,n,r,o){var i,u,l,f,c="modernizr",d=s("div"),p=a();if(parseInt(r,10))for(;r--;)l=s("div"),l.id=o?o[r]:c+(r+1),d.appendChild(l);return i=s("style"),i.type="text/css",i.id="s"+c,(p.fake?p:d).appendChild(i),p.appendChild(d),i.styleSheet?i.styleSheet.cssText=e:i.appendChild(t.createTextNode(e)),d.id=c,p.fake&&(p.style.background="",p.style.overflow="hidden",f=_.style.overflow,_.style.overflow="hidden",_.appendChild(p)),u=n(d,e),p.fake?(p.parentNode.removeChild(p),_.style.overflow=f,_.offsetHeight):d.parentNode.removeChild(d),!!u}function l(e,t){return!!~(""+e).indexOf(t)}function f(e){return e.replace(/([A-Z])/g,function(e,t){return"-"+t.toLowerCase()}).replace(/^ms-/,"-ms-")}function c(t,n,r){var o;if("getComputedStyle"in e){o=getComputedStyle.call(e,t,n);var i=e.console;if(null!==o)r&&(o=o.getPropertyValue(r));else if(i){var s=i.error?"error":"log";i[s].call(i,"getComputedStyle returning null, its possible modernizr test results are inaccurate")}}else o=!n&&t.currentStyle&&t.currentStyle[r];return o}function d(t,r){var o=t.length;if("CSS"in e&&"supports"in e.CSS){for(;o--;)if(e.CSS.supports(f(t[o]),r))return!0;return!1}if("CSSSupportsRule"in e){for(var i=[];o--;)i.push("("+f(t[o])+":"+r+")");return i=i.join(" or "),u("@supports ("+i+") { #modernizr { position: absolute; } }",function(e){return"absolute"==c(e,null,"position")})}return n}function p(e){return e.replace(/([a-z])-([a-z])/g,function(e,t,n){return t+n.toUpperCase()}).replace(/^-/,"")}function h(e,t,o,i){function a(){f&&(delete j.style,delete j.modElem)}if(i=!r(i,"undefined")&&i,!r(o,"undefined")){var u=d(e,o);if(!r(u,"undefined"))return u}for(var f,c,h,m,v,g=["modernizr","tspan","samp"];!j.style&&g.length;)f=!0,j.modElem=s(g.shift()),j.style=j.modElem.style;for(h=e.length,c=0;h>c;c++)if(m=e[c],v=j.style[m],l(m,"-")&&(m=p(m)),j.style[m]!==n){if(i||r(o,"undefined"))return a(),"pfx"!=t||m;try{j.style[m]=o}catch(e){}if(j.style[m]!=v)return a(),"pfx"!=t||m}return a(),!1}function m(e,t){return function(){return e.apply(t,arguments)}}function v(e,t,n){var o;for(var i in e)if(e[i]in t)return!1===n?e[i]:(o=t[e[i]],r(o,"function")?m(o,n||t):o);return!1}function g(e,t,n,o,i){var s=e.charAt(0).toUpperCase()+e.slice(1),a=(e+" "+k.join(s+" ")+s).split(" ");return r(t,"string")||r(t,"undefined")?h(a,t,o,i):(a=(e+" "+A.join(s+" ")+s).split(" "),v(a,t,n))}function y(e,t,r){return g(e,n,n,t,r)}var w=[],S={_version:"3.5.0",_config:{classPrefix:"",enableClasses:!0,enableJSClass:!0,usePrefixes:!0},_q:[],on:function(e,t){var n=this;setTimeout(function(){t(n[e])},0)},addTest:function(e,t,n){w.push({name:e,fn:t,options:n})},addAsyncTest:function(e){w.push({name:null,fn:e})}},C=function(){};C.prototype=S,C=new C;var b,x=[],_=t.documentElement,T="svg"===_.nodeName.toLowerCase();!function(){var e={}.hasOwnProperty;b=r(e,"undefined")||r(e.call,"undefined")?function(e,t){return t in e&&r(e.constructor.prototype[t],"undefined")}:function(t,n){return e.call(t,n)}}(),S._l={},S.on=function(e,t){this._l[e]||(this._l[e]=[]),this._l[e].push(t),C.hasOwnProperty(e)&&setTimeout(function(){C._trigger(e,C[e])},0)},S._trigger=function(e,t){if(this._l[e]){var n=this._l[e];setTimeout(function(){var e;for(e=0;e 2 | 3 | 4 | 5 | 6 | 7 | 8 | /home/vanessa/Desktop/Code/django-oci/dist-spec/conformance/01_pull_test.go:82 you have skipped this test. /home/vanessa/Desktop/Code/django-oci/dist-spec/conformance/setup.go:344 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | /home/vanessa/Desktop/Code/django-oci/dist-spec/conformance/02_push_test.go:234 you have skipped this test. /home/vanessa/Desktop/Code/django-oci/dist-spec/conformance/setup.go:338 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | /home/vanessa/Desktop/Code/django-oci/dist-spec/conformance/03_discovery_test.go:80 you have skipped this test. /home/vanessa/Desktop/Code/django-oci/dist-spec/conformance/setup.go:344 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsoch/django-oci/e420c55eb0f4e811a466f4291245d3f449a8928e/docs/favicon.ico -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Django-OCI 3 | permalink: / 4 | excluded_in_search: true 5 | --- 6 | 7 |
8 |
9 | 10 |
11 | 12 |
13 | If you are looking to implement an opencontainers [distribution-spec](https://github.com/opencontainers/distribution-spec) 14 | registry using Django, you've come to the right place! Let's get started. 15 | 16 | # Getting Started 17 | These sections cover rationale, background, and frequently asked questions. 18 | 19 | - [About](about): a brief description of Django OCI. 20 | - [Introduction](docs/introduction): Covers some background and basic information. 21 | - [Frequenty Asked Questions](docs/faq): Quick answers to some questions you might have on your mind. 22 | - [Policy](docs/policy): including license and usage 23 | 24 | 25 | ## Usage 26 | These sections discuss installation, setting up and customizing a project, and storage options. 27 | 28 | - [Install](docs/getting-started): django-oci as a python module 29 | - [Project Settings](docs/getting-started/#project-settings): to add django-oci to your project 30 | - [Customization Options](docs/getting-started/options): to customize django-oci for your needs. 31 | - [Example Project](docs/getting-started/example): an example application to get started or test. 32 | - [Testing](docs/getting-started/testing): including project and conformance testing. 33 | 34 | 35 | 36 | Do you want a new feature? Is something not working as it should? @vsoch wants [your input!]({{ site.repo }}/issues). 37 | -------------------------------------------------------------------------------- /docs/pages/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: About 3 | permalink: /about/ 4 | --- 5 | 6 | # About 7 | 8 | Django-OCI is an opencontainers compliant registry for containers. 9 | To read more about it's background, see {% include doc.html name="the introduction" path="introduction" %}. 10 | For license and usage information, see [the policy page]({{ site.baseurl }}/policy). 11 | 12 | ## What infrastructure is needed? 13 | 14 | Django-OCI can be deployed locally on your system easily with virtual environments, or part of a container 15 | orchestrated registry. The tests and examples here are done with a Python virtual environment. 16 | 17 | ## Support 18 | 19 | If you need help, please don't hesitate to [open an issue](https://www.github.com/{{ site.github_repo }}/{{ site.github_user }}). 20 | -------------------------------------------------------------------------------- /docs/pages/archive.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: Articles 4 | permalink: /archive/ 5 | --- 6 | # News Archive 7 | 8 | {% for post in site.posts %}{% capture this_year %}{{ post.date | date: "%Y" }}{% endcapture %}{% capture next_year %}{{ post.previous.date | date: "%Y" }}{% endcapture %} 9 | 10 | {% if forloop.first %}

{{this_year}}

11 |
    {% endif %} 12 |
  • 13 | {{ post.date | date: "%b %-d, %Y" }}: {{ post.title }} 14 |
  • {% if forloop.last %}
{% else %}{% if this_year != next_year %} 15 | 16 |

{{next_year}}

17 |
    {% endif %}{% endif %}{% endfor %} 18 | -------------------------------------------------------------------------------- /docs/pages/feed.xml: -------------------------------------------------------------------------------- 1 | --- 2 | layout: null 3 | permalink: /feed.xml 4 | --- 5 | 6 | 7 | 8 | {{ site.title | xml_escape }} 9 | {{ site.description | xml_escape }} 10 | {{ site.url }}{{ site.baseurl }}/ 11 | 12 | {{ site.time | date_to_rfc822 }} 13 | {{ site.time | date_to_rfc822 }} 14 | Jekyll v{{ jekyll.version }} 15 | {% for post in site.posts limit:10 %} 16 | 17 | {{ post.title | xml_escape }} 18 | {{ post.content | xml_escape }} 19 | {{ post.date | date_to_rfc822 }} 20 | {{ post.url | prepend: site.baseurl | prepend: site.url }} 21 | {{ post.url | prepend: site.baseurl | prepend: site.url }} 22 | {% for tag in post.tags %} 23 | {{ tag | xml_escape }} 24 | {% endfor %} 25 | {% for cat in post.categories %} 26 | {{ cat | xml_escape }} 27 | {% endfor %} 28 | 29 | {% endfor %} 30 | 31 | 32 | -------------------------------------------------------------------------------- /docs/pages/news.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: News 3 | permalink: /news/ 4 | --- 5 | 6 | # News 7 | 8 |

    Subscribe with RSS to keep up with the latest news. 9 | For site changes, see the changelog kept with the code base.

    10 | 11 | {% for post in site.posts limit:10 %} 12 |
    13 |

    {{ post.title }}

    14 |
    15 | {% if post.badges %}{% for badge in post.badges %}{{ badge.tag }}{% endfor %}{% endif %} 16 | {{ post.content | split:'' | first }} 17 | {% if post.content contains '' %} 18 | read more 19 | {% endif %} 20 |
    21 |
    22 | {% endfor %} 23 | 24 | Want to see more? See the News Archive. 25 | -------------------------------------------------------------------------------- /docs/pages/sitemap.xml: -------------------------------------------------------------------------------- 1 | --- 2 | layout: null 3 | permalink: /sitemap.xml 4 | --- 5 | 6 | 7 | 8 | 9 | / 10 | {{ "now" | date: "%Y-%m-%d" }} 11 | daily 12 | 13 | {% for section in site.data.toc %} 14 | {{ site.baseurl }}{{ section.url }}/ 15 | {{ "now" | date: "%Y-%m-%d" }} 16 | daily 17 | 18 | {% endfor %} 19 | 20 | -------------------------------------------------------------------------------- /docs/pages/usage-agreement.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Policy 3 | permalink: /policy/ 4 | --- 5 | 6 | # Policy 7 | 8 | ## License 9 | 10 | Usage of Django-OCI is governed by the terms of the [license]({{ site.repo }}/blob/master/LICENSE), which you should read and agree to before using the software. We encourage developers that create registries 11 | to ensure that their registries are [GDPR Compliant](https://en.wikipedia.org/wiki/General_Data_Protection_Regulation). 12 | -------------------------------------------------------------------------------- /docs/search/search_index.json: -------------------------------------------------------------------------------- 1 | --- 2 | layout: null 3 | permalink: /search/search_index.json 4 | --- 5 | {"config":{"lang":["en"],"prebuild_index":false,"separator":"[\\s\\-]+"},"docs":[{% for page in site.pages %}{% unless page.excluded_in_search %}{% if added %},{% endif %}{% assign added = false %}{"location": "{{ page.url }}", "text": "{{ page.content | strip_html | strip_newlines | slugify: 'ascii' | replace: '-', ' ' }}", "title": "{{ page.title }}"}{% assign added = true %}{% endunless %}{% endfor %}{% for post in site.posts %}{% unless page.excluded_in_search %}{% if added %},{% endif %}{% assign added = false %}{"location": "{{ post.url }}", "text": "{{ post.content | strip_html | strip_newlines | slugify: 'ascii' | replace: '-',' ' }}", "title": "{{ post.title }}"}{% assign added = true %}{% endunless %}{% endfor %}{% for doc in site.docs %}{% unless doc.excluded_in_search %}{% if added %},{% endif %}{% assign added = false %}{"location": "{{ doc.url }}", "text": "{{ doc.content | strip_html | strip_newlines | slugify: 'ascii' | replace: '-',' ' }}", "title": "{{ doc.title }}"}{% assign added = true %}{% endunless %}{% endfor %}]} 6 | -------------------------------------------------------------------------------- /examples/singularity/busybox_latest.sif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsoch/django-oci/e420c55eb0f4e811a466f4291245d3f449a8928e/examples/singularity/busybox_latest.sif -------------------------------------------------------------------------------- /examples/singularity/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ociVersion": "1.0.1", 3 | "process": { 4 | "terminal": true, 5 | "user": { 6 | "uid": 1, 7 | "gid": 1, 8 | "additionalGids": [ 9 | 5, 10 | 6 11 | ] 12 | }, 13 | "args": [ 14 | "sh" 15 | ], 16 | "env": [ 17 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", 18 | "TERM=xterm" 19 | ], 20 | "cwd": "/" 21 | }, 22 | "mounts": [ 23 | { 24 | "destination": "/proc", 25 | "type": "proc", 26 | "source": "proc" 27 | }, 28 | { 29 | "destination": "/dev", 30 | "type": "tmpfs", 31 | "source": "tmpfs", 32 | "options": [ 33 | "nosuid", 34 | "strictatime", 35 | "mode=755", 36 | "size=65536k" 37 | ] 38 | }, 39 | { 40 | "destination": "/dev/pts", 41 | "type": "devpts", 42 | "source": "devpts", 43 | "options": [ 44 | "nosuid", 45 | "noexec", 46 | "newinstance", 47 | "ptmxmode=0666", 48 | "mode=0620", 49 | "gid=5" 50 | ] 51 | }, 52 | { 53 | "destination": "/dev/shm", 54 | "type": "tmpfs", 55 | "source": "shm", 56 | "options": [ 57 | "nosuid", 58 | "noexec", 59 | "nodev", 60 | "mode=1777", 61 | "size=65536k" 62 | ] 63 | }, 64 | { 65 | "destination": "/dev/mqueue", 66 | "type": "mqueue", 67 | "source": "mqueue", 68 | "options": [ 69 | "nosuid", 70 | "noexec", 71 | "nodev" 72 | ] 73 | }, 74 | { 75 | "destination": "/sys", 76 | "type": "sysfs", 77 | "source": "sysfs", 78 | "options": [ 79 | "nosuid", 80 | "noexec", 81 | "nodev" 82 | ] 83 | }, 84 | { 85 | "destination": "/sys/fs/cgroup", 86 | "type": "cgroup", 87 | "source": "cgroup", 88 | "options": [ 89 | "nosuid", 90 | "noexec", 91 | "nodev", 92 | "relatime", 93 | "ro" 94 | ] 95 | } 96 | ], 97 | "hooks": { 98 | "prestart": [ 99 | { 100 | "path": "/usr/bin/fix-mounts", 101 | "args": [ 102 | "fix-mounts", 103 | "arg1", 104 | "arg2" 105 | ], 106 | "env": [ 107 | "key1=value1" 108 | ] 109 | }, 110 | { 111 | "path": "/usr/bin/setup-network" 112 | } 113 | ], 114 | "poststart": [ 115 | { 116 | "path": "/usr/bin/notify-start", 117 | "timeout": 5 118 | } 119 | ], 120 | "poststop": [ 121 | { 122 | "path": "/usr/sbin/cleanup.sh", 123 | "args": [ 124 | "cleanup.sh", 125 | "-f" 126 | ] 127 | } 128 | ] 129 | }, 130 | "linux": { 131 | "devices": [ 132 | { 133 | "path": "/dev/fuse", 134 | "type": "c", 135 | "major": 10, 136 | "minor": 229, 137 | "fileMode": 438, 138 | "uid": 0, 139 | "gid": 0 140 | }, 141 | { 142 | "path": "/dev/sda", 143 | "type": "b", 144 | "major": 8, 145 | "minor": 0, 146 | "fileMode": 432, 147 | "uid": 0, 148 | "gid": 0 149 | } 150 | ], 151 | "uidMappings": [ 152 | { 153 | "containerID": 0, 154 | "hostID": 1000, 155 | "size": 32000 156 | } 157 | ], 158 | "gidMappings": [ 159 | { 160 | "containerID": 0, 161 | "hostID": 1000, 162 | "size": 32000 163 | } 164 | ], 165 | "sysctl": { 166 | "net.ipv4.ip_forward": "1", 167 | "net.core.somaxconn": "256" 168 | }, 169 | "cgroupsPath": "/myRuntime/myContainer", 170 | "resources": { 171 | "network": { 172 | "classID": 1048577, 173 | "priorities": [ 174 | { 175 | "name": "eth0", 176 | "priority": 500 177 | }, 178 | { 179 | "name": "eth1", 180 | "priority": 1000 181 | } 182 | ] 183 | }, 184 | "pids": { 185 | "limit": 32771 186 | }, 187 | "hugepageLimits": [ 188 | { 189 | "pageSize": "2MB", 190 | "limit": 9223372036854772000 191 | }, 192 | { 193 | "pageSize": "64KB", 194 | "limit": 1000000 195 | } 196 | ], 197 | "memory": { 198 | "limit": 536870912, 199 | "reservation": 536870912, 200 | "swap": 536870912, 201 | "kernel": -1, 202 | "kernelTCP": -1, 203 | "swappiness": 0, 204 | "disableOOMKiller": false 205 | }, 206 | "cpu": { 207 | "shares": 1024, 208 | "quota": 1000000, 209 | "period": 500000, 210 | "realtimeRuntime": 950000, 211 | "realtimePeriod": 1000000, 212 | "cpus": "2-3", 213 | "mems": "0-7" 214 | }, 215 | "devices": [ 216 | { 217 | "allow": false, 218 | "access": "rwm" 219 | }, 220 | { 221 | "allow": true, 222 | "type": "c", 223 | "major": 10, 224 | "minor": 229, 225 | "access": "rw" 226 | }, 227 | { 228 | "allow": true, 229 | "type": "b", 230 | "major": 8, 231 | "minor": 0, 232 | "access": "r" 233 | } 234 | ], 235 | "blockIO": { 236 | "weight": 10, 237 | "leafWeight": 10, 238 | "weightDevice": [ 239 | { 240 | "major": 8, 241 | "minor": 0, 242 | "weight": 500, 243 | "leafWeight": 300 244 | }, 245 | { 246 | "major": 8, 247 | "minor": 16, 248 | "weight": 500 249 | } 250 | ], 251 | "throttleReadBpsDevice": [ 252 | { 253 | "major": 8, 254 | "minor": 0, 255 | "rate": 600 256 | } 257 | ], 258 | "throttleWriteIOPSDevice": [ 259 | { 260 | "major": 8, 261 | "minor": 16, 262 | "rate": 300 263 | } 264 | ] 265 | } 266 | }, 267 | "rootfsPropagation": "slave", 268 | "seccomp": { 269 | "defaultAction": "SCMP_ACT_ALLOW", 270 | "architectures": [ 271 | "SCMP_ARCH_X86", 272 | "SCMP_ARCH_X32" 273 | ], 274 | "syscalls": [ 275 | { 276 | "names": [ 277 | "getcwd", 278 | "chmod" 279 | ], 280 | "action": "SCMP_ACT_ERRNO" 281 | } 282 | ] 283 | }, 284 | "namespaces": [ 285 | { 286 | "type": "pid" 287 | }, 288 | { 289 | "type": "network" 290 | }, 291 | { 292 | "type": "ipc" 293 | }, 294 | { 295 | "type": "uts" 296 | }, 297 | { 298 | "type": "mount" 299 | }, 300 | { 301 | "type": "user" 302 | }, 303 | { 304 | "type": "cgroup" 305 | } 306 | ], 307 | "maskedPaths": [ 308 | "/proc/kcore", 309 | "/proc/latency_stats", 310 | "/proc/timer_stats", 311 | "/proc/sched_debug" 312 | ], 313 | "readonlyPaths": [ 314 | "/proc/asound", 315 | "/proc/bus", 316 | "/proc/fs", 317 | "/proc/irq", 318 | "/proc/sys", 319 | "/proc/sysrq-trigger" 320 | ], 321 | "mountLabel": "system_u:object_r:svirt_sandbox_file_t:s0:c715,c811" 322 | }, 323 | "annotations": { 324 | "com.example.key1": "value1", 325 | "com.example.key2": "value2" 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import absolute_import, unicode_literals 4 | 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | profile = "black" 3 | exclude = ["^env/"] 4 | 5 | [tool.isort] 6 | profile = "black" # needed for black/isort compatibility 7 | skip = [] 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | djangorestframework 2 | django-ratelimit==3.0.0 3 | pyjwt 4 | requests 5 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function setup { 4 | python manage.py makemigrations django_oci 5 | python manage.py makemigrations 6 | python manage.py migrate 7 | python manage.py migrate django_oci 8 | } 9 | 10 | function cleanup { 11 | rm db-test.sqlite3 12 | } 13 | 14 | # Test the API with authentication 15 | setup 16 | python manage.py test tests.test_api 17 | cleanup 18 | 19 | # Test conformance without authentication 20 | setup 21 | DISABLE_AUTHENTICATION=yes python manage.py test tests.test_conformance 22 | cleanup 23 | 24 | # python manage.py test --noinput 25 | fuser -k 8000/tcp 26 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | exclude = benchmarks docs 6 | max-line-length = 100 7 | ignore = E1 E2 E5 W5 8 | per-file-ignores = 9 | django_oci/views/__init__.py:F401 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import re 5 | 6 | try: 7 | from setuptools import setup 8 | except ImportError: 9 | from distutils.core import setup 10 | 11 | 12 | def get_version(*file_paths): 13 | """Retrieves the version from django_oci/__init__.py""" 14 | filename = os.path.join(os.path.dirname(__file__), *file_paths) 15 | version_file = open(filename).read() 16 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 17 | if version_match: 18 | return version_match.group(1) 19 | raise RuntimeError("Unable to find version string.") 20 | 21 | 22 | version = get_version("django_oci", "__init__.py") 23 | 24 | with open("README.md") as fd: 25 | readme = fd.read() 26 | 27 | setup( 28 | name="django-oci", 29 | version=version, 30 | description="""Open Containers distribution API for Django""", 31 | long_description=readme, 32 | long_description_content_type="text/markdown", 33 | author="Vanessa Sochat", 34 | author_email="vsochat@stanford.edu", 35 | url="https://github.com/vsoch/django-oci", 36 | packages=[ 37 | "django_oci", 38 | ], 39 | include_package_data=True, 40 | install_requires=["djangorestframework", "pyjwt", "django-ratelimit==3.0.0"], 41 | license="Apache Software License 2.0", 42 | zip_safe=False, 43 | keywords="django-oci", 44 | classifiers=[ 45 | "Development Status :: 3 - Alpha", 46 | "Framework :: Django :: 1.11", 47 | "Framework :: Django :: 2.1", 48 | "Intended Audience :: Developers", 49 | "License :: OSI Approved :: BSD License", 50 | "Natural Language :: English", 51 | "Programming Language :: Python :: 3.7", 52 | ], 53 | ) 54 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | ## Example Project for django_oci 2 | 3 | This example is provided as a convenience feature to allow potential users to 4 | try out Django OCI straight from the app repo without having to create a django project. 5 | It can also be used to develop django OCI in place. 6 | 7 | **Important** the secret key is hard coded into [settings.py](settings.py). 8 | You should obviously generate a new one for your project, and not add it to version control. 9 | 10 | To run this example, follow these instructions: 11 | 12 | 1. Navigate to the `tests/example` directory 13 | 2. Install the requirements for the package: 14 | 15 | ```bash 16 | pip install -r requirements.txt 17 | ``` 18 | 19 | 3. Make and apply migrations 20 | 21 | ```bash 22 | python manage.py makemigrations 23 | python manage.py migrate 24 | ``` 25 | 26 | 4. Run the server 27 | 28 | ```bash 29 | python manage.py runserver 30 | ``` 31 | 32 | 5. Access from the browser at `http://127.0.0.1:8000` 33 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsoch/django-oci/e420c55eb0f4e811a466f4291245d3f449a8928e/tests/__init__.py -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | django 2 | djangorestframework 3 | django-ratelimit==3.0.0 4 | opencontainers 5 | requests 6 | pyjwt 7 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example django oci project. 3 | Generated by Cookiecutter Django Package 4 | """ 5 | 6 | import os 7 | 8 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 9 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 10 | 11 | # Quick-start development settings - unsuitable for production 12 | # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ 13 | 14 | # SECURITY WARNING: keep the secret key used in production secret! 15 | SECRET_KEY = "@=n*^a0q4($45&jl5x+8_f_1yt5w+brp^&r5tk@5_yt-4=h27f" 16 | 17 | # SECURITY WARNING: don't run with debug turned on in production! 18 | DEBUG = True 19 | 20 | ALLOWED_HOSTS = [] 21 | 22 | # Application definition 23 | 24 | INSTALLED_APPS = [ 25 | "django.contrib.admin", 26 | "django.contrib.auth", 27 | "django.contrib.contenttypes", 28 | "django.contrib.sessions", 29 | "django.contrib.messages", 30 | "django.contrib.staticfiles", 31 | "django_oci", 32 | "rest_framework", 33 | "rest_framework.authtoken", 34 | ] 35 | 36 | MIDDLEWARE = [ 37 | "django.middleware.security.SecurityMiddleware", 38 | "django.contrib.sessions.middleware.SessionMiddleware", 39 | "django.middleware.common.CommonMiddleware", 40 | "django.middleware.csrf.CsrfViewMiddleware", 41 | "django.contrib.auth.middleware.AuthenticationMiddleware", 42 | "django.contrib.messages.middleware.MessageMiddleware", 43 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 44 | ] 45 | 46 | ROOT_URLCONF = "tests.urls" 47 | 48 | TEMPLATES = [ 49 | { 50 | "BACKEND": "django.template.backends.django.DjangoTemplates", 51 | "DIRS": [ 52 | os.path.join(BASE_DIR, "templates"), 53 | ], 54 | "APP_DIRS": True, 55 | "OPTIONS": { 56 | "context_processors": [ 57 | "django.template.context_processors.debug", 58 | "django.template.context_processors.request", 59 | "django.contrib.auth.context_processors.auth", 60 | "django.contrib.messages.context_processors.messages", 61 | ], 62 | }, 63 | }, 64 | ] 65 | 66 | WSGI_APPLICATION = "tests.wsgi.application" 67 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 68 | 69 | # Database 70 | # https://docs.djangoproject.com/en/1.9/ref/settings/#databases 71 | 72 | DATABASES = { 73 | "default": { 74 | "ENGINE": "django.db.backends.sqlite3", 75 | "NAME": os.path.join(BASE_DIR, "db-test.sqlite3"), 76 | } 77 | } 78 | 79 | # Django OCI Example (with defaults_ 80 | 81 | DJANGO_OCI = { 82 | # Url base prefix 83 | "URL_PREFIX": "v2", 84 | # Version of distribution spec 85 | "SPEC_VERSION": "1", 86 | # Repository permissions 87 | "PRIVATE_ONLY": False, 88 | # Disabled authentication, boolean triggered by environment for testing 89 | "DISABLE_AUTHENTICATION": os.environ.get("DISABLE_AUTHENTICATION") is not None, 90 | # "secret" for jwt decoding, hard coded for tests here. Likely you'd want to set in enviroment 91 | "JWT_SERVER_SECRET": "c4978944-8ea4-41f2-ac55-e38dcc09cff4'", 92 | } 93 | 94 | # Password validation 95 | # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators 96 | 97 | AUTH_PASSWORD_VALIDATORS = [ 98 | { 99 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 100 | }, 101 | { 102 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 103 | }, 104 | { 105 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 106 | }, 107 | { 108 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 109 | }, 110 | ] 111 | 112 | # Internationalization 113 | # https://docs.djangoproject.com/en/1.9/topics/i18n/ 114 | 115 | LANGUAGE_CODE = "en-us" 116 | TIME_ZONE = "UTC" 117 | USE_I18N = True 118 | USE_L10N = True 119 | USE_TZ = True 120 | 121 | # Static files (CSS, JavaScript, Images) 122 | # https://docs.djangoproject.com/en/1.9/howto/static-files/ 123 | 124 | STATIC_URL = "/static/" 125 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | test_django-oci api 3 | ------------------- 4 | 5 | Tests for `django-oci` api. 6 | """ 7 | 8 | import base64 9 | import hashlib 10 | import json 11 | import os 12 | import re 13 | import subprocess 14 | from time import sleep 15 | 16 | import requests 17 | from django.contrib.auth.models import User 18 | from django.urls import reverse 19 | from rest_framework import status 20 | from rest_framework.test import APITestCase 21 | 22 | here = os.path.abspath(os.path.dirname(__file__)) 23 | 24 | # Boolean from environment that determines authentication required variable 25 | auth_regex = re.compile('(\w+)[:=] ?"?([^"]+)"?') # noqa 26 | 27 | # Important: user needs to be created globally to be seen 28 | user, _ = User.objects.get_or_create(username="dinosaur") 29 | token = str(user.auth_token) 30 | 31 | 32 | def calculate_digest(blob): 33 | """ 34 | Given a blob (the body of a response) calculate the sha256 digest 35 | """ 36 | hasher = hashlib.sha256() 37 | hasher.update(blob) 38 | return hasher.hexdigest() 39 | 40 | 41 | def get_auth_header(username, password): 42 | """ 43 | django oci requires the user token as the password to generate a longer 44 | auth token that will expire after some number of seconds 45 | """ 46 | auth_str = "%s:%s" % (username, password) 47 | auth_header = base64.b64encode(auth_str.encode("utf-8")) 48 | return {"Authorization": "Basic %s" % auth_header.decode("utf-8")} 49 | 50 | 51 | def get_authentication_headers(response): 52 | """ 53 | Given a requests.Response, assert that it has status code 401 and 54 | provides the Www-Authenticate header that can be parsed for the request 55 | """ 56 | assert response.status_code == 401 57 | assert "Www-Authenticate" in response.headers 58 | matches = dict(auth_regex.findall(response.headers["Www-Authenticate"])) 59 | for key in ["scope", "realm", "service"]: 60 | assert key in matches 61 | 62 | # Prepare authentication headers and get token 63 | headers = get_auth_header(user.username, token) 64 | url = "%s?service=%s&scope=%s" % ( 65 | matches["realm"], 66 | matches["service"], 67 | matches["scope"], 68 | ) 69 | # With proper headers should be 200 70 | auth_response = requests.get(url, headers=headers) 71 | assert auth_response.status_code == 200 72 | body = auth_response.json() 73 | 74 | # Make sure we have the expected fields 75 | for key in ["token", "expires_in", "issued_at"]: 76 | assert key in body 77 | 78 | # Formulate new auth header 79 | return {"Authorization": "Bearer %s" % body["token"]} 80 | 81 | 82 | def read_in_chunks(image, chunk_size=1024): 83 | """ 84 | Helper function to read file in chunks, with default size 1k. 85 | """ 86 | while True: 87 | data = image.read(chunk_size) 88 | if not data: 89 | break 90 | yield data 91 | 92 | 93 | def get_manifest(config_digest, layer_digest): 94 | """ 95 | A dummy image manifest with a config and single image layer 96 | """ 97 | return json.dumps( 98 | { 99 | "schemaVersion": 2, 100 | "config": { 101 | "mediaType": "application/vnd.oci.image.config.v1+json", 102 | "size": 7023, 103 | "digest": config_digest, 104 | }, 105 | "layers": [ 106 | { 107 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 108 | "size": 32654, 109 | "digest": layer_digest, 110 | } 111 | ], 112 | "annotations": {"com.example.key1": "peas", "com.example.key2": "carrots"}, 113 | } 114 | ) 115 | 116 | 117 | class APIBaseTests(APITestCase): 118 | def setUp(self): 119 | self.process = subprocess.Popen(["python", "manage.py", "runserver"]) 120 | sleep(2) 121 | 122 | def tearDown(self): 123 | os.kill(self.process.pid, 9) 124 | 125 | def test_api_version_check(self): 126 | """ 127 | GET of /v2 should return a 200 response. 128 | """ 129 | url = reverse("django_oci:api_version_check") 130 | response = self.client.get(url, format="json") 131 | self.assertEqual(response.status_code, status.HTTP_200_OK) 132 | 133 | 134 | class APIPushTests(APITestCase): 135 | def push( 136 | self, 137 | digest, 138 | data, 139 | content_type="application/octet-stream", 140 | test_response=True, 141 | extra_headers={}, 142 | ): 143 | url = "http://127.0.0.1:8000%s?digest=%s" % ( 144 | reverse("django_oci:blob_upload", kwargs={"name": self.repository}), 145 | digest, 146 | ) 147 | print("Single Monolithic POST: %s" % url) 148 | headers = { 149 | "Content-Length": str(len(data)), 150 | "Content-Type": content_type, 151 | } 152 | headers.update(extra_headers) 153 | response = requests.post(url, data=data, headers=headers) 154 | if test_response: 155 | self.assertTrue( 156 | response.status_code 157 | in [status.HTTP_202_ACCEPTED, status.HTTP_201_CREATED] 158 | ) 159 | return response 160 | 161 | def test_push_post_then_put(self): 162 | """ 163 | POST /v2//blobs/uploads/ 164 | PUT /v2//blobs/uploads/ 165 | """ 166 | url = "http://127.0.0.1:8000%s" % ( 167 | reverse("django_oci:blob_upload", kwargs={"name": self.repository}) 168 | ) 169 | print("POST to request session: %s" % url) 170 | headers = {"Content-Type": "application/octet-stream"} 171 | response = requests.post(url, headers=headers) 172 | auth_headers = get_authentication_headers(response) 173 | headers.update(auth_headers) 174 | response = requests.post(url, headers=headers) 175 | 176 | # Location must be in response header 177 | self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) 178 | self.assertTrue("Location" in response.headers) 179 | blob_url = "http://127.0.0.1:8000%s?digest=%s" % ( 180 | response.headers["Location"], 181 | self.digest, 182 | ) 183 | # PUT to upload blob url 184 | headers = { 185 | "Content-Length": str(len(self.data)), 186 | "Content-Type": "application/octet-stream", 187 | } 188 | headers.update(auth_headers) 189 | print("PUT to upload: %s" % blob_url) 190 | response = requests.put(blob_url, data=self.data, headers=headers) 191 | 192 | # This should allow HTTP_202_ACCEPTED too 193 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 194 | self.assertTrue("Location" in response.headers) 195 | download_url = add_url_prefix(response.headers["Location"]) 196 | response = requests.get(download_url, headers=auth_headers) 197 | 198 | self.assertEqual(response.status_code, status.HTTP_200_OK) 199 | 200 | # Test upload request from another repository 201 | non_standard_name = "conformance-aedf05b6-6996-4dae-ad18-70a4db9e9061" 202 | url = "http://127.0.0.1:8000%s" % ( 203 | reverse("django_oci:blob_upload", kwargs={"name": non_standard_name}) 204 | ) 205 | url = "%s?mount=%s&from=%s" % (url, self.digest, self.repository) 206 | print("POST to request mount from another repository: %s" % url) 207 | headers = {"Content-Type": "application/octet-stream"} 208 | response = requests.post(url, headers=headers) 209 | auth_headers = get_authentication_headers(response) 210 | headers.update(auth_headers) 211 | response = requests.post(url, headers=headers) 212 | assert "Location" in response.headers 213 | 214 | assert non_standard_name in response.headers["Location"] 215 | download_url = add_url_prefix(response.headers["Location"]) 216 | response = requests.get(download_url, headers=auth_headers) 217 | self.assertEqual(response.status_code, status.HTTP_200_OK) 218 | 219 | def test_push_chunked(self): 220 | """ 221 | POST /v2//blobs/uploads/ 222 | PATCH 223 | PUT /v2//blobs/uploads/ 224 | """ 225 | url = "http://127.0.0.1:8000%s" % ( 226 | reverse("django_oci:blob_upload", kwargs={"name": self.repository}) 227 | ) 228 | print("POST to request chunked session: %s" % url) 229 | headers = {"Content-Type": "application/octet-stream", "Content-Length": "0"} 230 | response = requests.post(url, headers=headers) 231 | auth_headers = get_authentication_headers(response) 232 | headers.update(auth_headers) 233 | response = requests.post(url, headers=headers) 234 | 235 | # Location must be in response header 236 | self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) 237 | self.assertTrue("Location" in response.headers) 238 | session_url = "http://127.0.0.1:8000%s" % response.headers["Location"] 239 | 240 | # Read the file in chunks, for each do a patch 241 | start = 0 242 | with open(self.image, "rb") as fd: 243 | for chunk in read_in_chunks(fd): 244 | if not chunk: 245 | break 246 | 247 | end = start + len(chunk) - 1 248 | content_range = "%s-%s" % (start, end) 249 | headers = { 250 | "Content-Range": content_range, 251 | "Content-Length": str(len(chunk)), 252 | "Content-Type": "application/octet-stream", 253 | } 254 | headers.update(auth_headers) 255 | start = end + 1 256 | print("PATCH to upload content range: %s" % content_range) 257 | response = requests.patch(session_url, data=chunk, headers=headers) 258 | self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) 259 | self.assertTrue("Location" in response.headers) 260 | 261 | # Finally, issue a PUT request to close blob 262 | session_url = "%s?digest=%s" % (session_url, self.digest) 263 | response = requests.put(session_url, headers=auth_headers) 264 | 265 | # Location must be in response header 266 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 267 | self.assertTrue("Location" in response.headers) 268 | 269 | def test_push_view_delete_manifest(self): 270 | """ 271 | PUT /v2//manifests/ 272 | DELETE /v2//manifests/ 273 | """ 274 | url = "http://127.0.0.1:8000%s" % ( 275 | reverse( 276 | "django_oci:image_manifest", 277 | kwargs={"name": self.repository, "tag": "latest"}, 278 | ) 279 | ) 280 | print("PUT to create image manifest: %s" % url) 281 | 282 | # Calculate digest for config (yes, we haven't uploaded the blob, it's ok) 283 | with open(self.config, "r") as fd: 284 | content = fd.read() 285 | config_digest = calculate_digest(content.encode("utf-8")) 286 | 287 | # Prepare the manifest (already a text string) 288 | manifest = get_manifest(config_digest, self.digest) 289 | manifest_reference = "sha256:%s" % calculate_digest(manifest.encode("utf-8")) 290 | headers = { 291 | "Content-Type": "application/vnd.oci.image.manifest.v1+json", 292 | "Content-Length": str(len(manifest)), 293 | } 294 | response = requests.put(url, headers=headers, data=manifest) 295 | 296 | auth_headers = get_authentication_headers(response) 297 | headers.update(auth_headers) 298 | response = requests.put(url, headers=headers, data=manifest) 299 | 300 | # Location must be in response header 301 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 302 | self.assertTrue("Location" in response.headers) 303 | 304 | # test manifest download 305 | response = requests.get(url, headers=auth_headers).json() 306 | for key in ["schemaVersion", "config", "layers", "annotations"]: 307 | assert key in response 308 | 309 | # Retrieve newly created tag 310 | tags_url = "http://127.0.0.1:8000%s" % ( 311 | reverse("django_oci:image_tags", kwargs={"name": self.repository}) 312 | ) 313 | print("GET to list tags: %s" % tags_url) 314 | tags = requests.get(tags_url, headers=auth_headers) 315 | self.assertEqual(tags.status_code, status.HTTP_200_OK) 316 | tags = tags.json() 317 | for key in ["name", "tags"]: 318 | assert key in tags 319 | 320 | # First delete tag (we are allowed to have an untagged manifest) 321 | response = requests.delete(url, headers=auth_headers) 322 | self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) 323 | 324 | # Finally, delete the manifest 325 | url = "http://127.0.0.1:8000%s" % ( 326 | reverse( 327 | "django_oci:image_manifest", 328 | kwargs={"name": self.repository, "reference": manifest_reference}, 329 | ) 330 | ) 331 | response = requests.delete(url, headers=auth_headers) 332 | self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) 333 | 334 | def test_push_single_monolithic_post(self): 335 | """ 336 | POST /v2//blobs/uploads/ 337 | """ 338 | # Push the image blob, should return 401 without authentication 339 | response = self.push(digest=self.digest, data=self.data, test_response=False) 340 | headers = get_authentication_headers(response) 341 | response = self.push( 342 | digest=self.digest, 343 | data=self.data, 344 | test_response=False, 345 | extra_headers=headers, 346 | ) 347 | assert response.status_code == 201 348 | assert "Location" in response.headers 349 | download_url = add_url_prefix(response.headers["Location"]) 350 | 351 | response = requests.get(download_url, headers=headers if headers else None) 352 | self.assertEqual(response.status_code, status.HTTP_200_OK) 353 | 354 | # Upload an image manifest 355 | with open(self.config, "r") as fd: 356 | content = fd.read().encode("utf-8") 357 | config_digest = calculate_digest(content) 358 | self.push(digest=config_digest, data=content, extra_headers=headers) 359 | 360 | def setUp(self): 361 | self.repository = "vanessa/container" 362 | self.image = os.path.abspath( 363 | os.path.join(here, "..", "examples", "singularity", "busybox_latest.sif") 364 | ) 365 | self.config = os.path.abspath( 366 | os.path.join(here, "..", "examples", "singularity", "config.json") 367 | ) 368 | 369 | # Read binary data and calculate sha256 digest 370 | with open(self.image, "rb") as fd: 371 | self.data = fd.read() 372 | self._digest = calculate_digest(self.data) 373 | self.digest = "sha256:%s" % self._digest 374 | 375 | 376 | def add_url_prefix(download_url): 377 | if not download_url.startswith("http"): 378 | download_url = "http://127.0.0.1:8000%s" % download_url 379 | return download_url 380 | -------------------------------------------------------------------------------- /tests/test_conformance.py: -------------------------------------------------------------------------------- 1 | """ 2 | test_django-oci api 3 | ------------------- 4 | 5 | Tests for `django-oci` conformance 6 | """ 7 | 8 | import os 9 | import subprocess 10 | import sys 11 | from time import sleep 12 | 13 | from rest_framework.test import APITestCase 14 | 15 | here = os.path.abspath(os.path.dirname(__file__)) 16 | 17 | CONFORMANCE_BINARY_PATH = os.path.join(os.path.dirname(here), "conformance.test") 18 | SKIP_CONFORMANCE = os.environ.get("DJANGO_OCI_SKIP_CONFORMANCE") 19 | 20 | 21 | class ConformanceTests(APITestCase): 22 | def setUp(self): 23 | self.server_url = "127.0.0.1:8090" 24 | self.process = subprocess.Popen( 25 | ["python", "manage.py", "runserver", self.server_url] 26 | ) 27 | sleep(2) 28 | 29 | def tearDown(self): 30 | os.kill(self.process.pid, 9) 31 | 32 | def test_conformance(self): 33 | """ 34 | Given the conformance test binary exists, run tests 35 | """ 36 | if not os.path.exists(CONFORMANCE_BINARY_PATH) and not SKIP_CONFORMANCE: 37 | sys.exit( 38 | "Conformance testing binary conformance.test not found, set DJANGO_OCI_SKIP_CONFORMANCE in environment to skip." 39 | ) 40 | 41 | env = os.environ.copy() 42 | env["OCI_ROOT_URL"] = "http://" + self.server_url 43 | env["OCI_NAMESPACE"] = "vsoch/django-oci" 44 | env["OCI_DEBUG"] = "true" 45 | env["OCI_TEST_PUSH"] = "1" 46 | env["OCI_TEST_PULL"] = "1" 47 | env["OCI_TEST_CONTENT_DISCOVERY"] = "1" 48 | env["OCI_TEST_CONTENT_MANAGEMENT"] = "1" 49 | 50 | response = subprocess.call(CONFORMANCE_BINARY_PATH, env=env) 51 | self.assertEqual(response, 0) 52 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from django.contrib import admin 5 | from django.urls import include, re_path 6 | 7 | urlpatterns = [ 8 | re_path(r"^admin/", admin.site.urls), 9 | re_path(r"^", include("django_oci.urls", namespace="django_oci")), 10 | ] 11 | -------------------------------------------------------------------------------- /tests/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------