1#!/usr/bin/python
2# -*- coding: utf-8 -*-
3#
4# Copyright: (c) 2017, Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch>
5# Copyright: (c) 2019, René Moser <mail@renemoser.net>
6# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
7
8from __future__ import absolute_import, division, print_function
9__metaclass__ = type
10
11
12ANSIBLE_METADATA = {'metadata_version': '1.1',
13                    'status': ['preview'],
14                    'supported_by': 'community'}
15
16
17DOCUMENTATION = '''
18---
19module: cloudscale_server
20short_description: Manages servers on the cloudscale.ch IaaS service
21description:
22  - Create, update, start, stop and delete servers on the cloudscale.ch IaaS service.
23notes:
24  - Since version 2.8, I(uuid) and I(name) or not mutually exclusive anymore.
25  - If I(uuid) option is provided, it takes precedence over I(name) for server selection. This allows to update the server's name.
26  - If no I(uuid) option is provided, I(name) is used for server selection. If more than one server with this name exists, execution is aborted.
27  - Only the I(name) and I(flavor) are evaluated for the update.
28  - The option I(force=true) must be given to allow the reboot of existing running servers for applying the changes.
29version_added: '2.3'
30author:
31  - Gaudenz Steinlin (@gaudenz)
32  - René Moser (@resmo)
33options:
34  state:
35    description:
36      - State of the server.
37    choices: [ running, stopped, absent ]
38    default: running
39    type: str
40  name:
41    description:
42      - Name of the Server.
43      - Either I(name) or I(uuid) are required.
44    type: str
45  uuid:
46    description:
47      - UUID of the server.
48      - Either I(name) or I(uuid) are required.
49    type: str
50  flavor:
51    description:
52      - Flavor of the server.
53    type: str
54  image:
55    description:
56      - Image used to create the server.
57  volume_size_gb:
58    description:
59      - Size of the root volume in GB.
60    default: 10
61    type: int
62  bulk_volume_size_gb:
63    description:
64      - Size of the bulk storage volume in GB.
65      - No bulk storage volume if not set.
66    type: int
67  ssh_keys:
68    description:
69       - List of SSH public keys.
70       - Use the full content of your .pub file here.
71    type: list
72  password:
73    description:
74       - Password for the server.
75    type: str
76    version_added: '2.8'
77  use_public_network:
78    description:
79      - Attach a public network interface to the server.
80    default: yes
81    type: bool
82  use_private_network:
83    description:
84      - Attach a private network interface to the server.
85    default: no
86    type: bool
87  use_ipv6:
88    description:
89      - Enable IPv6 on the public network interface.
90    default: yes
91    type: bool
92  anti_affinity_with:
93    description:
94      - UUID of another server to create an anti-affinity group with.
95      - Mutually exclusive with I(server_groups).
96      - Deprecated, removed in version 2.11.
97    type: str
98  server_groups:
99    description:
100      - List of UUID or names of server groups.
101      - Mutually exclusive with I(anti_affinity_with).
102    type: list
103    version_added: '2.8'
104  user_data:
105    description:
106      - Cloud-init configuration (cloud-config) data to use for the server.
107    type: str
108  api_timeout:
109    version_added: '2.5'
110  force:
111    description:
112      - Allow to stop the running server for updating if necessary.
113    default: no
114    type: bool
115    version_added: '2.8'
116  tags:
117    description:
118      - Tags assosiated with the servers. Set this to C({}) to clear any tags.
119    type: dict
120    version_added: '2.9'
121extends_documentation_fragment: cloudscale
122'''
123
124EXAMPLES = '''
125# Create and start a server with an existing server group (shiny-group)
126- name: Start cloudscale.ch server
127  cloudscale_server:
128    name: my-shiny-cloudscale-server
129    image: debian-8
130    flavor: flex-4
131    ssh_keys: ssh-rsa XXXXXXXXXX...XXXX ansible@cloudscale
132    server_groups: shiny-group
133    use_private_network: True
134    bulk_volume_size_gb: 100
135    api_token: xxxxxx
136
137# Start another server in anti-affinity (server group shiny-group)
138- name: Start second cloudscale.ch server
139  cloudscale_server:
140    name: my-other-shiny-server
141    image: ubuntu-16.04
142    flavor: flex-8
143    ssh_keys: ssh-rsa XXXXXXXXXXX ansible@cloudscale
144    server_groups: shiny-group
145    api_token: xxxxxx
146
147
148# Force to update the flavor of a running server
149- name: Start cloudscale.ch server
150  cloudscale_server:
151    name: my-shiny-cloudscale-server
152    image: debian-8
153    flavor: flex-8
154    force: yes
155    ssh_keys: ssh-rsa XXXXXXXXXX...XXXX ansible@cloudscale
156    use_private_network: True
157    bulk_volume_size_gb: 100
158    api_token: xxxxxx
159  register: server1
160
161# Stop the first server
162- name: Stop my first server
163  cloudscale_server:
164    uuid: '{{ server1.uuid }}'
165    state: stopped
166    api_token: xxxxxx
167
168# Delete my second server
169- name: Delete my second server
170  cloudscale_server:
171    name: my-other-shiny-server
172    state: absent
173    api_token: xxxxxx
174
175# Start a server and wait for the SSH host keys to be generated
176- name: Start server and wait for SSH host keys
177  cloudscale_server:
178    name: my-cloudscale-server-with-ssh-key
179    image: debian-8
180    flavor: flex-4
181    ssh_keys: ssh-rsa XXXXXXXXXXX ansible@cloudscale
182    api_token: xxxxxx
183  register: server
184  until: server.ssh_fingerprints is defined and server.ssh_fingerprints
185  retries: 60
186  delay: 2
187'''
188
189RETURN = '''
190href:
191  description: API URL to get details about this server
192  returned: success when not state == absent
193  type: str
194  sample: https://api.cloudscale.ch/v1/servers/cfde831a-4e87-4a75-960f-89b0148aa2cc
195uuid:
196  description: The unique identifier for this server
197  returned: success
198  type: str
199  sample: cfde831a-4e87-4a75-960f-89b0148aa2cc
200name:
201  description: The display name of the server
202  returned: success
203  type: str
204  sample: its-a-me-mario.cloudscale.ch
205state:
206  description: The current status of the server
207  returned: success
208  type: str
209  sample: running
210flavor:
211  description: The flavor that has been used for this server
212  returned: success when not state == absent
213  type: str
214  sample: flex-8
215image:
216  description: The image used for booting this server
217  returned: success when not state == absent
218  type: str
219  sample: debian-8
220volumes:
221  description: List of volumes attached to the server
222  returned: success when not state == absent
223  type: list
224  sample: [ {"type": "ssd", "device": "/dev/vda", "size_gb": "50"} ]
225interfaces:
226  description: List of network ports attached to the server
227  returned: success when not state == absent
228  type: list
229  sample: [ { "type": "public", "addresses": [ ... ] } ]
230ssh_fingerprints:
231  description: A list of SSH host key fingerprints. Will be null until the host keys could be retrieved from the server.
232  returned: success when not state == absent
233  type: list
234  sample: ["ecdsa-sha2-nistp256 SHA256:XXXX", ... ]
235ssh_host_keys:
236  description: A list of SSH host keys. Will be null until the host keys could be retrieved from the server.
237  returned: success when not state == absent
238  type: list
239  sample: ["ecdsa-sha2-nistp256 XXXXX", ... ]
240anti_affinity_with:
241  description:
242  - List of servers in the same anti-affinity group
243  - Deprecated, removed in version 2.11.
244  returned: success when not state == absent
245  type: list
246  sample: []
247server_groups:
248  description: List of server groups
249  returned: success when not state == absent
250  type: list
251  sample: [ {"href": "https://api.cloudscale.ch/v1/server-groups/...", "uuid": "...", "name": "db-group"} ]
252  version_added: '2.8'
253tags:
254  description: Tags assosiated with the volume.
255  returned: success
256  type: dict
257  sample: { 'project': 'my project' }
258  version_added: '2.9'
259'''
260
261from datetime import datetime, timedelta
262from time import sleep
263from copy import deepcopy
264
265from ansible.module_utils.basic import AnsibleModule
266from ansible.module_utils.cloudscale import AnsibleCloudscaleBase, cloudscale_argument_spec
267
268ALLOWED_STATES = ('running',
269                  'stopped',
270                  'absent',
271                  )
272
273
274class AnsibleCloudscaleServer(AnsibleCloudscaleBase):
275
276    def __init__(self, module):
277        super(AnsibleCloudscaleServer, self).__init__(module)
278
279        # Initialize server dictionary
280        self._info = {}
281
282    def _init_server_container(self):
283        return {
284            'uuid': self._module.params.get('uuid') or self._info.get('uuid'),
285            'name': self._module.params.get('name') or self._info.get('name'),
286            'state': 'absent',
287        }
288
289    def _get_server_info(self, refresh=False):
290        if self._info and not refresh:
291            return self._info
292
293        self._info = self._init_server_container()
294
295        uuid = self._info.get('uuid')
296        if uuid is not None:
297            server_info = self._get('servers/%s' % uuid)
298            if server_info:
299                self._info = self._transform_state(server_info)
300
301        else:
302            name = self._info.get('name')
303            if name is not None:
304                servers = self._get('servers') or []
305                matching_server = []
306                for server in servers:
307                    if server['name'] == name:
308                        matching_server.append(server)
309
310                if len(matching_server) == 1:
311                    self._info = self._transform_state(matching_server[0])
312                elif len(matching_server) > 1:
313                    self._module.fail_json(msg="More than one server with name '%s' exists. "
314                                           "Use the 'uuid' parameter to identify the server." % name)
315
316        return self._info
317
318    @staticmethod
319    def _transform_state(server):
320        if 'status' in server:
321            server['state'] = server['status']
322            del server['status']
323        else:
324            server['state'] = 'absent'
325        return server
326
327    def _wait_for_state(self, states):
328        start = datetime.now()
329        timeout = self._module.params['api_timeout'] * 2
330        while datetime.now() - start < timedelta(seconds=timeout):
331            server_info = self._get_server_info(refresh=True)
332            if server_info.get('state') in states:
333                return server_info
334            sleep(1)
335
336        # Timeout succeeded
337        if server_info.get('name') is not None:
338            msg = "Timeout while waiting for a state change on server %s to states %s. " \
339                  "Current state is %s." % (server_info.get('name'), states, server_info.get('state'))
340        else:
341            name_uuid = self._module.params.get('name') or self._module.params.get('uuid')
342            msg = 'Timeout while waiting to find the server %s' % name_uuid
343
344        self._module.fail_json(msg=msg)
345
346    def _start_stop_server(self, server_info, target_state="running", ignore_diff=False):
347        actions = {
348            'stopped': 'stop',
349            'running': 'start',
350        }
351
352        server_state = server_info.get('state')
353        if server_state != target_state:
354            self._result['changed'] = True
355
356            if not ignore_diff:
357                self._result['diff']['before'].update({
358                    'state': server_info.get('state'),
359                })
360                self._result['diff']['after'].update({
361                    'state': target_state,
362                })
363            if not self._module.check_mode:
364                self._post('servers/%s/%s' % (server_info['uuid'], actions[target_state]))
365                server_info = self._wait_for_state((target_state, ))
366
367        return server_info
368
369    def _update_param(self, param_key, server_info, requires_stop=False):
370        param_value = self._module.params.get(param_key)
371        if param_value is None:
372            return server_info
373
374        if 'slug' in server_info[param_key]:
375            server_v = server_info[param_key]['slug']
376        else:
377            server_v = server_info[param_key]
378
379        if server_v != param_value:
380            # Set the diff output
381            self._result['diff']['before'].update({param_key: server_v})
382            self._result['diff']['after'].update({param_key: param_value})
383
384            if server_info.get('state') == "running":
385                if requires_stop and not self._module.params.get('force'):
386                    self._module.warn("Some changes won't be applied to running servers. "
387                                      "Use force=yes to allow the server '%s' to be stopped/started." % server_info['name'])
388                    return server_info
389
390            # Either the server is stopped or change is forced
391            self._result['changed'] = True
392            if not self._module.check_mode:
393
394                if requires_stop:
395                    self._start_stop_server(server_info, target_state="stopped", ignore_diff=True)
396
397                patch_data = {
398                    param_key: param_value,
399                }
400
401                # Response is 204: No Content
402                self._patch('servers/%s' % server_info['uuid'], patch_data)
403
404                # State changes to "changing" after update, waiting for stopped/running
405                server_info = self._wait_for_state(('stopped', 'running'))
406
407        return server_info
408
409    def _get_server_group_ids(self):
410        server_group_params = self._module.params['server_groups']
411        if not server_group_params:
412            return None
413
414        matching_group_names = []
415        results = []
416        server_groups = self._get('server-groups')
417        for server_group in server_groups:
418            if server_group['uuid'] in server_group_params:
419                results.append(server_group['uuid'])
420                server_group_params.remove(server_group['uuid'])
421
422            elif server_group['name'] in server_group_params:
423                results.append(server_group['uuid'])
424                server_group_params.remove(server_group['name'])
425                # Remember the names found
426                matching_group_names.append(server_group['name'])
427
428            # Names are not unique, verify if name already found in previous iterations
429            elif server_group['name'] in matching_group_names:
430                self._module.fail_json(msg="More than one server group with name exists: '%s'. "
431                                       "Use the 'uuid' parameter to identify the server group." % server_group['name'])
432
433        if server_group_params:
434            self._module.fail_json(msg="Server group name or UUID not found: %s" % ', '.join(server_group_params))
435
436        return results
437
438    def _create_server(self, server_info):
439        self._result['changed'] = True
440
441        data = deepcopy(self._module.params)
442        for i in ('uuid', 'state', 'force', 'api_timeout', 'api_token'):
443            del data[i]
444        data['server_groups'] = self._get_server_group_ids()
445
446        self._result['diff']['before'] = self._init_server_container()
447        self._result['diff']['after'] = deepcopy(data)
448        if not self._module.check_mode:
449            self._post('servers', data)
450            server_info = self._wait_for_state(('running', ))
451        return server_info
452
453    def _update_server(self, server_info):
454
455        previous_state = server_info.get('state')
456
457        # The API doesn't support to update server groups.
458        # Show a warning to the user if the desired state does not match.
459        desired_server_group_ids = self._get_server_group_ids()
460        if desired_server_group_ids is not None:
461            current_server_group_ids = [grp['uuid'] for grp in server_info['server_groups']]
462            if desired_server_group_ids != current_server_group_ids:
463                self._module.warn("Server groups can not be mutated, server needs redeployment to change groups.")
464
465        server_info = self._update_param('flavor', server_info, requires_stop=True)
466        server_info = self._update_param('name', server_info)
467        server_info = self._update_param('tags', server_info)
468
469        if previous_state == "running":
470            server_info = self._start_stop_server(server_info, target_state="running", ignore_diff=True)
471
472        return server_info
473
474    def present_server(self):
475        server_info = self._get_server_info()
476
477        if server_info.get('state') != "absent":
478
479            # If target state is stopped, stop before an potential update and force would not be required
480            if self._module.params.get('state') == "stopped":
481                server_info = self._start_stop_server(server_info, target_state="stopped")
482
483            server_info = self._update_server(server_info)
484
485            if self._module.params.get('state') == "running":
486                server_info = self._start_stop_server(server_info, target_state="running")
487        else:
488            server_info = self._create_server(server_info)
489            server_info = self._start_stop_server(server_info, target_state=self._module.params.get('state'))
490
491        return server_info
492
493    def absent_server(self):
494        server_info = self._get_server_info()
495        if server_info.get('state') != "absent":
496            self._result['changed'] = True
497            self._result['diff']['before'] = deepcopy(server_info)
498            self._result['diff']['after'] = self._init_server_container()
499            if not self._module.check_mode:
500                self._delete('servers/%s' % server_info['uuid'])
501                server_info = self._wait_for_state(('absent', ))
502        return server_info
503
504
505def main():
506    argument_spec = cloudscale_argument_spec()
507    argument_spec.update(dict(
508        state=dict(default='running', choices=ALLOWED_STATES),
509        name=dict(),
510        uuid=dict(),
511        flavor=dict(),
512        image=dict(),
513        volume_size_gb=dict(type='int', default=10),
514        bulk_volume_size_gb=dict(type='int'),
515        ssh_keys=dict(type='list'),
516        password=dict(no_log=True),
517        use_public_network=dict(type='bool', default=True),
518        use_private_network=dict(type='bool', default=False),
519        use_ipv6=dict(type='bool', default=True),
520        anti_affinity_with=dict(removed_in_version='2.11'),
521        server_groups=dict(type='list'),
522        user_data=dict(),
523        force=dict(type='bool', default=False),
524        tags=dict(type='dict'),
525    ))
526
527    module = AnsibleModule(
528        argument_spec=argument_spec,
529        required_one_of=(('name', 'uuid'),),
530        mutually_exclusive=(('anti_affinity_with', 'server_groups'),),
531        supports_check_mode=True,
532    )
533
534    cloudscale_server = AnsibleCloudscaleServer(module)
535    if module.params['state'] == "absent":
536        server = cloudscale_server.absent_server()
537    else:
538        server = cloudscale_server.present_server()
539
540    result = cloudscale_server.get_result(server)
541    module.exit_json(**result)
542
543
544if __name__ == '__main__':
545    main()
546