Resolves #369, add verbose output on --verbose flag

Signed-off-by: Daniel Nephin <dnephin@gmail.com>
This commit is contained in:
Daniel Nephin 2014-07-30 01:42:58 -04:00
parent f2bf7f9e0d
commit df7c2cc43f
9 changed files with 228 additions and 131 deletions

View File

@ -12,9 +12,10 @@ from ..packages import six
from ..project import Project
from ..service import ConfigError
from .docopt_command import DocoptCommand
from .formatter import Formatter
from .utils import cached_property, docker_url, call_silently, is_mac, is_ubuntu
from .utils import docker_url, call_silently, is_mac, is_ubuntu
from . import verbose_proxy
from . import errors
from .. import __version__
log = logging.getLogger(__name__)
@ -22,10 +23,6 @@ log = logging.getLogger(__name__)
class Command(DocoptCommand):
base_dir = '.'
def __init__(self):
self._yaml_path = os.environ.get('FIG_FILE', None)
self.explicit_project_name = None
def dispatch(self, *args, **kwargs):
try:
super(Command, self).dispatch(*args, **kwargs)
@ -40,60 +37,70 @@ class Command(DocoptCommand):
elif call_silently(['which', 'docker-osx']) == 0:
raise errors.ConnectionErrorDockerOSX()
else:
raise errors.ConnectionErrorGeneric(self.client.base_url)
raise errors.ConnectionErrorGeneric(self.get_client().base_url)
def perform_command(self, options, *args, **kwargs):
if options['--file'] is not None:
self.yaml_path = os.path.join(self.base_dir, options['--file'])
if options['--project-name'] is not None:
self.explicit_project_name = options['--project-name']
return super(Command, self).perform_command(options, *args, **kwargs)
def perform_command(self, options, handler, command_options):
explicit_config_path = options.get('--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'))
@cached_property
def client(self):
return Client(docker_url())
handler(project, command_options)
@cached_property
def project(self):
def get_client(self, verbose=False):
client = Client(docker_url())
if verbose:
version_info = six.iteritems(client.version())
log.info("Fig 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:
config = yaml.safe_load(open(self.yaml_path))
with open(config_path, 'r') as fh:
return yaml.safe_load(fh)
except IOError as e:
if e.errno == errno.ENOENT:
raise errors.FigFileNotFound(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.project_name, config, self.client)
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))
@cached_property
def project_name(self):
project = os.path.basename(os.path.dirname(os.path.abspath(self.yaml_path)))
if self.explicit_project_name is not None:
project = self.explicit_project_name
project = re.sub(r'[^a-zA-Z0-9]', '', project)
if not project:
project = 'default'
return project
def get_project_name(self, config_path, project_name=None):
def normalize_name(name):
return re.sub(r'[^a-zA-Z0-9]', '', name)
@cached_property
def formatter(self):
return Formatter()
if project_name is not None:
return normalize_name(project_name)
@cached_property
def yaml_path(self):
if self._yaml_path is not None:
return self._yaml_path
elif os.path.exists(os.path.join(self.base_dir, 'fig.yaml')):
project = os.path.basename(os.path.dirname(os.path.abspath(config_path)))
if project:
return normalize_name(project)
log.warning("Fig just read the file 'fig.yaml' on startup, rather than 'fig.yml'")
log.warning("Please be aware that fig.yml the expected extension in most cases, and using .yaml can cause compatibility issues in future")
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, 'fig.yaml')):
log.warning("Fig just read the file 'fig.yaml' on startup, rather "
"than 'fig.yml'")
log.warning("Please be aware that fig.yml the expected extension "
"in most cases, and using .yaml can cause compatibility "
"issues in future")
return os.path.join(self.base_dir, 'fig.yaml')
else:
return os.path.join(self.base_dir, 'fig.yml')
@yaml_path.setter
def yaml_path(self, value):
self._yaml_path = value
return os.path.join(self.base_dir, 'fig.yml')

View File

@ -23,7 +23,7 @@ class DocoptCommand(object):
def dispatch(self, argv, global_options):
self.perform_command(*self.parse(argv, global_options))
def perform_command(self, options, command, handler, command_options):
def perform_command(self, options, handler, command_options):
handler(command_options)
def parse(self, argv, global_options):
@ -43,7 +43,7 @@ class DocoptCommand(object):
raise NoSuchCommand(command, self)
command_options = docopt_full_help(docstring, options['ARGS'], options_first=True)
return (options, command, handler, command_options)
return options, handler, command_options
class NoSuchCommand(Exception):

View File

@ -98,7 +98,7 @@ class TopLevelCommand(Command):
options['version'] = "fig %s" % __version__
return options
def build(self, options):
def build(self, project, options):
"""
Build or rebuild services.
@ -112,9 +112,9 @@ class TopLevelCommand(Command):
--no-cache Do not use cache when building the image.
"""
no_cache = bool(options.get('--no-cache', False))
self.project.build(service_names=options['SERVICE'], no_cache=no_cache)
project.build(service_names=options['SERVICE'], no_cache=no_cache)
def help(self, options):
def help(self, project, options):
"""
Get help on a command.
@ -125,15 +125,15 @@ class TopLevelCommand(Command):
raise NoSuchCommand(command, self)
raise SystemExit(getdoc(getattr(self, command)))
def kill(self, options):
def kill(self, project, options):
"""
Force stop service containers.
Usage: kill [SERVICE...]
"""
self.project.kill(service_names=options['SERVICE'])
project.kill(service_names=options['SERVICE'])
def logs(self, options):
def logs(self, project, options):
"""
View output from containers.
@ -142,14 +142,13 @@ class TopLevelCommand(Command):
Options:
--no-color Produce monochrome output.
"""
containers = self.project.containers(service_names=options['SERVICE'], stopped=True)
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 ps(self, options):
def ps(self, project, options):
"""
List containers.
@ -158,7 +157,7 @@ class TopLevelCommand(Command):
Options:
-q Only display IDs
"""
containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + self.project.containers(service_names=options['SERVICE'], one_off=True)
containers = project.containers(service_names=options['SERVICE'], stopped=True) + project.containers(service_names=options['SERVICE'], one_off=True)
if options['-q']:
for container in containers:
@ -183,7 +182,7 @@ class TopLevelCommand(Command):
])
print(Formatter().table(headers, rows))
def rm(self, options):
def rm(self, project, options):
"""
Remove stopped service containers.
@ -193,21 +192,21 @@ class TopLevelCommand(Command):
--force Don't ask to confirm removal
-v Remove volumes associated with containers
"""
all_containers = self.project.containers(service_names=options['SERVICE'], stopped=True)
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):
self.project.remove_stopped(
project.remove_stopped(
service_names=options['SERVICE'],
v=options.get('-v', False)
)
else:
print("No stopped containers")
def run(self, options):
def run(self, project, options):
"""
Run a one-off command on a service.
@ -229,14 +228,13 @@ class TopLevelCommand(Command):
--rm Remove container after run. Ignored in detached mode.
--no-deps Don't start linked services.
"""
service = self.project.get_service(options['SERVICE'])
service = project.get_service(options['SERVICE'])
if not options['--no-deps']:
deps = service.get_linked_names()
if len(deps) > 0:
self.project.up(
project.up(
service_names=deps,
start_links=True,
recreate=False,
@ -262,14 +260,14 @@ class TopLevelCommand(Command):
print(container.name)
else:
service.start_container(container, ports=None, one_off=True)
dockerpty.start(self.client, container.id)
dockerpty.start(project.client, container.id)
exit_code = container.wait()
if options['--rm']:
log.info("Removing %s..." % container.name)
self.client.remove_container(container.id)
project.client.remove_container(container.id)
sys.exit(exit_code)
def scale(self, options):
def scale(self, project, options):
"""
Set number of containers to run for a service.
@ -290,19 +288,24 @@ class TopLevelCommand(Command):
raise UserError('Number of containers for service "%s" is not a '
'number' % service_name)
try:
self.project.get_service(service_name).scale(num)
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 fig.yml so Docker can choose a random port for each container.' % service_name)
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 fig.yml so Docker can choose a random '
'port for each container.' % service_name)
def start(self, options):
def start(self, project, options):
"""
Start existing containers.
Usage: start [SERVICE...]
"""
self.project.start(service_names=options['SERVICE'])
project.start(service_names=options['SERVICE'])
def stop(self, options):
def stop(self, project, options):
"""
Stop running containers without removing them.
@ -310,9 +313,9 @@ class TopLevelCommand(Command):
Usage: stop [SERVICE...]
"""
self.project.stop(service_names=options['SERVICE'])
project.stop(service_names=options['SERVICE'])
def up(self, options):
def up(self, project, options):
"""
Build, (re)create, start and attach to containers for a service.
@ -343,13 +346,13 @@ class TopLevelCommand(Command):
recreate = not options['--no-recreate']
service_names = options['SERVICE']
self.project.up(
project.up(
service_names=service_names,
start_links=start_links,
recreate=recreate
)
to_attach = [c for s in self.project.get_services(service_names) for c in s.containers()]
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))
@ -359,12 +362,12 @@ class TopLevelCommand(Command):
log_printer.run()
finally:
def handler(signal, frame):
self.project.kill(service_names=service_names)
project.kill(service_names=service_names)
sys.exit(0)
signal.signal(signal.SIGINT, handler)
print("Gracefully stopping... (press Ctrl+C again to force)")
self.project.stop(service_names=service_names)
project.stop(service_names=service_names)
def list_containers(containers):

View File

@ -7,25 +7,6 @@ import subprocess
import platform
def cached_property(f):
"""
returns a cached property that is calculated by function f
http://code.activestate.com/recipes/576563-cached-property/
"""
def get(self):
try:
return self._property_cache[f]
except AttributeError:
self._property_cache = {}
x = self._property_cache[f] = f(self)
return x
except KeyError:
x = self._property_cache[f] = f(self)
return x
return property(get)
def yesno(prompt, default=None):
"""
Prompt the user for a yes or no.

58
fig/cli/verbose_proxy.py Normal file
View File

@ -0,0 +1,58 @@
import functools
from itertools import chain
import logging
import pprint
from fig.packages 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

View File

@ -5,6 +5,7 @@ from fig.cli.main import TopLevelCommand
from fig.packages.six import StringIO
import sys
class CLITestCase(DockerClientTestCase):
def setUp(self):
super(CLITestCase, self).setUp()
@ -15,12 +16,16 @@ class CLITestCase(DockerClientTestCase):
def tearDown(self):
sys.exit = self.old_sys_exit
self.command.project.kill()
self.command.project.remove_stopped()
self.project.kill()
self.project.remove_stopped()
@property
def project(self):
return self.command.get_project(self.command.get_config_path())
@patch('sys.stdout', new_callable=StringIO)
def test_ps(self, mock_stdout):
self.command.project.get_service('simple').create_container()
self.project.get_service('simple').create_container()
self.command.dispatch(['ps'], None)
self.assertIn('simplefigfile_simple_1', mock_stdout.getvalue())
@ -64,17 +69,17 @@ class CLITestCase(DockerClientTestCase):
def test_up(self):
self.command.dispatch(['up', '-d'], None)
service = self.command.project.get_service('simple')
another = self.command.project.get_service('another')
service = self.project.get_service('simple')
another = self.project.get_service('another')
self.assertEqual(len(service.containers()), 1)
self.assertEqual(len(another.containers()), 1)
def test_up_with_links(self):
self.command.base_dir = 'tests/fixtures/links-figfile'
self.command.dispatch(['up', '-d', 'web'], None)
web = self.command.project.get_service('web')
db = self.command.project.get_service('db')
console = self.command.project.get_service('console')
web = self.project.get_service('web')
db = self.project.get_service('db')
console = self.project.get_service('console')
self.assertEqual(len(web.containers()), 1)
self.assertEqual(len(db.containers()), 1)
self.assertEqual(len(console.containers()), 0)
@ -82,16 +87,16 @@ class CLITestCase(DockerClientTestCase):
def test_up_with_no_deps(self):
self.command.base_dir = 'tests/fixtures/links-figfile'
self.command.dispatch(['up', '-d', '--no-deps', 'web'], None)
web = self.command.project.get_service('web')
db = self.command.project.get_service('db')
console = self.command.project.get_service('console')
web = self.project.get_service('web')
db = self.project.get_service('db')
console = self.project.get_service('console')
self.assertEqual(len(web.containers()), 1)
self.assertEqual(len(db.containers()), 0)
self.assertEqual(len(console.containers()), 0)
def test_up_with_recreate(self):
self.command.dispatch(['up', '-d'], None)
service = self.command.project.get_service('simple')
service = self.project.get_service('simple')
self.assertEqual(len(service.containers()), 1)
old_ids = [c.id for c in service.containers()]
@ -105,7 +110,7 @@ class CLITestCase(DockerClientTestCase):
def test_up_with_keep_old(self):
self.command.dispatch(['up', '-d'], None)
service = self.command.project.get_service('simple')
service = self.project.get_service('simple')
self.assertEqual(len(service.containers()), 1)
old_ids = [c.id for c in service.containers()]
@ -117,19 +122,18 @@ class CLITestCase(DockerClientTestCase):
self.assertEqual(old_ids, new_ids)
@patch('dockerpty.start')
def test_run_service_without_links(self, mock_stdout):
self.command.base_dir = 'tests/fixtures/links-figfile'
self.command.dispatch(['run', 'console', '/bin/true'], None)
self.assertEqual(len(self.command.project.containers()), 0)
self.assertEqual(len(self.project.containers()), 0)
@patch('dockerpty.start')
def test_run_service_with_links(self, __):
self.command.base_dir = 'tests/fixtures/links-figfile'
self.command.dispatch(['run', 'web', '/bin/true'], None)
db = self.command.project.get_service('db')
console = self.command.project.get_service('console')
db = self.project.get_service('db')
console = self.project.get_service('console')
self.assertEqual(len(db.containers()), 1)
self.assertEqual(len(console.containers()), 0)
@ -137,14 +141,14 @@ class CLITestCase(DockerClientTestCase):
def test_run_with_no_deps(self, __):
self.command.base_dir = 'tests/fixtures/links-figfile'
self.command.dispatch(['run', '--no-deps', 'web', '/bin/true'], None)
db = self.command.project.get_service('db')
db = self.project.get_service('db')
self.assertEqual(len(db.containers()), 0)
@patch('dockerpty.start')
def test_run_does_not_recreate_linked_containers(self, __):
self.command.base_dir = 'tests/fixtures/links-figfile'
self.command.dispatch(['up', '-d', 'db'], None)
db = self.command.project.get_service('db')
db = self.project.get_service('db')
self.assertEqual(len(db.containers()), 1)
old_ids = [c.id for c in db.containers()]
@ -161,11 +165,11 @@ class CLITestCase(DockerClientTestCase):
self.command.base_dir = 'tests/fixtures/commands-figfile'
self.client.build('tests/fixtures/simple-dockerfile', tag='figtest_test')
for c in self.command.project.containers(stopped=True, one_off=True):
for c in self.project.containers(stopped=True, one_off=True):
c.remove()
self.command.dispatch(['run', 'implicit'], None)
service = self.command.project.get_service('implicit')
service = self.project.get_service('implicit')
containers = service.containers(stopped=True, one_off=True)
self.assertEqual(
[c.human_readable_command for c in containers],
@ -173,7 +177,7 @@ class CLITestCase(DockerClientTestCase):
)
self.command.dispatch(['run', 'explicit'], None)
service = self.command.project.get_service('explicit')
service = self.project.get_service('explicit')
containers = service.containers(stopped=True, one_off=True)
self.assertEqual(
[c.human_readable_command for c in containers],
@ -181,7 +185,7 @@ class CLITestCase(DockerClientTestCase):
)
def test_rm(self):
service = self.command.project.get_service('simple')
service = self.project.get_service('simple')
service.create_container()
service.kill()
self.assertEqual(len(service.containers(stopped=True)), 1)
@ -189,24 +193,23 @@ class CLITestCase(DockerClientTestCase):
self.assertEqual(len(service.containers(stopped=True)), 0)
def test_scale(self):
project = self.command.project
project = self.project
self.command.scale({'SERVICE=NUM': ['simple=1']})
self.command.scale(project, {'SERVICE=NUM': ['simple=1']})
self.assertEqual(len(project.get_service('simple').containers()), 1)
self.command.scale({'SERVICE=NUM': ['simple=3', 'another=2']})
self.command.scale(project, {'SERVICE=NUM': ['simple=3', 'another=2']})
self.assertEqual(len(project.get_service('simple').containers()), 3)
self.assertEqual(len(project.get_service('another').containers()), 2)
self.command.scale({'SERVICE=NUM': ['simple=1', 'another=1']})
self.command.scale(project, {'SERVICE=NUM': ['simple=1', 'another=1']})
self.assertEqual(len(project.get_service('simple').containers()), 1)
self.assertEqual(len(project.get_service('another').containers()), 1)
self.command.scale({'SERVICE=NUM': ['simple=1', 'another=1']})
self.command.scale(project, {'SERVICE=NUM': ['simple=1', 'another=1']})
self.assertEqual(len(project.get_service('simple').containers()), 1)
self.assertEqual(len(project.get_service('another').containers()), 1)
self.command.scale({'SERVICE=NUM': ['simple=0', 'another=0']})
self.command.scale(project, {'SERVICE=NUM': ['simple=0', 'another=0']})
self.assertEqual(len(project.get_service('simple').containers()), 0)
self.assertEqual(len(project.get_service('another').containers()), 0)

View File

View File

@ -0,0 +1,30 @@
from __future__ import unicode_literals
from __future__ import absolute_import
from tests import unittest
from fig.cli import verbose_proxy
class VerboseProxy(unittest.TestCase):
def test_format_call(self):
expected = "(u'arg1', True, key=u'value')"
actual = verbose_proxy.format_call(
("arg1", True),
{'key': 'value'})
self.assertEqual(expected, actual)
def test_format_return_sequence(self):
expected = "(list with 10 items)"
actual = verbose_proxy.format_return(list(range(10)), 2)
self.assertEqual(expected, actual)
def test_format_return(self):
expected = "{u'Id': u'ok'}"
actual = verbose_proxy.format_return({'Id': 'ok'}, 2)
self.assertEqual(expected, actual)
def test_format_return_no_result(self):
actual = verbose_proxy.format_return(None, 2)
self.assertEqual(None, actual)

View File

@ -4,6 +4,8 @@ import logging
import os
from .. import unittest
import mock
from fig.cli import main
from fig.cli.main import TopLevelCommand
from fig.packages.six import StringIO
@ -16,24 +18,37 @@ class CLITestCase(unittest.TestCase):
try:
os.chdir('tests/fixtures/simple-figfile')
command = TopLevelCommand()
self.assertEquals('simplefigfile', command.project_name)
project_name = command.get_project_name(command.get_config_path())
self.assertEquals('simplefigfile', project_name)
finally:
os.chdir(cwd)
def test_project_name_with_explicit_base_dir(self):
command = TopLevelCommand()
command.base_dir = 'tests/fixtures/simple-figfile'
self.assertEquals('simplefigfile', command.project_name)
project_name = command.get_project_name(command.get_config_path())
self.assertEquals('simplefigfile', project_name)
def test_project_name_with_explicit_project_name(self):
command = TopLevelCommand()
command.explicit_project_name = 'explicit-project-name'
self.assertEquals('explicitprojectname', command.project_name)
name = 'explicit-project-name'
project_name = command.get_project_name(None, project_name=name)
self.assertEquals('explicitprojectname', project_name)
def test_yaml_filename_check(self):
command = TopLevelCommand()
command.base_dir = 'tests/fixtures/longer-filename-figfile'
self.assertTrue(command.project.get_service('definedinyamlnotyml'))
with mock.patch('fig.cli.command.log', autospec=True) as mock_log:
self.assertTrue(command.get_config_path())
self.assertEqual(mock_log.warning.call_count, 2)
def test_get_project(self):
command = TopLevelCommand()
command.base_dir = 'tests/fixtures/longer-filename-figfile'
project = command.get_project(command.get_config_path())
self.assertEqual(project.name, 'longerfilenamefigfile')
self.assertTrue(project.client)
self.assertTrue(project.services)
def test_help(self):
command = TopLevelCommand()