diff --git a/requirements.txt b/requirements.txt index f56e0aec..bcc703bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ oslo.i18n>=3.15.3 # Apache-2.0 oslo.utils>=3.28.0 # Apache-2.0 websocket-client>=0.33.0 # LGPLv2+ docker>=2.4.2 # Apache-2.0 +PyYAML>=3.10 # MIT diff --git a/zunclient/common/httpclient.py b/zunclient/common/httpclient.py index 8fd5a656..3a17078f 100644 --- a/zunclient/common/httpclient.py +++ b/zunclient/common/httpclient.py @@ -340,7 +340,6 @@ class SessionClient(adapter.LegacyJsonAdapter): endpoint_filter.setdefault('interface', self.interface) endpoint_filter.setdefault('service_type', self.service_type) endpoint_filter.setdefault('region_name', self.region_name) - resp = self.session.request(url, method, raise_exc=False, **kwargs) diff --git a/zunclient/common/template_format.py b/zunclient/common/template_format.py new file mode 100644 index 00000000..0dfd8d65 --- /dev/null +++ b/zunclient/common/template_format.py @@ -0,0 +1,63 @@ +# Copyright 2017 Arm Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json +import yaml + +from zunclient.i18n import _ + +yaml_loader = yaml.SafeLoader + +if hasattr(yaml, 'CSafeDumper'): + yaml_dumper = yaml.CSafeDumper +else: + yaml_dumper = yaml.SafeDumper + + +def _construct_yaml_str(self, node): + # Override the default string handling function + # to always return unicode objects + return self.construct_scalar(node) +yaml_loader.add_constructor(u'tag:yaml.org,2002:str', _construct_yaml_str) +# Unquoted dates like 2013-05-23 in yaml files get loaded as objects of type +# datetime.data which causes problems in API layer when being processed by +# openstack.common.jsonutils. Therefore, make unicode string out of timestamps +# until jsonutils can handle dates. +yaml_loader.add_constructor(u'tag:yaml.org,2002:timestamp', + _construct_yaml_str) + + +def parse(tmpl_str): + """Takes a string and returns a dict containing the parsed structure. + + This includes determination of whether the string is using the + JSON or YAML format. + """ + # strip any whitespace before the check + tmpl_str = tmpl_str.strip() + if tmpl_str.startswith('{'): + tpl = json.loads(tmpl_str) + else: + try: + tpl = yaml.safe_load(tmpl_str) + except yaml.YAMLError as yea: + raise ValueError(yea) + else: + if tpl is None: + tpl = {} + # Looking for supported version keys in the loaded template + if not ('CapsuleTemplateFormatVersion' in tpl + or 'capsule_template_version' in tpl): + raise ValueError(_("Template format version not found.")) + return tpl diff --git a/zunclient/common/template_utils.py b/zunclient/common/template_utils.py new file mode 100644 index 00000000..06952bc6 --- /dev/null +++ b/zunclient/common/template_utils.py @@ -0,0 +1,87 @@ +# Copyright 2017 Arm Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_serialization import jsonutils +import six +from six.moves.urllib import parse +from six.moves.urllib import request + +from zunclient.common import template_format +from zunclient.common import utils +from zunclient import exceptions +from zunclient.i18n import _ + + +def get_template_contents(template_file=None, template_url=None, + files=None): + + # Transform a bare file path to a file:// URL. + if template_file: # nosec + template_url = utils.normalise_file_path_to_url(template_file) + tpl = request.urlopen(template_url).read() + else: + raise exceptions.CommandErrorException(_('Need to specify exactly ' + 'one of %(arg1)s, %(arg2)s ' + 'or %(arg3)s') % + {'arg1': '--template-file', + 'arg2': '--template-url'}) + + if not tpl: + raise exceptions.CommandErrorException(_('Could not fetch ' + 'template from %s') % + template_url) + + try: + if isinstance(tpl, six.binary_type): + tpl = tpl.decode('utf-8') + template = template_format.parse(tpl) + except ValueError as e: + raise exceptions.CommandErrorException(_('Error parsing template ' + '%(url)s %(error)s') % + {'url': template_url, + 'error': e}) + return template + + +def is_template(file_content): + try: + if isinstance(file_content, six.binary_type): + file_content = file_content.decode('utf-8') + template_format.parse(file_content) + except (ValueError, TypeError): + return False + return True + + +def get_file_contents(from_data, files, base_url=None, + ignore_if=None): + + if isinstance(from_data, dict): + for key, value in from_data.items(): + if ignore_if and ignore_if(key, value): + continue + + if base_url and not base_url.endswith('/'): + base_url = base_url + '/' + + str_url = parse.urljoin(base_url, value) + if str_url not in files: + file_content = utils.read_url_content(str_url) + if is_template(file_content): + template = get_template_contents( + template_url=str_url, files=files)[1] + file_content = jsonutils.dumps(template) + files[str_url] = file_content + # replace the data value with the normalised absolute URL + from_data[key] = str_url diff --git a/zunclient/common/utils.py b/zunclient/common/utils.py index 90259fbe..0aa5cd0e 100644 --- a/zunclient/common/utils.py +++ b/zunclient/common/utils.py @@ -15,9 +15,11 @@ # under the License. import json +import os from oslo_utils import netutils - +from six.moves.urllib import parse +from six.moves.urllib import request from zunclient.common.apiclient import exceptions as apiexec from zunclient.common import cliutils as utils from zunclient import exceptions as exc @@ -232,3 +234,24 @@ def parse_nets(ns): nets.append(net_info) return nets + + +def normalise_file_path_to_url(path): + if parse.urlparse(path).scheme: + return path + path = os.path.abspath(path) + return parse.urljoin('file:', request.pathname2url(path)) + + +def base_url_for_url(url): + parsed = parse.urlparse(url) + parsed_dir = os.path.dirname(parsed.path) + return parse.urljoin(url, parsed_dir) + + +def list_capsules(capsules): + columns = ('uuid', 'status', 'meta_name', + 'meta_labels', 'containers_uuids', 'created_at', 'addresses') + utils.print_list(capsules, columns, + {'versions': print_list_field('versions')}, + sortby_index=None) diff --git a/zunclient/exceptions.py b/zunclient/exceptions.py index 2b1aa9b2..a42fc48c 100644 --- a/zunclient/exceptions.py +++ b/zunclient/exceptions.py @@ -23,6 +23,7 @@ HTTPBadRequest = BadRequest HTTPInternalServerError = InternalServerError HTTPNotFound = NotFound HTTPServiceUnavailable = ServiceUnavailable +CommandErrorException = CommandError class AmbiguousAuthSystem(ClientException): diff --git a/zunclient/experimental/__init__.py b/zunclient/experimental/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/zunclient/experimental/capsules.py b/zunclient/experimental/capsules.py new file mode 100644 index 00000000..405f2f08 --- /dev/null +++ b/zunclient/experimental/capsules.py @@ -0,0 +1,106 @@ +# Copyright 2017 Arm Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from zunclient.common import base +from zunclient.common import utils +from zunclient import exceptions + + +CREATION_ATTRIBUTES = ['spec'] + + +class Capsule(base.Resource): + def __repr__(self): + return "" % self._info + + +class CapsuleManager(base.Manager): + resource_class = Capsule + + @staticmethod + def _path(id=None): + + if id: + return '/capsules/%s' % id + else: + return '/capsules/' + + def get(self, id): + try: + return self._list(self._path(id))[0] + except IndexError: + return None + + def create(self, **kwargs): + new = {} + for (key, value) in kwargs.items(): + if key in CREATION_ATTRIBUTES: + new[key] = value + else: + raise exceptions.InvalidAttribute( + "Key must be in %s" % ','.join(CREATION_ATTRIBUTES)) + return self._create(self._path(), new) + + def list(self, marker=None, limit=None, sort_key=None, + sort_dir=None, detail=False, all_tenants=False): + """Retrieve a list of capsules. + + :param all_tenants: Optional, list containers in all tenants + + :param marker: Optional, the UUID of a containers, eg the last + containers from a previous result set. Return + the next result set. + :param limit: The maximum number of results to return per + request, if: + + 1) limit > 0, the maximum number of containers to return. + 2) limit == 0, return the entire list of containers. + 3) limit param is NOT specified (None), the number of items + returned respect the maximum imposed by the ZUN API + (see Zun's api.max_limit option). + + :param sort_key: Optional, field used for sorting. + + :param sort_dir: Optional, direction of sorting, either 'asc' (the + default) or 'desc'. + + :param detail: Optional, boolean whether to return detailed information + about containers. + + :returns: A list of containers. + + """ + if limit is not None: + limit = int(limit) + + filters = utils.common_filters(marker, limit, sort_key, + sort_dir, all_tenants) + + path = '' + if detail: + path += 'detail' + if filters: + path += '?' + '&'.join(filters) + + if limit is None: + return self._list(self._path(path), + "capsules") + else: + return self._list_pagination(self._path(path), + "capsules", + limit=limit) + + def delete(self, id, force): + return self._delete(self._path(id), + qparams={'force': force}) diff --git a/zunclient/experimental/capsules_shell.py b/zunclient/experimental/capsules_shell.py new file mode 100644 index 00000000..b21cf00d --- /dev/null +++ b/zunclient/experimental/capsules_shell.py @@ -0,0 +1,95 @@ +# Copyright 2017 Arm Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from zunclient.common import cliutils as utils +from zunclient.common import template_utils +from zunclient.common import utils as zun_utils +from zunclient.i18n import _ + + +def _show_capsule(capsule): + utils.print_dict(capsule._info) + + +@utils.arg('-f', '--template-file', metavar='', + required=True, help=_('Path to the template.')) +def do_capsule_create(cs, args): + """Create a capsule. + + Add '--experimental-api' due to capsule now is the experimental API + """ + opts = {} + if args.template_file: + template = template_utils.get_template_contents( + args.template_file) + opts['spec'] = template + cs.capsules.create(**opts) + + +@utils.arg('--all-tenants', + action="store_true", + default=False, + help='List containers in all tenants') +@utils.arg('--marker', + metavar='', + default=None, + help='The last container UUID of the previous page; ' + 'displays list of containers after "marker".') +@utils.arg('--limit', + metavar='', + type=int, + help='Maximum number of containers to return') +@utils.arg('--sort-key', + metavar='', + help='Column to sort results by') +@utils.arg('--sort-dir', + metavar='', + choices=['desc', 'asc'], + help='Direction to sort. "asc" or "desc".') +def do_capsule_list(cs, args): + """Print a list of available capsules. + + Add '--experimental-api' due to capsule now is the experimental API + """ + opts = {} + opts['all_tenants'] = args.all_tenants + opts['marker'] = args.marker + opts['limit'] = args.limit + opts['sort_key'] = args.sort_key + opts['sort_dir'] = args.sort_dir + opts = zun_utils.remove_null_parms(**opts) + capsules = cs.capsules.list(**opts) + zun_utils.list_capsules(capsules) + + +@utils.arg('capsules', + metavar='', + nargs='+', + help='ID or name of the (capsule)s to delete.') +@utils.arg('-f', '--force', + action='store_true', + help='Force delete the capsule.') +def do_capsule_delete(cs, args): + """Delete specified capsules. + + Add '--experimental-api' due to capsule now is the experimental API + """ + for capsule in args.capsules: + try: + cs.capsules.delete(capsule, args.force) + print("Request to delete capsule %s has been accepted." % + capsule) + except Exception as e: + print("Delete for capsule %(capsule)s failed: %(e)s" % + {'capsule': capsule, 'e': e}) diff --git a/zunclient/experimental/client.py b/zunclient/experimental/client.py new file mode 100644 index 00000000..64c45056 --- /dev/null +++ b/zunclient/experimental/client.py @@ -0,0 +1,123 @@ +# Copyright 2017 Arm Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain a +# copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from keystoneauth1 import loading +from keystoneauth1 import session as ksa_session +from oslo_utils import importutils + +from zunclient.common import httpclient +from zunclient.experimental import capsules + +profiler = importutils.try_import("osprofiler.profiler") + + +class Client(object): + def __init__(self, username=None, api_key=None, project_id=None, + project_name=None, auth_url=None, zun_url=None, + endpoint_type=None, endpoint_override=None, + service_type='container-experimental', + region_name=None, input_auth_token=None, + session=None, password=None, auth_type='password', + interface='public', service_name=None, insecure=False, + user_domain_id=None, user_domain_name=None, + project_domain_id=None, project_domain_name=None, + api_version=None, **kwargs): + + # We have to keep the api_key are for backwards compat, but let's + # remove it from the rest of our code since it's not a keystone + # concept + if not password: + password = api_key + # Backwards compat for people assing in endpoint_type + if endpoint_type: + interface = endpoint_type + + # fix (yolanda): os-cloud-config is using endpoint_override + # instead of zun_url + if endpoint_override and not zun_url: + zun_url = endpoint_override + + if zun_url and input_auth_token: + auth_type = 'admin_token' + session = None + loader_kwargs = dict( + token=input_auth_token, + endpoint=zun_url) + elif input_auth_token and not session: + auth_type = 'token' + loader_kwargs = dict( + token=input_auth_token, + auth_url=auth_url, + project_id=project_id, + project_name=project_name, + user_domain_id=user_domain_id, + user_domain_name=user_domain_name, + project_domain_id=project_domain_id, + project_domain_name=project_domain_name) + else: + loader_kwargs = dict( + username=username, + password=password, + auth_url=auth_url, + project_id=project_id, + project_name=project_name, + user_domain_id=user_domain_id, + user_domain_name=user_domain_name, + project_domain_id=project_domain_id, + project_domain_name=project_domain_name) + + # Backwards compatibility for people not passing in Session + if session is None: + loader = loading.get_plugin_loader(auth_type) + + # This should be able to handle v2 and v3 Keystone Auth + auth_plugin = loader.load_from_options(**loader_kwargs) + session = ksa_session.Session( + auth=auth_plugin, verify=(not insecure)) + + client_kwargs = {} + if zun_url: + client_kwargs['endpoint_override'] = zun_url + + if not zun_url: + try: + # Trigger an auth error so that we can throw the exception + # we always have + session.get_endpoint( + service_type=service_type, + service_name=service_name, + interface=interface, + region_name=region_name) + except Exception: + raise RuntimeError("Not Authorized") + + self.http_client = httpclient.SessionClient( + service_type=service_type, + service_name=service_name, + interface=interface, + region_name=region_name, + session=session, + api_version=api_version, + **client_kwargs) + self.capsules = capsules.CapsuleManager(self.http_client) + + profile = kwargs.pop("profile", None) + if profiler and profile: + # Initialize the root of the future trace: the created trace ID + # will be used as the very first parent to which all related + # traces will be bound to. The given HMAC key must correspond to + # the one set in zun-api zun.conf, otherwise the latter + # will fail to check the request signature and will skip + # initialization of osprofiler on the server side. + profiler.init(profile) diff --git a/zunclient/experimental/shell.py b/zunclient/experimental/shell.py new file mode 100644 index 00000000..677d0f06 --- /dev/null +++ b/zunclient/experimental/shell.py @@ -0,0 +1,19 @@ +# Copyright 2017 Arm Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain a +# copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from zunclient.experimental import capsules_shell + +COMMAND_MODULES = [ + capsules_shell, +] diff --git a/zunclient/shell.py b/zunclient/shell.py index 0fff5d6a..6220cba5 100644 --- a/zunclient/shell.py +++ b/zunclient/shell.py @@ -53,10 +53,12 @@ except ImportError: pass from zunclient import api_versions -from zunclient import client +from zunclient import client as base_client from zunclient.common.apiclient import auth from zunclient.common import cliutils from zunclient import exceptions as exc +from zunclient.experimental import client as client_experimental +from zunclient.experimental import shell as shell_experimental from zunclient.i18n import _ from zunclient.v1 import shell as shell_v1 from zunclient import version @@ -64,6 +66,7 @@ from zunclient import version DEFAULT_API_VERSION = api_versions.DEFAULT_API_VERSION DEFAULT_ENDPOINT_TYPE = 'publicURL' DEFAULT_SERVICE_TYPE = 'container' +EXPERIENTAL_SERVICE_TYPE = 'container-experimental' logger = logging.getLogger(__name__) @@ -361,6 +364,10 @@ class OpenStackZunShell(object): action='store_true', help="Do not verify https connections") + parser.add_argument('--experimental-api', + action='store_true', + help="Using experimental API") + if profiler: parser.add_argument('--profile', metavar='HMAC_KEY', @@ -382,21 +389,22 @@ class OpenStackZunShell(object): return parser - def get_subcommand_parser(self, version, do_help=False): + def get_subcommand_parser(self, version, experimental, do_help=False): parser = self.get_base_parser() self.subcommands = {} subparsers = parser.add_subparsers(metavar='') try: - actions_modules = { - '1': shell_v1.COMMAND_MODULES - }[version.ver_major] + actions_modules = shell_v1.COMMAND_MODULES + if experimental: + for items in shell_experimental.COMMAND_MODULES: + actions_modules.append(items) except KeyError: actions_modules = shell_v1.COMMAND_MODULES - for actions_module in actions_modules: - self._find_actions(subparsers, actions_module, version, do_help) + for action_modules in actions_modules: + self._find_actions(subparsers, action_modules, version, do_help) self._find_actions(subparsers, self, version, do_help) self._add_bash_completion_subparser(subparsers) @@ -504,8 +512,12 @@ class OpenStackZunShell(object): spot = argv.index('--endpoint_type') argv[spot] = '--endpoint-type' + experimental = False + if '--experimental-api' in argv: + experimental = True + subcommand_parser = self.get_subcommand_parser( - api_version, do_help=("help" in args)) + api_version, experimental, do_help=("help" in args)) self.parser = subcommand_parser @@ -549,6 +561,9 @@ class OpenStackZunShell(object): if not service_type: service_type = DEFAULT_SERVICE_TYPE + + if experimental: + service_type = EXPERIENTAL_SERVICE_TYPE # NA - there is only one service this CLI accesses # service_type = utils.get_service_type(args.func) or service_type @@ -608,6 +623,11 @@ class OpenStackZunShell(object): '--os-password, env[OS_PASSWORD], or ' 'prompted response') + if experimental: + client = client_experimental + else: + client = base_client + kwargs = {} if profiler: kwargs["profile"] = args.profile @@ -671,7 +691,6 @@ class OpenStackZunShell(object): """Display help about this program or one of its subcommands.""" # NOTE(jamespage): args.command is not guaranteed with python >= 3.4 command = getattr(args, 'command', '') - if command: if args.command in self.subcommands: self.subcommands[args.command].print_help()