# Copyright 2015 Mirantis, Inc. # # 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 abc import collections import json import operator import os from oslo_utils import fileutils import six from fuelclient.cli import error from fuelclient.commands import base from fuelclient.common import data_utils from fuelclient import utils class NodeMixIn(object): entity_name = 'node' numa_fields = ( 'numa_nodes', 'supported_hugepages', 'distances') supported_file_formats = ('json', 'yaml') allowed_attr_types = ('attributes', 'disks', 'interfaces') @classmethod def get_numa_topology_info(cls, data): numa_topology_info = {} numa_topology = data['meta'].get('numa_topology', {}) for key in cls.numa_fields: numa_topology_info[key] = numa_topology.get(key) return numa_topology_info @six.add_metaclass(abc.ABCMeta) class BaseUploadCommand(NodeMixIn, base.BaseCommand): """Base class for uploading attributes of a node.""" @abc.abstractproperty def attribute(self): """String with the name of the attribute.""" pass @abc.abstractproperty def uploader(self): """Callable for uploading data.""" pass def get_parser(self, prog_name): parser = super(BaseUploadCommand, self).get_parser(prog_name) parser.add_argument('id', type=int, help='Id of a node.') parser.add_argument('-f', '--format', required=True, choices=self.supported_file_formats, help='Format of serialized ' '{} data.'.format(self.attribute)) parser.add_argument('-d', '--directory', required=False, default=os.curdir, help='Source directory. Defaults to ' 'the current directory.') return parser def take_action(self, parsed_args): directory = parsed_args.directory file_path = self.get_attributes_path(self.attribute, parsed_args.format, parsed_args.id, directory) try: with open(file_path, 'r') as stream: attributes = data_utils.safe_load(parsed_args.format, stream) self.uploader(parsed_args.id, attributes) except (OSError, IOError): msg = 'Could not read configuration of {} at {}.' raise error.InvalidFileException(msg.format(self.attribute, file_path)) msg = ('Configuration of {t} for node with id ' '{node} was loaded from {path}\n') self.app.stdout.write(msg.format(t=self.attribute, node=parsed_args.id, path=file_path)) @six.add_metaclass(abc.ABCMeta) class BaseDownloadCommand(NodeMixIn, base.BaseCommand): """Base class for downloading attributes of a node.""" @abc.abstractproperty def attribute(self): """String with the name of the attribute.""" pass @abc.abstractproperty def downloader(self): """Callable for downloading data.""" pass def get_parser(self, prog_name): parser = super(BaseDownloadCommand, self).get_parser(prog_name) parser.add_argument('id', type=int, help='Id of a node.') parser.add_argument('-f', '--format', required=True, choices=self.supported_file_formats, help='Format of serialized ' '{} data.'.format(self.attribute)) parser.add_argument('-d', '--directory', required=False, default=os.curdir, help='Destination directory. Defaults to ' 'the current directory.') return parser def take_action(self, parsed_args): directory = parsed_args.directory attributes = self.downloader(parsed_args.id) file_path = self.get_attributes_path(self.attribute, parsed_args.format, parsed_args.id, directory) try: fileutils.ensure_tree(os.path.dirname(file_path)) fileutils.delete_if_exists(file_path) with open(file_path, 'w') as stream: data_utils.safe_dump(parsed_args.format, stream, attributes) except (OSError, IOError): msg = 'Could not store configuration of {} at {}.' raise error.InvalidFileException(msg.format(self.attribute, file_path)) msg = ('Configuration of {t} for node with id ' '{node} was stored in {path}\n') self.app.stdout.write(msg.format(t=self.attribute, node=parsed_args.id, path=file_path)) class NodeList(NodeMixIn, base.BaseListCommand): """Show list of all available nodes.""" columns = ('id', 'name', 'status', 'os_platform', 'roles', 'ip', 'mac', 'cluster', 'platform_name', 'online') def get_parser(self, prog_name): parser = super(NodeList, self).get_parser(prog_name) parser.add_argument( '-e', '--env', type=int, help='Show only nodes that are in the specified environment') parser.add_argument( '-l', '--labels', type=utils.str_to_unicode, nargs='+', help='Show only nodes that have specific labels') return parser def take_action(self, parsed_args): data = self.client.get_all( environment_id=parsed_args.env, labels=parsed_args.labels) data = data_utils.get_display_data_multi(self.columns, data) return (self.columns, data) class NodeShow(NodeMixIn, base.BaseShowCommand): """Show info about node with given id.""" columns = ('id', 'name', 'status', 'os_platform', 'roles', 'kernel_params', 'pending_roles', 'ip', 'mac', 'error_type', 'pending_addition', 'hostname', 'fqdn', 'platform_name', 'cluster', 'online', 'progress', 'pending_deletion', 'group_id', # TODO(romcheg): network_data mostly never fits the screen # 'network_data', 'manufacturer') columns += NodeMixIn.numa_fields def take_action(self, parsed_args): data = self.client.get_by_id(parsed_args.id) numa_topology = self.get_numa_topology_info(data) data.update(numa_topology) data = data_utils.get_display_data_single(self.columns, data) return self.columns, data class NodeUpdate(NodeMixIn, base.BaseShowCommand): """Change given attributes for a node.""" columns = NodeShow.columns def get_parser(self, prog_name): parser = super(NodeUpdate, self).get_parser(prog_name) parser.add_argument( '-H', '--hostname', type=str, default=None, help='New hostname for node') parser.add_argument( '--name', type=lambda x: x.decode('utf-8') if six.PY2 else x, default=None, help='New name for node') return parser def take_action(self, parsed_args): updates = {} for attr in self.client._updatable_attributes: if getattr(parsed_args, attr, None): updates[attr] = getattr(parsed_args, attr) updated_node = self.client.update( parsed_args.id, **updates) numa_topology = self.get_numa_topology_info(updated_node) updated_node.update(numa_topology) updated_node = data_utils.get_display_data_single( self.columns, updated_node) return self.columns, updated_node class NodeVmsList(NodeMixIn, base.BaseShowCommand): """Show list vms for node.""" columns = ('vms_conf',) def take_action(self, parsed_args): data = self.client.get_node_vms_conf(parsed_args.id) data = data_utils.get_display_data_single(self.columns, data) return (self.columns, data) class NodeCreateVMsConf(NodeMixIn, base.BaseCommand): """Create vms config in metadata for selected node.""" def get_parser(self, prog_name): parser = super(NodeCreateVMsConf, self).get_parser(prog_name) parser.add_argument('id', type=int, help='Id of the {0}.'.format(self.entity_name)) parser.add_argument( '--conf', type=json.loads, required=True, nargs='+', help='JSONs with VMs configuration', ) return parser def take_action(self, parsed_args): try: confs = utils.parse_to_list_of_dicts(parsed_args.conf) except TypeError: raise error.BadDataException( 'VM configuration should be a dictionary ' 'or a list of dictionaries') data = self.client.node_vms_create(parsed_args.id, confs) msg = "{0}".format(data) self.app.stdout.write(msg) class NodeLabelList(NodeMixIn, base.BaseListCommand): """Show list of all labels.""" columns = ( 'node_id', 'label_name', 'label_value') def get_parser(self, prog_name): parser = super(NodeLabelList, self).get_parser(prog_name) parser.add_argument( '-n', '--nodes', nargs='+', help='Show labels for specific nodes') return parser def take_action(self, parsed_args): data = self.client.get_all_labels_for_nodes( node_ids=parsed_args.nodes) data = data_utils.get_display_data_multi(self.columns, data) return (self.columns, data) class NodeLabelSet(NodeMixIn, base.BaseCommand): """Create or update specifc labels on nodes.""" def get_parser(self, prog_name): parser = super(NodeLabelSet, self).get_parser(prog_name) parser.add_argument( '-l', '--labels', required=True, nargs='+', help='List of labels for create or update') group = parser.add_mutually_exclusive_group(required=True) group.add_argument( '-n', '--nodes', nargs='+', help='Create or update labels only for specific nodes') group.add_argument( '--nodes-all', action='store_true', help='Create or update labels for all nodes') return parser def take_action(self, parsed_args): nodes_ids = None if parsed_args.nodes_all else parsed_args.nodes data = self.client.set_labels_for_nodes( labels=parsed_args.labels, node_ids=nodes_ids) msg = "Labels have been updated on nodes: {0} \n".format( ','.join(data)) self.app.stdout.write(msg) class NodeLabelDelete(NodeMixIn, base.BaseCommand): """Delete specific labels on nodes.""" def get_parser(self, prog_name): parser = super(NodeLabelDelete, self).get_parser(prog_name) group = parser.add_mutually_exclusive_group(required=True) group.add_argument( '-l', '--labels', nargs='+', help='List of labels keys for delete') group.add_argument( '--labels-all', action='store_true', help='Delete all labels for node') group = parser.add_mutually_exclusive_group(required=True) group.add_argument( '-n', '--nodes', nargs='+', help='Delete labels only for specific nodes') group.add_argument( '--nodes-all', action='store_true', help='Delete labels for all nodes') return parser def take_action(self, parsed_args): nodes_ids = None if parsed_args.nodes_all else parsed_args.nodes labels = None if parsed_args.labels_all \ else parsed_args.labels data = self.client.delete_labels_for_nodes( labels=labels, node_ids=nodes_ids) msg = "Labels have been deleted on nodes: {0} \n".format( ','.join(data)) self.app.stdout.write(msg) class NodeAttributesDownload(NodeMixIn, base.BaseCommand): """Download node attributes.""" def get_parser(self, prog_name): parser = super(NodeAttributesDownload, self).get_parser(prog_name) parser.add_argument( 'id', type=int, help='Node ID') parser.add_argument( '--dir', type=str, help='Directory to save attributes') return parser def take_action(self, parsed_args): file_path = self.client.download_attributes( parsed_args.id, parsed_args.dir) self.app.stdout.write( "Attributes for node {0} were written to {1}" .format(parsed_args.id, file_path) + os.linesep) class NodeAttributesUpload(NodeMixIn, base.BaseCommand): """Upload node attributes.""" def get_parser(self, prog_name): parser = super(NodeAttributesUpload, self).get_parser(prog_name) parser.add_argument( 'id', type=int, help='Node ID') parser.add_argument( '--dir', type=str, help='Directory to read attributes from') return parser def take_action(self, parsed_args): self.client.upload_attributes(parsed_args.id, parsed_args.dir) self.app.stdout.write( "Attributes for node {0} were uploaded." .format(parsed_args.id) + os.linesep) class NodeAnsibleInventory(NodeMixIn, base.BaseCommand): """Generate ansible inventory file based on the nodes list.""" def get_parser(self, prog_name): parser = super(NodeAnsibleInventory, self).get_parser(prog_name) # if this is a required argument, we'll avoid ambiguity of having nodes # of multiple different clusters in the same inventory file parser.add_argument( '-e', '--env', type=int, required=True, help='Use only nodes that are in the specified environment') parser.add_argument( '-l', '--labels', type=utils.str_to_unicode, nargs='+', help='Use only nodes that have specific labels') return parser def take_action(self, parsed_args): data = self.client.get_all(environment_id=parsed_args.env, labels=parsed_args.labels) nodes_by_role = collections.defaultdict(list) for node in data: for role in node['roles']: nodes_by_role[role].append(node) for role, nodes in sorted(nodes_by_role.items()): self.app.stdout.write(u'[{role}]\n'.format(role=role)) self.app.stdout.write( u'\n'.join( u'{name} ansible_host={ip}'.format(name=node['hostname'], ip=node['ip']) for node in sorted(nodes_by_role[role], key=operator.itemgetter('hostname')) ) ) self.app.stdout.write(u'\n\n') class NodeInterfacesDownload(BaseDownloadCommand): """Download and store configuration of interfaces for a node to a file.""" attribute = 'interfaces' @property def downloader(self): return self.client.get_interfaces class NodeInterfacesGetDefault(BaseDownloadCommand): """Download default configuration of interfaces for a node to a file.""" attribute = 'interfaces' @property def downloader(self): return self.client.get_default_interfaces class NodeInterfacesUpload(BaseUploadCommand): """Upload stored configuration of interfaces for a node from a file.""" attribute = 'interfaces' @property def uploader(self): return self.client.set_interfaces class NodeDisksDownload(BaseDownloadCommand): """Download and store configuration of disks for a node to a file.""" attribute = 'disks' @property def downloader(self): return self.client.get_disks class NodeDisksGetDefault(BaseDownloadCommand): """Download default configuration of disks for a node to a file.""" attribute = 'disks' @property def downloader(self): return self.client.get_default_disks class NodeDisksUpload(BaseUploadCommand): """Upload stored configuration of disks for a node from a file.""" attribute = 'disks' @property def uploader(self): return self.client.set_disks class NodeUndiscover(NodeMixIn, base.BaseCommand): """Remove nodes from database.""" def get_parser(self, prog_name): parser = super(NodeUndiscover, self).get_parser(prog_name) group = parser.add_mutually_exclusive_group(required=True) group.add_argument('-e', '--env', type=int, help='Id of environment to remove all nodes ' 'from database.') group.add_argument('-n', '--node', type=int, help='Id of the node to remove from database.') parser.add_argument( '-f', '--force', action='store_true', help='Forces deletion of nodes from database ' 'regardless of their state.') return parser def take_action(self, parsed_args): node_ids = self.client.undiscover_nodes(env_id=parsed_args.env, node_id=parsed_args.node, force=parsed_args.force) self.app.stdout.write( 'Nodes {0} were deleted from the database\n'.format(node_ids) )