mirror of
https://github.com/ansible-collections/community.general.git
synced 2025-04-26 04:11:25 -07:00
* Improve error message in cloudscale_server module Fix punctuation and add the full contents of "info" to the output in case of failed API calls. This is useful in case of connection timeouts and other error conditions where there is no response body available. * Increase timeouts in cloudscale_server module Increase the timeouts to not fail in case the API calls take a bit longer than usual. The default timeout of fetch_url is 10s which is quite short. Increase it to 30s. The timeout for waiting for a server change is increased as well as it calls the API in a loop. Therefore this value should be larger than the API timeout. * Send API parameters as JSON in cloudscale_server module Use JSON to send the POST data to the API instead of an urlencoded string. Urlencoding is not really a good match for some Python datatypes. This fixes an issue when submitting a list of SSH keys which did not get translated properly. * Fix typo in cloudscale_server documentation * cloudscale_sever: Replace timeout const by api_timeout param Replace the static TIMEOUT_API constant by a user configurable api_timeout parameter. Also eliminate the TIMEOUT_WAIT constant by 2*api_timeout. This means that the timeout to wait for server changes is always double the timeout for API calls. * Use Debian 9 image for cloudscale_server tests
449 lines
16 KiB
Python
449 lines
16 KiB
Python
#!/usr/bin/python
|
|
# -*- coding: utf-8 -*-
|
|
#
|
|
# (c) 2017, Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch>
|
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
|
|
from __future__ import absolute_import, division, print_function
|
|
__metaclass__ = type
|
|
|
|
|
|
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
|
'status': ['preview'],
|
|
'supported_by': 'community'}
|
|
|
|
|
|
DOCUMENTATION = '''
|
|
---
|
|
module: cloudscale_server
|
|
short_description: Manages servers on the cloudscale.ch IaaS service
|
|
description:
|
|
- Create, start, stop and delete servers on the cloudscale.ch IaaS service.
|
|
- All operations are performed using the cloudscale.ch public API v1.
|
|
- "For details consult the full API documentation: U(https://www.cloudscale.ch/en/api/v1)."
|
|
- A valid API token is required for all operations. You can create as many tokens as you like using the cloudscale.ch control panel at
|
|
U(https://control.cloudscale.ch).
|
|
notes:
|
|
- Instead of the api_token parameter the CLOUDSCALE_API_TOKEN environment variable can be used.
|
|
- To create a new server at least the C(name), C(ssh_key), C(image) and C(flavor) options are required.
|
|
- If more than one server with the name given by the C(name) option exists, execution is aborted.
|
|
- Once a server is created all parameters except C(state) are read-only. You can't change the name, flavor or any other property. This is a limitation
|
|
of the cloudscale.ch API. The module will silently ignore differences between the configured parameters and the running server if a server with the
|
|
correct name or UUID exists. Only state changes will be applied.
|
|
version_added: 2.3
|
|
author: "Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch>"
|
|
options:
|
|
state:
|
|
description:
|
|
- State of the server
|
|
default: running
|
|
choices: [ running, stopped, absent ]
|
|
name:
|
|
description:
|
|
- Name of the Server.
|
|
- Either C(name) or C(uuid) are required. These options are mutually exclusive.
|
|
uuid:
|
|
description:
|
|
- UUID of the server.
|
|
- Either C(name) or C(uuid) are required. These options are mutually exclusive.
|
|
flavor:
|
|
description:
|
|
- Flavor of the server.
|
|
image:
|
|
description:
|
|
- Image used to create the server.
|
|
volume_size_gb:
|
|
description:
|
|
- Size of the root volume in GB.
|
|
default: 10
|
|
bulk_volume_size_gb:
|
|
description:
|
|
- Size of the bulk storage volume in GB.
|
|
- No bulk storage volume if not set.
|
|
ssh_keys:
|
|
description:
|
|
- List of SSH public keys.
|
|
- Use the full content of your .pub file here.
|
|
use_public_network:
|
|
description:
|
|
- Attach a public network interface to the server.
|
|
default: True
|
|
use_private_network:
|
|
description:
|
|
- Attach a private network interface to the server.
|
|
default: False
|
|
use_ipv6:
|
|
description:
|
|
- Enable IPv6 on the public network interface.
|
|
default: True
|
|
anti_affinity_with:
|
|
description:
|
|
- UUID of another server to create an anti-affinity group with.
|
|
user_data:
|
|
description:
|
|
- Cloud-init configuration (cloud-config) data to use for the server.
|
|
api_token:
|
|
description:
|
|
- cloudscale.ch API token.
|
|
- This can also be passed in the CLOUDSCALE_API_TOKEN environment variable.
|
|
api_timeout:
|
|
description:
|
|
- Timeout in seconds for calls to the cloudscale.ch API.
|
|
default: 30
|
|
version_added: "2.5"
|
|
'''
|
|
|
|
EXAMPLES = '''
|
|
# Start a server (if it does not exist) and register the server details
|
|
- name: Start cloudscale.ch server
|
|
cloudscale_server:
|
|
name: my-shiny-cloudscale-server
|
|
image: debian-8
|
|
flavor: flex-4
|
|
ssh_keys: ssh-rsa XXXXXXXXXX...XXXX ansible@cloudscale
|
|
use_private_network: True
|
|
bulk_volume_size_gb: 100
|
|
api_token: xxxxxx
|
|
register: server1
|
|
|
|
# Start another server in anti-affinity to the first one
|
|
- name: Start second cloudscale.ch server
|
|
cloudscale_server:
|
|
name: my-other-shiny-server
|
|
image: ubuntu-16.04
|
|
flavor: flex-8
|
|
ssh_keys: ssh-rsa XXXXXXXXXXX ansible@cloudscale
|
|
anti_affinity_with: '{{ server1.uuid }}'
|
|
api_token: xxxxxx
|
|
|
|
# Stop the first server
|
|
- name: Stop my first server
|
|
cloudscale_server:
|
|
uuid: '{{ server1.uuid }}'
|
|
state: stopped
|
|
api_token: xxxxxx
|
|
|
|
# Delete my second server
|
|
- name: Delete my second server
|
|
cloudscale_server:
|
|
name: my-other-shiny-server
|
|
state: absent
|
|
api_token: xxxxxx
|
|
|
|
# Start a server and wait for the SSH host keys to be generated
|
|
- name: Start server and wait for SSH host keys
|
|
cloudscale_server:
|
|
name: my-cloudscale-server-with-ssh-key
|
|
image: debian-8
|
|
flavor: flex-4
|
|
ssh_keys: ssh-rsa XXXXXXXXXXX ansible@cloudscale
|
|
api_token: xxxxxx
|
|
register: server
|
|
until: server.ssh_fingerprints
|
|
retries: 60
|
|
delay: 2
|
|
'''
|
|
|
|
RETURN = '''
|
|
href:
|
|
description: API URL to get details about this server
|
|
returned: success when not state == absent
|
|
type: string
|
|
sample: https://api.cloudscale.ch/v1/servers/cfde831a-4e87-4a75-960f-89b0148aa2cc
|
|
uuid:
|
|
description: The unique identifier for this server
|
|
returned: success
|
|
type: string
|
|
sample: cfde831a-4e87-4a75-960f-89b0148aa2cc
|
|
name:
|
|
description: The display name of the server
|
|
returned: success
|
|
type: string
|
|
sample: its-a-me-mario.cloudscale.ch
|
|
state:
|
|
description: The current status of the server
|
|
returned: success
|
|
type: string
|
|
sample: running
|
|
flavor:
|
|
description: The flavor that has been used for this server
|
|
returned: success when not state == absent
|
|
type: string
|
|
sample: flex-8
|
|
image:
|
|
description: The image used for booting this server
|
|
returned: success when not state == absent
|
|
type: string
|
|
sample: debian-8
|
|
volumes:
|
|
description: List of volumes attached to the server
|
|
returned: success when not state == absent
|
|
type: list
|
|
sample: [ {"type": "ssd", "device": "/dev/vda", "size_gb": "50"} ]
|
|
interfaces:
|
|
description: List of network ports attached to the server
|
|
returned: success when not state == absent
|
|
type: list
|
|
sample: [ { "type": "public", "addresses": [ ... ] } ]
|
|
ssh_fingerprints:
|
|
description: A list of SSH host key fingerprints. Will be null until the host keys could be retrieved from the server.
|
|
returned: success when not state == absent
|
|
type: list
|
|
sample: ["ecdsa-sha2-nistp256 SHA256:XXXX", ... ]
|
|
ssh_host_keys:
|
|
description: A list of SSH host keys. Will be null until the host keys could be retrieved from the server.
|
|
returned: success when not state == absent
|
|
type: list
|
|
sample: ["ecdsa-sha2-nistp256 XXXXX", ... ]
|
|
anti_affinity_with:
|
|
description: List of servers in the same anti-affinity group
|
|
returned: success when not state == absent
|
|
type: string
|
|
sample: []
|
|
'''
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime, timedelta
|
|
from time import sleep
|
|
|
|
from ansible.module_utils.basic import AnsibleModule
|
|
from ansible.module_utils.urls import fetch_url
|
|
|
|
|
|
API_URL = 'https://api.cloudscale.ch/v1/'
|
|
ALLOWED_STATES = ('running',
|
|
'stopped',
|
|
'absent',
|
|
)
|
|
|
|
|
|
class AnsibleCloudscaleServer(object):
|
|
|
|
def __init__(self, module, api_token):
|
|
self._module = module
|
|
self._auth_header = {'Authorization': 'Bearer %s' % api_token}
|
|
|
|
# Check if server already exists and load properties
|
|
uuid = self._module.params['uuid']
|
|
name = self._module.params['name']
|
|
|
|
# Initialize server dictionary
|
|
self.info = {'uuid': uuid, 'name': name, 'state': 'absent'}
|
|
|
|
servers = self.list_servers()
|
|
matching_server = []
|
|
for s in servers:
|
|
if uuid:
|
|
# Look for server by UUID if given
|
|
if s['uuid'] == uuid:
|
|
self.info = self._transform_state(s)
|
|
break
|
|
else:
|
|
# Look for server by name
|
|
if s['name'] == name:
|
|
matching_server.append(s)
|
|
else:
|
|
if len(matching_server) == 1:
|
|
self.info = self._transform_state(matching_server[0])
|
|
elif len(matching_server) > 1:
|
|
self._module.fail_json(msg="More than one server with name '%s' exists. "
|
|
"Use the 'uuid' parameter to identify the server." % name)
|
|
|
|
def _get(self, api_call):
|
|
resp, info = fetch_url(self._module, API_URL + api_call, headers=self._auth_header, timeout=self._module.params['api_timeout'])
|
|
|
|
if info['status'] == 200:
|
|
return json.loads(resp.read())
|
|
else:
|
|
self._module.fail_json(msg='Failure while calling the cloudscale.ch API with GET for '
|
|
'"%s".' % api_call, fetch_url_info=info)
|
|
|
|
def _post(self, api_call, data=None):
|
|
headers = self._auth_header.copy()
|
|
if data is not None:
|
|
data = self._module.jsonify(data)
|
|
headers['Content-type'] = 'application/json'
|
|
|
|
resp, info = fetch_url(self._module,
|
|
API_URL + api_call,
|
|
headers=headers,
|
|
method='POST',
|
|
data=data,
|
|
timeout=self._module.params['api_timeout'])
|
|
|
|
if info['status'] == 201:
|
|
return json.loads(resp.read())
|
|
elif info['status'] == 204:
|
|
return None
|
|
else:
|
|
self._module.fail_json(msg='Failure while calling the cloudscale.ch API with POST for '
|
|
'"%s".' % api_call, fetch_url_info=info)
|
|
|
|
def _delete(self, api_call):
|
|
resp, info = fetch_url(self._module,
|
|
API_URL + api_call,
|
|
headers=self._auth_header,
|
|
method='DELETE',
|
|
timeout=self._module.params['api_timeout'])
|
|
|
|
if info['status'] == 204:
|
|
return None
|
|
else:
|
|
self._module.fail_json(msg='Failure while calling the cloudscale.ch API with DELETE for '
|
|
'"%s".' % api_call, fetch_url_info=info)
|
|
|
|
@staticmethod
|
|
def _transform_state(server):
|
|
if 'status' in server:
|
|
server['state'] = server['status']
|
|
del server['status']
|
|
else:
|
|
server['state'] = 'absent'
|
|
return server
|
|
|
|
def update_info(self):
|
|
|
|
# If we don't have a UUID (yet) there is nothing to update
|
|
if 'uuid' not in self.info:
|
|
return
|
|
|
|
# Can't use _get here because we want to handle 404
|
|
url_path = 'servers/' + self.info['uuid']
|
|
resp, info = fetch_url(self._module,
|
|
API_URL + url_path,
|
|
headers=self._auth_header,
|
|
timeout=self._module.params['api_timeout'])
|
|
if info['status'] == 200:
|
|
self.info = self._transform_state(json.loads(resp.read()))
|
|
elif info['status'] == 404:
|
|
self.info = {'uuid': self.info['uuid'],
|
|
'name': self.info.get('name', None),
|
|
'state': 'absent'}
|
|
else:
|
|
self._module.fail_json(msg='Failure while calling the cloudscale.ch API with GET for '
|
|
'"%s".' % url_path, fetch_url_info=info)
|
|
|
|
def wait_for_state(self, states):
|
|
start = datetime.now()
|
|
timeout = self._module.params['api_timeout'] * 2
|
|
while datetime.now() - start < timedelta(seconds=timeout):
|
|
self.update_info()
|
|
if self.info['state'] in states:
|
|
return True
|
|
sleep(1)
|
|
|
|
self._module.fail_json(msg='Timeout while waiting for a state change on server %s to states %s. Current state is %s.'
|
|
% (self.info['name'], states, self.info['state']))
|
|
|
|
def create_server(self):
|
|
data = self._module.params.copy()
|
|
|
|
# check for required parameters to create a server
|
|
missing_parameters = []
|
|
for p in ('name', 'ssh_keys', 'image', 'flavor'):
|
|
if p not in data or not data[p]:
|
|
missing_parameters.append(p)
|
|
|
|
if len(missing_parameters) > 0:
|
|
self._module.fail_json(msg='Missing required parameter(s) to create a new server: %s.' %
|
|
' '.join(missing_parameters))
|
|
|
|
# Sanitize data dictionary
|
|
for k, v in data.items():
|
|
|
|
# Remove items not relevant to the create server call
|
|
if k in ('api_token', 'api_timeout', 'uuid', 'state'):
|
|
del data[k]
|
|
continue
|
|
|
|
# Remove None values, these don't get correctly translated by urlencode
|
|
if v is None:
|
|
del data[k]
|
|
continue
|
|
|
|
self.info = self._transform_state(self._post('servers', data))
|
|
self.wait_for_state(('running', ))
|
|
|
|
def delete_server(self):
|
|
self._delete('servers/%s' % self.info['uuid'])
|
|
self.wait_for_state(('absent', ))
|
|
|
|
def start_server(self):
|
|
self._post('servers/%s/start' % self.info['uuid'])
|
|
self.wait_for_state(('running', ))
|
|
|
|
def stop_server(self):
|
|
self._post('servers/%s/stop' % self.info['uuid'])
|
|
self.wait_for_state(('stopped', ))
|
|
|
|
def list_servers(self):
|
|
return self._get('servers')
|
|
|
|
|
|
def main():
|
|
module = AnsibleModule(
|
|
argument_spec=dict(
|
|
state=dict(default='running', choices=ALLOWED_STATES),
|
|
name=dict(),
|
|
uuid=dict(),
|
|
flavor=dict(),
|
|
image=dict(),
|
|
volume_size_gb=dict(type='int', default=10),
|
|
bulk_volume_size_gb=dict(type='int'),
|
|
ssh_keys=dict(type='list'),
|
|
use_public_network=dict(type='bool', default=True),
|
|
use_private_network=dict(type='bool', default=False),
|
|
use_ipv6=dict(type='bool', default=True),
|
|
anti_affinity_with=dict(),
|
|
user_data=dict(),
|
|
api_token=dict(no_log=True),
|
|
api_timeout=dict(default=30, type='int'),
|
|
),
|
|
required_one_of=(('name', 'uuid'),),
|
|
mutually_exclusive=(('name', 'uuid'),),
|
|
supports_check_mode=True,
|
|
)
|
|
|
|
api_token = module.params['api_token'] or os.environ.get('CLOUDSCALE_API_TOKEN')
|
|
|
|
if not api_token:
|
|
module.fail_json(msg='The api_token module parameter or the CLOUDSCALE_API_TOKEN '
|
|
'environment varialbe are required for this module.')
|
|
|
|
target_state = module.params['state']
|
|
server = AnsibleCloudscaleServer(module, api_token)
|
|
# The server could be in a changeing or error state.
|
|
# Wait for one of the allowed states before doing anything.
|
|
# If an allowed state can't be reached, this module fails.
|
|
if not server.info['state'] in ALLOWED_STATES:
|
|
server.wait_for_state(ALLOWED_STATES)
|
|
current_state = server.info['state']
|
|
|
|
if module.check_mode:
|
|
module.exit_json(changed=not target_state == current_state,
|
|
**server.info)
|
|
|
|
changed = False
|
|
if current_state == 'absent' and target_state == 'running':
|
|
server.create_server()
|
|
changed = True
|
|
elif current_state == 'absent' and target_state == 'stopped':
|
|
server.create_server()
|
|
server.stop_server()
|
|
changed = True
|
|
elif current_state == 'stopped' and target_state == 'running':
|
|
server.start_server()
|
|
changed = True
|
|
elif current_state in ('running', 'stopped') and target_state == 'absent':
|
|
server.delete_server()
|
|
changed = True
|
|
elif current_state == 'running' and target_state == 'stopped':
|
|
server.stop_server()
|
|
changed = True
|
|
|
|
module.exit_json(changed=changed, **server.info)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|