Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for building contexts objects #589

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docker/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@

from .types import Ulimit, LogConfig # flake8: noqa
from .decorators import check_resource #flake8: noqa
from .context import (
create_context_from_path,
is_remote,
ContextError
) # flake8: noqa
167 changes: 167 additions & 0 deletions docker/utils/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import os
import tarfile
import six
from collections import namedtuple


class ContextError(Exception):
def __init__(self, msg):
self.message = msg


INVALID_CONTEXT_FORMAT_LONG_FMT = """
Build context at %s is not supported by Docker\\n
The path must point to either:
\t * A readable directory containing a valid Dockerfile
\t * A tarball (optionally compressed with gzip, xz or bzip2)
\t * A valid Dockerfile
\t * A valid URL for a remote build context.
%s"""


# these prefixes are treated as remote by the docker daemon
# (ref: pkg/urlutil/*) as of v1.6.0
REMOTE_CONTEXT_PREFIXES = ["http://",
"https://",
"git://",
"git@",
"github.com/"]


class BuildContext(namedtuple('BuildContext',
['format', 'path', 'dockerfile', 'job_params'])):
def __new__(cls, context_format,
path,
dockerfile='Dockerfile',
job_params=None):
ctx_tuple = super(BuildContext, cls)
return ctx_tuple.__new__(
cls,
context_format,
path,
dockerfile,
job_params,
)


def make_context_from_tarball(path, dockerfile='Dockerfile'):
return BuildContext(
'tarball',
path,
dockerfile=dockerfile,
job_params={
'encoding': 'gzip',
'custom_context': True,
'fileobj': open(path)
}
)


def make_context_from_dockerfile(path, dockerfile='Dockerfile'):
return BuildContext(
'dockerfile',
path=path,
dockerfile=dockerfile,
job_params={'fileobj': open(path, 'r')},
)


def make_context_from_url(path, dockerfile='Dockerfile'):
return BuildContext(
'remote',
path,
dockerfile=dockerfile,
job_params={},
)


def make_context_from_directory(path, dockerfile='Dockerfile'):
return BuildContext(
'directory',
path,
dockerfile=dockerfile,
job_params={}
)


context_builders = {
'tarball': make_context_from_tarball,
'dockerfile': make_context_from_dockerfile,
'remote': make_context_from_url,
'directory': make_context_from_directory
}


def create_context_from_path(path, dockerfile='Dockerfile'):
if path is None:
raise ContextError("'path' parameter cannot be None")
if dockerfile is None:
raise ContextError("'dockerfile' parameter cannot be None")

_dockerfile = dockerfile
_path = path
if isinstance(_dockerfile, six.string_types):
_dockerfile = dockerfile.encode('utf-8')
if isinstance(_path, six.string_types):
_path = path.encode('utf-8')

context_maker = detect_context_format(_path, _dockerfile)
if context_maker is None:
raise ContextError("Format not supported at "
"%s [dockerfile='%s']." % (path, dockerfile))

return context_maker(path, dockerfile)


def is_remote(path):
if path is None:
return False

_path = path
if isinstance(_path, six.binary_type):
_path = _path.decode('utf-8')
for prefix in REMOTE_CONTEXT_PREFIXES:
if _path.startswith(prefix):
return True
return False


def detect_context_format(path, dockerfile='Dockerfile'):
if is_remote(path):
return context_builders['remote']

try:
os.access(path, os.R_OK)
except IOError as ioe:
raise ContextError("%s: %s" % (path, ioe))

if os.path.isdir(path):
if dockerfile in os.listdir(path):
return context_builders['directory']
else:
raise ContextError("Directory %s does not contain a Dockerfile"
" with name %s" % (path, dockerfile))
elif is_tarball_context(path):
return context_builders['tarball']

elif os.path.isfile(path):
return context_builders['dockerfile']
else:
return None


# The actual contents of the tarball are not checked; this just makes sure the
# file exists and that this Python installation recognizes the format.
def is_tarball_context(path):
if path is None:
return False
_path = path
if isinstance(_path, six.binary_type):
_path = _path.decode('utf-8')
return (not os.path.isdir(_path) and (_path.endswith('.xz') or
tarfile.is_tarfile(_path)))


def is_directory_context(path, dockerfile='Dockerfile'):
dockerfile_path = os.path.abspath(os.path.join(path, dockerfile))
return os.path.isdir(path) and os.path.isfile(dockerfile_path)
68 changes: 68 additions & 0 deletions docs/context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# BuildContext object
An immutable representation (named tuple) of a Docker build context. This object
has the following fields:
* path (str): the absolute filesystem path to the build context
* format: a string tag for the context type; one of 'tarball', 'dockerfile',
'remote', 'directory'.
* dockerfile: the name of the Dockerfile for this context.
* job_params: a helper dictionary holding the parameters for a `Client.build`
invocation. The `create_context_from_path` function populates this dictionary
with the specific combination of values that is valid for the kind of build
context represented by the tuple. For example, if the `BuildContext` represents
a tarball context, its `job_params` field will contain a dict with the mappings:
```python
job_params = {
'encoding': 'gzip'
'custom_context': True
'fileobj': open(path)
}
```
When the `BuildContext` represents a single Dockerfile context, `job_params`
will contain:
```python
job_params = {
'fileobj': open(path)
}
```
## create_context_from_path

This is an intermediary call that you can use to create a `BuildContext`. Using
the returned object you can perform custom validation and filtering steps before
invoking `Client.build`. In a call to `create_context_from_path`, the parameter
`path` can point to any kind of resource supported by the docker daemon, namely:
* A local path to a directory containing a Dockerfile.
* An URL pointing to a git repository or a remote Dockerfile.
* A local path to a tarball containing a pre-packaged build context.

The returned `BuildContext` object can be used in an invocation of
`Client.build` as such:
```python
from docker import Client
from docker.utils.context import (
create_context_from_path,
ContextError
)
cli = Client(base_url='tcp://127.0.0.1:2375')
try:
ctxpath = '/context/path' # or '/context/Dockerfile',
# or '/context/ctx.tar'
# or 'https://github.com/user/repo.git'
ctx = create_context_from_path(ctxpath)
except ContextError as e
print(e.message)

# here you can perform custom validation, filtering, inserting, etc. on 'ctx'

cli.build(ctx.path, **ctx.job_params)
```

**Params**:

* path (str): Path to the build context.
* dockerfile (str): path within the build context to the Dockerfile

**Returns** (namedtuple): A `BuildContext` object.

**Raises** ContextError: when the contents at `path` are either inaccessible or
inconsistent with the parameters (e.g. a custom 'Dockerfile' name was specified
but the file does not exist at `path`.
43 changes: 43 additions & 0 deletions tests/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2424,6 +2424,49 @@ def test_build_container_invalid_container_limits(self):
})
)

def test_build_container_from_context_object_with_tarball(self):
base_path = os.path.join(
os.path.dirname(__file__),
'testdata/context'
)
tarball_path = os.path.join(base_path, 'ctx.tar.gz')
context = docker.utils.context.create_context_from_path(tarball_path)
try:
self.client.build(context.path, **context.job_params)
if context.job_params['fileobj'] is not None:
context.job_params['fileobj'].close()
except Exception as e:
self.fail('Command should not raise exception: {0}'.format(e))

def test_build_container_from_context_object_with_custom_dockerfile(self):
base_path = os.path.abspath(os.path.join(
os.path.dirname(__file__),
'testdata/context'
))
custom_dockerfile = 'custom_dockerfile'
try:
context = docker.utils.context.create_context_from_path(
base_path,
dockerfile=custom_dockerfile
)
self.client.build(context.path, **context.job_params)
except docker.utils.context.ContextError as ce:
self.fail(ce.message)
except Exception as e:
self.fail('Command should not raise exception: {0}'.format(e))

def test_build_container_from_remote_context(self):
ctxurl = 'https://localhost/staging/context.tar.gz'
try:
context = docker.utils.context.create_context_from_path(ctxurl)
self.assertEqual(context.path, ctxurl)
self.assertEqual(context.format, 'remote')
self.client.build(context.path, **context.job_params)
except docker.utils.context.ContextError as ce:
self.fail(ce.message)
except Exception as e:
self.fail('Command should not raise exception: {0}'.format(e))

#######################
# PY SPECIFIC TESTS #
#######################
Expand Down
2 changes: 2 additions & 0 deletions tests/testdata/context/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FROM busybox:latest
CMD echo "success"
Binary file added tests/testdata/context/ctx.tar.gz
Binary file not shown.
2 changes: 2 additions & 0 deletions tests/testdata/context/custom_dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FROM busybox:latest
CMD echo "success"