diff --git a/Dockerfile b/Dockerfile index 5579dadf..af6c70e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ FROM ubuntu:16.04 ENV DEBIAN_FRONTEND noninteractive ENV container docker +ENV PORT 9000 RUN apt -qq update && \ apt -y install git \ @@ -39,7 +40,7 @@ COPY . /tmp/drydock WORKDIR /tmp/drydock RUN python3 setup.py install -EXPOSE 8000 +EXPOSE $PORT ENTRYPOINT ["./entrypoint.sh"] diff --git a/docs/drydock_client.rst b/docs/drydock_client.rst new file mode 100644 index 00000000..66ac4187 --- /dev/null +++ b/docs/drydock_client.rst @@ -0,0 +1,82 @@ +=========================================================== + drydock_client - client for drydock_provisioner RESTful API +=========================================================== + +The drydock_client module can be used to access a remote (or local) +Drydock REST API server. It supports tokenized authentication and +marking API calls with an external context marker for log aggregation. + +It is composed of two parts - a DrydockSession which denotes the call +context for the API and a DrydockClient which gives access to actual +API calls. + +Simple Usage +============ + +The usage pattern for drydock_client is to build a DrydockSession +with your credentials and the target host. Then use this session +to build a DrydockClient to make one or more API calls. The +DrydockSession will care for TCP connection pooling and header +management:: + + import drydock_provisioner.drydock_client.client as client + import drydock_provisioner.drydock_client.session as session + + dd_session = session.DrydockSession('host.com', port=9000, token='abc123') + dd_client = client.DrydockClient(dd_session) + + drydock_task = dd_client.get_task('ba44e582-6b26-11e7-81cc-080027ef795a') + +Drydock Client Method API +========================= + +drydock_client.client.DrydockClient supports the following methods for +accessing the Drydock RESTful API + +get_design_ids +-------------- + +Return a list of UUID-formatted design IDs + +get_design +---------- + +Provide a UUID-formatted design ID, receive back a dictionary representing +a objects.site.SiteDesign instance. You can provide the kwarg 'source' with +the value of 'compiled' to see the site design after inheritance is applied. + +create_design +------------- + +Create a new design. Optionally provide a new base design (by UUID-formatted +design_id) that the new design uses as the starting state. Receive back a +UUID-formatted string of design_id + +get_part +-------- + +Get the attributes of a particular design part. Provide the design_id the part +is loaded in, the kind (one of 'Region', 'NetworkLink', 'Network', 'HardwareProfile', +'HostProfile' or 'BaremetalNode' and the part key (i.e. name). You can provide the kwarg +'source' with the value of 'compiled' to see the site design after inheritance is +applied. + +load_parts +---------- + +Parse a provided YAML string and load the parts into the provided design context + +get_tasks +--------- + +Get a list of all task ids + +get_task +-------- + +Get the attributes of the task identified by the provided task_id + +create_task +----------- + +Create a task to execute the provided action on the provided design context diff --git a/drydock_provisioner/cli/action.py b/drydock_provisioner/cli/action.py new file mode 100644 index 00000000..85d947ef --- /dev/null +++ b/drydock_provisioner/cli/action.py @@ -0,0 +1,29 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# 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. +""" Base classes for cli actions intended to invoke the api +""" +import logging + +class CliAction: # pylint: disable=too-few-public-methods + """ Action base for CliActions + """ + def __init__(self, api_client): + self.logger = logging.getLogger('drydock_cli') + self.api_client = api_client + self.logger.debug("Action initialized with client %s", self.api_client.session.host) + + def invoke(self): + """ The action to be taken. By default, this is not implemented + """ + raise NotImplementedError("Invoke method has not been implemented") diff --git a/drydock_provisioner/cli/commands.py b/drydock_provisioner/cli/commands.py index 482071f1..85f79fbb 100644 --- a/drydock_provisioner/cli/commands.py +++ b/drydock_provisioner/cli/commands.py @@ -11,39 +11,69 @@ # 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. +""" The entry point for the cli commands +""" import os +import logging +from urllib.parse import urlparse import click +from drydock_provisioner.drydock_client.session import DrydockSession +from drydock_provisioner.drydock_client.client import DrydockClient +from .design import commands as design +from .part import commands as part +from .task import commands as task @click.group() -@click.option('--debug/--no-debug', default=False) -@click.option('--token') -@click.option('--url') +@click.option('--debug/--no-debug', + help='Enable or disable debugging', + default=False) +@click.option('--token', + '-t', + help='The auth token to be used', + default=lambda: os.environ.get('DD_TOKEN', '')) +@click.option('--url', + '-u', + help='The url of the running drydock instance', + default=lambda: os.environ.get('DD_URL', '')) @click.pass_context def drydock(ctx, debug, token, url): + """ Drydock CLI to invoke the running instance of the drydock API + """ + if not ctx.obj: + ctx.obj = {} + ctx.obj['DEBUG'] = debug if not token: - ctx.obj['TOKEN'] = os.environ['DD_TOKEN'] - else: - ctx.obj['TOKEN'] = token + ctx.fail('Error: Token must be specified either by ' + '--token or DD_TOKEN from the environment') if not url: - ctx.obj['URL'] = os.environ['DD_URL'] - else: - ctx.obj['URL'] = url + ctx.fail('Error: URL must be specified either by ' + '--url or DD_URL from the environment') -@drydock.group() -def create(): - pass + # setup logging for the CLI + # Setup root logger + logger = logging.getLogger('drydock_cli') -@drydock.group() -def list() - pass + logger.setLevel(logging.DEBUG if debug else logging.INFO) + logging_handler = logging.StreamHandler() + formatter = logging.Formatter('%(asctime)s - %(levelname)s - ' + '%(filename)s:%(funcName)s - %(message)s') + logging_handler.setFormatter(formatter) + logger.addHandler(logging_handler) + logger.debug('logging for cli initialized') -@drydock.group() -def show(): - pass + # setup the drydock client using the passed parameters. + url_parse_result = urlparse(url) + logger.debug(url_parse_result) + if not url_parse_result.scheme: + ctx.fail('URL must specify a scheme and hostname, optionally a port') + ctx.obj['CLIENT'] = DrydockClient(DrydockSession(scheme=url_parse_result.scheme, + host=url_parse_result.netloc, + token=token)) -@create.command() -def design \ No newline at end of file +drydock.add_command(design.design) +drydock.add_command(part.part) +drydock.add_command(task.task) diff --git a/drydock_provisioner/cli/design/actions.py b/drydock_provisioner/cli/design/actions.py new file mode 100644 index 00000000..edf8e2d3 --- /dev/null +++ b/drydock_provisioner/cli/design/actions.py @@ -0,0 +1,57 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# 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. +""" Actions related to design +""" +from drydock_provisioner.cli.action import CliAction + +class DesignList(CliAction): # pylint: disable=too-few-public-methods + """ Action to list designs + """ + def __init__(self, api_client): + super().__init__(api_client) + self.logger.debug("DesignList action initialized") + + def invoke(self): + return self.api_client.get_design_ids() + +class DesignCreate(CliAction): # pylint: disable=too-few-public-methods + """ Action to create designs + """ + def __init__(self, api_client, base_design=None): + """ + :param string base_design: A UUID of the base design to model after + """ + super().__init__(api_client) + self.logger.debug("DesignCreate action initialized with base_design=%s", base_design) + self.base_design = base_design + + def invoke(self): + + return self.api_client.create_design(base_design=self.base_design) + + +class DesignShow(CliAction): # pylint: disable=too-few-public-methods + """ Action to show a design. + :param string design_id: A UUID design_id + :param string source: (Optional) The model source to return. 'designed' is as input, + 'compiled' is after merging + """ + def __init__(self, api_client, design_id, source='designed'): + super().__init__(api_client) + self.design_id = design_id + self.source = source + self.logger.debug("DesignShow action initialized for design_id = %s", design_id) + + def invoke(self): + return self.api_client.get_design(design_id=self.design_id, source=self.source) diff --git a/drydock_provisioner/cli/design/commands.py b/drydock_provisioner/cli/design/commands.py new file mode 100644 index 00000000..4e51a360 --- /dev/null +++ b/drydock_provisioner/cli/design/commands.py @@ -0,0 +1,57 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# 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. +""" cli.design.commands + Contains commands related to designs +""" +import click + +from drydock_provisioner.cli.design.actions import DesignList +from drydock_provisioner.cli.design.actions import DesignShow +from drydock_provisioner.cli.design.actions import DesignCreate + +@click.group() +def design(): + """ Drydock design commands + """ + pass + +@design.command(name='create') +@click.option('--base-design', + '-b', + help='The base design to model this new design after') +@click.pass_context +def design_create(ctx, base_design=None): + """ Create a design + """ + click.echo(DesignCreate(ctx.obj['CLIENT'], base_design).invoke()) + +@design.command(name='list') +@click.pass_context +def design_list(ctx): + """ List designs + """ + click.echo(DesignList(ctx.obj['CLIENT']).invoke()) + +@design.command(name='show') +@click.option('--design-id', + '-i', + help='The design id to show') +@click.pass_context +def design_show(ctx, design_id): + """ show designs + """ + if not design_id: + ctx.fail('The design id must be specified by --design-id') + + click.echo(DesignShow(ctx.obj['CLIENT'], design_id).invoke()) diff --git a/drydock_provisioner/cli/part/actions.py b/drydock_provisioner/cli/part/actions.py new file mode 100644 index 00000000..c1910818 --- /dev/null +++ b/drydock_provisioner/cli/part/actions.py @@ -0,0 +1,86 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# 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. +""" Actions related to part command +""" + + +from drydock_provisioner.cli.action import CliAction + +class PartBase(CliAction): # pylint: disable=too-few-public-methods + """ base class to set up part actions requiring a design_id + """ + def __init__(self, api_client, design_id): + super().__init__(api_client) + self.design_id = design_id + self.logger.debug('Initializing a Part action with design_id=%s', design_id) + +class PartList(PartBase): # pylint: disable=too-few-public-methods + """ Action to list parts of a design + """ + def __init__(self, api_client, design_id): + """ + :param DrydockClient api_client: The api client used for invocation. + :param string design_id: The UUID of the design for which to list parts + """ + super().__init__(api_client, design_id) + self.logger.debug('PartList action initialized') + + def invoke(self): + #TODO: change the api call + return 'This function does not yet have an implementation to support the request' + +class PartCreate(PartBase): # pylint: disable=too-few-public-methods + """ Action to create parts of a design + """ + def __init__(self, api_client, design_id, in_file): + """ + :param DrydockClient api_client: The api client used for invocation. + :param string design_id: The UUID of the design for which to create a part + :param in_file: The file containing the specification of the part + """ + super().__init__(api_client, design_id) + self.in_file = in_file + self.logger.debug('PartCreate action init. Input file (trunc to 100 chars)=%s', in_file[:100]) + + def invoke(self): + return self.api_client.load_parts(self.design_id, self.in_file) + +class PartShow(PartBase): # pylint: disable=too-few-public-methods + """ Action to show a part of a design. + """ + def __init__(self, api_client, design_id, kind, key, source='designed'): + """ + :param DrydockClient api_client: The api client used for invocation. + :param string design_id: the UUID of the design containing this part + :param string kind: the string represesnting the 'kind' of the document to return + :param string key: the string representing the key of the document to return. + :param string source: 'designed' (default) if this is the designed version, + 'compiled' if the compiled version (after merging) + """ + super().__init__(api_client, design_id) + self.kind = kind + self.key = key + self.source = source + self.logger.debug('DesignShow action initialized for design_id=%s,' + ' kind=%s, key=%s, source=%s', + design_id, + kind, + key, + source) + + def invoke(self): + return self.api_client.get_part(design_id=self.design_id, + kind=self.kind, + key=self.key, + source=self.source) diff --git a/drydock_provisioner/cli/part/commands.py b/drydock_provisioner/cli/part/commands.py new file mode 100644 index 00000000..5846d3ff --- /dev/null +++ b/drydock_provisioner/cli/part/commands.py @@ -0,0 +1,85 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# 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. +""" cli.part.commands + Contains commands related to parts of designs +""" +import click + +from drydock_provisioner.cli.part.actions import PartList +from drydock_provisioner.cli.part.actions import PartShow +from drydock_provisioner.cli.part.actions import PartCreate + +@click.group() +@click.option('--design-id', + '-d', + help='The id of the design containing the target parts') +@click.pass_context +def part(ctx, design_id=None): + """ Drydock part commands + """ + if not design_id: + ctx.fail('Error: Design id must be specified using --design-id') + + ctx.obj['DESIGN_ID'] = design_id + +@part.command(name='create') +@click.option('--file', + '-f', + help='The file name containing the part to create') +@click.pass_context +def part_create(ctx, file=None): + """ Create a part + """ + if not file: + ctx.fail('A file to create a part is required using --file') + + with open(file, 'r') as file_input: + file_contents = file_input.read() + # here is where some potential validation could be done on the input file + click.echo(PartCreate(ctx.obj['CLIENT'], + design_id=ctx.obj['DESIGN_ID'], + in_file=file_contents).invoke()) + +@part.command(name='list') +@click.pass_context +def part_list(ctx): + """ List parts of a design + """ + click.echo(PartList(ctx.obj['CLIENT'], design_id=ctx.obj['DESIGN_ID']).invoke()) + +@part.command(name='show') +@click.option('--source', + '-s', + help='designed | compiled') +@click.option('--kind', + '-k', + help='The kind value of the document to show') +@click.option('--key', + '-i', + help='The key value of the document to show') +@click.pass_context +def part_show(ctx, source, kind, key): + """ show a part of a design + """ + if not kind: + ctx.fail('The kind must be specified by --kind') + + if not key: + ctx.fail('The key must be specified by --key') + + click.echo(PartShow(ctx.obj['CLIENT'], + design_id=ctx.obj['DESIGN_ID'], + kind=kind, + key=key, + source=source).invoke()) diff --git a/drydock_provisioner/cli/task/actions.py b/drydock_provisioner/cli/task/actions.py new file mode 100644 index 00000000..c2d66683 --- /dev/null +++ b/drydock_provisioner/cli/task/actions.py @@ -0,0 +1,83 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# 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. +""" Actions related to task commands +""" + +from drydock_provisioner.cli.action import CliAction + +class TaskList(CliAction): # pylint: disable=too-few-public-methods + """ Action to list tasks + """ + def __init__(self, api_client): + """ + :param DrydockClient api_client: The api client used for invocation. + """ + super().__init__(api_client) + self.logger.debug('TaskList action initialized') + + def invoke(self): + return self.api_client.get_tasks() + +class TaskCreate(CliAction): # pylint: disable=too-few-public-methods + """ Action to create tasks against a design + """ + def __init__(self, api_client, design_id, action_name=None, node_names=None, rack_names=None, node_tags=None): + """ + :param DrydockClient api_client: The api client used for invocation. + :param string design_id: The UUID of the design for which to create a task + :param string action_name: The name of the action being performed for this task + :param List node_names: The list of node names to restrict action application + :param List rack_names: The list of rack names to restrict action application + :param List node_tags: The list of node tags to restrict action application + """ + super().__init__(api_client) + self.design_id = design_id + self.action_name = action_name + self.logger.debug('TaskCreate action initialized for design=%s', design_id) + self.logger.debug('Action is %s', action_name) + if node_names is None: + node_names = [] + if rack_names is None: + rack_names = [] + if node_tags is None: + node_tags = [] + + self.logger.debug("Node names = %s", node_names) + self.logger.debug("Rack names = %s", rack_names) + self.logger.debug("Node tags = %s", node_tags) + + self.node_filter = {'node_names' : node_names, + 'rack_names' : rack_names, + 'node_tags' : node_tags + } + + def invoke(self): + return self.api_client.create_task(design_id=self.design_id, + task_action=self.action_name, + node_filter=self.node_filter) + +class TaskShow(CliAction): # pylint: disable=too-few-public-methods + """ Action to show a task's detial. + """ + def __init__(self, api_client, task_id): + """ + :param DrydockClient api_client: The api client used for invocation. + :param string task_id: the UUID of the task to retrieve + """ + super().__init__(api_client) + self.task_id = task_id + self.logger.debug('TaskShow action initialized for task_id=%s,', task_id) + + def invoke(self): + return self.api_client.get_task(task_id=self.task_id) diff --git a/drydock_provisioner/cli/task/commands.py b/drydock_provisioner/cli/task/commands.py new file mode 100644 index 00000000..c9ad25e9 --- /dev/null +++ b/drydock_provisioner/cli/task/commands.py @@ -0,0 +1,81 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# 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. +""" cli.task.commands + Contains commands related to tasks against designs +""" +import click + +from drydock_provisioner.cli.task.actions import TaskList +from drydock_provisioner.cli.task.actions import TaskShow +from drydock_provisioner.cli.task.actions import TaskCreate + +@click.group() +def task(): + """ Drydock task commands + """ + +@task.command(name='create') +@click.option('--design-id', + '-d', + help='The design id for this action') +@click.option('--action', + '-a', + help='The action to perform') +@click.option('--node-names', + '-n', + help='The nodes targeted by this action, comma separated') +@click.option('--rack-names', + '-r', + help='The racks targeted by this action, comma separated') +@click.option('--node-tags', + '-t', + help='The nodes by tag name targeted by this action, comma separated') +@click.pass_context +def task_create(ctx, design_id=None, action=None, node_names=None, rack_names=None, node_tags=None): + """ Create a task + """ + if not design_id: + ctx.fail('Error: Design id must be specified using --design-id') + + if not action: + ctx.fail('Error: Action must be specified using --action') + + click.echo(TaskCreate(ctx.obj['CLIENT'], + design_id=design_id, + action_name=action, + node_names=[x.strip() for x in node_names.split(',')] if node_names else [], + rack_names=[x.strip() for x in rack_names.split(',')] if rack_names else [], + node_tags=[x.strip() for x in node_tags.split(',')] if node_tags else [] + ).invoke()) + +@task.command(name='list') +@click.pass_context +def task_list(ctx): + """ List tasks. + """ + click.echo(TaskList(ctx.obj['CLIENT']).invoke()) + +@task.command(name='show') +@click.option('--task-id', + '-t', + help='The required task id') +@click.pass_context +def task_show(ctx, task_id=None): + """ show a task's details + """ + if not task_id: + ctx.fail('The task id must be specified by --task-id') + + click.echo(TaskShow(ctx.obj['CLIENT'], + task_id=task_id).invoke()) diff --git a/drydock_provisioner/control/designs.py b/drydock_provisioner/control/designs.py index 23afc457..063d0394 100644 --- a/drydock_provisioner/control/designs.py +++ b/drydock_provisioner/control/designs.py @@ -114,6 +114,32 @@ class DesignsPartsResource(StatefulResource): except LookupError: self.return_error(resp, falcon.HTTP_400, message="Ingester %s not registered" % ingester_name, retry=False) + def on_get(self, req, resp, design_id): + try: + design = self.state_manager.get_design(design_id) + except DesignError: + self.return_error(resp, falcon.HTTP_404, message="Design %s nout found" % design_id, retry=False) + + part_catalog = [] + + site = design.get_site() + + part_catalog.append({'kind': 'Region', 'key': site.get_id()}) + + part_catalog.extend([{'kind': 'Network', 'key': n.get_id()} for n in design.networks]) + + part_catalog.extend([{'kind': 'NetworkLink', 'key': l.get_id()} for l in design.network_links]) + + part_catalog.extend([{'kind': 'HostProfile', 'key': p.get_id()} for p in design.host_profiles]) + + part_catalog.extend([{'kind': 'HardwareProfile', 'key': p.get_id()} for p in design.hardware_profiles]) + + part_catalog.extend([{'kind': 'BaremetalNode', 'key': n.get_id()} for n in design.baremetal_nodes]) + + resp.body = json.dumps(part_catalog) + resp.status = falcon.HTTP_200 + return + class DesignsPartsKindsResource(StatefulResource): def __init__(self, **kwargs): @@ -161,4 +187,4 @@ class DesignsPartResource(StatefulResource): resp.body = json.dumps(part.obj_to_simple()) except errors.DesignError as dex: self.error(req.context, str(dex)) - self.return_error(resp, falcon.HTTP_404, message=str(dex), retry=False) \ No newline at end of file + self.return_error(resp, falcon.HTTP_404, message=str(dex), retry=False) diff --git a/drydock_provisioner/control/middleware.py b/drydock_provisioner/control/middleware.py index acbdb27e..2ee9981c 100644 --- a/drydock_provisioner/control/middleware.py +++ b/drydock_provisioner/control/middleware.py @@ -84,8 +84,8 @@ class LoggingMiddleware(object): ctx = req.context extra = { 'user': ctx.user, - 'req_id': ctx.req_id, + 'req_id': ctx.request_id, 'external_ctx': ctx.external_marker, } - resp.append_header('X-Drydock-Req', ctx.req_id) + resp.append_header('X-Drydock-Req', ctx.request_id) self.logger.info("%s - %s" % (req.uri, resp.status), extra=extra) diff --git a/drydock_provisioner/control/tasks.py b/drydock_provisioner/control/tasks.py index fcca1eb9..595d2c71 100644 --- a/drydock_provisioner/control/tasks.py +++ b/drydock_provisioner/control/tasks.py @@ -34,18 +34,17 @@ class TasksResource(StatefulResource): try: json_data = self.req_json(req) - sitename = json_data.get('sitename', None) design_id = json_data.get('design_id', None) action = json_data.get('action', None) node_filter = json_data.get('node_filter', None) - if sitename is None or design_id is None or action is None: - self.info(req.context, "Task creation requires fields sitename, design_id, action") - self.return_error(resp, falcon.HTTP_400, message="Task creation requires fields sitename, design_id, action", retry=False) + if design_id is None or action is None: + self.info(req.context, "Task creation requires fields design_id, action") + self.return_error(resp, falcon.HTTP_400, message="Task creation requires fields design_id, action", retry=False) return - task = self.orchestrator.create_task(obj_task.OrchestratorTask, site=sitename, - design_id=design_id, action=action, node_filter=node_filter) + task = self.orchestrator.create_task(obj_task.OrchestratorTask, design_id=design_id, + action=action, node_filter=node_filter) task_thread = threading.Thread(target=self.orchestrator.execute_task, args=[task.get_id()]) task_thread.start() diff --git a/drydock_provisioner/drivers/node/maasdriver/driver.py b/drydock_provisioner/drivers/node/maasdriver/driver.py index 67159f99..1f4573e3 100644 --- a/drydock_provisioner/drivers/node/maasdriver/driver.py +++ b/drydock_provisioner/drivers/node/maasdriver/driver.py @@ -74,7 +74,7 @@ class MaasNodeDriver(NodeDriver): status=hd_fields.TaskStatus.Complete, result=hd_fields.ActionResult.Success) return - except errors.TransientDriverError(ex): + except errors.TransientDriverError as ex: result = { 'retry': True, 'detail': str(ex), @@ -84,7 +84,7 @@ class MaasNodeDriver(NodeDriver): result=hd_fields.ActionResult.Failure, result_details=result) return - except errors.PersistentDriverError(ex): + except errors.PersistentDriverError as ex: result = { 'retry': False, 'detail': str(ex), @@ -94,7 +94,7 @@ class MaasNodeDriver(NodeDriver): result=hd_fields.ActionResult.Failure, result_details=result) return - except Exception(ex): + except Exception as ex: result = { 'retry': False, 'detail': str(ex), @@ -111,11 +111,6 @@ class MaasNodeDriver(NodeDriver): raise errors.DriverError("No design ID specified in task %s" % (task_id)) - - if task.site_name is None: - raise errors.DriverError("No site specified for task %s." % - (task_id)) - self.orchestrator.task_field_update(task.get_id(), status=hd_fields.TaskStatus.Running) @@ -127,8 +122,7 @@ class MaasNodeDriver(NodeDriver): subtask = self.orchestrator.create_task(task_model.DriverTask, parent_task_id=task.get_id(), design_id=design_id, - action=task.action, site_name=task.site_name, - task_scope={'site': task.site_name}) + action=task.action) runner = MaasTaskRunner(state_manager=self.state_manager, orchestrator=self.orchestrator, task_id=subtask.get_id()) @@ -165,8 +159,7 @@ class MaasNodeDriver(NodeDriver): subtask = self.orchestrator.create_task(task_model.DriverTask, parent_task_id=task.get_id(), design_id=design_id, - action=task.action, site_name=task.site_name, - task_scope={'site': task.site_name}) + action=task.action) runner = MaasTaskRunner(state_manager=self.state_manager, orchestrator=self.orchestrator, task_id=subtask.get_id()) @@ -211,8 +204,7 @@ class MaasNodeDriver(NodeDriver): subtask = self.orchestrator.create_task(task_model.DriverTask, parent_task_id=task.get_id(), design_id=design_id, action=hd_fields.OrchestratorAction.IdentifyNode, - site_name=task.site_name, - task_scope={'site': task.site_name, 'node_names': [n]}) + task_scope={'node_names': [n]}) runner = MaasTaskRunner(state_manager=self.state_manager, orchestrator=self.orchestrator, task_id=subtask.get_id()) @@ -286,8 +278,7 @@ class MaasNodeDriver(NodeDriver): subtask = self.orchestrator.create_task(task_model.DriverTask, parent_task_id=task.get_id(), design_id=design_id, action=hd_fields.OrchestratorAction.ConfigureHardware, - site_name=task.site_name, - task_scope={'site': task.site_name, 'node_names': [n]}) + task_scope={'node_names': [n]}) runner = MaasTaskRunner(state_manager=self.state_manager, orchestrator=self.orchestrator, task_id=subtask.get_id()) @@ -361,8 +352,7 @@ class MaasNodeDriver(NodeDriver): subtask = self.orchestrator.create_task(task_model.DriverTask, parent_task_id=task.get_id(), design_id=design_id, action=hd_fields.OrchestratorAction.ApplyNodeNetworking, - site_name=task.site_name, - task_scope={'site': task.site_name, 'node_names': [n]}) + task_scope={'node_names': [n]}) runner = MaasTaskRunner(state_manager=self.state_manager, orchestrator=self.orchestrator, task_id=subtask.get_id()) @@ -436,8 +426,7 @@ class MaasNodeDriver(NodeDriver): subtask = self.orchestrator.create_task(task_model.DriverTask, parent_task_id=task.get_id(), design_id=design_id, action=hd_fields.OrchestratorAction.ApplyNodePlatform, - site_name=task.site_name, - task_scope={'site': task.site_name, 'node_names': [n]}) + task_scope={'node_names': [n]}) runner = MaasTaskRunner(state_manager=self.state_manager, orchestrator=self.orchestrator, task_id=subtask.get_id()) @@ -512,8 +501,7 @@ class MaasNodeDriver(NodeDriver): subtask = self.orchestrator.create_task(task_model.DriverTask, parent_task_id=task.get_id(), design_id=design_id, action=hd_fields.OrchestratorAction.DeployNode, - site_name=task.site_name, - task_scope={'site': task.site_name, 'node_names': [n]}) + task_scope={'node_names': [n]}) runner = MaasTaskRunner(state_manager=self.state_manager, orchestrator=self.orchestrator, task_id=subtask.get_id()) diff --git a/drydock_provisioner/drivers/oob/manual_driver/driver.py b/drydock_provisioner/drivers/oob/manual_driver/driver.py index 9d513144..e89dc428 100644 --- a/drydock_provisioner/drivers/oob/manual_driver/driver.py +++ b/drydock_provisioner/drivers/oob/manual_driver/driver.py @@ -57,11 +57,6 @@ class ManualDriver(oob.OobDriver): raise errors.DriverError("No design ID specified in task %s" % (task_id)) - - if task.site_name is None: - raise errors.DriverError("Not site specified for task %s." % - (task_id)) - self.orchestrator.task_field_update(task.get_id(), status=hd_fields.TaskStatus.Running) diff --git a/drydock_provisioner/drivers/oob/pyghmi_driver/__init__.py b/drydock_provisioner/drivers/oob/pyghmi_driver/__init__.py index 7456d115..402ec7a3 100644 --- a/drydock_provisioner/drivers/oob/pyghmi_driver/__init__.py +++ b/drydock_provisioner/drivers/oob/pyghmi_driver/__init__.py @@ -67,11 +67,6 @@ class PyghmiDriver(oob.OobDriver): raise errors.DriverError("No design ID specified in task %s" % (task_id)) - - if task.site_name is None: - raise errors.DriverError("Not site specified for task %s." % - (task_id)) - self.orchestrator.task_field_update(task.get_id(), status=hd_fields.TaskStatus.Running) @@ -98,8 +93,7 @@ class PyghmiDriver(oob.OobDriver): subtask = self.orchestrator.create_task(task_model.DriverTask, parent_task_id=task.get_id(), design_id=design_id, action=task.action, - task_scope={'site': task.site_name, - 'node_names': [n.get_name()]}) + task_scope={'node_names': [n.get_name()]}) incomplete_subtasks.append(subtask.get_id()) runner = PyghmiTaskRunner(state_manager=self.state_manager, diff --git a/drydock_provisioner/drydock_client/.client.py.swp b/drydock_provisioner/drydock_client/.client.py.swp deleted file mode 100644 index 9889eefc..00000000 Binary files a/drydock_provisioner/drydock_client/.client.py.swp and /dev/null differ diff --git a/drydock_provisioner/drydock_client/client.py b/drydock_provisioner/drydock_client/client.py index d0ddce3b..3fe12f5f 100644 --- a/drydock_provisioner/drydock_client/client.py +++ b/drydock_provisioner/drydock_client/client.py @@ -14,75 +14,180 @@ import json import requests -from .session import DrydockSession +from drydock_provisioner import error as errors class DrydockClient(object): """" A client for the Drydock API - :param string host: Hostname or IP address of Drydock API - :param string port: Port number of Drydock API - :param string version: API version to access - :param string token: Authentication token to use - :param string marker: (optional) External marker to include with requests + :param DrydockSession session: A instance of DrydockSession to be used by this client """ - def __init__(self, host=None, port=9000, version='1.0', token=None, marker=None): - self.version = version - self.session = DrydockSession(token=token, ext_marker=marker) - self.base_url = "http://%s:%d/api/%s/" % (host, port, version) + def __init__(self, session): + self.session = session - def send_get(self, api_url, query=None): - """ - Send a GET request to Drydock. - - :param string api_url: The URL string following the hostname and API prefix - :param dict query: A dict of k, v pairs to add to the query string - :return: A requests.Response object - """ - resp = requests.get(self.base_url + api_url, params=query) - - return resp - - def send_post(self, api_url, query=None, body=None, data=None): - """ - Send a POST request to Drydock. If both body and data are specified, - body will will be used. - - :param string api_url: The URL string following the hostname and API prefix - :param dict query: A dict of k, v parameters to add to the query string - :param string body: A string to use as the request body. Will be treated as raw - :param data: Something json.dumps(s) can serialize. Result will be used as the request body - :return: A requests.Response object - """ - - if body is not None: - resp = requests.post(self.base_url + api_url, params=query, data=body) - else: - resp = requests.post(self.base_url + api_url, params=query, json=data) - - return resp - - def get_designs(self): + def get_design_ids(self): """ Get list of Drydock design_ids :return: A list of string design_ids """ + endpoint = 'v1.0/designs' - def get_design(self, design_id): + resp = self.session.get(endpoint) - def create_design(self): + if resp.status_code != 200: + raise errors.ClientError("Received a %d from GET URL: %s" % (resp.status_code, endpoint), + code=resp.status_code) + else: + return resp.json() - def get_parts(self, design_id): + def get_design(self, design_id, source='designed'): + """ + Get a full design based on the passed design_id - def get_part(self, design_id, kind, key): + :param string design_id: A UUID design_id + :param string source: The model source to return. 'designed' is as input, 'compiled' is after merging + :return: A dict of the design and all currently loaded design parts + """ + endpoint = "v1.0/designs/%s" % design_id + + + resp = self.session.get(endpoint, query={'source': source}) + + if resp.status_code == 404: + raise errors.ClientError("Design ID %s not found." % (design_id), code=404) + elif resp.status_code != 200: + raise errors.ClientError("Received a %d from GET URL: %s" % (resp.status_code, endpoint), + code=resp.status_code) + else: + return resp.json() + + def create_design(self, base_design=None): + """ + Create a new design context for holding design parts + + :param string base_design: String UUID of the base design to build this design upon + :return string: String UUID of the design ID + """ + endpoint = 'v1.0/designs' + + if base_design is not None: + resp = self.session.post(endpoint, data={'base_design_id': base_design}) + else: + resp = self.session.post(endpoint) + + if resp.status_code != 201: + raise errors.ClientError("Received a %d from POST URL: %s" % (resp.status_code, endpoint), + code=resp.status_code) + else: + design = resp.json() + return design.get('id', None) + + def get_part(self, design_id, kind, key, source='designed'): + """ + Query the model definition of a design part + + :param string design_id: The string UUID of the design context to query + :param string kind: The design part kind as defined in the Drydock design YAML schema + :param string key: The design part key, generally a name. + :param string source: The model source to return. 'designed' is as input, 'compiled' is after merging + :return: A dict of the design part + """ + + endpoint = "v1.0/designs/%s/parts/%s/%s" % (design_id, kind, key) + + resp = self.session.get(endpoint, query={'source': source}) + + if resp.status_code == 404: + raise errors.ClientError("%s %s in design %s not found" % (key, kind, design_id), code=404) + elif resp.status_code != 200: + raise errors.ClientError("Received a %d from GET URL: %s" % (resp.status_code, endpoint), + code=resp.status_code) + else: + return resp.json() def load_parts(self, design_id, yaml_string=None): + """ + Load new design parts into a design context via YAML conforming to the Drydock design YAML schema + :param string design_id: String uuid design_id of the design context + :param string yaml_string: A single or multidoc YAML string to be ingested + :return: Dict of the parsed design parts + """ + + endpoint = "v1.0/designs/%s/parts" % (design_id) + + resp = self.session.post(endpoint, query={'ingester': 'yaml'}, body=yaml_string) + + if resp.status_code == 400: + raise errors.ClientError("Invalid inputs: %s" % resp.text, code=resp.status_code) + elif resp.status_code == 500: + raise errors.ClientError("Server error: %s" % resp.text, code=resp.status_code) + elif resp.status_code == 201: + return resp.json() + else: + raise errors.ClientError("Uknown error. Received %d" % resp.status_code, + code=resp.status_code) def get_tasks(self): + """ + Get a list of all the tasks, completed or running. + + :return: List of string uuid task IDs + """ + + endpoint = "v1.0/tasks" + + resp = self.session.get(endpoint) + + if resp.status_code != 200: + raise errors.ClientError("Server error: %s" % resp.text, code=resp.status_code) + else: + return resp.json() def get_task(self, task_id): + """ + Get the current description of a Drydock task + + :param string task_id: The string uuid task id to query + :return: A dict representing the current state of the task + """ + + endpoint = "v1.0/tasks/%s" % (task_id) + + resp = self.session.get(endpoint) + + if resp.status_code == 200: + return resp.json() + elif resp.status_code == 404: + raise errors.ClientError("Task %s not found" % task_id, code=resp.status_code) + else: + raise errors.ClientError("Server error: %s" % resp.text, code=resp.status_code) def create_task(self, design_id, task_action, node_filter=None): + """ + Create a new task in Drydock + + :param string design_id: A string uuid identifying the design context the task should operate on + :param string task_action: The action that should be executed + :param dict node_filter: A filter for narrowing the scope of the task. Valid fields are 'node_names', + 'rack_names', 'node_tags'. + :return: The string uuid of the create task's id + """ + + endpoint = 'v1.0/tasks' + + task_dict = { + 'action': task_action, + 'design_id': design_id, + 'node_filter': node_filter + } + + resp = self.session.post(endpoint, data=task_dict) + + if resp.status_code == 201: + return resp.json().get('task_id') + elif resp.status_code == 400: + raise errors.ClientError("Invalid inputs, received a %d: %s" % (resp.status_code, resp.text), + code=resp.status_code) diff --git a/drydock_provisioner/drydock_client/session.py b/drydock_provisioner/drydock_client/session.py index a93c2351..28c3fe37 100644 --- a/drydock_provisioner/drydock_client/session.py +++ b/drydock_provisioner/drydock_client/session.py @@ -11,16 +11,63 @@ # 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 requests -class DrydockSession(object) +class DrydockSession(object): """ A session to the Drydock API maintaining credentials and API options + :param string host: The Drydock server hostname or IP + :param int port: (optional) The service port appended if specified :param string token: Auth token :param string marker: (optional) external context marker """ - def __init__(self, token=None, marker=None): + def __init__(self, host, *, port=None, scheme='http', token=None, marker=None): + self.__session = requests.Session() + self.__session.headers.update({'X-Auth-Token': token, 'X-Context-Marker': marker}) + self.host = host + self.scheme = scheme + + if port: + self.port = port + self.base_url = "%s://%s:%s/api/" % (self.scheme, self.host, self.port) + else: + #assume default port for scheme + self.base_url = "%s://%s/api/" % (self.scheme, self.host) + self.token = token self.marker = marker + # TODO Add keystone authentication to produce a token for this session + def get(self, endpoint, query=None): + """ + Send a GET request to Drydock. + + :param string endpoint: The URL string following the hostname and API prefix + :param dict query: A dict of k, v pairs to add to the query string + :return: A requests.Response object + """ + resp = self.__session.get(self.base_url + endpoint, params=query, timeout=10) + + return resp + + def post(self, endpoint, query=None, body=None, data=None): + """ + Send a POST request to Drydock. If both body and data are specified, + body will will be used. + + :param string endpoint: The URL string following the hostname and API prefix + :param dict query: A dict of k, v parameters to add to the query string + :param string body: A string to use as the request body. Will be treated as raw + :param data: Something json.dumps(s) can serialize. Result will be used as the request body + :return: A requests.Response object + """ + + if body is not None: + resp = self.__session.post(self.base_url + endpoint, params=query, data=body, timeout=10) + else: + resp = self.__session.post(self.base_url + endpoint, params=query, json=data, timeout=10) + + return resp + diff --git a/drydock_provisioner/error.py b/drydock_provisioner/error.py index 04a38196..8f0076fd 100644 --- a/drydock_provisioner/error.py +++ b/drydock_provisioner/error.py @@ -11,6 +11,7 @@ # 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 class DesignError(Exception): pass @@ -37,7 +38,22 @@ class PersistentDriverError(DriverError): pass class ApiError(Exception): - pass + def __init__(self, msg, code=500): + super().__init__(msg) + self.message = msg + self.status_code = code + + def to_json(self): + err_dict = {'error': msg, 'type': self.__class__.__name__} + return json.dumps(err_dict) class InvalidFormat(ApiError): - pass \ No newline at end of file + def __init__(self, msg, code=400): + super(InvalidFormat, self).__init__(msg, code=code) + +class ClientError(Exception): + def __init__(self, msg, code=500): + super().__init__(msg) + self.message = msg + self.status_code = code + diff --git a/drydock_provisioner/ingester/plugins/yaml.py b/drydock_provisioner/ingester/plugins/yaml.py index f9e6b7d7..0aac87d8 100644 --- a/drydock_provisioner/ingester/plugins/yaml.py +++ b/drydock_provisioner/ingester/plugins/yaml.py @@ -333,6 +333,7 @@ class YamlIngester(IngesterPlugin): node_metadata = spec.get('metadata', {}) metadata_tags = node_metadata.get('tags', []) + model.tags = metadata_tags owner_data = node_metadata.get('owner_data', {}) model.owner_data = {} diff --git a/drydock_provisioner/objects/task.py b/drydock_provisioner/objects/task.py index 6da718c8..293c8b56 100644 --- a/drydock_provisioner/objects/task.py +++ b/drydock_provisioner/objects/task.py @@ -79,15 +79,9 @@ class Task(object): class OrchestratorTask(Task): - def __init__(self, site=None, design_id=None, **kwargs): + def __init__(self, design_id=None, **kwargs): super(OrchestratorTask, self).__init__(**kwargs) - # Validate parameters based on action - self.site = site - - if self.site is None: - raise ValueError("Orchestration Task requires 'site' parameter") - self.design_id = design_id if self.action in [hd_fields.OrchestratorAction.VerifyNode, @@ -99,7 +93,6 @@ class OrchestratorTask(Task): def to_dict(self): _dict = super(OrchestratorTask, self).to_dict() - _dict['site'] = self.site _dict['design_id'] = self.design_id _dict['node_filter'] = getattr(self, 'node_filter', None) @@ -109,17 +102,14 @@ class DriverTask(Task): def __init__(self, task_scope={}, **kwargs): super(DriverTask, self).__init__(**kwargs) - self.design_id = kwargs.get('design_id', 0) - - self.site_name = task_scope.get('site', None) + self.design_id = kwargs.get('design_id', None) self.node_list = task_scope.get('node_names', []) def to_dict(self): _dict = super(DriverTask, self).to_dict() - _dict['site_name'] = self.site_name _dict['design_id'] = self.design_id _dict['node_list'] = self.node_list - return _dict \ No newline at end of file + return _dict diff --git a/drydock_provisioner/orchestrator/__init__.py b/drydock_provisioner/orchestrator/__init__.py index 645108e1..f16f2aef 100644 --- a/drydock_provisioner/orchestrator/__init__.py +++ b/drydock_provisioner/orchestrator/__init__.py @@ -94,7 +94,6 @@ class Orchestrator(object): % (task_id)) design_id = task.design_id - task_site = task.site # Just for testing now, need to implement with enabled_drivers # logic @@ -157,16 +156,11 @@ class Orchestrator(object): result=hd_fields.ActionResult.Failure) return - task_scope = { - 'site': task.site - } - worked = failed = False site_network_task = self.create_task(tasks.DriverTask, parent_task_id=task.get_id(), design_id=design_id, - task_scope=task_scope, action=hd_fields.OrchestratorAction.CreateNetworkTemplate) self.logger.info("Starting node driver task %s to create network templates" % (site_network_task.get_id())) @@ -187,7 +181,6 @@ class Orchestrator(object): user_creds_task = self.create_task(tasks.DriverTask, parent_task_id=task.get_id(), design_id=design_id, - task_scope=task_scope, action=hd_fields.OrchestratorAction.ConfigureUserCredentials) self.logger.info("Starting node driver task %s to configure user credentials" % (user_creds_task.get_id())) @@ -217,8 +210,7 @@ class Orchestrator(object): result=final_result) return elif task.action == hd_fields.OrchestratorAction.VerifyNode: - self.task_field_update(task_id, - status=hd_fields.TaskStatus.Running) + self.task_field_update(task_id, status=hd_fields.TaskStatus.Running) site_design = self.get_effective_site(design_id) @@ -253,8 +245,7 @@ class Orchestrator(object): target_names = [x.get_name() for x in oob_nodes] - task_scope = {'site' : task_site, - 'node_names' : target_names} + task_scope = {'node_names' : target_names} oob_driver_task = self.create_task(tasks.DriverTask, parent_task_id=task.get_id(), @@ -341,8 +332,7 @@ class Orchestrator(object): target_names = [x.get_name() for x in oob_nodes] - task_scope = {'site' : task_site, - 'node_names' : target_names} + task_scope = {'node_names' : target_names} setboot_task = self.create_task(tasks.DriverTask, parent_task_id=task.get_id(), @@ -429,8 +419,7 @@ class Orchestrator(object): node_commission_task = self.create_task(tasks.DriverTask, parent_task_id=task.get_id(), design_id=design_id, action=hd_fields.OrchestratorAction.ConfigureHardware, - task_scope={'site': task_site, - 'node_names': node_identify_task.result_detail['successful_nodes']}) + task_scope={'node_names': node_identify_task.result_detail['successful_nodes']}) self.logger.info("Starting node driver task %s to commission nodes." % (node_commission_task.get_id())) node_driver.execute_task(node_commission_task.get_id()) @@ -482,8 +471,7 @@ class Orchestrator(object): target_names = [x.get_name() for x in target_nodes] - task_scope = {'site' : task_site, - 'node_names' : target_names} + task_scope = {'node_names' : target_names} node_networking_task = self.create_task(tasks.DriverTask, parent_task_id=task.get_id(), design_id=design_id, @@ -510,8 +498,7 @@ class Orchestrator(object): node_platform_task = self.create_task(tasks.DriverTask, parent_task_id=task.get_id(), design_id=design_id, action=hd_fields.OrchestratorAction.ApplyNodePlatform, - task_scope={'site': task_site, - 'node_names': node_networking_task.result_detail['successful_nodes']}) + task_scope={'node_names': node_networking_task.result_detail['successful_nodes']}) self.logger.info("Starting node driver task %s to configure node platform." % (node_platform_task.get_id())) node_driver.execute_task(node_platform_task.get_id()) @@ -532,8 +519,7 @@ class Orchestrator(object): node_deploy_task = self.create_task(tasks.DriverTask, parent_task_id=task.get_id(), design_id=design_id, action=hd_fields.OrchestratorAction.DeployNode, - task_scope={'site': task_site, - 'node_names': node_platform_task.result_detail['successful_nodes']}) + task_scope={'node_names': node_platform_task.result_detail['successful_nodes']}) self.logger.info("Starting node driver task %s to deploy nodes." % (node_deploy_task.get_id())) node_driver.execute_task(node_deploy_task.get_id()) diff --git a/entrypoint.sh b/entrypoint.sh index d6ceca38..c981906f 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,7 +2,7 @@ set -ex CMD="drydock" -PORT="8000" +PORT=${PORT:-9000} if [ "$1" = 'server' ]; then exec uwsgi --http :${PORT} -w drydock_provisioner.drydock --callable drydock --enable-threads -L --pyargv "--config-file /etc/drydock/drydock.conf" diff --git a/examples/designparts_v1.0.yaml b/examples/designparts_v1.0.yaml index 9953e68b..f174b73b 100644 --- a/examples/designparts_v1.0.yaml +++ b/examples/designparts_v1.0.yaml @@ -346,4 +346,4 @@ spec: address: 'dhcp' - network: 'mgmt' address: '172.16.1.83' ---- \ No newline at end of file +... \ No newline at end of file diff --git a/requirements-direct.txt b/requirements-direct.txt index bf099b84..5c210bb9 100644 --- a/requirements-direct.txt +++ b/requirements-direct.txt @@ -8,3 +8,4 @@ oauthlib uwsgi>1.4 bson===0.4.7 oslo.config +click===6.7 diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 00000000..a7a17ab5 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,6 @@ +pytest-mock +pytest +responses +mock +tox +oslo.versionedobjects[fixtures]>=1.23.0 diff --git a/setup.py b/setup.py index 88bef9fc..3e1fa69a 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,12 @@ setup(name='drydock_provisioner', 'drydock_provisioner.drivers.node', 'drydock_provisioner.drivers.node.maasdriver', 'drydock_provisioner.drivers.node.maasdriver.models', - 'drydock_provisioner.control'], + 'drydock_provisioner.control', + 'drydock_provisioner.cli', + 'drydock_provisioner.cli.design', + 'drydock_provisioner.cli.part', + 'drydock_provisioner.cli.task', + 'drydock_provisioner.drydock_client'], install_requires=[ 'PyYAML', 'pyghmi>=1.0.18', @@ -55,6 +60,7 @@ setup(name='drydock_provisioner', ], entry_points={ 'oslo.config.opts': 'drydock_provisioner = drydock_provisioner.config:list_opts', + 'console_scripts': 'drydock = drydock_provisioner.cli.commands:drydock' } ) diff --git a/testrequirements.txt b/testrequirements.txt deleted file mode 100644 index 16222260..00000000 --- a/testrequirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -pytest-mock -pytest -mock -tox -oslo.versionedobjects[fixtures]>=1.23.0 \ No newline at end of file diff --git a/tests/unit/test_drydock_client.py b/tests/unit/test_drydock_client.py new file mode 100644 index 00000000..aa2b3ff2 --- /dev/null +++ b/tests/unit/test_drydock_client.py @@ -0,0 +1,141 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# 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 pytest +import responses + +import drydock_provisioner.drydock_client.session as dc_session +import drydock_provisioner.drydock_client.client as dc_client + +def test_blank_session_error(): + with pytest.raises(Exception): + dd_ses = dc_session.DrydockSession() + +def test_session_init_minimal(): + port = 9000 + host = 'foo.bar.baz' + + dd_ses = dc_session.DrydockSession(host, port=port) + + assert dd_ses.base_url == "http://%s:%d/api/" % (host, port) + +def test_session_init_minimal_no_port(): + host = 'foo.bar.baz' + + dd_ses = dc_session.DrydockSession(host) + + assert dd_ses.base_url == "http://%s/api/" % (host) + +def test_session_init_uuid_token(): + host = 'foo.bar.baz' + token = '5f1e08b6-38ec-4a99-9d0f-00d29c4e325b' + + dd_ses = dc_session.DrydockSession(host, token=token) + + assert dd_ses.base_url == "http://%s/api/" % (host) + assert dd_ses.token == token + +def test_session_init_fernet_token(): + host = 'foo.bar.baz' + token = 'gAAAAABU7roWGiCuOvgFcckec-0ytpGnMZDBLG9hA7Hr9qfvdZDHjsak39YN98HXxoYLIqVm19Egku5YR3wyI7heVrOmPNEtmr-fIM1rtahudEdEAPM4HCiMrBmiA1Lw6SU8jc2rPLC7FK7nBCia_BGhG17NVHuQu0S7waA306jyKNhHwUnpsBQ' + + dd_ses = dc_session.DrydockSession(host, token=token) + + assert dd_ses.base_url == "http://%s/api/" % (host) + assert dd_ses.token == token + +def test_session_init_marker(): + host = 'foo.bar.baz' + marker = '5f1e08b6-38ec-4a99-9d0f-00d29c4e325b' + + dd_ses = dc_session.DrydockSession(host, marker=marker) + + assert dd_ses.base_url == "http://%s/api/" % (host) + assert dd_ses.marker == marker + +@responses.activate +def test_session_get(): + responses.add(responses.GET, 'http://foo.bar.baz/api/v1.0/test', body='okay', + status=200) + host = 'foo.bar.baz' + token = '5f1e08b6-38ec-4a99-9d0f-00d29c4e325b' + marker = '40c3eaf6-6a8a-11e7-a4bd-080027ef795a' + + dd_ses = dc_session.DrydockSession(host, token=token, marker=marker) + + resp = dd_ses.get('v1.0/test') + req = resp.request + + assert req.headers.get('X-Auth-Token', None) == token + assert req.headers.get('X-Context-Marker', None) == marker + +@responses.activate +def test_client_designs_get(): + design_id = '828e88dc-6a8b-11e7-97ae-080027ef795a' + responses.add(responses.GET, 'http://foo.bar.baz/api/v1.0/designs', + json=[design_id], status=200) + + host = 'foo.bar.baz' + token = '5f1e08b6-38ec-4a99-9d0f-00d29c4e325b' + + dd_ses = dc_session.DrydockSession(host, token=token) + dd_client = dc_client.DrydockClient(dd_ses) + design_list = dd_client.get_design_ids() + + assert design_id in design_list + +@responses.activate +def test_client_design_get(): + design = { 'id': '828e88dc-6a8b-11e7-97ae-080027ef795a', + 'model_type': 'SiteDesign' + } + + responses.add(responses.GET, 'http://foo.bar.baz/api/v1.0/designs/828e88dc-6a8b-11e7-97ae-080027ef795a', + json=design, status=200) + + host = 'foo.bar.baz' + + dd_ses = dc_session.DrydockSession(host) + dd_client = dc_client.DrydockClient(dd_ses) + + design_resp = dd_client.get_design('828e88dc-6a8b-11e7-97ae-080027ef795a') + + assert design_resp['id'] == design['id'] + assert design_resp['model_type'] == design['model_type'] + +@responses.activate +def test_client_task_get(): + task = {'action': 'deploy_node', + 'result': 'success', + 'parent_task': '444a1a40-7b5b-4b80-8265-cadbb783fa82', + 'subtasks': [], + 'status': 'complete', + 'result_detail': { + 'detail': ['Node cab23-r720-17 deployed'] + }, + 'site_name': 'mec_demo', + 'task_id': '1476902c-758b-49c0-b618-79ff3fd15166', + 'node_list': ['cab23-r720-17'], + 'design_id': 'fcf37ba1-4cde-48e5-a713-57439fc6e526'} + + host = 'foo.bar.baz' + + responses.add(responses.GET, "http://%s/api/v1.0/tasks/1476902c-758b-49c0-b618-79ff3fd15166" % (host), + json=task, status=200) + + dd_ses = dc_session.DrydockSession(host) + dd_client = dc_client.DrydockClient(dd_ses) + + task_resp = dd_client.get_task('1476902c-758b-49c0-b618-79ff3fd15166') + + assert task_resp['status'] == task['status'] diff --git a/tox.ini b/tox.ini index c92d6f00..8b8d3061 100644 --- a/tox.ini +++ b/tox.ini @@ -3,8 +3,8 @@ envlist = py35 [testenv] deps= - -rrequirements.txt - -rtestrequirements.txt + -rrequirements-direct.txt + -rrequirements-test.txt setenv= PYTHONWARNING=all commands= @@ -12,4 +12,4 @@ commands= {posargs} [flake8] -ignore=E302,H306 \ No newline at end of file +ignore=E302,H306