2017-05-23 19:29:27 +00:00
|
|
|
"""repo2docker: convert git repositories into jupyter-suitable docker images
|
|
|
|
|
|
|
|
Images produced by repo2docker can be used with Jupyter notebooks standalone or via JupyterHub.
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
|
|
|
python -m repo2docker https://github.com/you/your-repo
|
|
|
|
"""
|
2017-05-19 07:07:39 +00:00
|
|
|
import sys
|
2017-05-09 08:37:19 +00:00
|
|
|
import json
|
|
|
|
import os
|
2017-05-16 01:54:51 +00:00
|
|
|
import time
|
|
|
|
import logging
|
2017-05-22 21:41:52 +00:00
|
|
|
import uuid
|
|
|
|
import shutil
|
2017-05-16 01:54:51 +00:00
|
|
|
from pythonjsonlogger import jsonlogger
|
2017-05-23 05:16:30 +00:00
|
|
|
import escapism
|
2017-05-09 08:37:19 +00:00
|
|
|
|
|
|
|
|
2017-05-22 21:41:52 +00:00
|
|
|
from traitlets.config import Application, LoggingConfigurable
|
2017-05-24 21:11:37 +00:00
|
|
|
from traitlets import Type, Bool, Unicode, Dict, List, default
|
2017-05-09 08:37:19 +00:00
|
|
|
import docker
|
2017-05-22 17:29:48 +00:00
|
|
|
from docker.utils import kwargs_from_env
|
2017-05-09 08:37:19 +00:00
|
|
|
|
|
|
|
import subprocess
|
|
|
|
|
2017-05-25 22:15:00 +00:00
|
|
|
from .detectors import (
|
|
|
|
BuildPack, PythonBuildPack, DockerBuildPack, LegacyBinderDockerBuildPack,
|
2017-07-04 17:28:23 +00:00
|
|
|
CondaBuildPack, JuliaBuildPack, Python2BuildPack, BaseImage
|
2017-05-25 22:15:00 +00:00
|
|
|
)
|
2017-05-16 01:54:51 +00:00
|
|
|
from .utils import execute_cmd
|
2017-05-23 19:29:27 +00:00
|
|
|
from . import __version__
|
2017-05-09 08:37:19 +00:00
|
|
|
|
2017-07-04 17:28:23 +00:00
|
|
|
|
|
|
|
def c(args):
|
|
|
|
"""
|
|
|
|
Shortcut to compose many buildpacks together
|
|
|
|
"""
|
|
|
|
image = args[0]()
|
|
|
|
for arg in args[1:]:
|
|
|
|
image = image.compose_with(arg())
|
|
|
|
return image
|
|
|
|
|
|
|
|
|
2017-05-22 23:22:36 +00:00
|
|
|
class Repo2Docker(Application):
|
2017-05-23 19:29:27 +00:00
|
|
|
name = 'jupyter-repo2docker'
|
|
|
|
version = __version__
|
|
|
|
description = __doc__
|
2017-05-24 00:56:03 +00:00
|
|
|
|
2017-05-09 08:37:19 +00:00
|
|
|
config_file = Unicode(
|
2017-05-22 23:22:36 +00:00
|
|
|
'repo2docker_config.py',
|
2017-05-23 03:28:28 +00:00
|
|
|
config=True,
|
|
|
|
help="""
|
|
|
|
Path to read traitlets configuration file from.
|
|
|
|
"""
|
2017-05-09 08:37:19 +00:00
|
|
|
)
|
2017-05-31 05:39:37 +00:00
|
|
|
|
2017-05-24 21:11:37 +00:00
|
|
|
@default('log_level')
|
|
|
|
def _default_log_level(self):
|
|
|
|
return logging.INFO
|
2017-05-09 08:37:19 +00:00
|
|
|
|
2017-05-23 03:28:38 +00:00
|
|
|
repo = Unicode(
|
2017-05-23 05:16:46 +00:00
|
|
|
os.getcwd(),
|
2017-05-09 08:37:19 +00:00
|
|
|
allow_none=True,
|
2017-05-23 03:10:59 +00:00
|
|
|
config=True,
|
|
|
|
help="""
|
2017-05-23 03:28:38 +00:00
|
|
|
The git repository to clone.
|
|
|
|
|
2017-05-23 19:29:27 +00:00
|
|
|
Could be a git URL or a file path.
|
2017-05-23 03:10:59 +00:00
|
|
|
"""
|
2017-05-09 08:37:19 +00:00
|
|
|
)
|
|
|
|
|
2017-05-23 03:28:38 +00:00
|
|
|
ref = Unicode(
|
2017-05-19 07:07:39 +00:00
|
|
|
'master',
|
|
|
|
allow_none=True,
|
2017-05-23 03:10:59 +00:00
|
|
|
config=True,
|
|
|
|
help="""
|
|
|
|
The git ref in the git repository to build.
|
|
|
|
|
|
|
|
Can be a tag, ref or branch.
|
|
|
|
"""
|
2017-05-19 07:07:39 +00:00
|
|
|
)
|
|
|
|
|
2017-05-09 08:37:19 +00:00
|
|
|
output_image_spec = Unicode(
|
|
|
|
None,
|
|
|
|
allow_none=True,
|
2017-05-23 03:10:59 +00:00
|
|
|
config=True,
|
|
|
|
help="""
|
|
|
|
The spec of the image to build.
|
|
|
|
|
|
|
|
Should be the same as the value passed to `-t` param of docker build.
|
|
|
|
"""
|
2017-05-09 08:37:19 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
git_workdir = Unicode(
|
2017-05-23 03:10:59 +00:00
|
|
|
"/tmp",
|
|
|
|
config=True,
|
|
|
|
help="""
|
|
|
|
The directory to use to check out git repositories into.
|
|
|
|
|
|
|
|
Should be somewhere ephemeral, such as /tmp
|
|
|
|
"""
|
2017-05-09 08:37:19 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
buildpacks = List(
|
2017-07-04 17:28:23 +00:00
|
|
|
[
|
|
|
|
(LegacyBinderDockerBuildPack, ),
|
|
|
|
(DockerBuildPack, ),
|
|
|
|
|
|
|
|
(BaseImage, CondaBuildPack, JuliaBuildPack),
|
|
|
|
(BaseImage, CondaBuildPack),
|
|
|
|
|
|
|
|
(BaseImage, PythonBuildPack, Python2BuildPack, JuliaBuildPack),
|
|
|
|
(BaseImage, PythonBuildPack, JuliaBuildPack),
|
|
|
|
(BaseImage, PythonBuildPack, Python2BuildPack),
|
|
|
|
(BaseImage, PythonBuildPack),
|
|
|
|
],
|
2017-05-23 03:10:59 +00:00
|
|
|
config=True,
|
|
|
|
help="""
|
|
|
|
Ordered list of BuildPacks to try to use to build a git repository.
|
|
|
|
"""
|
2017-05-09 08:37:19 +00:00
|
|
|
)
|
|
|
|
|
2017-05-22 21:41:52 +00:00
|
|
|
cleanup_checkout = Bool(
|
|
|
|
True,
|
|
|
|
config=True,
|
|
|
|
help="""
|
|
|
|
Set to True to clean up the checked out directory after building is done.
|
|
|
|
|
|
|
|
Will only clean up after a successful build - failed builds will still leave their
|
|
|
|
checkouts intact.
|
|
|
|
"""
|
|
|
|
)
|
|
|
|
|
2017-05-23 05:17:02 +00:00
|
|
|
push = Bool(
|
|
|
|
False,
|
|
|
|
config=True,
|
|
|
|
help="""
|
|
|
|
If the image should be pushed after it is built.
|
|
|
|
"""
|
|
|
|
)
|
|
|
|
|
2017-05-23 05:55:17 +00:00
|
|
|
run = Bool(
|
|
|
|
True,
|
|
|
|
config=True,
|
|
|
|
help="""
|
|
|
|
Run the image after it is built, if the build succeeds.
|
2017-05-23 06:00:47 +00:00
|
|
|
|
|
|
|
DANGEROUS WHEN DONE IN A CLOUD ENVIRONMENT! ONLY USE LOCALLY!
|
2017-05-23 05:55:17 +00:00
|
|
|
"""
|
|
|
|
)
|
2017-05-24 21:11:37 +00:00
|
|
|
json_logs = Bool(
|
|
|
|
False,
|
|
|
|
config=True,
|
|
|
|
help="""
|
|
|
|
Enable JSON logging for easier consumption by external services.
|
|
|
|
"""
|
|
|
|
)
|
2017-05-23 05:55:17 +00:00
|
|
|
|
2017-05-09 08:37:19 +00:00
|
|
|
aliases = Dict({
|
2017-05-23 03:28:38 +00:00
|
|
|
'repo': 'Repo2Docker.repo',
|
|
|
|
'ref': 'Repo2Docker.ref',
|
2017-05-23 03:13:20 +00:00
|
|
|
'image': 'Repo2Docker.output_image_spec',
|
2017-05-22 23:22:36 +00:00
|
|
|
'f': 'Repo2Docker.config_file',
|
2017-05-09 08:37:19 +00:00
|
|
|
})
|
|
|
|
|
2017-05-23 05:55:17 +00:00
|
|
|
flags = Dict({
|
|
|
|
'no-clean': ({'Repo2Docker': {'cleanup_checkout': False}}, 'Do not clean up git checkout'),
|
|
|
|
'no-run': ({'Repo2Docker': {'run': False}}, 'Do not run built container image'),
|
|
|
|
'push': ({'Repo2Docker': {'push': True}}, 'Push built image to a docker registry'),
|
2017-05-24 21:11:37 +00:00
|
|
|
'json-logs': ({'Repo2Docker': {'json_logs': True}}, 'Enable JSON logging'),
|
2017-05-23 05:55:17 +00:00
|
|
|
})
|
2017-05-09 08:37:19 +00:00
|
|
|
|
2017-05-23 03:26:27 +00:00
|
|
|
def fetch(self, url, ref, checkout_path):
|
2017-05-19 07:07:39 +00:00
|
|
|
try:
|
2017-05-24 21:11:37 +00:00
|
|
|
for line in execute_cmd(['git', 'clone', url, checkout_path],
|
|
|
|
capture=self.json_logs):
|
2017-05-19 07:07:39 +00:00
|
|
|
self.log.info(line, extra=dict(phase='fetching'))
|
|
|
|
except subprocess.CalledProcessError:
|
|
|
|
self.log.error('Failed to clone repository!', extra=dict(phase='failed'))
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
try:
|
2017-05-24 21:11:37 +00:00
|
|
|
for line in execute_cmd(['git', 'reset', '--hard', ref], cwd=checkout_path,
|
|
|
|
capture=self.json_logs):
|
2017-05-19 07:07:39 +00:00
|
|
|
self.log.info(line, extra=dict(phase='fetching'))
|
|
|
|
except subprocess.CalledProcessError:
|
|
|
|
self.log.error('Failed to check out ref %s', ref, extra=dict(phase='failed'))
|
|
|
|
sys.exit(1)
|
2017-05-09 08:37:19 +00:00
|
|
|
|
|
|
|
def initialize(self, *args, **kwargs):
|
|
|
|
super().initialize(*args, **kwargs)
|
|
|
|
self.load_config_file(self.config_file)
|
|
|
|
|
2017-05-24 21:11:37 +00:00
|
|
|
if self.json_logs:
|
|
|
|
# Need to reset existing handlers, or we repeat messages
|
|
|
|
logHandler = logging.StreamHandler()
|
|
|
|
formatter = jsonlogger.JsonFormatter()
|
|
|
|
logHandler.setFormatter(formatter)
|
|
|
|
self.log.handlers = []
|
|
|
|
self.log.addHandler(logHandler)
|
|
|
|
self.log.setLevel(logging.INFO)
|
|
|
|
else:
|
|
|
|
# due to json logger stuff above,
|
|
|
|
# our log messages include carriage returns, newlines, etc.
|
|
|
|
# remove the additional newline from the stream handler
|
|
|
|
self.log.handlers[0].terminator = ''
|
|
|
|
|
2017-05-23 19:29:27 +00:00
|
|
|
if len(self.extra_args) == 1:
|
|
|
|
# accept repo as a positional arg
|
|
|
|
self.repo = self.extra_args[0]
|
|
|
|
elif len(self.extra_args) > 1:
|
|
|
|
print("%s accepts at most one positional argument." % self.name, file=sys.stderr)
|
|
|
|
print("See python -m repo2docker --help for usage", file=sys.stderr)
|
|
|
|
self.exit(1)
|
|
|
|
|
2017-05-23 05:16:30 +00:00
|
|
|
if self.output_image_spec is None:
|
|
|
|
# Attempt to set a sane default!
|
|
|
|
# HACK: Provide something more descriptive?
|
|
|
|
self.output_image_spec = escapism.escape(self.repo).lower() + ':' + self.ref.lower()
|
|
|
|
|
|
|
|
|
2017-05-23 05:55:17 +00:00
|
|
|
def push_image(self):
|
|
|
|
client = docker.APIClient(version='auto', **kwargs_from_env())
|
|
|
|
# Build a progress setup for each layer, and only emit per-layer info every 1.5s
|
|
|
|
layers = {}
|
|
|
|
last_emit_time = time.time()
|
|
|
|
for line in client.push(self.output_image_spec, stream=True):
|
|
|
|
progress = json.loads(line.decode('utf-8'))
|
|
|
|
if 'error' in progress:
|
|
|
|
self.log.error(progress['error'], extra=dict(phase='failed'))
|
|
|
|
sys.exit(1)
|
|
|
|
if 'id' not in progress:
|
|
|
|
continue
|
|
|
|
if 'progressDetail' in progress and progress['progressDetail']:
|
|
|
|
layers[progress['id']] = progress['progressDetail']
|
|
|
|
else:
|
|
|
|
layers[progress['id']] = progress['status']
|
|
|
|
if time.time() - last_emit_time > 1.5:
|
2017-05-24 21:11:37 +00:00
|
|
|
self.log.info('Pushing image\n', extra=dict(progress=layers, phase='pushing'))
|
2017-05-23 05:55:17 +00:00
|
|
|
last_emit_time = time.time()
|
|
|
|
|
|
|
|
def run_image(self):
|
|
|
|
client = docker.from_env(version='auto')
|
2017-05-24 01:08:53 +00:00
|
|
|
port = self._get_free_port()
|
2017-05-23 05:55:17 +00:00
|
|
|
container = client.containers.run(
|
|
|
|
self.output_image_spec,
|
2017-05-24 01:08:53 +00:00
|
|
|
ports={'%s/tcp' % port: port},
|
|
|
|
detach=True,
|
|
|
|
command=['jupyter', 'notebook', '--ip', '0.0.0.0', '--port', str(port)],
|
2017-05-23 05:55:17 +00:00
|
|
|
)
|
2017-05-23 06:57:40 +00:00
|
|
|
while container.status == 'created':
|
|
|
|
time.sleep(0.5)
|
|
|
|
container.reload()
|
|
|
|
|
2017-05-23 06:00:37 +00:00
|
|
|
try:
|
|
|
|
for line in container.logs(stream=True):
|
2017-05-24 21:11:37 +00:00
|
|
|
self.log.info(line.decode('utf-8'), extra=dict(phase='running'))
|
2017-05-23 06:00:37 +00:00
|
|
|
finally:
|
2017-05-24 21:11:37 +00:00
|
|
|
self.log.info('Stopping container...\n', extra=dict(phase='running'))
|
2017-05-23 06:00:37 +00:00
|
|
|
container.kill()
|
|
|
|
container.remove()
|
2017-05-23 05:55:17 +00:00
|
|
|
|
2017-05-24 01:08:53 +00:00
|
|
|
def _get_free_port(self):
|
|
|
|
"""
|
|
|
|
Hacky method to get a free random port on local host
|
|
|
|
"""
|
|
|
|
import socket
|
|
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
|
|
s.bind(("",0))
|
|
|
|
port = s.getsockname()[1]
|
|
|
|
s.close()
|
|
|
|
return port
|
|
|
|
|
2017-05-24 00:56:03 +00:00
|
|
|
def start(self):
|
2017-05-31 05:39:37 +00:00
|
|
|
checkout_path = os.path.join(self.git_workdir, str(uuid.uuid4()))
|
|
|
|
self.fetch(
|
|
|
|
self.repo,
|
|
|
|
self.ref,
|
|
|
|
checkout_path
|
|
|
|
)
|
2017-05-22 21:41:52 +00:00
|
|
|
|
2017-07-04 17:28:23 +00:00
|
|
|
os.chdir(checkout_path)
|
|
|
|
for bp_spec in self.buildpacks:
|
|
|
|
bp = c(bp_spec)
|
|
|
|
if bp.detect():
|
2017-05-31 05:39:37 +00:00
|
|
|
self.log.info('Using %s builder\n', bp.name, extra=dict(phase='building'))
|
2017-07-04 17:28:23 +00:00
|
|
|
for l in bp.build(self.output_image_spec):
|
|
|
|
if 'stream' in l:
|
|
|
|
self.log.info(l['stream'], extra=dict(phase='building'))
|
|
|
|
elif 'error' in l:
|
|
|
|
self.log.info(l['error'], extra=dict(phase='failure'))
|
|
|
|
sys.exit(1)
|
|
|
|
else:
|
2017-07-29 02:31:52 +00:00
|
|
|
self.log.info(json.dumps(l), extra=dict(phase='building'))
|
2017-07-04 18:16:52 +00:00
|
|
|
break
|
2017-07-29 02:31:52 +00:00
|
|
|
else:
|
|
|
|
raise Exception("No builder found!")
|
2017-05-31 05:39:37 +00:00
|
|
|
|
|
|
|
if self.cleanup_checkout:
|
|
|
|
shutil.rmtree(checkout_path)
|
2017-05-23 05:55:17 +00:00
|
|
|
|
2017-05-23 05:17:02 +00:00
|
|
|
if self.push:
|
2017-05-23 05:55:17 +00:00
|
|
|
self.push_image()
|
2017-05-23 05:17:02 +00:00
|
|
|
|
2017-05-23 05:55:17 +00:00
|
|
|
if self.run:
|
|
|
|
self.run_image()
|