Skip to content

Clients

While there is no official client, here we discuss several strategies for communicating with your django-oci registry.

Python Requests

If you don’t want a lot of extra dependnecies, Python requests is a good way to go! You can look at the api tests to see updated examples for push, pull, and other content management.

Reggie

If you are looking for a more structured Python client to interact with an opencontainers/distribution-spec registry like django-oci, oci-python serves a client, Reggie (python) - “the saint of content management” that mimics the official Reggie client to interact with an OCI registry. You can read complete documentation served at the repository and keep reading for a small tutorial.

Authentication

Without Authentication

For testing, it’s easiest to set up a server that doesn’t require authentication. If you want to do the same but have authentication, see the next section.

With Authentication

It’s more likely that you’ll want a registry with authentication! If this is the case, keep out for comments in the tutorial below that show extra steps needed for authentication. In the tutorial below, we will show how to create a user with a token, and then issue interactions. This is all on the command line, but in the case of a registry with an interface you would likely be able to create the user there.

Steps

1. Start a server

Let’s first start a django-oci server, and disable authentication. Here is a quick set of steps to get a server running.

git clone https://github.com/vsoch/django-oci
cd django-oci

# Install dependencies
python -m venv env
source env/bin/activate
pip install -r requirements.txt
pip install opencontainers

The next step should only be issued if you want to disable authentication.

# Disable authentication for the demo
export DISABLE_AUTHENTICATION=yes

And finally, make migrations and run your server!

# Database migrations
python manage.py makemigrations
python manage.py makemigrations django_oci
python manage.py migrate
python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
October 17, 2020 - 21:53:15
Django version 3.1.2, using settings 'tests.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

If you need to customize the port:

python manage.py runserver 127.0.0.0:9999

This should get a development server running! Also note that we are installing reggie with pip install opencontainers. Now you can open another Python interactive terminal (I like ipython) and test the opencontainers reggie client. You’ll again want to source the same environment, and probably install ipython for a nicer terminal experience in the database shell.

source env/bin/activate
pip install ipython
python manage.py shell

Without Authentication

If you don’t need authentication and exported DISABLE_AUTHENTICATION above, then you can create the client as follows.

from opencontainers.distribution.reggie import *
client = NewClient("http://127.0.0.1:8000", WithDefaultName("myorg/myrepo"), WithDebug(True))

You can see that the client settings are under the “Config” attribute.

client.Config.DefaultName
# 'myorg/myrepo'

client.Config.Debug
# True

With Authentication

To authenticate we need a username and password. Let’s then first create your username, and get the token, which we will need for authenticated requests.

from django.contrib.auth.models import User
user = User.objects.create(username='dinosaur')
# <User: dinosaur>

The token is automatically generated for the user. Let’s put it into it’s own variable.

user.auth_token
# <Token: 93e033f944ff11626662a5ac4d74b700d465d91b>
token = str(user.auth_token)

Great! Now let’s create our Reggie client. This command is similar to the one before, but this time we add the WithUsernamePassword function to provide it with the basic authentication parameters, username and the token as the password, to prepare for the 401 response and auth flow

from opencontainers.distribution.reggie import *
client = NewClient("http://127.0.0.1:8000",
          WithDefaultName("myorg/myrepo"),
          WithUsernamePassword(user.username, token),
          WithDebug(True))

You can again see the config parameters, but this time, we have a username and password

client.Config.DefaultName
# 'myorg/myrepo'

client.Config.Debug
# True

client.Config.Username
# 'dinosaur'

client.Config.Password
# '93e033f944ff11626662a5ac4d74b700d465d91b'

Next, let’s walk through a few basic requests to demonstrate how Reggie Python works. These next steps assume you’ve created the client above, and the server is still running.

2. Ping the registry

You can ping a registry generally at the /v2/ endpoint. The registry should return a 200 response to say “Hello, yes I’m here!” Let’s prepare and issue the request:

req = client.NewRequest("GET", "/v2/")
response = client.Do(req)

# We get a 200 response!
response
# <Response [200]>

response.json()
{'success': True}

This particular endpoint doesn’t require authentication. Let’s now try more substantial requests.

3. Upload a blob with POST then PUT

Let’s walk through creating and uploading a blob to our registry. For fun to show that the blob can be for other kinds of containers (not just Docker) let’s upload a Singularity image, which is provided in the examples folder of django-oci. Let’s prepare the path for the image and config.json from the base of the repository.

import os
image = os.path.abspath(os.path.join("examples", "singularity", "busybox_latest.sif"))
config = os.path.abspath(os.path.join("examples", "singularity", "config.json"))

Next, prepare the request to upload. Since this is a fairly small image we will use a single POST request. Remember that you are allowed to do a single monolithic upload, a POST then PUT, or a chunked upload.

# Request an upload session URL
req = client.NewRequest("POST", "/v2/<name>/blobs/uploads/")

req.url
# 'http://localhost:8000/v2/myorg/myrepo/blobs/uploads'

req.method
# 'POST'

And do the request. You should get back a 202 response with a “Location” header.

response = client.Do(req)
response
# <Response [202]>

response.headers['Location']
# '/v2/put/1/session-942e656f-d08f-4df9-a9e4-575eb59aae77/blobs/upload/'

Note that you’ll have about 10 minutes for this URL to be valid, which is one of the settings you can configure for your registry. You also actually don’t need to worry about knowing the Location header, because it will be provided to the Reggie client with the GetRelativeLocation() function provided with the response object. Next, let’s upload our image blob! First, we need a digest. Here is a function to calculate one:

import hashlib
def calculate_digest(blob):
    """Given a blob (the body of a response) calculate the sha256 digest"""
    hasher = hashlib.sha256()
    hasher.update(blob)
    return "sha256:%s" % hasher.hexdigest()

And here we can do it:

# Read binary data and calculate sha256 digest
with open(image, "rb") as fd:
    data = fd.read()
digest = calculate_digest(data)
# sha256:bdebf360662e987574743eeb862950e5d6ac15fbb90150de7ac8f3af02834c7a

Next, let’s upload the blob.

req = (client.NewRequest("PUT", response.GetRelativeLocation()).
        SetHeader("Content-Type", "application/octet-stream").
        SetHeader("Content-Length", str(len(data))).
        SetQueryParam("digest", digest).
        SetBody(data)
      )
blobResponse = client.Do(req)
# <Response [201]>

You’ll again have a Location header, but this time, you get back a url to pull the blob.

blobResponse.headers['Location']
http://127.0.0.1:8000/v2/myorg/myrepo/blobs/sha256:bdebf360662e987574743eeb862950e5d6ac15fbb90150de7ac8f3af02834c7a/

Let’s test doing that!

req = client.NewRequest("GET", blobResponse.GetRelativeLocation())
downloadResponse = client.Do(req)
# <Response [200]>

4. Chunked upload of a blob

This time, we want to do a chunked upload. We’ll need to essentially break our binary into pieces and upload in chunks. First, let’s write a function to split it into chunks and yield each one:

def read_in_chunks(image, chunk_size=1024):
    """Helper function to read file in chunks, with default size 1k."""
    while True:
        data = image.read(chunk_size)
        if not data:
            break
        yield data

And again prepare the request. This time, we provide a content length of 0 and no digest.

req = (client.NewRequest("POST", "/v2/<name>/blobs/uploads").
        SetHeader("Content-Type", "application/octet-stream").
        SetHeader("Content-Length", "0"))
response = client.Do(req)
# <Response [202]>

And again, now let’s use the Location header to upload our blob, but this time in a chunked fashion.

# Read the file in chunks, for each do a patch
start = 0
with open(image, "rb") as fd:
    for chunk in read_in_chunks(fd):
        if not chunk:
            break

        end = start + len(chunk) - 1
        content_range = "%s-%s" % (start, end)

        # Prepare the request
        req = (client.NewRequest("PATCH", response.GetRelativeLocation()).
            SetHeader("Content-Type", "application/octet-stream").
            SetHeader("Content-Length", str(len(chunk))).
            SetHeader("Content-Range", content_range).
            SetBody(chunk))
        start = end + 1
        chunkResponse = client.Do(req)

If you are watching your registry output, you should see a bunch of 202 responses for each chunk.

...
[19/Oct/2020 19:20:36] "PATCH /v2/put/2/session-1af6f518-9156-4a9c-ac23-5505e67ed4f7/blobs/upload HTTP/1.1" 202 0
[19/Oct/2020 19:20:36] "PATCH /v2/put/2/session-1af6f518-9156-4a9c-ac23-5505e67ed4f7/blobs/upload HTTP/1.1" 202 0

Finally, let’s issue a PUT request to finish the blob

req = (client.NewRequest("PUT",  response.GetRelativeLocation()).
        SetQueryParam("digest", digest))
putResponse = client.Do(req)
# <Response [201]>

We can again see the download url in the Location header.

putResponse.GetAbsoluteLocation()
'http://127.0.0.1:8000/v2/myorg/myrepo/blobs/sha256:bdebf360662e987574743eeb862950e5d6ac15fbb90150de7ac8f3af02834c7a/'

putResponse.GetRelativeLocation()
'/v2/myorg/myrepo/blobs/sha256:bdebf360662e987574743eeb862950e5d6ac15fbb90150de7ac8f3af02834c7a/'

5. Upload a manifest

Let’s pretend that we just uploaded a manifest config blob, and we’ll use it to create a manifest with no layers (this is all kinds of wrong, but will work for the example). For an actual use case, you would upload a config blob, and then one or more layer blobs, and put them together to form the manifest. Here we will pretend that the same blob is both a layer and a config blob. :)

manifest = {
  "schemaVersion": 2,
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": len(data),
    "digest": digest
  },
  "layers": [ {
    "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
    "size": len(data),
    "digest": digest,
   }]
}

We can validate the manifest.

from opencontainers.image.v1 import Manifest
m = Manifest()
m.load(manifest)

Now prepare and issue the request to upload the manifest. Notice that we are adding a tag reference “latest”:

req = (client.NewRequest("PUT", "/v2/<name>/manifests/<reference>",
     WithReference("latest")).
     SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json").
     SetBody(manifest))
response = client.Do(req)

We should see a 201 response!

# <Response [201]>

Now we can validate the uploaded content. We are changing PUT to GET.

req = (client.NewRequest("GET", "/v2/<name>/manifests/<reference>",
        WithReference("latest")).
        SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json"))
response = client.Do(req)
response.json()
{'schemaVersion': 2,
 'config': {'mediaType': 'application/vnd.oci.image.config.v1+json',
  'size': 786432,
  'digest': 'sha256:bdebf360662e987574743eeb862950e5d6ac15fbb90150de7ac8f3af02834c7a'},
 'layers': [{'mediaType': 'application/vnd.oci.image.layer.v1.tar+gzip',
   'size': 786432,
   'digest': 'sha256:bdebf360662e987574743eeb862950e5d6ac15fbb90150de7ac8f3af02834c7a'}]}

6. List Tags

Let’s list the tag we just uploaded!

req = client.NewRequest("GET", "/v2/<name>/tags/list")

req.url
# 'http://127.0.0.1:8000/v2/myorg/myrepo/tags/list'

Remember that you could change the name of the repository on the fly:

req = (client.NewRequest("GET", "/v2/<name>/tags/list",
    WithName("vsoch/django-oci")))

req.url
# 'http://127.0.0.1:8000/v2/vsoch/django-oci/tags/list'

req.method
# 'GET'

Let’s do the request!

response = client.Do(req)

We get the tags!

response
# <Response [200]>

response.json()
# {'name': 'myorg/myrepo', 'tags': ['latest']}

If you have a question or want to contribute, please let us know.