48252ca1d8dd441b929e6735fd84577597b16b75
[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 remote.default.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_remote('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:
412         /server metadata set <server id> key1=value1 key2=value2
413     Old, unreferenced metadata will remain intact
414     """
415
416     @errors.generic.all
417     @errors.cyclades.connection
418     @errors.cyclades.server_id
419     def _run(self, server_id, keyvals):
420         assert keyvals, 'Please, add some metadata ( key=value)'
421         metadata = dict()
422         for keyval in keyvals:
423             k, sep, v = keyval.partition('=')
424             if sep and k:
425                 metadata[k] = v
426             else:
427                 raiseCLIError(
428                     'Invalid piece of metadata %s' % keyval,
429                     importance=2, details=[
430                         'Correct metadata format: key=val',
431                         'For example:',
432                         '/server metadata set <server id>'
433                         'key1=value1 key2=value2'])
434         self._print(
435             self.client.update_server_metadata(int(server_id), **metadata),
436             print_dict)
437
438     def main(self, server_id, *key_equals_val):
439         super(self.__class__, self)._run()
440         self._run(server_id=server_id, keyvals=key_equals_val)
441
442
443 @command(server_cmds)
444 class server_metadata_delete(_init_cyclades, _optional_output_cmd):
445     """Delete server (VM) metadata"""
446
447     @errors.generic.all
448     @errors.cyclades.connection
449     @errors.cyclades.server_id
450     @errors.cyclades.metadata
451     def _run(self, server_id, key):
452         self._optional_output(
453             self.client.delete_server_metadata(int(server_id), key))
454
455     def main(self, server_id, key):
456         super(self.__class__, self)._run()
457         self._run(server_id=server_id, key=key)
458
459
460 @command(server_cmds)
461 class server_stats(_init_cyclades, _optional_json):
462     """Get server (VM) statistics"""
463
464     @errors.generic.all
465     @errors.cyclades.connection
466     @errors.cyclades.server_id
467     def _run(self, server_id):
468         self._print(self.client.get_server_stats(int(server_id)), print_dict)
469
470     def main(self, server_id):
471         super(self.__class__, self)._run()
472         self._run(server_id=server_id)
473
474
475 @command(server_cmds)
476 class server_wait(_init_cyclades):
477     """Wait for server to finish [BUILD, STOPPED, REBOOT, ACTIVE]"""
478
479     arguments = dict(
480         progress_bar=ProgressBarArgument(
481             'do not show progress bar',
482             ('-N', '--no-progress-bar'),
483             False
484         )
485     )
486
487     @errors.generic.all
488     @errors.cyclades.connection
489     @errors.cyclades.server_id
490     def _run(self, server_id, currect_status):
491         (progress_bar, wait_cb) = self._safe_progress_bar(
492             'Server %s still in %s mode' % (server_id, currect_status))
493
494         try:
495             new_mode = self.client.wait_server(
496                 server_id,
497                 currect_status,
498                 wait_cb=wait_cb)
499         except Exception:
500             self._safe_progress_bar_finish(progress_bar)
501             raise
502         finally:
503             self._safe_progress_bar_finish(progress_bar)
504         if new_mode:
505             print('Server %s is now in %s mode' % (server_id, new_mode))
506         else:
507             raiseCLIError(None, 'Time out')
508
509     def main(self, server_id, currect_status='BUILD'):
510         super(self.__class__, self)._run()
511         self._run(server_id=server_id, currect_status=currect_status)
512
513
514 @command(flavor_cmds)
515 class flavor_list(_init_cyclades, _optional_json):
516     """List available hardware flavors"""
517
518     arguments = dict(
519         detail=FlagArgument('show detailed output', ('-l', '--details')),
520         limit=IntArgument('limit # of listed flavors', ('-n', '--number')),
521         more=FlagArgument(
522             'output results in pages (-n to set items per page, default 10)',
523             '--more'),
524         enum=FlagArgument('Enumerate results', '--enumerate')
525     )
526
527     @errors.generic.all
528     @errors.cyclades.connection
529     def _run(self):
530         flavors = self.client.list_flavors(self['detail'])
531         pg_size = 10 if self['more'] and not self['limit'] else self['limit']
532         self._print(
533             flavors,
534             with_redundancy=self['detail'],
535             page_size=pg_size,
536             with_enumeration=self['enum'])
537
538     def main(self):
539         super(self.__class__, self)._run()
540         self._run()
541
542
543 @command(flavor_cmds)
544 class flavor_info(_init_cyclades, _optional_json):
545     """Detailed information on a hardware flavor
546     To get a list of available flavors and flavor ids, try /flavor list
547     """
548
549     @errors.generic.all
550     @errors.cyclades.connection
551     @errors.cyclades.flavor_id
552     def _run(self, flavor_id):
553         self._print(
554             self.client.get_flavor_details(int(flavor_id)), print_dict)
555
556     def main(self, flavor_id):
557         super(self.__class__, self)._run()
558         self._run(flavor_id=flavor_id)
559
560
561 @command(network_cmds)
562 class network_info(_init_cyclades, _optional_json):
563     """Detailed information on a network
564     To get a list of available networks and network ids, try /network list
565     """
566
567     @errors.generic.all
568     @errors.cyclades.connection
569     @errors.cyclades.network_id
570     def _run(self, network_id):
571         network = self.client.get_network_details(int(network_id))
572         self._print(network, print_dict, exclude=('id'))
573
574     def main(self, network_id):
575         super(self.__class__, self)._run()
576         self._run(network_id=network_id)
577
578
579 @command(network_cmds)
580 class network_list(_init_cyclades, _optional_json):
581     """List networks"""
582
583     arguments = dict(
584         detail=FlagArgument('show detailed output', ('-l', '--details')),
585         limit=IntArgument('limit # of listed networks', ('-n', '--number')),
586         more=FlagArgument(
587             'output results in pages (-n to set items per page, default 10)',
588             '--more'),
589         enum=FlagArgument('Enumerate results', '--enumerate')
590     )
591
592     @errors.generic.all
593     @errors.cyclades.connection
594     def _run(self):
595         networks = self.client.list_networks(self['detail'])
596         kwargs = dict(with_enumeration=self['enum'])
597         if self['more']:
598             kwargs['page_size'] = self['limit'] or 10
599         elif self['limit']:
600             networks = networks[:self['limit']]
601         self._print(networks, **kwargs)
602
603     def main(self):
604         super(self.__class__, self)._run()
605         self._run()
606
607
608 @command(network_cmds)
609 class network_create(_init_cyclades, _optional_json):
610     """Create an (unconnected) network"""
611
612     arguments = dict(
613         cidr=ValueArgument('explicitly set cidr', '--with-cidr'),
614         gateway=ValueArgument('explicitly set gateway', '--with-gateway'),
615         dhcp=FlagArgument('Use dhcp (default: off)', '--with-dhcp'),
616         type=ValueArgument(
617             'Valid network types are '
618             'CUSTOM, IP_LESS_ROUTED, MAC_FILTERED (default), PHYSICAL_VLAN',
619             '--with-type',
620             default='MAC_FILTERED')
621     )
622
623     @errors.generic.all
624     @errors.cyclades.connection
625     @errors.cyclades.network_max
626     def _run(self, name):
627         self._print(self.client.create_network(
628             name,
629             cidr=self['cidr'],
630             gateway=self['gateway'],
631             dhcp=self['dhcp'],
632             type=self['type']), print_dict)
633
634     def main(self, name):
635         super(self.__class__, self)._run()
636         self._run(name)
637
638
639 @command(network_cmds)
640 class network_rename(_init_cyclades, _optional_output_cmd):
641     """Set the name of a network"""
642
643     @errors.generic.all
644     @errors.cyclades.connection
645     @errors.cyclades.network_id
646     def _run(self, network_id, new_name):
647         self._optional_output(
648                 self.client.update_network_name(int(network_id), new_name))
649
650     def main(self, network_id, new_name):
651         super(self.__class__, self)._run()
652         self._run(network_id=network_id, new_name=new_name)
653
654
655 @command(network_cmds)
656 class network_delete(_init_cyclades, _optional_output_cmd):
657     """Delete a network"""
658
659     @errors.generic.all
660     @errors.cyclades.connection
661     @errors.cyclades.network_id
662     @errors.cyclades.network_in_use
663     def _run(self, network_id):
664         self._optional_output(self.client.delete_network(int(network_id)))
665
666     def main(self, network_id):
667         super(self.__class__, self)._run()
668         self._run(network_id=network_id)
669
670
671 @command(network_cmds)
672 class network_connect(_init_cyclades, _optional_output_cmd):
673     """Connect a server to a network"""
674
675     @errors.generic.all
676     @errors.cyclades.connection
677     @errors.cyclades.server_id
678     @errors.cyclades.network_id
679     def _run(self, server_id, network_id):
680         self._optional_output(
681                 self.client.connect_server(int(server_id), int(network_id)))
682
683     def main(self, server_id, network_id):
684         super(self.__class__, self)._run()
685         self._run(server_id=server_id, network_id=network_id)
686
687
688 @command(network_cmds)
689 class network_disconnect(_init_cyclades):
690     """Disconnect a nic that connects a server to a network
691     Nic ids are listed as "attachments" in detailed network information
692     To get detailed network information: /network info <network id>
693     """
694
695     @errors.cyclades.nic_format
696     def _server_id_from_nic(self, nic_id):
697         return nic_id.split('-')[1]
698
699     @errors.generic.all
700     @errors.cyclades.connection
701     @errors.cyclades.server_id
702     @errors.cyclades.nic_id
703     def _run(self, nic_id, server_id):
704         num_of_disconnected = self.client.disconnect_server(server_id, nic_id)
705         if not num_of_disconnected:
706             raise ClientError(
707                 'Network Interface %s not found on server %s' % (
708                     nic_id,
709                     server_id),
710                 status=404)
711         print('Disconnected %s connections' % num_of_disconnected)
712
713     def main(self, nic_id):
714         super(self.__class__, self)._run()
715         server_id = self._server_id_from_nic(nic_id=nic_id)
716         self._run(nic_id=nic_id, server_id=server_id)