summaryrefslogtreecommitdiffstats
path: root/dcae-cli/dcae_cli/commands/component/commands.py
blob: d91027f6053a14d40db0104c522345637e95a405 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
# ============LICENSE_START=======================================================
# org.onap.dcae
# ================================================================================
# Copyright (c) 2017-2018 AT&T Intellectual Property. All 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.
# ============LICENSE_END=========================================================
#
# ECOMP is a trademark and service mark of AT&T Intellectual Property.

# -*- coding: utf-8 -*-
"""
Provides component commands
"""
import json
from pprint import pformat

import click
import os

from discovery_client import resolve_name

from dcae_cli.util import profiles, load_json, dmaap, inputs, policy
from dcae_cli.util.run import run_component, dev_component
from dcae_cli.util import discovery as dis
from dcae_cli.util import docker_util as du
from dcae_cli.util.discovery import DiscoveryNoDownstreamComponentError
from dcae_cli.util.undeploy import undeploy_component
from dcae_cli.util.exc import DcaeException

from dcae_cli.commands import util
from dcae_cli.commands.util import parse_input, parse_input_pair, create_table

from dcae_cli.catalog.exc import MissingEntry


@click.group()
def component():
    pass


@component.command(name='list')
@click.option('--latest', is_flag=True, default=True, help='Only list the latest version of components which match the filter criteria')
@click.option('--subscribes', '-sub', multiple=True, help='Only list components which subscribe to FORMAT')
@click.option('--publishes', '-pub', multiple=True, help='Only list components which publish FORMAT')
@click.option('--provides', '-pro', multiple=True, type=(str, str), help='Only list components which provide services REQ_FORMAT RESP_FORMAT')
@click.option('--calls', '-cal', multiple=True, type=(str, str), help='Only list components which call services REQ_FORMAT RESP_FORMAT')
@click.option('--deployed', is_flag=True, default=False, help='Display the deployed view. Shows details of deployed instances.')
@click.pass_obj
def list_component(obj, latest, subscribes, publishes, provides, calls, deployed):
    '''Lists components in the public catalog. Uses flags to filter results.'''
    subs = list(map(parse_input, subscribes)) if subscribes else None
    pubs = list(map(parse_input, publishes)) if publishes else None
    provs = list(map(parse_input_pair, provides)) if provides else None
    cals = list(map(parse_input_pair, calls)) if calls else None

    user, catalog = obj['config']['user'], obj['catalog']
    # TODO: How about components that you don't own but you have deployed?
    comps = catalog.list_components(subs, pubs, provs, cals, latest, user=user)

    active_profile = profiles.get_profile()
    consul_host = active_profile.consul_host

    click.echo("Active profile: {0}".format(profiles.get_active_name()))
    click.echo("")

    def format_resolve_results(results):
        """Format the results from the resolve_name function call"""
        if results:
            # Most likely the results will always be length one until we migrate
            # to a different way of registering names
            return "\n".join([ pformat(result) for result in results ])
        else:
            return None

    def get_instances_as_rows(comp):
        """Get all deployed running instances of a component plus details about
        those instances and return as a list of rows"""
        cname = comp["name"]
        cver = comp["version"]
        ctype = comp["component_type"]

        instances = dis.get_healthy_instances(user, cname, cver)
        instances_status = ["Healthy"]*len(instances)
        instances_conns = [ format_resolve_results(resolve_name(consul_host, instance)) \
                for instance in instances ]

        instances_defective = dis.get_defective_instances(user, cname, cver)
        instances_status += ["Defective"]*len(instances_defective)
        instances_conns += [""]*len(instances_defective)

        instances += instances_defective

        return list(zip(instances, instances_status, instances_conns))

    # Generate grouped rows where a grouped row is (name, version, type, [instances])
    grouped_rows = [ (comp, get_instances_as_rows(comp)) for comp in comps ]

    # Display
    if deployed:
        def display_deployed(comp, instances):
            cname = comp["name"]
            cver = comp["version"]
            ctype = comp["component_type"]

            click.echo("Name: {0}".format(cname))
            click.echo("Version: {0}".format(cver))
            click.echo("Type: {0}".format(ctype))
            click.echo(create_table(('Instance', 'Status', 'Connection'), instances))
            click.echo("")

        [ display_deployed(*row) for row in grouped_rows ]
    else:
        def format_row(comp, instances):
            return comp["name"], comp["version"], comp["component_type"], \
                util.format_description(comp["description"]), \
                util.get_status_string(comp), comp["modified"], len(instances)

        rows = [ format_row(*grouped_row) for grouped_row in grouped_rows ]
        click.echo(create_table(('Name', 'Version', 'Type', 'Description',
            'Status', 'Modified', '#Deployed'), rows))
        click.echo("\nUse the \"--deployed\" option to see more details on deployments")


@component.command()
@click.argument('component', metavar="name:version")
@click.pass_obj
def show(obj, component):
    '''Provides more information about a COMPONENT'''
    cname, cver = parse_input(component)
    catalog = obj['catalog']
    comp_spec = catalog.get_component_spec(cname, cver)

    click.echo(util.format_json(comp_spec))


_help_dmaap_file = """
Path to a file that contains a json of dmaap client information.  The structure of the json is expected to be:

  {
    <config_key1>: {..client object 1..},
    <config_key2>: {..client object 2..},
    ...
  }

Where "client object" can be for message or data router. The "config_key" matches the value of specified in the message router "streams" in the component specification.

Please refer to the documentation for examples of "client object".
"""

def _parse_dmaap_file(dmaap_file):
    try:
        with open(dmaap_file, 'r+') as f:
            dmaap_map = json.load(f)
            dmaap.validate_dmaap_map_schema(dmaap_map)
            return dmaap.apply_defaults_dmaap_map(dmaap_map)
    except Exception as e:
        message = "Problems with parsing the dmaap file. Check to make sure that it is a valid json and is in the expected format."
        raise DcaeException(message)


_help_inputs_file = """
Path to a file that contains a json that contains values to be used to bind to configuration parameters that have been marked as "sourced_at_deployment". The structure of the json is expected to be:

 {
   <parameter1 name>: value,
   <parameter2 name>: value
 }

The "parameter name" is the value of the "name" property for the given configuration parameter.
"""

def _parse_inputs_file(inputs_file):
    try:
        with open(inputs_file, 'r+') as f:
            inputs_map = json.load(f)
            # TODO: Validation of schema in the future?
            return inputs_map
    except Exception as e:
        message = "Problems with parsing the inputs file. Check to make sure that it is a valid json and is in the expected format."
        raise DcaeException(message)


_help_policy_file = """
Path to a file that contains a json of an (update/remove) Policy change.
All "policies" can also be specified.
The structure of the json is expected to be:

{
"updated_policies": [{"policyName": "value", "": ""},{"policyName": "value", "": ""}],
"removed_policies": [{"policyName": "value", "": ""},{"policyName": "value", "": ""}],
"policies": [{"policyName": "value", "": ""},{"policyName": "value", "": ""}]
}
"""

def _parse_policy_file(policy_file):
    try:
        with open(policy_file, 'r+') as f:
            policy_change_file = json.load(f)
            policy.validate_against_policy_schema(policy_change_file)
            return policy_change_file
    except Exception as e:
        click.echo(format(e))
        message = "Problems with parsing the Policy file. Check to make sure that it is a valid json and is in the expected format."
        raise DcaeException(message)

@component.command()
@click.option('--external-ip', '-ip', default=None, help='The external IP address of the Docker host. Only used for Docker components.')
@click.option('--additional-user', default=None, help='Additional user to grab instances from.')
@click.option('--attached', is_flag=True, help='(Docker) dcae-cli deploys then attaches to the component when set')
@click.option('--force', is_flag=True, help='Force component to run without valid downstream dependencies')
@click.option('--dmaap-file', type=click.Path(resolve_path=True, exists=True, dir_okay=False),
        help=_help_dmaap_file)
@click.option('--inputs-file', type=click.Path(resolve_path=True, exists=True, dir_okay=False),
        help=_help_inputs_file)
@click.argument('component')
@click.pass_obj
def run(obj, external_ip, additional_user, attached, force, dmaap_file, component,
        inputs_file):
    '''Runs latest (or specific) COMPONENT version. You may optionally specify version via COMPONENT:VERSION'''

    click.echo("Running the Component.....")
    click.echo("")

    cname, cver = parse_input(component)
    user, catalog = obj['config']['user'], obj['catalog']

    dmaap_map = _parse_dmaap_file(dmaap_file) if dmaap_file else {}
    inputs_map = _parse_inputs_file(inputs_file) if inputs_file else {}

    try:
        run_component(user, cname, cver, catalog, additional_user, attached, force,
                dmaap_map, inputs_map, external_ip)
    except DiscoveryNoDownstreamComponentError as e:
        message = "Either run a compatible downstream component first or run with the --force flag to ignore this error"
        raise DcaeException(message)
    except inputs.InputsValidationError as e:
        click.echo("ERROR: There is a problem. {0}".format(e))
        click.echo("")
        message = "Component requires inputs. Please look at the use of --inputs-file and make sure the format is correct"
        raise DcaeException(message)

@component.command()
@click.argument('component')
@click.pass_obj
def undeploy(obj,  component):
    '''Undeploy latest (or specific) COMPONENT version. You may optionally specify version via COMPONENT:VERSION'''
    cname, cver = parse_input(component)
    user, catalog = obj['config']['user'], obj['catalog']
    undeploy_component(user, cname, cver, catalog)


@component.command()
@click.argument('specification', type=click.Path(resolve_path=True, exists=True))
@click.option('--additional-user', default=None, help='Additional user to grab instances from.')
@click.option('--force', is_flag=True, help='Force component to run without valid downstream dependencies')
@click.option('--dmaap-file', type=click.Path(resolve_path=True, exists=True, dir_okay=False),
        help=_help_dmaap_file)
@click.option('--inputs-file', type=click.Path(resolve_path=True, exists=True, dir_okay=False),
        help=_help_inputs_file)
@click.pass_obj
def dev(obj, specification, additional_user, force, dmaap_file, inputs_file):
    '''Set up component in development for discovery, use for local development'''
    user, catalog = obj['config']['user'], obj['catalog']

    dmaap_map = _parse_dmaap_file(dmaap_file) if dmaap_file else {}
    inputs_map = _parse_inputs_file(inputs_file) if inputs_file else {}

    with open(specification, 'r+') as f:
        spec = json.loads(f.read())
        try:
            dev_component(user, catalog, spec, additional_user, force, dmaap_map,
                    inputs_map)
        except DiscoveryNoDownstreamComponentError as e:
            message = "Either run a compatible downstream component first or run with the --force flag to ignore this error"
            raise DcaeException(message)
        except inputs.InputsValidationError as e:
            click.echo("ERROR: There is a problem. {0}".format(e))
            click.echo("")
            message = "Component requires inputs. Please look at the use of --inputs-file and make sure the format is correct"
            raise DcaeException(message)


@component.command()
@click.argument('component')
@click.pass_obj
def publish(obj, component):
    """Pushes a COMPONENT to the public catalog"""
    name, version = parse_input(component)
    user, catalog = obj['config']['user'], obj['catalog']

    try:
        # Dependent data formats must be published first before publishing
        # component. Check that here
        unpub_formats = catalog.get_unpublished_formats(name, version)

        if unpub_formats:
            click.echo("ERROR: You must publish dependent data formats first:")
            click.echo("")
            click.echo("\n".join([":".join(uf) for uf in unpub_formats]))
            click.echo("")
            return
    except MissingEntry as e:
        raise DcaeException("Component not found")

    if catalog.publish_component(user, name, version):
        click.echo("Component has been published")
    else:
        click.echo("ERROR: Component could not be published")


@component.command()
@click.option('--update', is_flag=True, help='Updates a locally added component if it has not already been published')
@click.argument('specification', type=click.Path(resolve_path=True, exists=True))
@click.pass_obj
def add(obj, update, specification):
    """Add Component to local onboarding catalog"""
    user, catalog = obj['config']['user'], obj['catalog']

    spec = load_json(specification)
    catalog.add_component(user, spec, update)


@component.command()
@click.option('--policy-file', type=click.Path(resolve_path=True, exists=True, dir_okay=False), help=_help_policy_file)
@click.argument('component')
@click.pass_obj
def reconfig(obj, policy_file, component):
    """Reconfigure COMPONENT for Policy change.
        Modify Consul KV pairs for ('updated_policies', 'removed_policies', and 'policies') for Policy change event,
        Execute the reconfig script(s) in the Docker container"""

    click.echo("Running Component Reconfiguration.....")
    click.echo("")

    #  Read and Validate the policy-file
    policy_change_file = _parse_policy_file(policy_file) if policy_file else {}

    if not (policy_change_file):
        click.echo("ERROR: For component 'reconfig', you must specify a --policy-file")
        click.echo("")
        return
    else:
        #  The Component Spec contains the Policy 'Reconfig Script Path/ScriptName'
        cname, cver = parse_input(component)
        catalog     = obj['catalog']
        comp_spec   = catalog.get_component_spec(cname, cver)

    #  Check if component is running and healthy
    active_profile = profiles.get_profile()
    consul_host    = active_profile.consul_host
    service_name   = os.environ["SERVICE_NAME"]
    if dis.is_healthy(consul_host, service_name):
        pass
    else:
        click.echo("ERROR: The component must be running and healthy.  It is not.")
        click.echo("")
        return

    try:
        policy_reconfig_path = comp_spec['auxilary']['policy']['script_path']
    except KeyError:
        click.echo("ERROR: Policy Reconfig Path (auxilary/policy/script_path) is not specified in the Component Spec")
        click.echo("")
        return

    kvUpdated = dis.policy_update(policy_change_file)

    if kvUpdated:
        active_profile = profiles.get_profile()
        docker_logins  = dis.get_docker_logins()

        command = dis.build_policy_command(policy_reconfig_path, policy_change_file)

        #  Run the Policy Reconfig script
        client = du.get_docker_client(active_profile, docker_logins)
        du.reconfigure(client, service_name, command)
    else:
        click.echo("ERROR: There was a problem updating the policies in Consul")
        click.echo("")
        return

    click.echo("")
    click.echo("The End of Component Reconfiguration")