diff --git a/docker/utils/__init__.py b/docker/utils/__init__.py index 6189ed83b..0ec1c7c8e 100644 --- a/docker/utils/__init__.py +++ b/docker/utils/__init__.py @@ -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 diff --git a/docker/utils/context.py b/docker/utils/context.py new file mode 100644 index 000000000..964a1440d --- /dev/null +++ b/docker/utils/context.py @@ -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) diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 000000000..47a3b6388 --- /dev/null +++ b/docs/context.md @@ -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`. diff --git a/tests/test.py b/tests/test.py index 8f082808c..1dc182c30 100644 --- a/tests/test.py +++ b/tests/test.py @@ -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 # ####################### diff --git a/tests/testdata/context/Dockerfile b/tests/testdata/context/Dockerfile new file mode 100644 index 000000000..d1ceac6b7 --- /dev/null +++ b/tests/testdata/context/Dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +CMD echo "success" diff --git a/tests/testdata/context/ctx.tar.gz b/tests/testdata/context/ctx.tar.gz new file mode 100644 index 000000000..c14e5b971 Binary files /dev/null and b/tests/testdata/context/ctx.tar.gz differ diff --git a/tests/testdata/context/custom_dockerfile b/tests/testdata/context/custom_dockerfile new file mode 100644 index 000000000..d1ceac6b7 --- /dev/null +++ b/tests/testdata/context/custom_dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +CMD echo "success"