507fe7db35ad09a84f3b27099a96586792e1996e
[kamaki] / kamaki / cli / commands / cyclades.py
1 # Copyright 2011-2013 GRNET S.A. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
5 # conditions are met:
6 #
7 #   1. Redistributions of source code must retain the above
8 #      copyright notice, this list of conditions and the following
9 #      disclaimer.
10 #
11 #   2. Redistributions in binary form must reproduce the above
12 #      copyright notice, this list of conditions and the following
13 #      disclaimer in the documentation and/or other materials
14 #      provided with the distribution.
15 #
16 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27 # POSSIBILITY OF SUCH DAMAGE.
28 #
29 # The views and conclusions contained in the software and
30 # documentation are those of the authors and should not be
31 # interpreted as representing official policies, either expressed
32 # or implied, of GRNET S.A.
33
34 from kamaki.cli import command
35 from kamaki.cli.command_tree import CommandTree
36 from kamaki.cli.utils import print_dict
37 from kamaki.cli.errors import raiseCLIError, CLISyntaxError, CLIBaseUrlError
38 from kamaki.clients.cyclades import CycladesClient, ClientError
39 from kamaki.cli.argument import FlagArgument, ValueArgument, KeyValueArgument
40 from kamaki.cli.argument import ProgressBarArgument, DateArgument, IntArgument
41 from kamaki.cli.commands import _command_init, errors, addLogSettings
42 from kamaki.cli.commands import _optional_output_cmd, _optional_json
43
44 from base64 import b64encode
45 from os.path import exists
46
47
48 server_cmds = CommandTree('server', 'Cyclades/Compute API server commands')
49 flavor_cmds = CommandTree('flavor', 'Cyclades/Compute API flavor commands')
50 network_cmds = CommandTree('network', 'Cyclades/Compute API network commands')
51 _commands = [server_cmds, flavor_cmds, network_cmds]
52
53
54 about_authentication = '\nUser Authentication:\
55     \n* to check authentication: /user authenticate\
56     \n* to set authentication token: /config set cloud.<cloud>.token <token>'
57
58 howto_personality = [
59     'Defines a file to be injected to VMs personality.',
60     'Personality value syntax: PATH,[SERVER_PATH,[OWNER,[GROUP,[MODE]]]]',
61     '  PATH: of local file to be injected',
62     '  SERVER_PATH: destination location inside server Image',
63     '  OWNER: user id of destination file owner',
64     '  GROUP: group id or name to own destination file',
65     '  MODEL: permition in octal (e.g. 0777 or o+rwx)']
66
67
68 class _init_cyclades(_command_init):
69     @errors.generic.all
70     @addLogSettings
71     def _run(self, service='compute'):
72         if getattr(self, 'cloud', None):
73             base_url = self._custom_url(service)\
74                 or self._custom_url('cyclades')
75             if base_url:
76                 token = self._custom_token(service)\
77                     or self._custom_token('cyclades')\
78                     or self.config.get_cloud('token')
79                 self.client = CycladesClient(
80                     base_url=base_url, token=token)
81                 return
82         else:
83             self.cloud = 'default'
84         if getattr(self, 'auth_base', False):
85             cyclades_endpoints = self.auth_base.get_service_endpoints(
86                 self._custom_type('cyclades') or 'compute',
87                 self._custom_version('cyclades') or '')
88             base_url = cyclades_endpoints['publicURL']
89             token = self.auth_base.token
90             self.client = CycladesClient(base_url=base_url, token=token)
91         else:
92             raise CLIBaseUrlError(service='cyclades')
93
94     def main(self):
95         self._run()
96
97
98 @command(server_cmds)
99 class server_list(_init_cyclades, _optional_json):
100     """List Virtual Machines accessible by user"""
101
102     __doc__ += about_authentication
103
104     arguments = dict(
105         detail=FlagArgument('show detailed output', ('-l', '--details')),
106         since=DateArgument(
107             'show only items since date (\' d/m/Y H:M:S \')',
108             '--since'),
109         limit=IntArgument('limit number of listed VMs', ('-n', '--number')),
110         more=FlagArgument(
111             'output results in pages (-n to set items per page, default 10)',
112             '--more'),
113         enum=FlagArgument('Enumerate results', '--enumerate')
114     )
115
116     @errors.generic.all
117     @errors.cyclades.connection
118     @errors.cyclades.date
119     def _run(self):
120         servers = self.client.list_servers(self['detail'], self['since'])
121
122         kwargs = dict(with_enumeration=self['enum'])
123         if self['more']:
124             kwargs['page_size'] = self['limit'] if self['limit'] else 10
125         elif self['limit']:
126             servers = servers[:self['limit']]
127         self._print(servers, **kwargs)
128
129     def main(self):
130         super(self.__class__, self)._run()
131         self._run()
132
133
134 @command(server_cmds)
135 class server_info(_init_cyclades, _optional_json):
136     """Detailed information on a Virtual Machine
137     Contains:
138     - name, id, status, create/update dates
139     - network interfaces
140     - metadata (e.g. os, superuser) and diagnostics
141     - hardware flavor and os image ids
142     """
143
144     @errors.generic.all
145     @errors.cyclades.connection
146     @errors.cyclades.server_id
147     def _run(self, server_id):
148         self._print(self.client.get_server_details(server_id), print_dict)
149
150     def main(self, server_id):
151         super(self.__class__, self)._run()
152         self._run(server_id=server_id)
153
154
155 class PersonalityArgument(KeyValueArgument):
156     @property
157     def value(self):
158         return self._value if hasattr(self, '_value') else []
159
160     @value.setter
161     def value(self, newvalue):
162         if newvalue == self.default:
163             return self.value
164         self._value = []
165         for i, terms in enumerate(newvalue):
166             termlist = terms.split(',')
167             if len(termlist) > 5:
168                 msg = 'Wrong number of terms (should be 1 to 5)'
169                 raiseCLIError(CLISyntaxError(msg), details=howto_personality)
170             path = termlist[0]
171             if not exists(path):
172                 raiseCLIError(
173                     None,
174                     '--personality: File %s does not exist' % path,
175                     importance=1,
176                     details=howto_personality)
177             self._value.append(dict(path=path))
178             with open(path) as f:
179                 self._value[i]['contents'] = b64encode(f.read())
180             try:
181                 self._value[i]['path'] = termlist[1]
182                 self._value[i]['owner'] = termlist[2]
183                 self._value[i]['group'] = termlist[3]
184                 self._value[i]['mode'] = termlist[4]
185             except IndexError:
186                 pass
187
188
189 @command(server_cmds)
190 class server_create(_init_cyclades, _optional_json):
191     """Create a server (aka Virtual Machine)
192     Parameters:
193     - name: (single quoted text)
194     - flavor id: Hardware flavor. Pick one from: /flavor list
195     - image id: OS images. Pick one from: /image list
196     """
197
198     arguments = dict(
199         personality=PersonalityArgument(
200             (80 * ' ').join(howto_personality), ('-p', '--personality'))
201     )
202
203     @errors.generic.all
204     @errors.cyclades.connection
205     @errors.plankton.id
206     @errors.cyclades.flavor_id
207     def _run(self, name, flavor_id, image_id):
208         self._print(
209             self.client.create_server(
210                 name, int(flavor_id), image_id, self['personality']),
211             print_dict)
212
213     def main(self, name, flavor_id, image_id):
214         super(self.__class__, self)._run()
215         self._run(name=name, flavor_id=flavor_id, image_id=image_id)
216
217
218 @command(server_cmds)
219 class server_rename(_init_cyclades, _optional_output_cmd):
220     """Set/update a server (VM) name
221     VM names are not unique, therefore multiple servers may share the same name
222     """
223
224     @errors.generic.all
225     @errors.cyclades.connection
226     @errors.cyclades.server_id
227     def _run(self, server_id, new_name):
228         self._optional_output(
229             self.client.update_server_name(int(server_id), new_name))
230
231     def main(self, server_id, new_name):
232         super(self.__class__, self)._run()
233         self._run(server_id=server_id, new_name=new_name)
234
235
236 @command(server_cmds)
237 class server_delete(_init_cyclades, _optional_output_cmd):
238     """Delete a server (VM)"""
239
240     @errors.generic.all
241     @errors.cyclades.connection
242     @errors.cyclades.server_id
243     def _run(self, server_id):
244             self._optional_output(self.client.delete_server(int(server_id)))
245
246     def main(self, server_id):
247         super(self.__class__, self)._run()
248         self._run(server_id=server_id)
249
250
251 @command(server_cmds)
252 class server_reboot(_init_cyclades, _optional_output_cmd):
253     """Reboot a server (VM)"""
254
255     arguments = dict(
256         hard=FlagArgument('perform a hard reboot', ('-f', '--force'))
257     )
258
259     @errors.generic.all
260     @errors.cyclades.connection
261     @errors.cyclades.server_id
262     def _run(self, server_id):
263         self._optional_output(
264             self.client.reboot_server(int(server_id), self['hard']))
265
266     def main(self, server_id):
267         super(self.__class__, self)._run()
268         self._run(server_id=server_id)
269
270
271 @command(server_cmds)
272 class server_start(_init_cyclades, _optional_output_cmd):
273     """Start an existing server (VM)"""
274
275     @errors.generic.all
276     @errors.cyclades.connection
277     @errors.cyclades.server_id
278     def _run(self, server_id):
279         self._optional_output(self.client.start_server(int(server_id)))
280
281     def main(self, server_id):
282         super(self.__class__, self)._run()
283         self._run(server_id=server_id)
284
285
286 @command(server_cmds)
287 class server_shutdown(_init_cyclades, _optional_output_cmd):
288     """Shutdown an active server (VM)"""
289
290     @errors.generic.all
291     @errors.cyclades.connection
292     @errors.cyclades.server_id
293     def _run(self, server_id):
294         self._optional_output(self.client.shutdown_server(int(server_id)))
295
296     def main(self, server_id):
297         super(self.__class__, self)._run()
298         self._run(server_id=server_id)
299
300
301 @command(server_cmds)
302 class server_console(_init_cyclades, _optional_json):
303     """Get a VNC console to access an existing server (VM)
304     Console connection information provided (at least):
305     - host: (url or address) a VNC host
306     - port: (int) the gateway to enter VM on host
307     - password: for VNC authorization
308     """
309
310     @errors.generic.all
311     @errors.cyclades.connection
312     @errors.cyclades.server_id
313     def _run(self, server_id):
314         self._print(
315             self.client.get_server_console(int(server_id)), print_dict)
316
317     def main(self, server_id):
318         super(self.__class__, self)._run()
319         self._run(server_id=server_id)
320
321
322 @command(server_cmds)
323 class server_firewall(_init_cyclades):
324     """Manage server (VM) firewall profiles for public networks"""
325
326
327 @command(server_cmds)
328 class server_firewall_set(_init_cyclades, _optional_output_cmd):
329     """Set the server (VM) firewall profile on VMs public network
330     Values for profile:
331     - DISABLED: Shutdown firewall
332     - ENABLED: Firewall in normal mode
333     - PROTECTED: Firewall in secure mode
334     """
335
336     @errors.generic.all
337     @errors.cyclades.connection
338     @errors.cyclades.server_id
339     @errors.cyclades.firewall
340     def _run(self, server_id, profile):
341         self._optional_output(self.client.set_firewall_profile(
342             server_id=int(server_id), profile=('%s' % profile).upper()))
343
344     def main(self, server_id, profile):
345         super(self.__class__, self)._run()
346         self._run(server_id=server_id, profile=profile)
347
348
349 @command(server_cmds)
350 class server_firewall_get(_init_cyclades):
351     """Get the server (VM) firewall profile for its public network"""
352
353     @errors.generic.all
354     @errors.cyclades.connection
355     @errors.cyclades.server_id
356     def _run(self, server_id):
357         print(self.client.get_firewall_profile(server_id))
358
359     def main(self, server_id):
360         super(self.__class__, self)._run()
361         self._run(server_id=server_id)
362
363
364 @command(server_cmds)
365 class server_addr(_init_cyclades, _optional_json):
366     """List the addresses of all network interfaces on a server (VM)"""
367
368     arguments = dict(
369         enum=FlagArgument('Enumerate results', '--enumerate')
370     )
371
372     @errors.generic.all
373     @errors.cyclades.connection
374     @errors.cyclades.server_id
375     def _run(self, server_id):
376         reply = self.client.list_server_nics(int(server_id))
377         self._print(
378             reply, with_enumeration=self['enum'] and len(reply) > 1)
379
380     def main(self, server_id):
381         super(self.__class__, self)._run()
382         self._run(server_id=server_id)
383
384
385 @command(server_cmds)
386 class server_metadata(_init_cyclades):
387     """Manage Server metadata (key:value pairs of server attributes)"""
388
389
390 @command(server_cmds)
391 class server_metadata_list(_init_cyclades, _optional_json):
392     """Get server metadata"""
393
394     @errors.generic.all
395     @errors.cyclades.connection
396     @errors.cyclades.server_id
397     @errors.cyclades.metadata
398     def _run(self, server_id, key=''):
399         self._print(
400             self.client.get_server_metadata(int(server_id), key), print_dict)
401
402     def main(self, server_id, key=''):
403         super(self.__class__, self)._run()
404         self._run(server_id=server_id, key=key)
405
406
407 @command(server_cmds)
408 class server_metadata_set(_init_cyclades, _optional_json):
409     """Set / update server(VM) metadata
410     Metadata should be given in key/value pairs in key=value format
411     For example: /server metadata set <server id> key1=value1 key2=value2
412     Old, unreferenced metadata will remain intact
413     """
414
415     @errors.generic.all
416     @errors.cyclades.connection
417     @errors.cyclades.server_id
418     def _run(self, server_id, keyvals):
419         assert keyvals, 'Please, add some metadata ( key=value)'
420         metadata = dict()
421         for keyval in keyvals:
422             k, sep, v = keyval.partition('=')
423             if sep and k:
424                 metadata[k] = v
425             else:
426                 raiseCLIError(
427                     'Invalid piece of metadata %s' % keyval,
428                     importance=2, details=[
429                         'Correct metadata format: key=val',
430                         'For example:',
431                         '/server metadata set <server id>'
432                         'key1=value1 key2=value2'])
433         self._print(
434             self.client.update_server_metadata(int(server_id), **metadata),
435             print_dict)
436
437     def main(self, server_id, *key_equals_val):
438         super(self.__class__, self)._run()
439         self._run(server_id=server_id, keyvals=key_equals_val)
440
441
442 @command(server_cmds)
443 class server_metadata_delete(_init_cyclades, _optional_output_cmd):
444     """Delete server (VM) metadata"""
445
446     @errors.generic.all
447     @errors.cyclades.connection
448     @errors.cyclades.server_id
449     @errors.cyclades.metadata
450     def _run(self, server_id, key):
451         self._optional_output(
452             self.client.delete_server_metadata(int(server_id), key))
453
454     def main(self, server_id, key):
455         super(self.__class__, self)._run()
456         self._run(server_id=server_id, key=key)
457
458
459 @command(server_cmds)
460 class server_stats(_init_cyclades, _optional_json):
461     """Get server (VM) statistics"""
462
463     @errors.generic.all
464     @errors.cyclades.connection
465     @errors.cyclades.server_id
466     def _run(self, server_id):
467         self._print(self.client.get_server_stats(int(server_id)), print_dict)
468
469     def main(self, server_id):
470         super(self.__class__, self)._run()
471         self._run(server_id=server_id)
472
473
474 @command(server_cmds)
475 class server_wait(_init_cyclades):
476     """Wait for server to finish [BUILD, STOPPED, REBOOT, ACTIVE]"""
477
478     arguments = dict(
479         progress_bar=ProgressBarArgument(
480             'do not show progress bar',
481             ('-N', '--no-progress-bar'),
482             False
483         )
484     )
485
486     @errors.generic.all
487     @errors.cyclades.connection
488     @errors.cyclades.server_id
489     def _run(self, server_id, currect_status):
490         (progress_bar, wait_cb) = self._safe_progress_bar(
491             'Server %s still in %s mode' % (server_id, currect_status))
492
493         try:
494             new_mode = self.client.wait_server(
495                 server_id,
496                 currect_status,
497                 wait_cb=wait_cb)
498         except Exception:
499             self._safe_progress_bar_finish(progress_bar)
500             raise
501         finally:
502             self._safe_progress_bar_finish(progress_bar)
503         if new_mode:
504             print('Server %s is now in %s mode' % (server_id, new_mode))
505         else:
506             raiseCLIError(None, 'Time out')
507
508     def main(self, server_id, currect_status='BUILD'):
509         super(self.__class__, self)._run()
510         self._run(server_id=server_id, currect_status=currect_status)
511
512
513 @command(flavor_cmds)
514 class flavor_list(_init_cyclades, _optional_json):
515     """List available hardware flavors"""
516
517     arguments = dict(
518         detail=FlagArgument('show detailed output', ('-l', '--details')),
519         limit=IntArgument('limit # of listed flavors', ('-n', '--number')),
520         more=FlagArgument(
521             'output results in pages (-n to set items per page, default 10)',
522             '--more'),
523         enum=FlagArgument('Enumerate results', '--enumerate')
524     )
525
526     @errors.generic.all
527     @errors.cyclades.connection
528     def _run(self):
529         flavors = self.client.list_flavors(self['detail'])
530         pg_size = 10 if self['more'] and not self['limit'] else self['limit']
531         self._print(
532             flavors,
533             with_redundancy=self['detail'],
534             page_size=pg_size,
535             with_enumeration=self['enum'])
536
537     def main(self):
538         super(self.__class__, self)._run()
539         self._run()
540
541
542 @command(flavor_cmds)
543 class flavor_info(_init_cyclades, _optional_json):
544     """Detailed information on a hardware flavor
545     To get a list of available flavors and flavor ids, try /flavor list
546     """
547
548     @errors.generic.all
549     @errors.cyclades.connection
550     @errors.cyclades.flavor_id
551     def _run(self, flavor_id):
552         self._print(
553             self.client.get_flavor_details(int(flavor_id)), print_dict)
554
555     def main(self, flavor_id):
556         super(self.__class__, self)._run()
557         self._run(flavor_id=flavor_id)
558
559
560 @command(network_cmds)
561 class network_info(_init_cyclades, _optional_json):
562     """Detailed information on a network
563     To get a list of available networks and network ids, try /network list
564     """
565
566     @errors.generic.all
567     @errors.cyclades.connection
568     @errors.cyclades.network_id
569     def _run(self, network_id):
570         network = self.client.get_network_details(int(network_id))
571         self._print(network, print_dict, exclude=('id'))
572
573     def main(self, network_id):
574         super(self.__class__, self)._run()
575         self._run(network_id=network_id)
576
577
578 @command(network_cmds)
579 class network_list(_init_cyclades, _optional_json):
580     """List networks"""
581
582     arguments = dict(
583         detail=FlagArgument('show detailed output', ('-l', '--details')),
584         limit=IntArgument('limit # of listed networks', ('-n', '--number')),
585         more=FlagArgument(
586             'output results in pages (-n to set items per page, default 10)',
587             '--more'),
588         enum=FlagArgument('Enumerate results', '--enumerate')
589     )
590
591     @errors.generic.all
592     @errors.cyclades.connection
593     def _run(self):
594         networks = self.client.list_networks(self['detail'])
595         kwargs = dict(with_enumeration=self['enum'])
596         if self['more']:
597             kwargs['page_size'] = self['limit'] or 10
598         elif self['limit']:
599             networks = networks[:self['limit']]
600         self._print(networks, **kwargs)
601
602     def main(self):
603         super(self.__class__, self)._run()
604         self._run()
605
606
607 @command(network_cmds)
608 class network_create(_init_cyclades, _optional_json):
609     """Create an (unconnected) network"""
610
611     arguments = dict(
612         cidr=ValueArgument('explicitly set cidr', '--with-cidr'),
613         gateway=ValueArgument('explicitly set gateway', '--with-gateway'),
614         dhcp=FlagArgument('Use dhcp (default: off)', '--with-dhcp'),
615         type=ValueArgument(
616             'Valid network types are '
617             'CUSTOM, IP_LESS_ROUTED, MAC_FILTERED (default), PHYSICAL_VLAN',
618             '--with-type',
619             default='MAC_FILTERED')
620     )
621
622     @errors.generic.all
623     @errors.cyclades.connection
624     @errors.cyclades.network_max
625     def _run(self, name):
626         self._print(self.client.create_network(
627             name,
628             cidr=self['cidr'],
629             gateway=self['gateway'],
630             dhcp=self['dhcp'],
631             type=self['type']), print_dict)
632
633     def main(self, name):
634         super(self.__class__, self)._run()
635         self._run(name)
636
637
638 @command(network_cmds)
639 class network_rename(_init_cyclades, _optional_output_cmd):
640     """Set the name of a network"""
641
642     @errors.generic.all
643     @errors.cyclades.connection
644     @errors.cyclades.network_id
645     def _run(self, network_id, new_name):
646         self._optional_output(
647                 self.client.update_network_name(int(network_id), new_name))
648
649     def main(self, network_id, new_name):
650         super(self.__class__, self)._run()
651         self._run(network_id=network_id, new_name=new_name)
652
653
654 @command(network_cmds)
655 class network_delete(_init_cyclades, _optional_output_cmd):
656     """Delete a network"""
657
658     @errors.generic.all
659     @errors.cyclades.connection
660     @errors.cyclades.network_id
661     @errors.cyclades.network_in_use
662     def _run(self, network_id):
663         self._optional_output(self.client.delete_network(int(network_id)))
664
665     def main(self, network_id):
666         super(self.__class__, self)._run()
667         self._run(network_id=network_id)
668
669
670 @command(network_cmds)
671 class network_connect(_init_cyclades, _optional_output_cmd):
672     """Connect a server to a network"""
673
674     @errors.generic.all
675     @errors.cyclades.connection
676     @errors.cyclades.server_id
677     @errors.cyclades.network_id
678     def _run(self, server_id, network_id):
679         self._optional_output(
680                 self.client.connect_server(int(server_id), int(network_id)))
681
682     def main(self, server_id, network_id):
683         super(self.__class__, self)._run()
684         self._run(server_id=server_id, network_id=network_id)
685
686
687 @command(network_cmds)
688 class network_disconnect(_init_cyclades):
689     """Disconnect a nic that connects a server to a network
690     Nic ids are listed as "attachments" in detailed network information
691     To get detailed network information: /network info <network id>
692     """
693
694     @errors.cyclades.nic_format
695     def _server_id_from_nic(self, nic_id):
696         return nic_id.split('-')[1]
697
698     @errors.generic.all
699     @errors.cyclades.connection
700     @errors.cyclades.server_id
701     @errors.cyclades.nic_id
702     def _run(self, nic_id, server_id):
703         num_of_disconnected = self.client.disconnect_server(server_id, nic_id)
704         if not num_of_disconnected:
705             raise ClientError(
706                 'Network Interface %s not found on server %s' % (
707                     nic_id,
708                     server_id),
709                 status=404)
710         print('Disconnected %s connections' % num_of_disconnected)
711
712     def main(self, nic_id):
713         super(self.__class__, self)._run()
714         server_id = self._server_id_from_nic(nic_id=nic_id)
715         self._run(nic_id=nic_id, server_id=server_id)