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

Plugin system for compose #3905

Closed
wants to merge 16 commits 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.idea

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be in your global gitignore file. Or edit $repo_dir/info/exclude

*.egg-info
*.pyc
.coverage*
Expand Down
8 changes: 7 additions & 1 deletion compose/cli/docopt_command.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import absolute_import
from __future__ import unicode_literals

from inspect import cleandoc
from inspect import getdoc

from docopt import docopt
Expand All @@ -21,7 +22,12 @@ def __init__(self, command_class, options):
self.options = options

def parse(self, argv):
command_help = getdoc(self.command_class)
# Fix for http://bugs.python.org/issue12773
if hasattr(self.command_class, '__modified_doc__'):
command_help = cleandoc(self.command_class.__modified_doc__)
else:
command_help = getdoc(self.command_class)

Copy link

@electrofelix electrofelix Dec 6, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was http://bugs.python.org/issue12773 not fixed before python 3.3 released?

https://github.com/python/cpython/blob/3.3/Misc/NEWS#L4652 suggests it was, and testing against python 2.7.12, 3.3.5 and 3.5 locally there doesn't appear to be an issue in mutating the __doc__ string on user defined classes. So wondering if this part is really needed?

options = docopt_full_help(command_help, argv, **self.options)
command = options['COMMAND']

Expand Down
127 changes: 120 additions & 7 deletions compose/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
from ..const import DEFAULT_TIMEOUT
from ..const import IS_WINDOWS_PLATFORM
from ..errors import StreamParseError
from ..plugin import PluginError
from ..plugin_manager import InvalidPluginError
from ..plugin_manager import InvalidPluginFileTypeError
from ..plugin_manager import PluginDoesNotExistError
from ..plugin_manager import PluginManager
from ..progress_stream import StreamOutputError
from ..project import NoSuchService
from ..project import OneOffFilter
Expand All @@ -44,6 +49,7 @@
from .formatter import Formatter
from .log_printer import build_log_presenters
from .log_printer import LogPrinter
from .utils import get_plugin_dir
from .utils import get_version_info
from .utils import yesno

Expand All @@ -56,7 +62,12 @@


def main():
command = dispatch()
try:
command = dispatch()
except PluginError as e:
setup_logging()
log.error("Plugin error: %s", str(e))
sys.exit(1)

try:
command()
Expand All @@ -76,11 +87,15 @@ def main():
except NeedsBuildError as e:
log.error("Service '%s' needs to be built, but --no-build was passed." % e.service.name)
sys.exit(1)
except PluginError as e:
log.error(e.msg)
sys.exit(1)
except (errors.ConnectionError, StreamParseError):
sys.exit(1)


def dispatch():
plugin_manager = PluginManager(get_plugin_dir())

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be after setup_logging() in case you need to log something from the PluginManager class setup?

setup_logging()
dispatcher = DocoptDispatcher(
TopLevelCommand,
Expand All @@ -94,22 +109,35 @@ def dispatch():
sys.exit(1)

setup_console_handler(console_handler, options.get('--verbose'))
return functools.partial(perform_command, options, handler, command_options)
plugin_manager.load_config('.', options)
return functools.partial(perform_command, options, handler, command_options, plugin_manager)


def perform_command(options, handler, command_options):
def perform_command(options, handler, command_options, plugin_manager):
if options['COMMAND'] in ('help', 'version'):
# Skip looking up the compose file.
handler(command_options)
return

if options['COMMAND'] in ('config', 'bundle'):
command = TopLevelCommand(None)
command = TopLevelCommand(None, plugin_manager)
handler(command, options, command_options)
return

none_project_commands = ['plugin']

for tlc_command in TopLevelCommand.__dict__:
if hasattr(getattr(TopLevelCommand, tlc_command), '__standalone__') and \
getattr(TopLevelCommand, tlc_command).__standalone__ is True:
none_project_commands.append(tlc_command)

if options['COMMAND'] in none_project_commands:
command = TopLevelCommand(None, plugin_manager)
handler(command, command_options)
return

project = project_from_options('.', options)
command = TopLevelCommand(project)
command = TopLevelCommand(project, plugin_manager)
with errors.handle_connection_errors(project.client):
handler(command, command_options)

Expand Down Expand Up @@ -179,6 +207,7 @@ class TopLevelCommand(object):
kill Kill containers
logs View output from containers
pause Pause services
plugin Manages the plugins
port Print the public port for a port binding
ps List containers
pull Pull service images
Expand All @@ -194,9 +223,10 @@ class TopLevelCommand(object):
version Show the Docker-Compose version information
"""

def __init__(self, project, project_dir='.'):
def __init__(self, project, plugin_manager, project_dir='.'):
self.project = project
self.project_dir = '.'
self.plugin_manager = plugin_manager
self.project_dir = project_dir

def build(self, options):
"""
Expand Down Expand Up @@ -516,6 +546,73 @@ def pause(self, options):
containers = self.project.pause(service_names=options['SERVICE'])
exit_if(not containers, 'No containers to pause', 1)

def plugin(self, options):
"""
Manages docker-compose plugins

Usage: plugin [list|install|update|config|uninstall] [PLUGIN]
"""

if options['list']:
plugins = self.plugin_manager.get_plugins()

if len(plugins) <= 0:
print('No plugins installed.')
else:
headers = [
'ID',
'Name',
'Description',
'Version'
]
rows = []

for plugin_name, plugin in plugins.items():
rows.append([
plugin.id,
plugin.name,
plugin.description,
plugin.version
])

print(Formatter().table(headers, rows))
elif options['install']:
plugin_command(
self.plugin_manager,
'install_plugin',
options['PLUGIN'],
"Plugin '{}' successfully installed.",
"An error occurred during the installation of plugin '{}'."
)
elif options['uninstall']:
if self.plugin_manager.is_plugin_installed(options['PLUGIN']):
print("Going to remove plugin '{}'".format(options['PLUGIN']))

if options.get('--force') or yesno("Are you sure? [yN] ", default=False):
self.plugin_manager.uninstall_plugin(options['PLUGIN'])
else:
log.error("Plugin '{}' isn't installed".format(options['PLUGIN']))
sys.exit(1)
elif options['config']:
plugin_command(
self.plugin_manager,
'configure_plugin',
options['PLUGIN'],
"Configuration of plugin '{}' successfully.",
"An error occurred during the configuration of plugin '{}'."
)
elif options['update']:
plugin_command(
self.plugin_manager,
'update_plugin',
options['PLUGIN'],
"Update of plugin '{}' successfully.",
"An error occurred during the update of plugin '{}'."
)
else:
subject = get_handler(self, 'plugin')
print(getdoc(subject))

def port(self, options):
"""
Print the public port for a port binding.
Expand Down Expand Up @@ -1044,3 +1141,19 @@ def exit_if(condition, message, exit_code):
if condition:
log.error(message)
raise SystemExit(exit_code)


def plugin_command(plugin_manager, command, plugin_name, success_message, error_message):
try:
success = getattr(plugin_manager, command)(plugin_name)
except (PluginDoesNotExistError, InvalidPluginError, InvalidPluginFileTypeError) as e:
log.error(str(e))
sys.exit(1)

if success is True:
print(success_message.format(plugin_name))
elif success is False:
log.error(error_message.format(plugin_name))
sys.exit(1)
elif success is None:
print("Nothing to do.")
11 changes: 11 additions & 0 deletions compose/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
import docker

import compose
from ..const import HOME_DIR
from ..const import PLUGIN_DIR


# WindowsError is not defined on non-win32 platforms. Avoid runtime errors by
# defining it as OSError (its parent class) if missing.
Expand Down Expand Up @@ -124,6 +127,14 @@ def generate_user_agent():
return " ".join(parts)


def get_user_home():
return os.path.expanduser("~")


def get_plugin_dir():
return os.path.join(get_user_home(), HOME_DIR, PLUGIN_DIR)


def unquote_path(s):
if not s:
return s
Expand Down
17 changes: 15 additions & 2 deletions compose/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,11 @@ def get_volumes(self):
def get_networks(self):
return {} if self.version == V1 else self.config.get('networks', {})

def get_plugins(self):
return {} if self.version == V1 else self.config.get('plugins', {})

class Config(namedtuple('_Config', 'version services volumes networks')):

class Config(namedtuple('_Config', 'version services volumes networks plugins')):
"""
:param version: configuration version
:type version: int
Expand All @@ -205,6 +208,8 @@ class Config(namedtuple('_Config', 'version services volumes networks')):
:type volumes: :class:`dict`
:param networks: Dictionary mapping network names to description dictionaries
:type networks: :class:`dict`
:param plugins: Dictionary mapping plugin names to description dictionaries
:type plugins: :class:`dict`
"""


Expand Down Expand Up @@ -320,13 +325,16 @@ def load(config_details):
networks = load_mapping(
config_details.config_files, 'get_networks', 'Network'
)
plugins = load_mapping(
config_details.config_files, 'get_plugins', 'Plugins'
)
service_dicts = load_services(config_details, main_file)

if main_file.version != V1:
for service_dict in service_dicts:
match_named_volumes(service_dict, volumes)

return Config(main_file.version, service_dicts, volumes, networks)
return Config(main_file.version, service_dicts, volumes, networks, plugins)


def load_mapping(config_files, get_func, entity_type):
Expand Down Expand Up @@ -438,6 +446,11 @@ def process_config_file(config_file, environment, service_name=None):
config_file.get_networks(),
'network',
environment,)
processed_config['plugins'] = interpolate_config_section(
config_file.filename,
config_file.get_plugins(),
'plugin',
environment,)

if config_file.version == V1:
processed_config = services
Expand Down
24 changes: 24 additions & 0 deletions compose/config/config_schema_v2.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@
}
},
"additionalProperties": false
},

"plugins": {
"id": "#/properties/plugins",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/plugin"
}
},
"additionalProperties": false
}
},

Expand Down Expand Up @@ -323,6 +334,19 @@
}
}
}
},

"plugin": {
"id": "#/definitions/plugin",
"type": "object",
"properties": {
"version": {
"type": "string"
},
"options": {
"$ref": "#/definitions/list_or_dict"
}
}
}
}
}
24 changes: 24 additions & 0 deletions compose/config/config_schema_v2.1.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@
}
},
"additionalProperties": false
},

"plugins": {
"id": "#/properties/plugins",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/plugin"
}
},
"additionalProperties": false
}
},

Expand Down Expand Up @@ -314,6 +325,19 @@
}
}
}
},

"plugin": {
"id": "#/definitions/plugin",
"type": "object",
"properties": {
"version": {
"type": "string"
},
"options": {
"$ref": "#/definitions/list_or_dict"
}
}
}
}
}
1 change: 1 addition & 0 deletions compose/config/serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def denormalize_config(config):
'services': services,
'networks': networks,
'volumes': config.volumes,
'plugins': config.plugins,
}


Expand Down
Loading