Policy Reconfiguration change to function parms
[dcaegen2/platform/cli.git] / dcae-cli / dcae_cli / commands / component / commands.py
1 # ============LICENSE_START=======================================================
2 # org.onap.dcae
3 # ================================================================================
4 # Copyright (c) 2017-2018 AT&T Intellectual Property. All rights reserved.
5 # ================================================================================
6 # Licensed under the Apache License, Version 2.0 (the "License");
7 # you may not use this file except in compliance with the License.
8 # You may obtain a copy of the License at
9 #
10 #      http://www.apache.org/licenses/LICENSE-2.0
11 #
12 # Unless required by applicable law or agreed to in writing, software
13 # distributed under the License is distributed on an "AS IS" BASIS,
14 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 # See the License for the specific language governing permissions and
16 # limitations under the License.
17 # ============LICENSE_END=========================================================
18 #
19 # ECOMP is a trademark and service mark of AT&T Intellectual Property.
20
21 # -*- coding: utf-8 -*-
22 """
23 Provides component commands
24 """
25 import json
26 from pprint import pformat
27
28 import click
29 import os
30
31 from discovery_client import resolve_name
32
33 from dcae_cli.util import profiles, load_json, dmaap, inputs, policy
34 from dcae_cli.util.run import run_component, dev_component
35 from dcae_cli.util import discovery as dis
36 from dcae_cli.util import docker_util as du
37 from dcae_cli.util.discovery import DiscoveryNoDownstreamComponentError
38 from dcae_cli.util.undeploy import undeploy_component
39 from dcae_cli.util.exc import DcaeException
40
41 from dcae_cli.commands import util
42 from dcae_cli.commands.util import parse_input, parse_input_pair, create_table
43
44 from dcae_cli.catalog.exc import MissingEntry
45
46
47 @click.group()
48 def component():
49     pass
50
51
52 @component.command(name='list')
53 @click.option('--latest', is_flag=True, default=True, help='Only list the latest version of components which match the filter criteria')
54 @click.option('--subscribes', '-sub', multiple=True, help='Only list components which subscribe to FORMAT')
55 @click.option('--publishes', '-pub', multiple=True, help='Only list components which publish FORMAT')
56 @click.option('--provides', '-pro', multiple=True, type=(str, str), help='Only list components which provide services REQ_FORMAT RESP_FORMAT')
57 @click.option('--calls', '-cal', multiple=True, type=(str, str), help='Only list components which call services REQ_FORMAT RESP_FORMAT')
58 @click.option('--deployed', is_flag=True, default=False, help='Display the deployed view. Shows details of deployed instances.')
59 @click.pass_obj
60 def list_component(obj, latest, subscribes, publishes, provides, calls, deployed):
61     '''Lists components in the public catalog. Uses flags to filter results.'''
62     subs = list(map(parse_input, subscribes)) if subscribes else None
63     pubs = list(map(parse_input, publishes)) if publishes else None
64     provs = list(map(parse_input_pair, provides)) if provides else None
65     cals = list(map(parse_input_pair, calls)) if calls else None
66
67     user, catalog = obj['config']['user'], obj['catalog']
68     # TODO: How about components that you don't own but you have deployed?
69     comps = catalog.list_components(subs, pubs, provs, cals, latest, user=user)
70
71     active_profile = profiles.get_profile()
72     consul_host = active_profile.consul_host
73
74     click.echo("Active profile: {0}".format(profiles.get_active_name()))
75     click.echo("")
76
77     def format_resolve_results(results):
78         """Format the results from the resolve_name function call"""
79         if results:
80             # Most likely the results will always be length one until we migrate
81             # to a different way of registering names
82             return "\n".join([ pformat(result) for result in results ])
83         else:
84             return None
85
86     def get_instances_as_rows(comp):
87         """Get all deployed running instances of a component plus details about
88         those instances and return as a list of rows"""
89         cname = comp["name"]
90         cver = comp["version"]
91         ctype = comp["component_type"]
92
93         instances = dis.get_healthy_instances(user, cname, cver)
94         instances_status = ["Healthy"]*len(instances)
95         instances_conns = [ format_resolve_results(resolve_name(consul_host, instance)) \
96                 for instance in instances ]
97
98         instances_defective = dis.get_defective_instances(user, cname, cver)
99         instances_status += ["Defective"]*len(instances_defective)
100         instances_conns += [""]*len(instances_defective)
101
102         instances += instances_defective
103
104         return list(zip(instances, instances_status, instances_conns))
105
106     # Generate grouped rows where a grouped row is (name, version, type, [instances])
107     grouped_rows = [ (comp, get_instances_as_rows(comp)) for comp in comps ]
108
109     # Display
110     if deployed:
111         def display_deployed(comp, instances):
112             cname = comp["name"]
113             cver = comp["version"]
114             ctype = comp["component_type"]
115
116             click.echo("Name: {0}".format(cname))
117             click.echo("Version: {0}".format(cver))
118             click.echo("Type: {0}".format(ctype))
119             click.echo(create_table(('Instance', 'Status', 'Connection'), instances))
120             click.echo("")
121
122         [ display_deployed(*row) for row in grouped_rows ]
123     else:
124         def format_row(comp, instances):
125             return comp["name"], comp["version"], comp["component_type"], \
126                 util.format_description(comp["description"]), \
127                 util.get_status_string(comp), comp["modified"], len(instances)
128
129         rows = [ format_row(*grouped_row) for grouped_row in grouped_rows ]
130         click.echo(create_table(('Name', 'Version', 'Type', 'Description',
131             'Status', 'Modified', '#Deployed'), rows))
132         click.echo("\nUse the \"--deployed\" option to see more details on deployments")
133
134
135 @component.command()
136 @click.argument('component', metavar="name:version")
137 @click.pass_obj
138 def show(obj, component):
139     '''Provides more information about a COMPONENT'''
140     cname, cver = parse_input(component)
141     catalog = obj['catalog']
142     comp_spec = catalog.get_component_spec(cname, cver)
143
144     click.echo(util.format_json(comp_spec))
145
146
147 _help_dmaap_file = """
148 Path to a file that contains a json of dmaap client information.  The structure of the json is expected to be:
149
150   {
151     <config_key1>: {..client object 1..},
152     <config_key2>: {..client object 2..},
153     ...
154   }
155
156 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.
157
158 Please refer to the documentation for examples of "client object".
159 """
160
161 def _parse_dmaap_file(dmaap_file):
162     try:
163         with open(dmaap_file, 'r+') as f:
164             dmaap_map = json.load(f)
165             dmaap.validate_dmaap_map_schema(dmaap_map)
166             return dmaap.apply_defaults_dmaap_map(dmaap_map)
167     except Exception as e:
168         message = "Problems with parsing the dmaap file. Check to make sure that it is a valid json and is in the expected format."
169         raise DcaeException(message)
170
171
172 _help_inputs_file = """
173 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:
174
175  {
176    <parameter1 name>: value,
177    <parameter2 name>: value
178  }
179
180 The "parameter name" is the value of the "name" property for the given configuration parameter.
181 """
182
183 def _parse_inputs_file(inputs_file):
184     try:
185         with open(inputs_file, 'r+') as f:
186             inputs_map = json.load(f)
187             # TODO: Validation of schema in the future?
188             return inputs_map
189     except Exception as e:
190         message = "Problems with parsing the inputs file. Check to make sure that it is a valid json and is in the expected format."
191         raise DcaeException(message)
192
193
194 _help_policy_file = """
195 Path to a file that contains a json of an (update/remove) Policy change.
196 All "policies" can also be specified.
197 The structure of the json is expected to be:
198
199 {
200 "updated_policies": [{"policyName": "value", "": ""},{"policyName": "value", "": ""}],
201 "removed_policies": [{"policyName": "value", "": ""},{"policyName": "value", "": ""}],
202 "policies": [{"policyName": "value", "": ""},{"policyName": "value", "": ""}]
203 }
204 """
205
206 def _parse_policy_file(policy_file):
207     try:
208         with open(policy_file, 'r+') as f:
209             policy_change_file = json.load(f)
210             policy.validate_against_policy_schema(policy_change_file)
211             return policy_change_file
212     except Exception as e:
213         click.echo(format(e))
214         message = "Problems with parsing the Policy file. Check to make sure that it is a valid json and is in the expected format."
215         raise DcaeException(message)
216
217 @component.command()
218 @click.option('--external-ip', '-ip', default=None, help='The external IP address of the Docker host. Only used for Docker components.')
219 @click.option('--additional-user', default=None, help='Additional user to grab instances from.')
220 @click.option('--attached', is_flag=True, help='(Docker) dcae-cli deploys then attaches to the component when set')
221 @click.option('--force', is_flag=True, help='Force component to run without valid downstream dependencies')
222 @click.option('--dmaap-file', type=click.Path(resolve_path=True, exists=True, dir_okay=False),
223         help=_help_dmaap_file)
224 @click.option('--inputs-file', type=click.Path(resolve_path=True, exists=True, dir_okay=False),
225         help=_help_inputs_file)
226 @click.argument('component')
227 @click.pass_obj
228 def run(obj, external_ip, additional_user, attached, force, dmaap_file, component,
229         inputs_file):
230     '''Runs latest (or specific) COMPONENT version. You may optionally specify version via COMPONENT:VERSION'''
231
232     click.echo("Running the Component.....")
233     click.echo("")
234
235     cname, cver = parse_input(component)
236     user, catalog = obj['config']['user'], obj['catalog']
237
238     dmaap_map = _parse_dmaap_file(dmaap_file) if dmaap_file else {}
239     inputs_map = _parse_inputs_file(inputs_file) if inputs_file else {}
240
241     try:
242         run_component(user, cname, cver, catalog, additional_user, attached, force,
243                 dmaap_map, inputs_map, external_ip)
244     except DiscoveryNoDownstreamComponentError as e:
245         message = "Either run a compatible downstream component first or run with the --force flag to ignore this error"
246         raise DcaeException(message)
247     except inputs.InputsValidationError as e:
248         click.echo("ERROR: There is a problem. {0}".format(e))
249         click.echo("")
250         message = "Component requires inputs. Please look at the use of --inputs-file and make sure the format is correct"
251         raise DcaeException(message)
252
253 @component.command()
254 @click.argument('component')
255 @click.pass_obj
256 def undeploy(obj,  component):
257     '''Undeploy latest (or specific) COMPONENT version. You may optionally specify version via COMPONENT:VERSION'''
258     cname, cver = parse_input(component)
259     user, catalog = obj['config']['user'], obj['catalog']
260     undeploy_component(user, cname, cver, catalog)
261
262
263 @component.command()
264 @click.argument('specification', type=click.Path(resolve_path=True, exists=True))
265 @click.option('--additional-user', default=None, help='Additional user to grab instances from.')
266 @click.option('--force', is_flag=True, help='Force component to run without valid downstream dependencies')
267 @click.option('--dmaap-file', type=click.Path(resolve_path=True, exists=True, dir_okay=False),
268         help=_help_dmaap_file)
269 @click.option('--inputs-file', type=click.Path(resolve_path=True, exists=True, dir_okay=False),
270         help=_help_inputs_file)
271 @click.pass_obj
272 def dev(obj, specification, additional_user, force, dmaap_file, inputs_file):
273     '''Set up component in development for discovery, use for local development'''
274     user, catalog = obj['config']['user'], obj['catalog']
275
276     dmaap_map = _parse_dmaap_file(dmaap_file) if dmaap_file else {}
277     inputs_map = _parse_inputs_file(inputs_file) if inputs_file else {}
278
279     with open(specification, 'r+') as f:
280         spec = json.loads(f.read())
281         try:
282             dev_component(user, catalog, spec, additional_user, force, dmaap_map,
283                     inputs_map)
284         except DiscoveryNoDownstreamComponentError as e:
285             message = "Either run a compatible downstream component first or run with the --force flag to ignore this error"
286             raise DcaeException(message)
287         except inputs.InputsValidationError as e:
288             click.echo("ERROR: There is a problem. {0}".format(e))
289             click.echo("")
290             message = "Component requires inputs. Please look at the use of --inputs-file and make sure the format is correct"
291             raise DcaeException(message)
292
293
294 @component.command()
295 @click.argument('component')
296 @click.pass_obj
297 def publish(obj, component):
298     """Pushes a COMPONENT to the public catalog"""
299     name, version = parse_input(component)
300     user, catalog = obj['config']['user'], obj['catalog']
301
302     try:
303         # Dependent data formats must be published first before publishing
304         # component. Check that here
305         unpub_formats = catalog.get_unpublished_formats(name, version)
306
307         if unpub_formats:
308             click.echo("ERROR: You must publish dependent data formats first:")
309             click.echo("")
310             click.echo("\n".join([":".join(uf) for uf in unpub_formats]))
311             click.echo("")
312             return
313     except MissingEntry as e:
314         raise DcaeException("Component not found")
315
316     if catalog.publish_component(user, name, version):
317         click.echo("Component has been published")
318     else:
319         click.echo("ERROR: Component could not be published")
320
321
322 @component.command()
323 @click.option('--update', is_flag=True, help='Updates a locally added component if it has not already been published')
324 @click.argument('specification', type=click.Path(resolve_path=True, exists=True))
325 @click.pass_obj
326 def add(obj, update, specification):
327     """Add Component to local onboarding catalog"""
328     user, catalog = obj['config']['user'], obj['catalog']
329
330     spec = load_json(specification)
331     catalog.add_component(user, spec, update)
332
333
334 @component.command()
335 @click.option('--policy-file', type=click.Path(resolve_path=True, exists=True, dir_okay=False), help=_help_policy_file)
336 @click.argument('component')
337 @click.pass_obj
338 def reconfig(obj, policy_file, component):
339     """Reconfigure COMPONENT for Policy change.
340         Modify Consul KV pairs for ('updated_policies', 'removed_policies', and 'policies') for Policy change event,
341         Execute the reconfig script(s) in the Docker container"""
342
343     click.echo("Running Component Reconfiguration.....")
344     click.echo("")
345
346     #  Read and Validate the policy-file
347     policy_change_file = _parse_policy_file(policy_file) if policy_file else {}
348
349     if not (policy_change_file):
350         click.echo("ERROR: For component 'reconfig', you must specify a --policy-file")
351         click.echo("")
352         return
353     else:
354         #  The Component Spec contains the Policy 'Reconfig Script Path/ScriptName'
355         cname, cver = parse_input(component)
356         catalog     = obj['catalog']
357         comp_spec   = catalog.get_component_spec(cname, cver)
358
359     #  Check if component is running and healthy
360     active_profile = profiles.get_profile()
361     consul_host    = active_profile.consul_host
362     service_name   = os.environ["SERVICE_NAME"]
363     if dis.is_healthy(consul_host, service_name):
364         pass
365     else:
366         click.echo("ERROR: The component must be running and healthy.  It is not.")
367         click.echo("")
368         return
369
370     try:
371         policy_reconfig_path = comp_spec['auxilary']['policy']['script_path']
372     except KeyError:
373         click.echo("ERROR: Policy Reconfig Path (auxilary/policy/script_path) is not specified in the Component Spec")
374         click.echo("")
375         return
376
377     kvUpdated = dis.policy_update(policy_change_file, dis.default_consul_host())
378
379     if kvUpdated:
380         active_profile = profiles.get_profile()
381         docker_logins  = dis.get_docker_logins()
382
383         command = dis.build_policy_command(policy_reconfig_path, policy_change_file, dis.default_consul_host())
384
385         #  Run the Policy Reconfig script
386         client = du.get_docker_client(active_profile, docker_logins)
387         du.reconfigure(client, service_name, command)
388     else:
389         click.echo("ERROR: There was a problem updating the policies in Consul")
390         click.echo("")
391         return
392
393     click.echo("")
394     click.echo("The End of Component Reconfiguration")