WIP: rename Fig to Compose
Signed-off-by: Aanand Prasad <aanand.prasad@gmail.com>
This commit is contained in:
parent
7be8b4c06d
commit
2af7693e64
54 changed files with 199 additions and 211 deletions
0
compose/cli/__init__.py
Normal file
0
compose/cli/__init__.py
Normal file
42
compose/cli/colors.py
Normal file
42
compose/cli/colors.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import unicode_literals
|
||||
NAMES = [
|
||||
'grey',
|
||||
'red',
|
||||
'green',
|
||||
'yellow',
|
||||
'blue',
|
||||
'magenta',
|
||||
'cyan',
|
||||
'white'
|
||||
]
|
||||
|
||||
|
||||
def get_pairs():
|
||||
for i, name in enumerate(NAMES):
|
||||
yield(name, str(30 + i))
|
||||
yield('intense_' + name, str(30 + i) + ';1')
|
||||
|
||||
|
||||
def ansi(code):
|
||||
return '\033[{0}m'.format(code)
|
||||
|
||||
|
||||
def ansi_color(code, s):
|
||||
return '{0}{1}{2}'.format(ansi(code), s, ansi(0))
|
||||
|
||||
|
||||
def make_color_fn(code):
|
||||
return lambda s: ansi_color(code, s)
|
||||
|
||||
|
||||
for (name, code) in get_pairs():
|
||||
globals()[name] = make_color_fn(code)
|
||||
|
||||
|
||||
def rainbow():
|
||||
cs = ['cyan', 'yellow', 'green', 'magenta', 'red', 'blue',
|
||||
'intense_cyan', 'intense_yellow', 'intense_green',
|
||||
'intense_magenta', 'intense_red', 'intense_blue']
|
||||
|
||||
for c in cs:
|
||||
yield globals()[c]
|
||||
122
compose/cli/command.py
Normal file
122
compose/cli/command.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import
|
||||
from requests.exceptions import ConnectionError, SSLError
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
import six
|
||||
|
||||
from ..project import Project
|
||||
from ..service import ConfigError
|
||||
from .docopt_command import DocoptCommand
|
||||
from .utils import call_silently, is_mac, is_ubuntu
|
||||
from .docker_client import docker_client
|
||||
from . import verbose_proxy
|
||||
from . import errors
|
||||
from .. import __version__
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(DocoptCommand):
|
||||
base_dir = '.'
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
try:
|
||||
super(Command, self).dispatch(*args, **kwargs)
|
||||
except SSLError, e:
|
||||
raise errors.UserError('SSL error: %s' % e)
|
||||
except ConnectionError:
|
||||
if call_silently(['which', 'docker']) != 0:
|
||||
if is_mac():
|
||||
raise errors.DockerNotFoundMac()
|
||||
elif is_ubuntu():
|
||||
raise errors.DockerNotFoundUbuntu()
|
||||
else:
|
||||
raise errors.DockerNotFoundGeneric()
|
||||
elif call_silently(['which', 'boot2docker']) == 0:
|
||||
raise errors.ConnectionErrorBoot2Docker()
|
||||
else:
|
||||
raise errors.ConnectionErrorGeneric(self.get_client().base_url)
|
||||
|
||||
def perform_command(self, options, handler, command_options):
|
||||
if options['COMMAND'] == 'help':
|
||||
# Skip looking up the compose file.
|
||||
handler(None, command_options)
|
||||
return
|
||||
|
||||
if 'FIG_FILE' in os.environ:
|
||||
log.warn('The FIG_FILE environment variable is deprecated.')
|
||||
log.warn('Please use COMPOSE_FILE instead.')
|
||||
|
||||
explicit_config_path = options.get('--file') or os.environ.get('COMPOSE_FILE') or os.environ.get('FIG_FILE')
|
||||
project = self.get_project(
|
||||
self.get_config_path(explicit_config_path),
|
||||
project_name=options.get('--project-name'),
|
||||
verbose=options.get('--verbose'))
|
||||
|
||||
handler(project, command_options)
|
||||
|
||||
def get_client(self, verbose=False):
|
||||
client = docker_client()
|
||||
if verbose:
|
||||
version_info = six.iteritems(client.version())
|
||||
log.info("Compose version %s", __version__)
|
||||
log.info("Docker base_url: %s", client.base_url)
|
||||
log.info("Docker version: %s",
|
||||
", ".join("%s=%s" % item for item in version_info))
|
||||
return verbose_proxy.VerboseProxy('docker', client)
|
||||
return client
|
||||
|
||||
def get_config(self, config_path):
|
||||
try:
|
||||
with open(config_path, 'r') as fh:
|
||||
return yaml.safe_load(fh)
|
||||
except IOError as e:
|
||||
if e.errno == errno.ENOENT:
|
||||
raise errors.ComposeFileNotFound(os.path.basename(e.filename))
|
||||
raise errors.UserError(six.text_type(e))
|
||||
|
||||
def get_project(self, config_path, project_name=None, verbose=False):
|
||||
try:
|
||||
return Project.from_config(
|
||||
self.get_project_name(config_path, project_name),
|
||||
self.get_config(config_path),
|
||||
self.get_client(verbose=verbose))
|
||||
except ConfigError as e:
|
||||
raise errors.UserError(six.text_type(e))
|
||||
|
||||
def get_project_name(self, config_path, project_name=None):
|
||||
def normalize_name(name):
|
||||
return re.sub(r'[^a-z0-9]', '', name.lower())
|
||||
|
||||
if 'FIG_PROJECT_NAME' in os.environ:
|
||||
log.warn('The FIG_PROJECT_NAME environment variable is deprecated.')
|
||||
log.warn('Please use COMPOSE_PROJECT_NAME instead.')
|
||||
|
||||
project_name = project_name or os.environ.get('COMPOSE_PROJECT_NAME') or os.environ.get('FIG_PROJECT_NAME')
|
||||
if project_name is not None:
|
||||
return normalize_name(project_name)
|
||||
|
||||
project = os.path.basename(os.path.dirname(os.path.abspath(config_path)))
|
||||
if project:
|
||||
return normalize_name(project)
|
||||
|
||||
return 'default'
|
||||
|
||||
def get_config_path(self, file_path=None):
|
||||
if file_path:
|
||||
return os.path.join(self.base_dir, file_path)
|
||||
|
||||
if os.path.exists(os.path.join(self.base_dir, 'compose.yaml')):
|
||||
log.warning("Fig just read the file 'compose.yaml' on startup, rather "
|
||||
"than 'compose.yml'")
|
||||
log.warning("Please be aware that .yml is the expected extension "
|
||||
"in most cases, and using .yaml can cause compatibility "
|
||||
"issues in future")
|
||||
|
||||
return os.path.join(self.base_dir, 'compose.yaml')
|
||||
|
||||
return os.path.join(self.base_dir, 'compose.yml')
|
||||
35
compose/cli/docker_client.py
Normal file
35
compose/cli/docker_client.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from docker import Client
|
||||
from docker import tls
|
||||
import ssl
|
||||
import os
|
||||
|
||||
|
||||
def docker_client():
|
||||
"""
|
||||
Returns a docker-py client configured using environment variables
|
||||
according to the same logic as the official Docker client.
|
||||
"""
|
||||
cert_path = os.environ.get('DOCKER_CERT_PATH', '')
|
||||
if cert_path == '':
|
||||
cert_path = os.path.join(os.environ.get('HOME', ''), '.docker')
|
||||
|
||||
base_url = os.environ.get('DOCKER_HOST')
|
||||
tls_config = None
|
||||
|
||||
if os.environ.get('DOCKER_TLS_VERIFY', '') != '':
|
||||
parts = base_url.split('://', 1)
|
||||
base_url = '%s://%s' % ('https', parts[1])
|
||||
|
||||
client_cert = (os.path.join(cert_path, 'cert.pem'), os.path.join(cert_path, 'key.pem'))
|
||||
ca_cert = os.path.join(cert_path, 'ca.pem')
|
||||
|
||||
tls_config = tls.TLSConfig(
|
||||
ssl_version=ssl.PROTOCOL_TLSv1,
|
||||
verify=True,
|
||||
assert_hostname=False,
|
||||
client_cert=client_cert,
|
||||
ca_cert=ca_cert,
|
||||
)
|
||||
|
||||
timeout = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))
|
||||
return Client(base_url=base_url, tls=tls_config, version='1.14', timeout=timeout)
|
||||
54
compose/cli/docopt_command.py
Normal file
54
compose/cli/docopt_command.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import
|
||||
import sys
|
||||
|
||||
from inspect import getdoc
|
||||
from docopt import docopt, DocoptExit
|
||||
|
||||
|
||||
def docopt_full_help(docstring, *args, **kwargs):
|
||||
try:
|
||||
return docopt(docstring, *args, **kwargs)
|
||||
except DocoptExit:
|
||||
raise SystemExit(docstring)
|
||||
|
||||
|
||||
class DocoptCommand(object):
|
||||
def docopt_options(self):
|
||||
return {'options_first': True}
|
||||
|
||||
def sys_dispatch(self):
|
||||
self.dispatch(sys.argv[1:], None)
|
||||
|
||||
def dispatch(self, argv, global_options):
|
||||
self.perform_command(*self.parse(argv, global_options))
|
||||
|
||||
def perform_command(self, options, handler, command_options):
|
||||
handler(command_options)
|
||||
|
||||
def parse(self, argv, global_options):
|
||||
options = docopt_full_help(getdoc(self), argv, **self.docopt_options())
|
||||
command = options['COMMAND']
|
||||
|
||||
if command is None:
|
||||
raise SystemExit(getdoc(self))
|
||||
|
||||
if not hasattr(self, command):
|
||||
raise NoSuchCommand(command, self)
|
||||
|
||||
handler = getattr(self, command)
|
||||
docstring = getdoc(handler)
|
||||
|
||||
if docstring is None:
|
||||
raise NoSuchCommand(command, self)
|
||||
|
||||
command_options = docopt_full_help(docstring, options['ARGS'], options_first=True)
|
||||
return options, handler, command_options
|
||||
|
||||
|
||||
class NoSuchCommand(Exception):
|
||||
def __init__(self, command, supercommand):
|
||||
super(NoSuchCommand, self).__init__("No such command: %s" % command)
|
||||
|
||||
self.command = command
|
||||
self.supercommand = supercommand
|
||||
62
compose/cli/errors.py
Normal file
62
compose/cli/errors.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import absolute_import
|
||||
from textwrap import dedent
|
||||
|
||||
|
||||
class UserError(Exception):
|
||||
def __init__(self, msg):
|
||||
self.msg = dedent(msg).strip()
|
||||
|
||||
def __unicode__(self):
|
||||
return self.msg
|
||||
|
||||
__str__ = __unicode__
|
||||
|
||||
|
||||
class DockerNotFoundMac(UserError):
|
||||
def __init__(self):
|
||||
super(DockerNotFoundMac, self).__init__("""
|
||||
Couldn't connect to Docker daemon. You might need to install docker-osx:
|
||||
|
||||
https://github.com/noplay/docker-osx
|
||||
""")
|
||||
|
||||
|
||||
class DockerNotFoundUbuntu(UserError):
|
||||
def __init__(self):
|
||||
super(DockerNotFoundUbuntu, self).__init__("""
|
||||
Couldn't connect to Docker daemon. You might need to install Docker:
|
||||
|
||||
http://docs.docker.io/en/latest/installation/ubuntulinux/
|
||||
""")
|
||||
|
||||
|
||||
class DockerNotFoundGeneric(UserError):
|
||||
def __init__(self):
|
||||
super(DockerNotFoundGeneric, self).__init__("""
|
||||
Couldn't connect to Docker daemon. You might need to install Docker:
|
||||
|
||||
http://docs.docker.io/en/latest/installation/
|
||||
""")
|
||||
|
||||
|
||||
class ConnectionErrorBoot2Docker(UserError):
|
||||
def __init__(self):
|
||||
super(ConnectionErrorBoot2Docker, self).__init__("""
|
||||
Couldn't connect to Docker daemon - you might need to run `boot2docker up`.
|
||||
""")
|
||||
|
||||
|
||||
class ConnectionErrorGeneric(UserError):
|
||||
def __init__(self, url):
|
||||
super(ConnectionErrorGeneric, self).__init__("""
|
||||
Couldn't connect to Docker daemon at %s - is it running?
|
||||
|
||||
If it's at a non-standard location, specify the URL with the DOCKER_HOST environment variable.
|
||||
""" % url)
|
||||
|
||||
|
||||
class ComposeFileNotFound(UserError):
|
||||
def __init__(self, filename):
|
||||
super(ComposeFileNotFound, self).__init__("""
|
||||
Can't find %s. Are you in the right directory?
|
||||
""" % filename)
|
||||
23
compose/cli/formatter.py
Normal file
23
compose/cli/formatter.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import
|
||||
import os
|
||||
import texttable
|
||||
|
||||
|
||||
def get_tty_width():
|
||||
tty_size = os.popen('stty size', 'r').read().split()
|
||||
if len(tty_size) != 2:
|
||||
return 80
|
||||
_, width = tty_size
|
||||
return int(width)
|
||||
|
||||
|
||||
class Formatter(object):
|
||||
def table(self, headers, rows):
|
||||
table = texttable.Texttable(max_width=get_tty_width())
|
||||
table.set_cols_dtype(['t' for h in headers])
|
||||
table.add_rows([headers] + rows)
|
||||
table.set_deco(table.HEADER)
|
||||
table.set_chars(['-', '|', '+', '-'])
|
||||
|
||||
return table.draw()
|
||||
79
compose/cli/log_printer.py
Normal file
79
compose/cli/log_printer.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import
|
||||
import sys
|
||||
|
||||
from itertools import cycle
|
||||
|
||||
from .multiplexer import Multiplexer, STOP
|
||||
from . import colors
|
||||
from .utils import split_buffer
|
||||
|
||||
|
||||
class LogPrinter(object):
|
||||
def __init__(self, containers, attach_params=None, output=sys.stdout, monochrome=False):
|
||||
self.containers = containers
|
||||
self.attach_params = attach_params or {}
|
||||
self.prefix_width = self._calculate_prefix_width(containers)
|
||||
self.generators = self._make_log_generators(monochrome)
|
||||
self.output = output
|
||||
|
||||
def run(self):
|
||||
mux = Multiplexer(self.generators)
|
||||
for line in mux.loop():
|
||||
self.output.write(line)
|
||||
|
||||
def _calculate_prefix_width(self, containers):
|
||||
"""
|
||||
Calculate the maximum width of container names so we can make the log
|
||||
prefixes line up like so:
|
||||
|
||||
db_1 | Listening
|
||||
web_1 | Listening
|
||||
"""
|
||||
prefix_width = 0
|
||||
for container in containers:
|
||||
prefix_width = max(prefix_width, len(container.name_without_project))
|
||||
return prefix_width
|
||||
|
||||
def _make_log_generators(self, monochrome):
|
||||
color_fns = cycle(colors.rainbow())
|
||||
generators = []
|
||||
|
||||
for container in self.containers:
|
||||
if monochrome:
|
||||
color_fn = lambda s: s
|
||||
else:
|
||||
color_fn = color_fns.next()
|
||||
generators.append(self._make_log_generator(container, color_fn))
|
||||
|
||||
return generators
|
||||
|
||||
def _make_log_generator(self, container, color_fn):
|
||||
prefix = color_fn(self._generate_prefix(container)).encode('utf-8')
|
||||
# Attach to container before log printer starts running
|
||||
line_generator = split_buffer(self._attach(container), '\n')
|
||||
|
||||
for line in line_generator:
|
||||
yield prefix + line
|
||||
|
||||
exit_code = container.wait()
|
||||
yield color_fn("%s exited with code %s\n" % (container.name, exit_code))
|
||||
yield STOP
|
||||
|
||||
def _generate_prefix(self, container):
|
||||
"""
|
||||
Generate the prefix for a log line without colour
|
||||
"""
|
||||
name = container.name_without_project
|
||||
padding = ' ' * (self.prefix_width - len(name))
|
||||
return ''.join([name, padding, ' | '])
|
||||
|
||||
def _attach(self, container):
|
||||
params = {
|
||||
'stdout': True,
|
||||
'stderr': True,
|
||||
'stream': True,
|
||||
}
|
||||
params.update(self.attach_params)
|
||||
params = dict((name, 1 if value else 0) for (name, value) in list(params.items()))
|
||||
return container.attach(**params)
|
||||
467
compose/cli/main.py
Normal file
467
compose/cli/main.py
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
from __future__ import print_function
|
||||
from __future__ import unicode_literals
|
||||
import logging
|
||||
import sys
|
||||
import re
|
||||
import signal
|
||||
from operator import attrgetter
|
||||
|
||||
from inspect import getdoc
|
||||
import dockerpty
|
||||
|
||||
from .. import __version__
|
||||
from ..project import NoSuchService, ConfigurationError
|
||||
from ..service import BuildError, CannotBeScaledError
|
||||
from .command import Command
|
||||
from .formatter import Formatter
|
||||
from .log_printer import LogPrinter
|
||||
from .utils import yesno
|
||||
|
||||
from docker.errors import APIError
|
||||
from .errors import UserError
|
||||
from .docopt_command import NoSuchCommand
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
setup_logging()
|
||||
try:
|
||||
command = TopLevelCommand()
|
||||
command.sys_dispatch()
|
||||
except KeyboardInterrupt:
|
||||
log.error("\nAborting.")
|
||||
sys.exit(1)
|
||||
except (UserError, NoSuchService, ConfigurationError) as e:
|
||||
log.error(e.msg)
|
||||
sys.exit(1)
|
||||
except NoSuchCommand as e:
|
||||
log.error("No such command: %s", e.command)
|
||||
log.error("")
|
||||
log.error("\n".join(parse_doc_section("commands:", getdoc(e.supercommand))))
|
||||
sys.exit(1)
|
||||
except APIError as e:
|
||||
log.error(e.explanation)
|
||||
sys.exit(1)
|
||||
except BuildError as e:
|
||||
log.error("Service '%s' failed to build: %s" % (e.service.name, e.reason))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def setup_logging():
|
||||
console_handler = logging.StreamHandler(sys.stderr)
|
||||
console_handler.setFormatter(logging.Formatter())
|
||||
console_handler.setLevel(logging.INFO)
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Disable requests logging
|
||||
logging.getLogger("requests").propagate = False
|
||||
|
||||
|
||||
# stolen from docopt master
|
||||
def parse_doc_section(name, source):
|
||||
pattern = re.compile('^([^\n]*' + name + '[^\n]*\n?(?:[ \t].*?(?:\n|$))*)',
|
||||
re.IGNORECASE | re.MULTILINE)
|
||||
return [s.strip() for s in pattern.findall(source)]
|
||||
|
||||
|
||||
class TopLevelCommand(Command):
|
||||
"""Fast, isolated development environments using Docker.
|
||||
|
||||
Usage:
|
||||
compose [options] [COMMAND] [ARGS...]
|
||||
compose -h|--help
|
||||
|
||||
Options:
|
||||
--verbose Show more output
|
||||
--version Print version and exit
|
||||
-f, --file FILE Specify an alternate compose file (default: compose.yml)
|
||||
-p, --project-name NAME Specify an alternate project name (default: directory name)
|
||||
|
||||
Commands:
|
||||
build Build or rebuild services
|
||||
help Get help on a command
|
||||
kill Kill containers
|
||||
logs View output from containers
|
||||
port Print the public port for a port binding
|
||||
ps List containers
|
||||
pull Pulls service images
|
||||
rm Remove stopped containers
|
||||
run Run a one-off command
|
||||
scale Set number of containers for a service
|
||||
start Start services
|
||||
stop Stop services
|
||||
restart Restart services
|
||||
up Create and start containers
|
||||
|
||||
"""
|
||||
def docopt_options(self):
|
||||
options = super(TopLevelCommand, self).docopt_options()
|
||||
options['version'] = "compose %s" % __version__
|
||||
return options
|
||||
|
||||
def build(self, project, options):
|
||||
"""
|
||||
Build or rebuild services.
|
||||
|
||||
Services are built once and then tagged as `project_service`,
|
||||
e.g. `composetest_db`. If you change a service's `Dockerfile` or the
|
||||
contents of its build directory, you can run `compose build` to rebuild it.
|
||||
|
||||
Usage: build [options] [SERVICE...]
|
||||
|
||||
Options:
|
||||
--no-cache Do not use cache when building the image.
|
||||
"""
|
||||
no_cache = bool(options.get('--no-cache', False))
|
||||
project.build(service_names=options['SERVICE'], no_cache=no_cache)
|
||||
|
||||
def help(self, project, options):
|
||||
"""
|
||||
Get help on a command.
|
||||
|
||||
Usage: help COMMAND
|
||||
"""
|
||||
command = options['COMMAND']
|
||||
if not hasattr(self, command):
|
||||
raise NoSuchCommand(command, self)
|
||||
raise SystemExit(getdoc(getattr(self, command)))
|
||||
|
||||
def kill(self, project, options):
|
||||
"""
|
||||
Force stop service containers.
|
||||
|
||||
Usage: kill [options] [SERVICE...]
|
||||
|
||||
Options:
|
||||
-s SIGNAL SIGNAL to send to the container.
|
||||
Default signal is SIGKILL.
|
||||
"""
|
||||
signal = options.get('-s', 'SIGKILL')
|
||||
|
||||
project.kill(service_names=options['SERVICE'], signal=signal)
|
||||
|
||||
def logs(self, project, options):
|
||||
"""
|
||||
View output from containers.
|
||||
|
||||
Usage: logs [options] [SERVICE...]
|
||||
|
||||
Options:
|
||||
--no-color Produce monochrome output.
|
||||
"""
|
||||
containers = project.containers(service_names=options['SERVICE'], stopped=True)
|
||||
|
||||
monochrome = options['--no-color']
|
||||
print("Attaching to", list_containers(containers))
|
||||
LogPrinter(containers, attach_params={'logs': True}, monochrome=monochrome).run()
|
||||
|
||||
def port(self, project, options):
|
||||
"""
|
||||
Print the public port for a port binding.
|
||||
|
||||
Usage: port [options] SERVICE PRIVATE_PORT
|
||||
|
||||
Options:
|
||||
--protocol=proto tcp or udp (defaults to tcp)
|
||||
--index=index index of the container if there are multiple
|
||||
instances of a service (defaults to 1)
|
||||
"""
|
||||
service = project.get_service(options['SERVICE'])
|
||||
try:
|
||||
container = service.get_container(number=options.get('--index') or 1)
|
||||
except ValueError as e:
|
||||
raise UserError(str(e))
|
||||
print(container.get_local_port(
|
||||
options['PRIVATE_PORT'],
|
||||
protocol=options.get('--protocol') or 'tcp') or '')
|
||||
|
||||
def ps(self, project, options):
|
||||
"""
|
||||
List containers.
|
||||
|
||||
Usage: ps [options] [SERVICE...]
|
||||
|
||||
Options:
|
||||
-q Only display IDs
|
||||
"""
|
||||
containers = sorted(
|
||||
project.containers(service_names=options['SERVICE'], stopped=True) +
|
||||
project.containers(service_names=options['SERVICE'], one_off=True),
|
||||
key=attrgetter('name'))
|
||||
|
||||
if options['-q']:
|
||||
for container in containers:
|
||||
print(container.id)
|
||||
else:
|
||||
headers = [
|
||||
'Name',
|
||||
'Command',
|
||||
'State',
|
||||
'Ports',
|
||||
]
|
||||
rows = []
|
||||
for container in containers:
|
||||
command = container.human_readable_command
|
||||
if len(command) > 30:
|
||||
command = '%s ...' % command[:26]
|
||||
rows.append([
|
||||
container.name,
|
||||
command,
|
||||
container.human_readable_state,
|
||||
container.human_readable_ports,
|
||||
])
|
||||
print(Formatter().table(headers, rows))
|
||||
|
||||
def pull(self, project, options):
|
||||
"""
|
||||
Pulls images for services.
|
||||
|
||||
Usage: pull [options] [SERVICE...]
|
||||
|
||||
Options:
|
||||
--allow-insecure-ssl Allow insecure connections to the docker
|
||||
registry
|
||||
"""
|
||||
insecure_registry = options['--allow-insecure-ssl']
|
||||
project.pull(
|
||||
service_names=options['SERVICE'],
|
||||
insecure_registry=insecure_registry
|
||||
)
|
||||
|
||||
def rm(self, project, options):
|
||||
"""
|
||||
Remove stopped service containers.
|
||||
|
||||
Usage: rm [options] [SERVICE...]
|
||||
|
||||
Options:
|
||||
--force Don't ask to confirm removal
|
||||
-v Remove volumes associated with containers
|
||||
"""
|
||||
all_containers = project.containers(service_names=options['SERVICE'], stopped=True)
|
||||
stopped_containers = [c for c in all_containers if not c.is_running]
|
||||
|
||||
if len(stopped_containers) > 0:
|
||||
print("Going to remove", list_containers(stopped_containers))
|
||||
if options.get('--force') \
|
||||
or yesno("Are you sure? [yN] ", default=False):
|
||||
project.remove_stopped(
|
||||
service_names=options['SERVICE'],
|
||||
v=options.get('-v', False)
|
||||
)
|
||||
else:
|
||||
print("No stopped containers")
|
||||
|
||||
def run(self, project, options):
|
||||
"""
|
||||
Run a one-off command on a service.
|
||||
|
||||
For example:
|
||||
|
||||
$ compose run web python manage.py shell
|
||||
|
||||
By default, linked services will be started, unless they are already
|
||||
running. If you do not want to start linked services, use
|
||||
`compose run --no-deps SERVICE COMMAND [ARGS...]`.
|
||||
|
||||
Usage: run [options] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...]
|
||||
|
||||
Options:
|
||||
--allow-insecure-ssl Allow insecure connections to the docker
|
||||
registry
|
||||
-d Detached mode: Run container in the background, print
|
||||
new container name.
|
||||
--entrypoint CMD Override the entrypoint of the image.
|
||||
-e KEY=VAL Set an environment variable (can be used multiple times)
|
||||
--no-deps Don't start linked services.
|
||||
--rm Remove container after run. Ignored in detached mode.
|
||||
--service-ports Run command with the service's ports enabled and mapped
|
||||
to the host.
|
||||
-T Disable pseudo-tty allocation. By default `compose run`
|
||||
allocates a TTY.
|
||||
"""
|
||||
service = project.get_service(options['SERVICE'])
|
||||
|
||||
insecure_registry = options['--allow-insecure-ssl']
|
||||
|
||||
if not options['--no-deps']:
|
||||
deps = service.get_linked_names()
|
||||
|
||||
if len(deps) > 0:
|
||||
project.up(
|
||||
service_names=deps,
|
||||
start_links=True,
|
||||
recreate=False,
|
||||
insecure_registry=insecure_registry,
|
||||
detach=options['-d']
|
||||
)
|
||||
|
||||
tty = True
|
||||
if options['-d'] or options['-T'] or not sys.stdin.isatty():
|
||||
tty = False
|
||||
|
||||
if options['COMMAND']:
|
||||
command = [options['COMMAND']] + options['ARGS']
|
||||
else:
|
||||
command = service.options.get('command')
|
||||
|
||||
container_options = {
|
||||
'command': command,
|
||||
'tty': tty,
|
||||
'stdin_open': not options['-d'],
|
||||
'detach': options['-d'],
|
||||
}
|
||||
|
||||
if options['-e']:
|
||||
for option in options['-e']:
|
||||
if 'environment' not in service.options:
|
||||
service.options['environment'] = {}
|
||||
k, v = option.split('=', 1)
|
||||
service.options['environment'][k] = v
|
||||
|
||||
if options['--entrypoint']:
|
||||
container_options['entrypoint'] = options.get('--entrypoint')
|
||||
container = service.create_container(
|
||||
one_off=True,
|
||||
insecure_registry=insecure_registry,
|
||||
**container_options
|
||||
)
|
||||
|
||||
service_ports = None
|
||||
if options['--service-ports']:
|
||||
service_ports = service.options['ports']
|
||||
if options['-d']:
|
||||
service.start_container(container, ports=service_ports, one_off=True)
|
||||
print(container.name)
|
||||
else:
|
||||
service.start_container(container, ports=service_ports, one_off=True)
|
||||
dockerpty.start(project.client, container.id, interactive=not options['-T'])
|
||||
exit_code = container.wait()
|
||||
if options['--rm']:
|
||||
log.info("Removing %s..." % container.name)
|
||||
project.client.remove_container(container.id)
|
||||
sys.exit(exit_code)
|
||||
|
||||
def scale(self, project, options):
|
||||
"""
|
||||
Set number of containers to run for a service.
|
||||
|
||||
Numbers are specified in the form `service=num` as arguments.
|
||||
For example:
|
||||
|
||||
$ compose scale web=2 worker=3
|
||||
|
||||
Usage: scale [SERVICE=NUM...]
|
||||
"""
|
||||
for s in options['SERVICE=NUM']:
|
||||
if '=' not in s:
|
||||
raise UserError('Arguments to scale should be in the form service=num')
|
||||
service_name, num = s.split('=', 1)
|
||||
try:
|
||||
num = int(num)
|
||||
except ValueError:
|
||||
raise UserError('Number of containers for service "%s" is not a '
|
||||
'number' % service_name)
|
||||
try:
|
||||
project.get_service(service_name).scale(num)
|
||||
except CannotBeScaledError:
|
||||
raise UserError(
|
||||
'Service "%s" cannot be scaled because it specifies a port '
|
||||
'on the host. If multiple containers for this service were '
|
||||
'created, the port would clash.\n\nRemove the ":" from the '
|
||||
'port definition in compose.yml so Docker can choose a random '
|
||||
'port for each container.' % service_name)
|
||||
|
||||
def start(self, project, options):
|
||||
"""
|
||||
Start existing containers.
|
||||
|
||||
Usage: start [SERVICE...]
|
||||
"""
|
||||
project.start(service_names=options['SERVICE'])
|
||||
|
||||
def stop(self, project, options):
|
||||
"""
|
||||
Stop running containers without removing them.
|
||||
|
||||
They can be started again with `compose start`.
|
||||
|
||||
Usage: stop [SERVICE...]
|
||||
"""
|
||||
project.stop(service_names=options['SERVICE'])
|
||||
|
||||
def restart(self, project, options):
|
||||
"""
|
||||
Restart running containers.
|
||||
|
||||
Usage: restart [SERVICE...]
|
||||
"""
|
||||
project.restart(service_names=options['SERVICE'])
|
||||
|
||||
def up(self, project, options):
|
||||
"""
|
||||
Build, (re)create, start and attach to containers for a service.
|
||||
|
||||
By default, `compose up` will aggregate the output of each container, and
|
||||
when it exits, all containers will be stopped. If you run `compose up -d`,
|
||||
it'll start the containers in the background and leave them running.
|
||||
|
||||
If there are existing containers for a service, `compose up` will stop
|
||||
and recreate them (preserving mounted volumes with volumes-from),
|
||||
so that changes in `compose.yml` are picked up. If you do not want existing
|
||||
containers to be recreated, `compose up --no-recreate` will re-use existing
|
||||
containers.
|
||||
|
||||
Usage: up [options] [SERVICE...]
|
||||
|
||||
Options:
|
||||
--allow-insecure-ssl Allow insecure connections to the docker
|
||||
registry
|
||||
-d Detached mode: Run containers in the background,
|
||||
print new container names.
|
||||
--no-color Produce monochrome output.
|
||||
--no-deps Don't start linked services.
|
||||
--no-recreate If containers already exist, don't recreate them.
|
||||
--no-build Don't build an image, even if it's missing
|
||||
"""
|
||||
insecure_registry = options['--allow-insecure-ssl']
|
||||
detached = options['-d']
|
||||
|
||||
monochrome = options['--no-color']
|
||||
|
||||
start_links = not options['--no-deps']
|
||||
recreate = not options['--no-recreate']
|
||||
service_names = options['SERVICE']
|
||||
|
||||
project.up(
|
||||
service_names=service_names,
|
||||
start_links=start_links,
|
||||
recreate=recreate,
|
||||
insecure_registry=insecure_registry,
|
||||
detach=options['-d'],
|
||||
do_build=not options['--no-build'],
|
||||
)
|
||||
|
||||
to_attach = [c for s in project.get_services(service_names) for c in s.containers()]
|
||||
|
||||
if not detached:
|
||||
print("Attaching to", list_containers(to_attach))
|
||||
log_printer = LogPrinter(to_attach, attach_params={"logs": True}, monochrome=monochrome)
|
||||
|
||||
try:
|
||||
log_printer.run()
|
||||
finally:
|
||||
def handler(signal, frame):
|
||||
project.kill(service_names=service_names)
|
||||
sys.exit(0)
|
||||
signal.signal(signal.SIGINT, handler)
|
||||
|
||||
print("Gracefully stopping... (press Ctrl+C again to force)")
|
||||
project.stop(service_names=service_names)
|
||||
|
||||
|
||||
def list_containers(containers):
|
||||
return ", ".join(c.name for c in containers)
|
||||
42
compose/cli/multiplexer.py
Normal file
42
compose/cli/multiplexer.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import absolute_import
|
||||
from threading import Thread
|
||||
|
||||
try:
|
||||
from Queue import Queue, Empty
|
||||
except ImportError:
|
||||
from queue import Queue, Empty # Python 3.x
|
||||
|
||||
|
||||
# Yield STOP from an input generator to stop the
|
||||
# top-level loop without processing any more input.
|
||||
STOP = object()
|
||||
|
||||
|
||||
class Multiplexer(object):
|
||||
def __init__(self, generators):
|
||||
self.generators = generators
|
||||
self.queue = Queue()
|
||||
|
||||
def loop(self):
|
||||
self._init_readers()
|
||||
|
||||
while True:
|
||||
try:
|
||||
item = self.queue.get(timeout=0.1)
|
||||
if item is STOP:
|
||||
break
|
||||
else:
|
||||
yield item
|
||||
except Empty:
|
||||
pass
|
||||
|
||||
def _init_readers(self):
|
||||
for generator in self.generators:
|
||||
t = Thread(target=_enqueue_output, args=(generator, self.queue))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
|
||||
def _enqueue_output(generator, queue):
|
||||
for item in generator:
|
||||
queue.put(item)
|
||||
103
compose/cli/utils.py
Normal file
103
compose/cli/utils.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import
|
||||
from __future__ import division
|
||||
import datetime
|
||||
import os
|
||||
import subprocess
|
||||
import platform
|
||||
|
||||
|
||||
def yesno(prompt, default=None):
|
||||
"""
|
||||
Prompt the user for a yes or no.
|
||||
|
||||
Can optionally specify a default value, which will only be
|
||||
used if they enter a blank line.
|
||||
|
||||
Unrecognised input (anything other than "y", "n", "yes",
|
||||
"no" or "") will return None.
|
||||
"""
|
||||
answer = raw_input(prompt).strip().lower()
|
||||
|
||||
if answer == "y" or answer == "yes":
|
||||
return True
|
||||
elif answer == "n" or answer == "no":
|
||||
return False
|
||||
elif answer == "":
|
||||
return default
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
# http://stackoverflow.com/a/5164027
|
||||
def prettydate(d):
|
||||
diff = datetime.datetime.utcnow() - d
|
||||
s = diff.seconds
|
||||
if diff.days > 7 or diff.days < 0:
|
||||
return d.strftime('%d %b %y')
|
||||
elif diff.days == 1:
|
||||
return '1 day ago'
|
||||
elif diff.days > 1:
|
||||
return '{0} days ago'.format(diff.days)
|
||||
elif s <= 1:
|
||||
return 'just now'
|
||||
elif s < 60:
|
||||
return '{0} seconds ago'.format(s)
|
||||
elif s < 120:
|
||||
return '1 minute ago'
|
||||
elif s < 3600:
|
||||
return '{0} minutes ago'.format(s / 60)
|
||||
elif s < 7200:
|
||||
return '1 hour ago'
|
||||
else:
|
||||
return '{0} hours ago'.format(s / 3600)
|
||||
|
||||
|
||||
def mkdir(path, permissions=0o700):
|
||||
if not os.path.exists(path):
|
||||
os.mkdir(path)
|
||||
|
||||
os.chmod(path, permissions)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def split_buffer(reader, separator):
|
||||
"""
|
||||
Given a generator which yields strings and a separator string,
|
||||
joins all input, splits on the separator and yields each chunk.
|
||||
|
||||
Unlike string.split(), each chunk includes the trailing
|
||||
separator, except for the last one if none was found on the end
|
||||
of the input.
|
||||
"""
|
||||
buffered = str('')
|
||||
separator = str(separator)
|
||||
|
||||
for data in reader:
|
||||
buffered += data
|
||||
while True:
|
||||
index = buffered.find(separator)
|
||||
if index == -1:
|
||||
break
|
||||
yield buffered[:index + 1]
|
||||
buffered = buffered[index + 1:]
|
||||
|
||||
if len(buffered) > 0:
|
||||
yield buffered
|
||||
|
||||
|
||||
def call_silently(*args, **kwargs):
|
||||
"""
|
||||
Like subprocess.call(), but redirects stdout and stderr to /dev/null.
|
||||
"""
|
||||
with open(os.devnull, 'w') as shutup:
|
||||
return subprocess.call(*args, stdout=shutup, stderr=shutup, **kwargs)
|
||||
|
||||
|
||||
def is_mac():
|
||||
return platform.system() == 'Darwin'
|
||||
|
||||
|
||||
def is_ubuntu():
|
||||
return platform.system() == 'Linux' and platform.linux_distribution()[0] == 'Ubuntu'
|
||||
58
compose/cli/verbose_proxy.py
Normal file
58
compose/cli/verbose_proxy.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
|
||||
import functools
|
||||
from itertools import chain
|
||||
import logging
|
||||
import pprint
|
||||
|
||||
import six
|
||||
|
||||
|
||||
def format_call(args, kwargs):
|
||||
args = (repr(a) for a in args)
|
||||
kwargs = ("{0!s}={1!r}".format(*item) for item in six.iteritems(kwargs))
|
||||
return "({0})".format(", ".join(chain(args, kwargs)))
|
||||
|
||||
|
||||
def format_return(result, max_lines):
|
||||
if isinstance(result, (list, tuple, set)):
|
||||
return "({0} with {1} items)".format(type(result).__name__, len(result))
|
||||
|
||||
if result:
|
||||
lines = pprint.pformat(result).split('\n')
|
||||
extra = '\n...' if len(lines) > max_lines else ''
|
||||
return '\n'.join(lines[:max_lines]) + extra
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class VerboseProxy(object):
|
||||
"""Proxy all function calls to another class and log method name, arguments
|
||||
and return values for each call.
|
||||
"""
|
||||
|
||||
def __init__(self, obj_name, obj, log_name=None, max_lines=10):
|
||||
self.obj_name = obj_name
|
||||
self.obj = obj
|
||||
self.max_lines = max_lines
|
||||
self.log = logging.getLogger(log_name or __name__)
|
||||
|
||||
def __getattr__(self, name):
|
||||
attr = getattr(self.obj, name)
|
||||
|
||||
if not six.callable(attr):
|
||||
return attr
|
||||
|
||||
return functools.partial(self.proxy_callable, name)
|
||||
|
||||
def proxy_callable(self, call_name, *args, **kwargs):
|
||||
self.log.info("%s %s <- %s",
|
||||
self.obj_name,
|
||||
call_name,
|
||||
format_call(args, kwargs))
|
||||
|
||||
result = getattr(self.obj, call_name)(*args, **kwargs)
|
||||
self.log.info("%s %s -> %s",
|
||||
self.obj_name,
|
||||
call_name,
|
||||
format_return(result, self.max_lines))
|
||||
return result
|
||||
Loading…
Add table
Add a link
Reference in a new issue