Update copyright dates for changes files
[kamaki] / kamaki / cli / commands / cyclades.py
1 # Copyright 2011-2014 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 import cStringIO
34 import codecs
35 from base64 import b64encode
36 from os.path import exists, expanduser
37 from io import StringIO
38 from pydoc import pager
39 from json import dumps
40
41 from kamaki.cli import command
42 from kamaki.cli.command_tree import CommandTree
43 from kamaki.cli.utils import remove_from_items, filter_dicts_by_dict
44 from kamaki.cli.errors import (
45     raiseCLIError, CLISyntaxError, CLIBaseUrlError, CLIInvalidArgument)
46 from kamaki.clients.cyclades import CycladesClient
47 from kamaki.cli.argument import (
48     FlagArgument, ValueArgument, KeyValueArgument, RepeatableArgument,
49     ProgressBarArgument, DateArgument, IntArgument, StatusArgument)
50 from kamaki.cli.commands import (
51     _command_init, errors, addLogSettings, dataModification,
52     _optional_output_cmd, _optional_json, _name_filter, _id_filter)
53
54
55 server_cmds = CommandTree('server', 'Cyclades/Compute API server commands')
56 flavor_cmds = CommandTree('flavor', 'Cyclades/Compute API flavor commands')
57 _commands = [server_cmds, flavor_cmds]
58
59
60 about_authentication = '\nUser Authentication:\
61     \n* to check authentication: /user authenticate\
62     \n* to set authentication token: /config set cloud.<cloud>.token <token>'
63
64 howto_personality = [
65     'Defines a file to be injected to virtual servers file system.',
66     'syntax:  PATH,[SERVER_PATH,[OWNER,[GROUP,[MODE]]]]',
67     '  [local-path=]PATH: local file to be injected (relative or absolute)',
68     '  [server-path=]SERVER_PATH: destination location inside server Image',
69     '  [owner=]OWNER: virtual servers user id for the remote file',
70     '  [group=]GROUP: virtual servers group id or name for the remote file',
71     '  [mode=]MODE: permission in octal (e.g., 0777)',
72     'e.g., -p /tmp/my.file,owner=root,mode=0777']
73
74 server_states = ('BUILD', 'ACTIVE', 'STOPPED', 'REBOOT')
75
76
77 class _service_wait(object):
78
79     wait_arguments = dict(
80         progress_bar=ProgressBarArgument(
81             'do not show progress bar', ('-N', '--no-progress-bar'), False)
82     )
83
84     def _wait(
85             self, service, service_id, status_method, current_status,
86             countdown=True, timeout=60):
87         (progress_bar, wait_cb) = self._safe_progress_bar(
88             '%s %s: status is still %s' % (
89                 service, service_id, current_status),
90             countdown=countdown, timeout=timeout)
91
92         try:
93             new_mode = status_method(
94                 service_id, current_status, max_wait=timeout, wait_cb=wait_cb)
95             if new_mode:
96                 self.error('%s %s: status is now %s' % (
97                     service, service_id, new_mode))
98             else:
99                 self.error('%s %s: status is still %s' % (
100                     service, service_id, current_status))
101         except KeyboardInterrupt:
102             self.error('\n- canceled')
103         finally:
104             self._safe_progress_bar_finish(progress_bar)
105
106
107 class _server_wait(_service_wait):
108
109     def _wait(self, server_id, current_status, timeout=60):
110         super(_server_wait, self)._wait(
111             'Server', server_id, self.client.wait_server, current_status,
112             countdown=(current_status not in ('BUILD', )),
113             timeout=timeout if current_status not in ('BUILD', ) else 100)
114
115
116 class _init_cyclades(_command_init):
117     @errors.generic.all
118     @addLogSettings
119     def _run(self, service='compute'):
120         if getattr(self, 'cloud', None):
121             base_url = self._custom_url(service) or self._custom_url(
122                 'cyclades')
123             if base_url:
124                 token = self._custom_token(service) or self._custom_token(
125                     'cyclades') or self.config.get_cloud('token')
126                 self.client = CycladesClient(base_url=base_url, token=token)
127                 return
128         else:
129             self.cloud = 'default'
130         if getattr(self, 'auth_base', False):
131             cyclades_endpoints = self.auth_base.get_service_endpoints(
132                 self._custom_type('cyclades') or 'compute',
133                 self._custom_version('cyclades') or '')
134             base_url = cyclades_endpoints['publicURL']
135             token = self.auth_base.token
136             self.client = CycladesClient(base_url=base_url, token=token)
137         else:
138             raise CLIBaseUrlError(service='cyclades')
139
140     @dataModification
141     def _restruct_server_info(self, vm):
142         if not vm:
143             return vm
144         img = vm['image']
145         try:
146             img.pop('links', None)
147             img['name'] = self.client.get_image_details(img['id'])['name']
148         except Exception:
149             pass
150         flv = vm['flavor']
151         try:
152             flv.pop('links', None)
153             flv['name'] = self.client.get_flavor_details(flv['id'])['name']
154         except Exception:
155             pass
156         vm['ports'] = vm.pop('attachments', dict())
157         for port in vm['ports']:
158             netid = port.get('network_id')
159             for k in vm['addresses'].get(netid, []):
160                 k.pop('addr', None)
161                 k.pop('version', None)
162                 port.update(k)
163         uuids = self._uuids2usernames([vm['user_id'], vm['tenant_id']])
164         vm['user_id'] += ' (%s)' % uuids[vm['user_id']]
165         for key in ('addresses', 'tenant_id', 'links'):
166             vm.pop(key, None)
167         return vm
168
169     def main(self):
170         self._run()
171
172
173 @command(server_cmds)
174 class server_list(_init_cyclades, _optional_json, _name_filter, _id_filter):
175     """List virtual servers accessible by user
176     Use filtering arguments (e.g., --name-like) to manage long server lists
177     """
178
179     PERMANENTS = ('id', 'name')
180
181     arguments = dict(
182         detail=FlagArgument('show detailed output', ('-l', '--details')),
183         since=DateArgument(
184             'show only items since date (\' d/m/Y H:M:S \')',
185             '--since'),
186         limit=IntArgument(
187             'limit number of listed virtual servers', ('-n', '--number')),
188         more=FlagArgument(
189             'output results in pages (-n to set items per page, default 10)',
190             '--more'),
191         enum=FlagArgument('Enumerate results', '--enumerate'),
192         flavor_id=ValueArgument('filter by flavor id', ('--flavor-id')),
193         image_id=ValueArgument('filter by image id', ('--image-id')),
194         user_id=ValueArgument('filter by user id', ('--user-id')),
195         user_name=ValueArgument('filter by user name', ('--user-name')),
196         status=ValueArgument(
197             'filter by status (ACTIVE, STOPPED, REBOOT, ERROR, etc.)',
198             ('--status')),
199         meta=KeyValueArgument('filter by metadata key=values', ('--metadata')),
200         meta_like=KeyValueArgument(
201             'print only if in key=value, the value is part of actual value',
202             ('--metadata-like')),
203     )
204
205     def _add_user_name(self, servers):
206         uuids = self._uuids2usernames(list(set(
207                 [srv['user_id'] for srv in servers] +
208                 [srv['tenant_id'] for srv in servers])))
209         for srv in servers:
210             srv['user_id'] += ' (%s)' % uuids[srv['user_id']]
211             srv['tenant_id'] += ' (%s)' % uuids[srv['tenant_id']]
212         return servers
213
214     def _apply_common_filters(self, servers):
215         common_filters = dict()
216         if self['status']:
217             common_filters['status'] = self['status']
218         if self['user_id'] or self['user_name']:
219             uuid = self['user_id'] or self._username2uuid(self['user_name'])
220             common_filters['user_id'] = uuid
221         return filter_dicts_by_dict(servers, common_filters)
222
223     def _filter_by_image(self, servers):
224         iid = self['image_id']
225         return [srv for srv in servers if srv['image']['id'] == iid]
226
227     def _filter_by_flavor(self, servers):
228         fid = self['flavor_id']
229         return [srv for srv in servers if (
230             '%s' % srv['image']['id'] == '%s' % fid)]
231
232     def _filter_by_metadata(self, servers):
233         new_servers = []
234         for srv in servers:
235             if not 'metadata' in srv:
236                 continue
237             meta = [dict(srv['metadata'])]
238             if self['meta']:
239                 meta = filter_dicts_by_dict(meta, self['meta'])
240             if meta and self['meta_like']:
241                 meta = filter_dicts_by_dict(
242                     meta, self['meta_like'], exact_match=False)
243             if meta:
244                 new_servers.append(srv)
245         return new_servers
246
247     @errors.generic.all
248     @errors.cyclades.connection
249     @errors.cyclades.date
250     def _run(self):
251         withimage = bool(self['image_id'])
252         withflavor = bool(self['flavor_id'])
253         withmeta = bool(self['meta'] or self['meta_like'])
254         withcommons = bool(
255             self['status'] or self['user_id'] or self['user_name'])
256         detail = self['detail'] or (
257             withimage or withflavor or withmeta or withcommons)
258         servers = self.client.list_servers(detail, self['since'])
259
260         servers = self._filter_by_name(servers)
261         servers = self._filter_by_id(servers)
262         servers = self._apply_common_filters(servers)
263         if withimage:
264             servers = self._filter_by_image(servers)
265         if withflavor:
266             servers = self._filter_by_flavor(servers)
267         if withmeta:
268             servers = self._filter_by_metadata(servers)
269
270         if detail and self['detail']:
271             #  servers = [self._restruct_server_info(vm) for vm in servers]
272             pass
273         else:
274             for srv in servers:
275                 for key in set(srv).difference(self.PERMANENTS):
276                     srv.pop(key)
277
278         kwargs = dict(with_enumeration=self['enum'])
279         if self['more']:
280             codecinfo = codecs.lookup('utf-8')
281             kwargs['out'] = codecs.StreamReaderWriter(
282                 cStringIO.StringIO(),
283                 codecinfo.streamreader,
284                 codecinfo.streamwriter)
285             kwargs['title'] = ()
286         if self['limit']:
287             servers = servers[:self['limit']]
288         self._print(servers, **kwargs)
289         if self['more']:
290             pager(kwargs['out'].getvalue())
291
292     def main(self):
293         super(self.__class__, self)._run()
294         self._run()
295
296
297 @command(server_cmds)
298 class server_info(_init_cyclades, _optional_json):
299     """Detailed information on a Virtual Machine"""
300
301     arguments = dict(
302         nics=FlagArgument(
303             'Show only the network interfaces of this virtual server',
304             '--nics'),
305         stats=FlagArgument('Get URLs for server statistics', '--stats'),
306         diagnostics=FlagArgument('Diagnostic information', '--diagnostics')
307     )
308
309     @errors.generic.all
310     @errors.cyclades.connection
311     @errors.cyclades.server_id
312     def _run(self, server_id):
313         if self['nics']:
314             self._print(
315                 self.client.get_server_nics(server_id), self.print_dict)
316         elif self['stats']:
317             self._print(
318                 self.client.get_server_stats(server_id), self.print_dict)
319         elif self['diagnostics']:
320             self._print(self.client.get_server_diagnostics(server_id))
321         else:
322             vm = self.client.get_server_details(server_id)
323             # self._print(self._restruct_server_info(vm), self.print_dict)
324             self._print(vm, self.print_dict)
325
326     def main(self, server_id):
327         super(self.__class__, self)._run()
328         choose_one = ('nics', 'stats', 'diagnostics')
329         count = len([a for a in choose_one if self[a]])
330         if count > 1:
331             raise CLIInvalidArgument('Invalid argument combination', details=[
332                 'Arguments %s cannot be used simultaneously' % ', '.join(
333                     [self.arguments[a].lvalue for a in choose_one])])
334         self._run(server_id=server_id)
335
336
337 class PersonalityArgument(KeyValueArgument):
338
339     terms = (
340         ('local-path', 'contents'),
341         ('server-path', 'path'),
342         ('owner', 'owner'),
343         ('group', 'group'),
344         ('mode', 'mode'))
345
346     @property
347     def value(self):
348         return getattr(self, '_value', [])
349
350     @value.setter
351     def value(self, newvalue):
352         if newvalue == self.default:
353             return self.value
354         self._value, input_dict = [], {}
355         for i, terms in enumerate(newvalue):
356             termlist = terms.split(',')
357             if len(termlist) > len(self.terms):
358                 msg = 'Wrong number of terms (1<=terms<=%s)' % len(self.terms)
359                 raiseCLIError(CLISyntaxError(msg), details=howto_personality)
360
361             for k, v in self.terms:
362                 prefix = '%s=' % k
363                 for item in termlist:
364                     if item.lower().startswith(prefix):
365                         input_dict[k] = item[len(k) + 1:]
366                         break
367                     item = None
368                 if item:
369                     termlist.remove(item)
370
371             try:
372                 path = input_dict['local-path']
373             except KeyError:
374                 path = termlist.pop(0)
375                 if not path:
376                     raise CLIInvalidArgument(
377                         '--personality: No local path specified',
378                         details=howto_personality)
379
380             if not exists(path):
381                 raise CLIInvalidArgument(
382                     '--personality: File %s does not exist' % path,
383                     details=howto_personality)
384
385             self._value.append(dict(path=path))
386             with open(expanduser(path)) as f:
387                 self._value[i]['contents'] = b64encode(f.read())
388             for k, v in self.terms[1:]:
389                 try:
390                     self._value[i][v] = input_dict[k]
391                 except KeyError:
392                     try:
393                         self._value[i][v] = termlist.pop(0)
394                     except IndexError:
395                         continue
396                 if k in ('mode', ) and self._value[i][v]:
397                     try:
398                         self._value[i][v] = int(self._value[i][v], 8)
399                     except ValueError as ve:
400                         raise CLIInvalidArgument(
401                             'Personality mode must be in octal', details=[
402                                 '%s' % ve])
403
404
405 class NetworkArgument(RepeatableArgument):
406     """[id=]NETWORK_ID[,[ip=]IP]"""
407
408     @property
409     def value(self):
410         return getattr(self, '_value', self.default)
411
412     @value.setter
413     def value(self, new_value):
414         for v in new_value or []:
415             part1, sep, part2 = v.partition(',')
416             netid, ip = '', ''
417             if part1.startswith('id='):
418                 netid = part1[len('id='):]
419             elif part1.startswith('ip='):
420                 ip = part1[len('ip='):]
421             else:
422                 netid = part1
423             if part2:
424                 if (part2.startswith('id=') and netid) or (
425                         part2.startswith('ip=') and ip):
426                     raise CLIInvalidArgument(
427                         'Invalid network argument %s' % v, details=[
428                         'Valid format: [id=]NETWORK_ID[,[ip=]IP]'])
429                 if part2.startswith('id='):
430                     netid = part2[len('id='):]
431                 elif part2.startswith('ip='):
432                     ip = part2[len('ip='):]
433                 elif netid:
434                     ip = part2
435                 else:
436                     netid = part2
437             if not netid:
438                 raise CLIInvalidArgument(
439                     'Invalid network argument %s' % v, details=[
440                     'Valid format: [id=]NETWORK_ID[,[ip=]IP]'])
441             self._value = getattr(self, '_value', [])
442             self._value.append(dict(uuid=netid))
443             if ip:
444                 self._value[-1]['fixed_ip'] = ip
445
446
447 @command(server_cmds)
448 class server_create(_init_cyclades, _optional_json, _server_wait):
449     """Create a server (aka Virtual Machine)"""
450
451     arguments = dict(
452         server_name=ValueArgument('The name of the new server', '--name'),
453         flavor_id=IntArgument('The ID of the flavor', '--flavor-id'),
454         image_id=ValueArgument('The ID of the image', '--image-id'),
455         personality=PersonalityArgument(
456             (80 * ' ').join(howto_personality), ('-p', '--personality')),
457         wait=FlagArgument('Wait server to build', ('-w', '--wait')),
458         cluster_size=IntArgument(
459             'Create a cluster of servers of this size. In this case, the name'
460             'parameter is the prefix of each server in the cluster (e.g.,'
461             'srv1, srv2, etc.',
462             '--cluster-size'),
463         max_threads=IntArgument(
464             'Max threads in cluster mode (default 1)', '--threads'),
465         network_configuration=NetworkArgument(
466             'Connect server to network: [id=]NETWORK_ID[,[ip=]IP]        . '
467             'Use only NETWORK_ID for private networks.        . '
468             'Use NETWORK_ID,[ip=]IP for networks with IP.        . '
469             'Can be repeated, mutually exclussive with --no-network',
470             '--network'),
471         no_network=FlagArgument(
472             'Do not create any network NICs on the server.        . '
473             'Mutually exclusive to --network        . '
474             'If neither --network or --no-network are used, the default '
475             'network policy is applied. These policies are set on the cloud, '
476             'so kamaki is oblivious to them',
477             '--no-network')
478     )
479     required = ('server_name', 'flavor_id', 'image_id')
480
481     @errors.cyclades.cluster_size
482     def _create_cluster(self, prefix, flavor_id, image_id, size):
483         networks = self['network_configuration'] or (
484             [] if self['no_network'] else None)
485         servers = [dict(
486             name='%s%s' % (prefix, i if size > 1 else ''),
487             flavor_id=flavor_id,
488             image_id=image_id,
489             personality=self['personality'],
490             networks=networks) for i in range(1, 1 + size)]
491         if size == 1:
492             return [self.client.create_server(**servers[0])]
493         self.client.MAX_THREADS = int(self['max_threads'] or 1)
494         try:
495             r = self.client.async_run(self.client.create_server, servers)
496             return r
497         except Exception as e:
498             if size == 1:
499                 raise e
500             try:
501                 requested_names = [s['name'] for s in servers]
502                 spawned_servers = [dict(
503                     name=s['name'],
504                     id=s['id']) for s in self.client.list_servers() if (
505                         s['name'] in requested_names)]
506                 self.error('Failed to build %s servers' % size)
507                 self.error('Found %s matching servers:' % len(spawned_servers))
508                 self._print(spawned_servers, out=self._err)
509                 self.error('Check if any of these servers should be removed\n')
510             except Exception as ne:
511                 self.error('Error (%s) while notifying about errors' % ne)
512             finally:
513                 raise e
514
515     @errors.generic.all
516     @errors.cyclades.connection
517     @errors.plankton.id
518     @errors.cyclades.flavor_id
519     def _run(self, name, flavor_id, image_id):
520         for r in self._create_cluster(
521                 name, flavor_id, image_id, size=self['cluster_size'] or 1):
522             if not r:
523                 self.error('Create %s: server response was %s' % (name, r))
524                 continue
525             #  self._print(self._restruct_server_info(r), self.print_dict)
526             self._print(r, self.print_dict)
527             if self['wait']:
528                 self._wait(r['id'], r['status'] or 'BUILD')
529             self.writeln(' ')
530
531     def main(self):
532         super(self.__class__, self)._run()
533         if self['no_network'] and self['network_configuration']:
534             raise CLIInvalidArgument(
535                 'Invalid argument compination', importance=2, details=[
536                 'Arguments %s and %s are mutually exclusive' % (
537                     self.arguments['no_network'].lvalue,
538                     self.arguments['network_configuration'].lvalue)])
539         self._run(
540             name=self['server_name'],
541             flavor_id=self['flavor_id'],
542             image_id=self['image_id'])
543
544
545 class FirewallProfileArgument(ValueArgument):
546
547     profiles = ('DISABLED', 'ENABLED', 'PROTECTED')
548
549     @property
550     def value(self):
551         return getattr(self, '_value', None)
552
553     @value.setter
554     def value(self, new_profile):
555         if new_profile:
556             new_profile = new_profile.upper()
557             if new_profile in self.profiles:
558                 self._value = new_profile
559             else:
560                 raise CLIInvalidArgument(
561                     'Invalid firewall profile %s' % new_profile,
562                     details=['Valid values: %s' % ', '.join(self.profiles)])
563
564
565 @command(server_cmds)
566 class server_modify(_init_cyclades, _optional_output_cmd):
567     """Modify attributes of a virtual server"""
568
569     arguments = dict(
570         server_name=ValueArgument('The new name', '--name'),
571         flavor_id=IntArgument('Resize (set another flavor)', '--flavor-id'),
572         firewall_profile=FirewallProfileArgument(
573             'Valid values: %s' % (', '.join(FirewallProfileArgument.profiles)),
574             '--firewall'),
575         metadata_to_set=KeyValueArgument(
576             'Set metadata in key=value form (can be repeated)',
577             '--metadata-set'),
578         metadata_to_delete=RepeatableArgument(
579             'Delete metadata by key (can be repeated)', '--metadata-del'),
580         public_network_port_id=ValueArgument(
581             'Connection to set new firewall (* for all)', '--port-id'),
582     )
583     required = [
584         'server_name', 'flavor_id', 'firewall_profile', 'metadata_to_set',
585         'metadata_to_delete']
586
587     def _set_firewall_profile(self, server_id):
588         vm = self._restruct_server_info(
589             self.client.get_server_details(server_id))
590         ports = [p for p in vm['ports'] if 'firewallProfile' in p]
591         pick_port = self.arguments['public_network_port_id']
592         if pick_port.value:
593             ports = [p for p in ports if pick_port.value in (
594                 '*', '%s' % p['id'])]
595         elif len(ports) > 1:
596             port_strings = ['Server %s ports to public networks:' % server_id]
597             for p in ports:
598                 port_strings.append('  %s' % p['id'])
599                 for k in ('network_id', 'ipv4', 'ipv6', 'firewallProfile'):
600                     v = p.get(k)
601                     if v:
602                         port_strings.append('\t%s: %s' % (k, v))
603             raiseCLIError(
604                 'Multiple public connections on server %s' % (
605                     server_id), details=port_strings + [
606                         'To select one:',
607                         '  %s <port id>' % pick_port.lvalue,
608                         'To set all:',
609                         '  %s *' % pick_port.lvalue, ])
610         if not ports:
611             pp = pick_port.value
612             raiseCLIError(
613                 'No *public* networks attached on server %s%s' % (
614                     server_id, ' through port %s' % pp if pp else ''),
615                 details=[
616                     'To see all networks:',
617                     '  kamaki network list',
618                     'To connect to a network:',
619                     '  kamaki network connect <net id> --device-id %s' % (
620                         server_id)])
621         for port in ports:
622             self.error('Set port %s firewall to %s' % (
623                 port['id'], self['firewall_profile']))
624             self.client.set_firewall_profile(
625                 server_id=server_id,
626                 profile=self['firewall_profile'],
627                 port_id=port['id'])
628
629     @errors.generic.all
630     @errors.cyclades.connection
631     @errors.cyclades.server_id
632     def _run(self, server_id):
633         if self['server_name'] is not None:
634             self.client.update_server_name((server_id), self['server_name'])
635         if self['flavor_id']:
636             self.client.resize_server(server_id, self['flavor_id'])
637         if self['firewall_profile']:
638             self._set_firewall_profile(server_id)
639         if self['metadata_to_set']:
640             self.client.update_server_metadata(
641                 server_id, **self['metadata_to_set'])
642         for key in (self['metadata_to_delete'] or []):
643             errors.cyclades.metadata(
644                 self.client.delete_server_metadata)(server_id, key=key)
645         if self['with_output']:
646             self._optional_output(self.client.get_server_details(server_id))
647
648     def main(self, server_id):
649         super(self.__class__, self)._run()
650         pnpid = self.arguments['public_network_port_id']
651         fp = self.arguments['firewall_profile']
652         if pnpid.value and not fp.value:
653             raise CLIInvalidArgument('Invalid argument compination', details=[
654                 'Argument %s should always be combined with %s' % (
655                     pnpid.lvalue, fp.lvalue)])
656         self._run(server_id=server_id)
657
658
659 @command(server_cmds)
660 class server_delete(_init_cyclades, _optional_output_cmd, _server_wait):
661     """Delete a virtual server"""
662
663     arguments = dict(
664         wait=FlagArgument('Wait server to be destroyed', ('-w', '--wait')),
665         cluster=FlagArgument(
666             '(DANGEROUS) Delete all virtual servers prefixed with the cluster '
667             'prefix. In that case, the prefix replaces the server id',
668             '--cluster')
669     )
670
671     def _server_ids(self, server_var):
672         if self['cluster']:
673             return [s['id'] for s in self.client.list_servers() if (
674                 s['name'].startswith(server_var))]
675
676         @errors.cyclades.server_id
677         def _check_server_id(self, server_id):
678             return server_id
679
680         return [_check_server_id(self, server_id=server_var), ]
681
682     @errors.generic.all
683     @errors.cyclades.connection
684     def _run(self, server_var):
685         for server_id in self._server_ids(server_var):
686             if self['wait']:
687                 details = self.client.get_server_details(server_id)
688                 status = details['status']
689
690             r = self.client.delete_server(server_id)
691             self._optional_output(r)
692
693             if self['wait']:
694                 self._wait(server_id, status)
695
696     def main(self, server_id_or_cluster_prefix):
697         super(self.__class__, self)._run()
698         self._run(server_id_or_cluster_prefix)
699
700
701 @command(server_cmds)
702 class server_reboot(_init_cyclades, _optional_output_cmd, _server_wait):
703     """Reboot a virtual server"""
704
705     arguments = dict(
706         hard=FlagArgument(
707             'perform a hard reboot (deprecated)', ('-f', '--force')),
708         type=ValueArgument('SOFT or HARD - default: SOFT', ('--type')),
709         wait=FlagArgument('Wait server to be destroyed', ('-w', '--wait'))
710     )
711
712     @errors.generic.all
713     @errors.cyclades.connection
714     @errors.cyclades.server_id
715     def _run(self, server_id):
716         hard_reboot = self['hard']
717         if hard_reboot:
718             self.error(
719                 'WARNING: -f/--force will be deprecated in version 0.12\n'
720                 '\tIn the future, please use --type=hard instead')
721         if self['type']:
722             if self['type'].lower() in ('soft', ):
723                 hard_reboot = False
724             elif self['type'].lower() in ('hard', ):
725                 hard_reboot = True
726             else:
727                 raise CLISyntaxError(
728                     'Invalid reboot type %s' % self['type'],
729                     importance=2, details=[
730                         '--type values are either SOFT (default) or HARD'])
731
732         r = self.client.reboot_server(int(server_id), hard_reboot)
733         self._optional_output(r)
734
735         if self['wait']:
736             self._wait(server_id, 'REBOOT')
737
738     def main(self, server_id):
739         super(self.__class__, self)._run()
740         self._run(server_id=server_id)
741
742
743 @command(server_cmds)
744 class server_start(_init_cyclades, _optional_output_cmd, _server_wait):
745     """Start an existing virtual server"""
746
747     arguments = dict(
748         wait=FlagArgument('Wait server to be destroyed', ('-w', '--wait'))
749     )
750
751     @errors.generic.all
752     @errors.cyclades.connection
753     @errors.cyclades.server_id
754     def _run(self, server_id):
755         status = 'ACTIVE'
756         if self['wait']:
757             details = self.client.get_server_details(server_id)
758             status = details['status']
759             if status in ('ACTIVE', ):
760                 return
761
762         r = self.client.start_server(int(server_id))
763         self._optional_output(r)
764
765         if self['wait']:
766             self._wait(server_id, status)
767
768     def main(self, server_id):
769         super(self.__class__, self)._run()
770         self._run(server_id=server_id)
771
772
773 @command(server_cmds)
774 class server_shutdown(_init_cyclades, _optional_output_cmd, _server_wait):
775     """Shutdown an active virtual server"""
776
777     arguments = dict(
778         wait=FlagArgument('Wait server to be destroyed', ('-w', '--wait'))
779     )
780
781     @errors.generic.all
782     @errors.cyclades.connection
783     @errors.cyclades.server_id
784     def _run(self, server_id):
785         status = 'STOPPED'
786         if self['wait']:
787             details = self.client.get_server_details(server_id)
788             status = details['status']
789             if status in ('STOPPED', ):
790                 return
791
792         r = self.client.shutdown_server(int(server_id))
793         self._optional_output(r)
794
795         if self['wait']:
796             self._wait(server_id, status)
797
798     def main(self, server_id):
799         super(self.__class__, self)._run()
800         self._run(server_id=server_id)
801
802
803 @command(server_cmds)
804 class server_console(_init_cyclades, _optional_json):
805     """Create a VMC console and show connection information"""
806
807     @errors.generic.all
808     @errors.cyclades.connection
809     @errors.cyclades.server_id
810     def _run(self, server_id):
811         self.error('The following credentials will be invalidated shortly')
812         self._print(
813             self.client.get_server_console(server_id), self.print_dict)
814
815     def main(self, server_id):
816         super(self.__class__, self)._run()
817         self._run(server_id=server_id)
818
819
820 @command(server_cmds)
821 class server_wait(_init_cyclades, _server_wait):
822     """Wait for server to change its status (default: BUILD)"""
823
824     arguments = dict(
825         timeout=IntArgument(
826             'Wait limit in seconds (default: 60)', '--timeout', default=60),
827         server_status=StatusArgument(
828             'Status to wait for (%s, default: %s)' % (
829                 ', '.join(server_states), server_states[0]),
830             '--status',
831             valid_states=server_states)
832     )
833
834     @errors.generic.all
835     @errors.cyclades.connection
836     @errors.cyclades.server_id
837     def _run(self, server_id, current_status):
838         r = self.client.get_server_details(server_id)
839         if r['status'].lower() == current_status.lower():
840             self._wait(server_id, current_status, timeout=self['timeout'])
841         else:
842             self.error(
843                 'Server %s: Cannot wait for status %s, '
844                 'status is already %s' % (
845                     server_id, current_status, r['status']))
846
847     def main(self, server_id):
848         super(self.__class__, self)._run()
849         self._run(
850             server_id=server_id,
851             current_status=self['server_status'] or 'BUILD')
852
853
854 @command(flavor_cmds)
855 class flavor_list(_init_cyclades, _optional_json, _name_filter, _id_filter):
856     """List available hardware flavors"""
857
858     PERMANENTS = ('id', 'name')
859
860     arguments = dict(
861         detail=FlagArgument('show detailed output', ('-l', '--details')),
862         limit=IntArgument('limit # of listed flavors', ('-n', '--number')),
863         more=FlagArgument(
864             'output results in pages (-n to set items per page, default 10)',
865             '--more'),
866         enum=FlagArgument('Enumerate results', '--enumerate'),
867         ram=ValueArgument('filter by ram', ('--ram')),
868         vcpus=ValueArgument('filter by number of VCPUs', ('--vcpus')),
869         disk=ValueArgument('filter by disk size in GB', ('--disk')),
870         disk_template=ValueArgument(
871             'filter by disk_templace', ('--disk-template'))
872     )
873
874     def _apply_common_filters(self, flavors):
875         common_filters = dict()
876         if self['ram']:
877             common_filters['ram'] = self['ram']
878         if self['vcpus']:
879             common_filters['vcpus'] = self['vcpus']
880         if self['disk']:
881             common_filters['disk'] = self['disk']
882         if self['disk_template']:
883             common_filters['SNF:disk_template'] = self['disk_template']
884         return filter_dicts_by_dict(flavors, common_filters)
885
886     @errors.generic.all
887     @errors.cyclades.connection
888     def _run(self):
889         withcommons = self['ram'] or self['vcpus'] or (
890             self['disk'] or self['disk_template'])
891         detail = self['detail'] or withcommons
892         flavors = self.client.list_flavors(detail)
893         flavors = self._filter_by_name(flavors)
894         flavors = self._filter_by_id(flavors)
895         if withcommons:
896             flavors = self._apply_common_filters(flavors)
897         if not (self['detail'] or (
898                 self['json_output'] or self['output_format'])):
899             remove_from_items(flavors, 'links')
900         if detail and not self['detail']:
901             for flv in flavors:
902                 for key in set(flv).difference(self.PERMANENTS):
903                     flv.pop(key)
904         kwargs = dict(out=StringIO(), title=()) if self['more'] else {}
905         self._print(
906             flavors,
907             with_redundancy=self['detail'], with_enumeration=self['enum'],
908             **kwargs)
909         if self['more']:
910             pager(kwargs['out'].getvalue())
911
912     def main(self):
913         super(self.__class__, self)._run()
914         self._run()
915
916
917 @command(flavor_cmds)
918 class flavor_info(_init_cyclades, _optional_json):
919     """Detailed information on a hardware flavor
920     To get a list of available flavors and flavor ids, try /flavor list
921     """
922
923     @errors.generic.all
924     @errors.cyclades.connection
925     @errors.cyclades.flavor_id
926     def _run(self, flavor_id):
927         self._print(
928             self.client.get_flavor_details(int(flavor_id)), self.print_dict)
929
930     def main(self, flavor_id):
931         super(self.__class__, self)._run()
932         self._run(flavor_id=flavor_id)
933
934
935 def _add_name(self, net):
936         user_id, tenant_id, uuids = net['user_id'], net['tenant_id'], []
937         if user_id:
938             uuids.append(user_id)
939         if tenant_id:
940             uuids.append(tenant_id)
941         if uuids:
942             usernames = self._uuids2usernames(uuids)
943             if user_id:
944                 net['user_id'] += ' (%s)' % usernames[user_id]
945             if tenant_id:
946                 net['tenant_id'] += ' (%s)' % usernames[tenant_id]