cyclades.py 37.5 KB
Newer Older
1
# Copyright 2011-2014 GRNET S.A. All rights reserved.
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
#   1. Redistributions of source code must retain the above
#      copyright notice, this list of conditions and the following
#      disclaimer.
#
#   2. Redistributions in binary form must reproduce the above
#      copyright notice, this list of conditions and the following
#      disclaimer in the documentation and/or other materials
#      provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# The views and conclusions contained in the software and
# documentation are those of the authors and should not be
# interpreted as representing official policies, either expressed
# or implied, of GRNET S.A.
33 34
import cStringIO
import codecs
35
from base64 import b64encode
36
from os.path import exists, expanduser
37 38 39
from io import StringIO
from pydoc import pager

40
from kamaki.cli import command
41
from kamaki.cli.cmdtree import CommandTree
42
from kamaki.cli.utils import remove_from_items, filter_dicts_by_dict
43
from kamaki.cli.errors import raiseCLIError, CLISyntaxError, CLIInvalidArgument
44 45
from kamaki.clients.cyclades import (
    CycladesComputeClient, ClientError, CycladesNetworkClient)
46 47
from kamaki.cli.argument import (
    FlagArgument, ValueArgument, KeyValueArgument, RepeatableArgument,
48
    DateArgument, IntArgument, StatusArgument)
49
from kamaki.cli.cmds import (
50 51
    CommandInit, fall_back, OptionalOutput, NameFilter, IDFilter, Wait, errors,
    client_log)
52

53

54 55
server_cmds = CommandTree('server', 'Cyclades/Compute API server commands')
flavor_cmds = CommandTree('flavor', 'Cyclades/Compute API flavor commands')
56
namespaces = [server_cmds, flavor_cmds]
57

58
howto_personality = [
59
    'Defines a file to be injected to virtual servers file system.',
60
    'syntax:  PATH,[SERVER_PATH,[OWNER,[GROUP,[MODE]]]]',
61 62 63 64
    '  [local-path=]PATH: local file to be injected (relative or absolute)',
    '  [server-path=]SERVER_PATH: destination location inside server Image',
    '  [owner=]OWNER: virtual servers user id for the remote file',
    '  [group=]GROUP: virtual servers group id or name for the remote file',
65
    '  [mode=]MODE: permission in octal (e.g., 0777)',
66
    'e.g., -p /tmp/my.file,owner=root,mode=0777']
67

68 69
server_states = ('BUILD', 'ACTIVE', 'STOPPED', 'REBOOT')

70

71
class _ServerWait(Wait):
72

73 74
    def wait(self, server_id, current_status, timeout=60):
        super(_ServerWait, self).wait(
75
            'Server', server_id, self.client.wait_server, current_status,
76 77
            countdown=(current_status not in ('BUILD', )),
            timeout=timeout if current_status not in ('BUILD', ) else 100)
78

79 80 81 82 83 84 85 86 87 88 89
    def assert_not_in_status(self, server_id, status):
        """
        :returns: current server status
        :raises CLIError: if server is already in this status
        :raises ClientError: (404) if server not found
        """
        current = self.client.get_server_details(server_id).get('status', None)
        if current in (status, ):
            raiseCLIError('Server %s is already %s' % (server_id, status))
        return current

90

91
class _CycladesInit(CommandInit):
92
    @errors.Generic.all
93
    @client_log
94
    def _run(self):
95
        self.client = self.get_client(CycladesComputeClient, 'cyclades')
96

97 98 99 100
    @errors.Cyclades.flavor_id
    def _flavor_exists(self, flavor_id):
        self.client.get_flavor_details(flavor_id=flavor_id)

101
    @fall_back
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129
    def _restruct_server_info(self, vm):
        if not vm:
            return vm
        img = vm['image']
        try:
            img.pop('links', None)
            img['name'] = self.client.get_image_details(img['id'])['name']
        except Exception:
            pass
        flv = vm['flavor']
        try:
            flv.pop('links', None)
            flv['name'] = self.client.get_flavor_details(flv['id'])['name']
        except Exception:
            pass
        vm['ports'] = vm.pop('attachments', dict())
        for port in vm['ports']:
            netid = port.get('network_id')
            for k in vm['addresses'].get(netid, []):
                k.pop('addr', None)
                k.pop('version', None)
                port.update(k)
        uuids = self._uuids2usernames([vm['user_id'], vm['tenant_id']])
        vm['user_id'] += ' (%s)' % uuids[vm['user_id']]
        for key in ('addresses', 'tenant_id', 'links'):
            vm.pop(key, None)
        return vm

130 131
    def main(self):
        self._run()
132

133

134
@command(server_cmds)
135
class server_list(_CycladesInit, OptionalOutput, NameFilter, IDFilter):
136
    """List virtual servers accessible by user
137
    Use filtering arguments (e.g., --name-like) to manage long server lists
138
    """
139

140
    arguments = dict(
141
        detail=FlagArgument('show detailed output', ('-l', '--details')),
142
        since=DateArgument(
143 144
            'show only items modified since date (\'H:M:S YYYY-mm-dd\') '
            'Can look back up to a limit (POLL_TIME) defined on service-side',
145
            '--since'),
146 147
        limit=IntArgument(
            'limit number of listed virtual servers', ('-n', '--number')),
148
        more=FlagArgument(
149
            'output results in pages (-n to set items per page, default 10)',
150
            '--more'),
151 152 153
        enum=FlagArgument('Enumerate results', '--enumerate'),
        flavor_id=ValueArgument('filter by flavor id', ('--flavor-id')),
        image_id=ValueArgument('filter by image id', ('--image-id')),
154 155 156 157 158
        user_id=ValueArgument('filter by user id', ('--user-id')),
        user_name=ValueArgument('filter by user name', ('--user-name')),
        status=ValueArgument(
            'filter by status (ACTIVE, STOPPED, REBOOT, ERROR, etc.)',
            ('--status')),
159 160 161 162
        meta=KeyValueArgument('filter by metadata key=values', ('--metadata')),
        meta_like=KeyValueArgument(
            'print only if in key=value, the value is part of actual value',
            ('--metadata-like')),
163 164
    )

165
    def _add_user_name(self, servers):
166
        uuids = self._uuids2usernames(list(set(
167
            [srv['user_id'] for srv in servers])))
168 169 170 171
        for srv in servers:
            srv['user_id'] += ' (%s)' % uuids[srv['user_id']]
        return servers

172 173 174 175 176 177 178 179 180
    def _apply_common_filters(self, servers):
        common_filters = dict()
        if self['status']:
            common_filters['status'] = self['status']
        if self['user_id'] or self['user_name']:
            uuid = self['user_id'] or self._username2uuid(self['user_name'])
            common_filters['user_id'] = uuid
        return filter_dicts_by_dict(servers, common_filters)

181
    def _filter_by_image(self, servers):
182
        iid = self['image_id']
183
        return [srv for srv in servers if srv['image']['id'] == iid]
184

185
    def _filter_by_flavor(self, servers):
186
        fid = self['flavor_id']
187
        return [srv for srv in servers if (
188
            '%s' % srv['flavor']['id'] == '%s' % fid)]
189

190
    def _filter_by_metadata(self, servers):
191 192
        new_servers = []
        for srv in servers:
193
            if 'metadata' not in srv:
194 195 196 197 198 199 200 201 202 203 204
                continue
            meta = [dict(srv['metadata'])]
            if self['meta']:
                meta = filter_dicts_by_dict(meta, self['meta'])
            if meta and self['meta_like']:
                meta = filter_dicts_by_dict(
                    meta, self['meta_like'], exact_match=False)
            if meta:
                new_servers.append(srv)
        return new_servers

205 206 207
    @errors.Generic.all
    @errors.Cyclades.connection
    @errors.Cyclades.date
208
    def _run(self):
209 210 211
        withimage = bool(self['image_id'])
        withflavor = bool(self['flavor_id'])
        withmeta = bool(self['meta'] or self['meta_like'])
212 213 214 215
        withcommons = bool(
            self['status'] or self['user_id'] or self['user_name'])
        detail = self['detail'] or (
            withimage or withflavor or withmeta or withcommons)
216
        ch_since = self.arguments['since'].isoformat if self['since'] else None
217
        servers = list(self.client.list_servers(detail, ch_since) or [])
218

219 220
        servers = self._filter_by_name(servers)
        servers = self._filter_by_id(servers)
221
        servers = self._apply_common_filters(servers)
222
        if withimage:
223
            servers = self._filter_by_image(servers)
224
        if withflavor:
225
            servers = self._filter_by_flavor(servers)
226
        if withmeta:
227
            servers = self._filter_by_metadata(servers)
228

229
        if detail and self['detail']:
230
            pass
231
        else:
232
            for srv in servers:
233
                for key in set(srv).difference(['id', 'name']):
234
                    srv.pop(key)
235

236
        kwargs = dict(with_enumeration=self['enum'])
237
        if self['more']:
238 239 240 241 242
            codecinfo = codecs.lookup('utf-8')
            kwargs['out'] = codecs.StreamReaderWriter(
                cStringIO.StringIO(),
                codecinfo.streamreader,
                codecinfo.streamwriter)
243 244
            kwargs['title'] = ()
        if self['limit']:
245
            servers = servers[:self['limit']]
246
        self.print_(servers, **kwargs)
247 248
        if self['more']:
            pager(kwargs['out'].getvalue())
249

250 251 252 253
    def main(self):
        super(self.__class__, self)._run()
        self._run()

254

255
@command(server_cmds)
256
class server_info(_CycladesInit, OptionalOutput):
257 258 259
    """Detailed information on a Virtual Machine"""

    arguments = dict(
260
        nics=FlagArgument(
261 262
            'Show only the network interfaces of this virtual server',
            '--nics'),
263 264
        stats=FlagArgument('Get URLs for server statistics', '--stats'),
        diagnostics=FlagArgument('Diagnostic information', '--diagnostics')
265
    )
266

267 268 269
    @errors.Generic.all
    @errors.Cyclades.connection
    @errors.Cyclades.server_id
270
    def _run(self, server_id):
271
        if self['nics']:
272
            self.print_(
273
                self.client.get_server_nics(server_id), self.print_dict)
274
        elif self['stats']:
275
            self.print_(
276
                self.client.get_server_stats(server_id), self.print_dict)
277
        elif self['diagnostics']:
278
            self.print_(self.client.get_server_diagnostics(server_id))
279
        else:
280
            vm = self.client.get_server_details(server_id)
281
            self.print_(vm, self.print_dict)
282

283 284
    def main(self, server_id):
        super(self.__class__, self)._run()
285
        choose_one = ('nics', 'stats', 'diagnostics')
286 287
        count = len([a for a in choose_one if self[a]])
        if count > 1:
Dionysis Grigoropoulos's avatar
Dionysis Grigoropoulos committed
288
            raise CLIInvalidArgument('Invalid argument combination', details=[
289 290
                'Arguments %s cannot be used simultaneously' % ', '.join(
                    [self.arguments[a].lvalue for a in choose_one])])
291 292
        self._run(server_id=server_id)

293

294
class PersonalityArgument(KeyValueArgument):
295 296 297 298 299 300 301 302

    terms = (
        ('local-path', 'contents'),
        ('server-path', 'path'),
        ('owner', 'owner'),
        ('group', 'group'),
        ('mode', 'mode'))

303
    @property
304
    def value(self):
305
        return getattr(self, '_value', [])
306 307

    @value.setter
308 309 310
    def value(self, newvalue):
        if newvalue == self.default:
            return self.value
311
        self._value, input_dict = [], {}
312 313
        for i, terms in enumerate(newvalue):
            termlist = terms.split(',')
314 315
            if len(termlist) > len(self.terms):
                msg = 'Wrong number of terms (1<=terms<=%s)' % len(self.terms)
316
                raiseCLIError(CLISyntaxError(msg), details=howto_personality)
317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336

            for k, v in self.terms:
                prefix = '%s=' % k
                for item in termlist:
                    if item.lower().startswith(prefix):
                        input_dict[k] = item[len(k) + 1:]
                        break
                    item = None
                if item:
                    termlist.remove(item)

            try:
                path = input_dict['local-path']
            except KeyError:
                path = termlist.pop(0)
                if not path:
                    raise CLIInvalidArgument(
                        '--personality: No local path specified',
                        details=howto_personality)

337
            if not exists(path):
338
                raise CLIInvalidArgument(
339
                    '--personality: File %s does not exist' % path,
340 341
                    details=howto_personality)

342
            self._value.append(dict(path=path))
343
            with open(expanduser(path)) as f:
344
                self._value[i]['contents'] = b64encode(f.read())
345 346 347 348 349 350 351 352
            for k, v in self.terms[1:]:
                try:
                    self._value[i][v] = input_dict[k]
                except KeyError:
                    try:
                        self._value[i][v] = termlist.pop(0)
                    except IndexError:
                        continue
353 354 355 356 357 358 359
                if k in ('mode', ) and self._value[i][v]:
                    try:
                        self._value[i][v] = int(self._value[i][v], 8)
                    except ValueError as ve:
                        raise CLIInvalidArgument(
                            'Personality mode must be in octal', details=[
                                '%s' % ve])
360

361

362 363
class NetworkArgument(RepeatableArgument):
    """[id=]NETWORK_ID[,[ip=]IP]"""
364 365 366

    @property
    def value(self):
367
        return getattr(self, '_value', self.default)
368 369 370

    @value.setter
    def value(self, new_value):
371 372 373 374 375 376 377 378 379 380 381 382 383
        for v in new_value or []:
            part1, sep, part2 = v.partition(',')
            netid, ip = '', ''
            if part1.startswith('id='):
                netid = part1[len('id='):]
            elif part1.startswith('ip='):
                ip = part1[len('ip='):]
            else:
                netid = part1
            if part2:
                if (part2.startswith('id=') and netid) or (
                        part2.startswith('ip=') and ip):
                    raise CLIInvalidArgument(
384 385
                        'Invalid network argument %s' % v,
                        details=['Valid format: [id=]NETWORK_ID[,[ip=]IP]'])
386 387 388 389 390 391 392 393 394
                if part2.startswith('id='):
                    netid = part2[len('id='):]
                elif part2.startswith('ip='):
                    ip = part2[len('ip='):]
                elif netid:
                    ip = part2
                else:
                    netid = part2
            if not netid:
395
                raise CLIInvalidArgument(
396 397
                    'Invalid network argument %s' % v,
                    details=['Valid format: [id=]NETWORK_ID[,[ip=]IP]'])
398 399 400 401
            self._value = getattr(self, '_value', [])
            self._value.append(dict(uuid=netid))
            if ip:
                self._value[-1]['fixed_ip'] = ip
402 403


404
@command(server_cmds)
405
class server_create(_CycladesInit, OptionalOutput, _ServerWait):
406
    """Create a server (aka Virtual Machine)"""
407

408
    arguments = dict(
409
        server_name=ValueArgument('The name of the new server', '--name'),
410 411
        flavor_id=IntArgument('The ID of the flavor', '--flavor-id'),
        image_id=ValueArgument('The ID of the image', '--image-id'),
412
        personality=PersonalityArgument(
413
            (80 * ' ').join(howto_personality), ('-p', '--personality')),
414 415 416 417 418
        wait=FlagArgument('Wait server to build', ('-w', '--wait')),
        cluster_size=IntArgument(
            'Create a cluster of servers of this size. In this case, the name'
            'parameter is the prefix of each server in the cluster (e.g.,'
            'srv1, srv2, etc.',
419 420
            '--cluster-size'),
        max_threads=IntArgument(
421
            'Max threads in cluster mode (default 1)', '--threads'),
422 423 424 425 426 427 428 429 430 431
        network_configuration=NetworkArgument(
            'Connect server to network: [id=]NETWORK_ID[,[ip=]IP]        . '
            'Use only NETWORK_ID for private networks.        . '
            'Use NETWORK_ID,[ip=]IP for networks with IP.        . '
            'Can be repeated, mutually exclussive with --no-network',
            '--network'),
        no_network=FlagArgument(
            'Do not create any network NICs on the server.        . '
            'Mutually exclusive to --network        . '
            'If neither --network or --no-network are used, the default '
432 433
            'network policy is applied. These policies are set on the cloud, '
            'so kamaki is oblivious to them',
434
            '--no-network'),
435
        project_id=ValueArgument('Assign server to project', '--project-id'),
436 437 438 439
        metadata=KeyValueArgument(
            'Add custom metadata in key=value form (can be repeated). '
            'Overwrites metadata defined otherwise (i.e., image).',
            ('-m', '--metadata'))
440
    )
441
    required = ('server_name', 'flavor_id', 'image_id')
442

443
    @errors.Cyclades.cluster_size
444
    def _create_cluster(self, prefix, flavor_id, image_id, size):
445
        networks = self['network_configuration'] or (
446
            [] if self['no_network'] else None)
447
        servers = [dict(
448
            name='%s%s' % (prefix, i if size > 1 else ''),
449 450
            flavor_id=flavor_id,
            image_id=image_id,
451
            project_id=self['project_id'],
452
            personality=self['personality'],
453
            metadata=self['metadata'],
454
            networks=networks) for i in range(1, 1 + size)]
455 456
        if size == 1:
            return [self.client.create_server(**servers[0])]
457
        self.client.MAX_THREADS = int(self['max_threads'] or 1)
458
        try:
459 460
            r = self.client.async_run(self.client.create_server, servers)
            return r
461 462 463 464 465 466 467 468 469 470
        except Exception as e:
            if size == 1:
                raise e
            try:
                requested_names = [s['name'] for s in servers]
                spawned_servers = [dict(
                    name=s['name'],
                    id=s['id']) for s in self.client.list_servers() if (
                        s['name'] in requested_names)]
                self.error('Failed to build %s servers' % size)
471
                self.error('Found %s matching servers:' % len(spawned_servers))
472
                self.print_(spawned_servers, out=self._err)
473
                self.error('Check if any of these servers should be removed')
474 475 476 477
            except Exception as ne:
                self.error('Error (%s) while notifying about errors' % ne)
            finally:
                raise e
478

479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517
    def _get_network_client(self):
        network = getattr(self, '_network_client', None)
        if not network:
            net_URL = self.astakos.get_endpoint_url(
                CycladesNetworkClient.service_type)
            network = CycladesNetworkClient(net_URL, self.client.token)
            self._network_client = network
        return network

    @errors.Image.id
    def _image_exists(self, image_id):
        self.client.get_image_details(image_id)

    @errors.Cyclades.network_id
    def _network_exists(self, network_id):
        network = self._get_network_client()
        network.get_network_details(network_id)

    def _ip_ready(self, ip, network_id, cerror):
        network = self._get_network_client()
        ips = [fip for fip in network.list_floatingips() if (
            fip['floating_ip_address'] == ip)]
        if not ips:
            msg = 'IP %s not available for current user' % ip
            raiseCLIError(cerror, details=[msg] + errors.Cyclades.about_ips)
        ipnet, ipvm = ips[0]['floating_network_id'], ips[0]['instance_id']
        if getattr(cerror, 'status', 0) in (409, ):
            msg = ''
            if ipnet != network_id:
                msg = 'IP %s belong to network %s, not %s' % (
                    ip, ipnet, network_id)
            elif ipvm:
                msg = 'IP %s is already used by device %s' % (ip, ipvm)
            if msg:
                raiseCLIError(cerror, details=[
                    msg,
                    'To get details on IP',
                    '  kamaki ip info %s' % ip] + errors.Cyclades.about_ips)

518 519
    @errors.Generic.all
    @errors.Cyclades.connection
520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537
    def _run(self):
        try:
            for r in self._create_cluster(
                    self['server_name'], self['flavor_id'], self['image_id'],
                    size=self['cluster_size'] or 1):
                if not r:
                    self.error('Create %s: server response was %s' % (
                        self['server_name'], r))
                    continue
                self.print_(r, self.print_dict)
                if self['wait']:
                    self.wait(r['id'], r['status'] or 'BUILD')
                self.writeln(' ')
        except ClientError as ce:
            if ce.status in (404, 400):
                self._flavor_exists(flavor_id=self['flavor_id'])
                self._image_exists(image_id=self['image_id'])
            if ce.status in (404, 400, 409):
538
                for net in self['network_configuration'] or []:
539 540 541
                    self._network_exists(network_id=net['uuid'])
                    if 'fixed_ip' in net:
                        self._ip_ready(net['fixed_ip'], net['uuid'], ce)
542
            if self['project_id'] and ce.status in (400, 403, 404):
543
                self._project_id_exists(project_id=self['project_id'])
544
            raise
545

546
    def main(self):
547
        super(self.__class__, self)._run()
548
        if self['no_network'] and self['network_configuration']:
549 550
            raise CLIInvalidArgument(
                'Invalid argument compination', importance=2, details=[
551 552 553
                    'Arguments %s and %s are mutually exclusive' % (
                        self.arguments['no_network'].lvalue,
                        self.arguments['network_configuration'].lvalue)])
554
        self._run()
555

556

557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574
class FirewallProfileArgument(ValueArgument):

    profiles = ('DISABLED', 'ENABLED', 'PROTECTED')

    @property
    def value(self):
        return getattr(self, '_value', None)

    @value.setter
    def value(self, new_profile):
        if new_profile:
            new_profile = new_profile.upper()
            if new_profile in self.profiles:
                self._value = new_profile
            else:
                raise CLIInvalidArgument(
                    'Invalid firewall profile %s' % new_profile,
                    details=['Valid values: %s' % ', '.join(self.profiles)])
575

576

577
@command(server_cmds)
578
class server_modify(_CycladesInit):
579 580 581 582
    """Modify attributes of a virtual server"""

    arguments = dict(
        server_name=ValueArgument('The new name', '--name'),
583
        flavor_id=IntArgument('Resize (set another flavor)', '--flavor-id'),
584 585 586 587 588
        firewall_profile=FirewallProfileArgument(
            'Valid values: %s' % (', '.join(FirewallProfileArgument.profiles)),
            '--firewall'),
        metadata_to_set=KeyValueArgument(
            'Set metadata in key=value form (can be repeated)',
589
            '--metadata-set'),
590
        metadata_to_delete=RepeatableArgument(
591 592 593
            'Delete metadata by key (can be repeated)', '--metadata-del'),
        public_network_port_id=ValueArgument(
            'Connection to set new firewall (* for all)', '--port-id'),
594
    )
595 596
    required = [
        'server_name', 'flavor_id', 'firewall_profile', 'metadata_to_set',
597
        'metadata_to_delete']
598

599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618
    def _set_firewall_profile(self, server_id):
        vm = self._restruct_server_info(
            self.client.get_server_details(server_id))
        ports = [p for p in vm['ports'] if 'firewallProfile' in p]
        pick_port = self.arguments['public_network_port_id']
        if pick_port.value:
            ports = [p for p in ports if pick_port.value in (
                '*', '%s' % p['id'])]
        elif len(ports) > 1:
            port_strings = ['Server %s ports to public networks:' % server_id]
            for p in ports:
                port_strings.append('  %s' % p['id'])
                for k in ('network_id', 'ipv4', 'ipv6', 'firewallProfile'):
                    v = p.get(k)
                    if v:
                        port_strings.append('\t%s: %s' % (k, v))
            raiseCLIError(
                'Multiple public connections on server %s' % (
                    server_id), details=port_strings + [
                        'To select one:',
619
                        '  %s PORT_ID' % pick_port.lvalue,
620 621 622 623 624
                        'To set all:',
                        '  %s *' % pick_port.lvalue, ])
        if not ports:
            pp = pick_port.value
            raiseCLIError(
625
                'No public networks attached on server %s%s' % (
626 627
                    server_id, ' through port %s' % pp if pp else ''),
                details=[
628 629 630
                    'To see all networks:', '  kamaki network list',
                    'To see all connections:',
                    '  kamaki server info %s --nics' % server_id,
631
                    'To connect to a network:',
632
                    '  kamaki network connect NETWORK_ID --device-id %s' % (
633 634 635 636 637 638 639 640 641
                        server_id)])
        for port in ports:
            self.error('Set port %s firewall to %s' % (
                port['id'], self['firewall_profile']))
            self.client.set_firewall_profile(
                server_id=server_id,
                profile=self['firewall_profile'],
                port_id=port['id'])

642 643 644 645 646 647 648 649 650
    def _server_is_stopped(self, server_id, cerror):
        vm = self.client.get_server_details(server_id)
        if vm['status'].lower() not in ('stopped'):
            raiseCLIError(cerror, details=[
                'To resize a virtual server, it must be STOPPED',
                'Server %s status is %s' % (server_id, vm['status']),
                'To stop the server',
                '  kamaki server shutdown %s -w' % server_id])

651 652 653
    @errors.Generic.all
    @errors.Cyclades.connection
    @errors.Cyclades.server_id
654
    def _run(self, server_id):
655
        if self['server_name'] is not None:
656 657
            self.client.update_server_name((server_id), self['server_name'])
        if self['flavor_id']:
658 659 660 661 662 663 664 665
            try:
                self.client.resize_server(server_id, self['flavor_id'])
            except ClientError as ce:
                if ce.status in (404, ):
                    self._flavor_exists(flavor_id=self['flavor_id'])
                if ce.status in (400, ):
                    self._server_is_stopped(server_id, ce)
                raise
666
        if self['firewall_profile']:
667
            self._set_firewall_profile(server_id)
668 669 670
        if self['metadata_to_set']:
            self.client.update_server_metadata(
                server_id, **self['metadata_to_set'])
671
        for key in (self['metadata_to_delete'] or []):
672
            errors.Cyclades.metadata(
673
                self.client.delete_server_metadata)(server_id, key=key)
674

675
    def main(self, server_id):
676
        super(self.__class__, self)._run()
677 678 679 680 681 682
        pnpid = self.arguments['public_network_port_id']
        fp = self.arguments['firewall_profile']
        if pnpid.value and not fp.value:
            raise CLIInvalidArgument('Invalid argument compination', details=[
                'Argument %s should always be combined with %s' % (
                    pnpid.lvalue, fp.lvalue)])
683
        self._run(server_id=server_id)
684

685

686
@command(server_cmds)
687
class server_reassign(_CycladesInit, OptionalOutput):
688 689 690 691 692 693
    """Assign a virtual server to a different project"""

    arguments = dict(
        project_id=ValueArgument('The project to assign', '--project-id')
    )
    required = ('project_id', )
694

695 696 697
    @errors.Generic.all
    @errors.Cyclades.connection
    @errors.Cyclades.server_id
698
    def _run(self, server_id):
699 700 701 702
        try:
            self.client.reassign_server(server_id, self['project_id'])
        except ClientError as ce:
            if ce.status in (400, 403, 404):
703
                self._project_id_exists(project_id=self['project_id'])
704
            raise
705

706
    def main(self, server_id):
707
        super(self.__class__, self)._run()
708
        self._run(server_id=server_id)
709 710


711
@command(server_cmds)
712
class server_delete(_CycladesInit, _ServerWait):
713
    """Delete a virtual server"""
714

715
    arguments = dict(
716 717
        wait=FlagArgument('Wait server to be destroyed', ('-w', '--wait')),
        cluster=FlagArgument(
718 719 720
            '(DANGEROUS) Delete all VMs with names starting with the cluster '
            'prefix. Do not use it if unsure. Syntax:'
            ' kamaki server delete --cluster CLUSTER_PREFIX',
721
            '--cluster')
722 723
    )

724 725 726 727 728
    def _server_ids(self, server_var):
        if self['cluster']:
            return [s['id'] for s in self.client.list_servers() if (
                s['name'].startswith(server_var))]

729 730 731 732 733 734 735
        return [server_var, ]

    @errors.Cyclades.server_id
    def _delete_server(self, server_id):
        if self['wait']:
            details = self.client.get_server_details(server_id)
            status = details['status']
736

737 738 739
        self.client.delete_server(server_id)
        if self['wait']:
            self.wait(server_id, status)
740

741 742
    @errors.Generic.all
    @errors.Cyclades.connection
743
    def _run(self, server_var):
744
        deleted_vms = []
745
        for server_id in self._server_ids(server_var):
746 747 748 749
            self._delete_server(server_id=server_id)
            deleted_vms.append(server_id)
        if self['cluster']:
            dlen = len(deleted_vms)
750
            self.error('%s virtual server %s deleted' % (
751
                dlen, '' if dlen == 1 else 's'))
752

753
    def main(self, server_id_or_cluster_prefix):
754
        super(self.__class__, self)._run()
755
        self._run(server_id_or_cluster_prefix)
756

757

758
@command(server_cmds)
759
class server_reboot(_CycladesInit, _ServerWait):
760
    """Reboot a virtual server"""
761

762
    arguments = dict(
763
        type=ValueArgument('SOFT or HARD - default: SOFT', ('--type')),
764
        wait=FlagArgument('Wait server to be destroyed', ('-w', '--wait'))
765
    )
766

767 768 769
    @errors.Generic.all
    @errors.Cyclades.connection
    @errors.Cyclades.server_id
770
    def _run(self, server_id):
771
        hard_reboot = None
772 773 774 775 776 777 778 779 780 781 782
        if self['type']:
            if self['type'].lower() in ('soft', ):
                hard_reboot = False
            elif self['type'].lower() in ('hard', ):
                hard_reboot = True
            else:
                raise CLISyntaxError(
                    'Invalid reboot type %s' % self['type'],
                    importance=2, details=[
                        '--type values are either SOFT (default) or HARD'])

783
        self.client.reboot_server(int(server_id), hard_reboot)
784
        if self['wait']:
785
            self.wait(server_id, 'REBOOT')
786

787
    def main(self, server_id):
788 789
        super(self.__class__, self)._run()
        self._run(server_id=server_id)
790

791

792
@command(server_cmds)
793
class server_start(_CycladesInit, _ServerWait):
794
    """Start an existing virtual server"""
795

796 797 798 799
    arguments = dict(
        wait=FlagArgument('Wait server to be destroyed', ('-w', '--wait'))
    )

800 801 802
    @errors.Generic.all
    @errors.Cyclades.connection
    @errors.Cyclades.server_id
803
    def _run(self, server_id):
804
        status = self.assert_not_in_status(server_id, 'ACTIVE')
805
        self.client.start_server(int(server_id))
806
        if self['wait']:
807
            self.wait(server_id, status)
808

809
    def main(self, server_id):
810 811
        super(self.__class__, self)._run()
        self._run(server_id=server_id)
812

813

814
@command(server_cmds)
815
class server_shutdown(_CycladesInit,  _ServerWait):
816
    """Shutdown an active virtual server"""
817

818 819 820 821
    arguments = dict(
        wait=FlagArgument('Wait server to be destroyed', ('-w', '--wait'))
    )

822 823 824
    @errors.Generic.all
    @errors.Cyclades.connection
    @errors.Cyclades.server_id
825
    def _run(self, server_id):
826
        status = self.assert_not_in_status(server_id, 'STOPPED')
827
        self.client.shutdown_server(int(server_id))
828
        if self['wait']:
829
            self.wait(server_id, status)
830

831
    def main(self, server_id):
832 833
        super(self.__class__, self)._run()
        self._run(server_id=server_id)
834

835

836 837 838
_basic_cons = CycladesComputeClient.CONSOLE_TYPES


839 840
class ConsoleTypeArgument(ValueArgument):

841 842
    TRANSLATE = {'no-vnc': 'vnc-ws', 'no-vnc-encrypted': 'vnc-wss'}

843 844 845 846 847 848
    @property
    def value(self):
        return getattr(self, '_value', None)

    @value.setter
    def value(self, new_value):
849
        global _basic_cons
850 851
        if new_value:
            v = new_value.lower()
852 853
            v = self.TRANSLATE.get(v, v)
            if v in _basic_cons:
854 855
                self._value = v
            else:
856 857 858
                ctypes = set(_basic_cons).difference(self.TRANSLATE.values())
                ctypes = list(ctypes) + [
                    '%s (aka %s)' % (a, t) for t, a in self.TRANSLATE.items()]
859 860
                raise CLIInvalidArgument(
                    'Invalid console type %s' % new_value, details=[
861 862 863 864 865 866
                        'Valid console types: %s' % (', '.join(ctypes)), ])


_translated = ConsoleTypeArgument.TRANSLATE
VALID_CONSOLE_TYPES = list(set(_basic_cons).difference(_translated.values()))
VALID_CONSOLE_TYPES += ['%s (aka %s)' % (a, t) for t, a in _translated.items()]
867 868


869
@command(server_cmds)
870
class server_console(_CycladesInit, OptionalOutput):
871
    """Create a VNC console and show connection information"""
872

873 874 875
    arguments = dict(
        console_type=ConsoleTypeArgument(
            'Valid values: %s Default: %s' % (
876
                ', '.join(VALID_CONSOLE_TYPES), VALID_CONSOLE_TYPES[0]),
877 878 879
            '--type'),
    )

880 881 882
    @errors.Generic.all
    @errors.Cyclades.connection
    @errors.Cyclades.server_id
883 884
    def _run(self, server_id):
        self.error('The following credentials will be invalidated shortly')
885
        ctype = self['console_type'] or VALID_CONSOLE_TYPES[0]
886 887
        self.print_(
            self.client.get_server_console(server_id, ctype), self.print_dict)
888 889 890 891

    def main(self, server_id):
        super(self.__class__, self)._run()
        self._run(server_id=server_id)
892

893

894
@command(server_cmds)
895
class server_wait(_CycladesInit, _ServerWait):
896
    """Wait for server to change its status (default: BUILD)"""
897

898 899
    arguments = dict(
        timeout=IntArgument(
900 901 902 903 904 905
            'Wait limit in seconds (default: 60)', '--timeout', default=60),
        server_status=StatusArgument(
            'Status to wait for (%s, default: %s)' % (
                ', '.join(server_states), server_states[0]),
            '--status',
            valid_states=server_states)
906 907
    )

908 909 910
    @errors.Generic.all
    @errors.Cyclades.connection
    @errors.Cyclades.server_id
911 912 913
    def _run(self, server_id, current_status):
        r = self.client.get_server_details(server_id)
        if r['status'].lower() == current_status.lower():
914
            self.wait(server_id, current_status, timeout=self['timeout'])
915 916 917 918 919
        else:
            self.error(
                'Server %s: Cannot wait for status %s, '
                'status is already %s' % (
                    server_id, current_status, r['status']))
920

921
    def main(self, server_id):
922
        super(self.__class__, self)._run()
923
        self._run(
924 925
            server_id=server_id,
            current_status=self['server_status'] or 'BUILD')
926

927

928
@command(flavor_cmds)
929
class flavor_list(_CycladesInit, OptionalOutput, NameFilter, IDFilter):
930
    """List available hardware flavors"""
931

932
    arguments = dict(
933 934
        detail=FlagArgument('show detailed output', ('-l', '--details')),
        limit=IntArgument('limit # of listed flavors', ('-n', '--number')),
935
        more=FlagArgument(
936
            'output results in pages (-n to set items per page, default 10)',
937
            '--more'),
938 939 940 941 942 943
        enum=FlagArgument('Enumerate results', '--enumerate'),
        ram=ValueArgument('filter by ram', ('--ram')),
        vcpus=ValueArgument('filter by number of VCPUs', ('--vcpus')),
        disk=ValueArgument('filter by disk size in GB', ('--disk')),
        disk_template=ValueArgument(
            'filter by disk_templace', ('--disk-template'))
944
    )
945

946 947 948 949 950 951 952 953 954 955 956 957
    def _apply_common_filters(self, flavors):
        common_filters = dict()
        if self['ram']:
            common_filters['ram'] = self['ram']
        if self['vcpus']:
            common_filters['vcpus'] = self['vcpus']
        if self['disk']:
            common_filters['disk'] = self['disk']
        if self['disk_template']:
            common_filters['SNF:disk_template'] = self['disk_template']
        return filter_dicts_by_dict(flavors, common_filters)

958 959
    @errors.Generic.all
    @errors.Cyclades.connection
960
    def _run(self):
961 962 963 964
        withcommons = self['ram'] or self['vcpus'] or (
            self['disk'] or self['disk_template'])
        detail = self['detail'] or withcommons
        flavors = self.client.list_flavors(detail)
965 966
        flavors = self._filter_by_name(flavors)
        flavors = self._filter_by_id(flavors)
967 968
        if withcommons:
            flavors = self._apply_common_filters(flavors)
969
        if not (self['detail'] or self['output_format']):
970
            remove_from_items(flavors, 'links')
971 972
        if detail and not self['detail']:
            for flv in flavors:
973
                for key in set(flv).difference(['id', 'name']):
974
                    flv.pop(key)
975
        kwargs = dict(out=StringIO(), title=()) if self['more'] else {}
976
        self.print_(flavors, with_enumeration=self['enum'], **kwargs)
977 978
        if self['more']:
            pager(kwargs['out'].getvalue())
979

980
    def main(self):
981 982
        super(self.__class__, self)._run()
        self._run()
983

984

985
@command(flavor_cmds)
986
class flavor_info(_CycladesInit, OptionalOutput):
987
    """Detailed information on a hardware flavor"""
988

989 990 991
    @errors.Generic.all
    @errors.Cyclades.connection
    @errors.Cyclades.flavor_id
992
    def _run(self, flavor_id):
993
        self.print_(self.client.get_flavor_details(flavor_id), self.print_dict)
994

995 996 997
    def main(self, flavor_id):
        super(self.__class__, self)._run()
        self._run(flavor_id=flavor_id)