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