1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# Copyright: (c) 2019, Nurfet Becirevic <nurfet.becirevic@gmail.com>
5# Copyright: (c) 2017, Tomas Karasek <tom.to.the.k@gmail.com>
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
12DOCUMENTATION = '''
13---
14module: packet_volume_attachment
15
16short_description: Attach/detach a volume to a device in the Packet host.
17
18description:
19     - Attach/detach a volume to a device in the Packet host.
20     - API is documented at U(https://www.packet.com/developers/api/volumes/).
21     - "This module creates the attachment route in the Packet API. In order to discover
22       the block devices on the server, you have to run the Attach Scripts,
23       as documented at U(https://help.packet.net/technical/storage/packet-block-storage-linux)."
24
25version_added: '0.2.0'
26
27author:
28    - Tomas Karasek (@t0mk) <tom.to.the.k@gmail.com>
29    - Nurfet Becirevic (@nurfet-becirevic) <nurfet.becirevic@gmail.com>
30
31options:
32  state:
33    description:
34      - Indicate desired state of the attachment.
35    default: present
36    choices: ['present', 'absent']
37    type: str
38
39  auth_token:
40    description:
41      - Packet API token. You can also supply it in env var C(PACKET_API_TOKEN).
42    type: str
43
44  project_id:
45    description:
46      - UUID of the project to which the device and volume belong.
47    type: str
48    required: true
49
50  volume:
51    description:
52      - Selector for the volume.
53      - It can be a UUID, an API-generated volume name, or user-defined description string.
54      - 'Example values: 4a347482-b546-4f67-8300-fb5018ef0c5, volume-4a347482, "my volume"'
55    type: str
56    required: true
57
58  device:
59    description:
60      - Selector for the device.
61      - It can be a UUID of the device, or a hostname.
62      - 'Example values: 98a14f7a-3d27-4478-b7cf-35b5670523f3, "my device"'
63    type: str
64
65requirements:
66  - "python >= 2.6"
67  - "packet-python >= 1.35"
68
69'''
70
71EXAMPLES = '''
72# All the examples assume that you have your Packet API token in env var PACKET_API_TOKEN.
73# You can also pass the api token in module param auth_token.
74
75- hosts: localhost
76
77  vars:
78    volname: testvol
79    devname: testdev
80    project_id: 52000fb2-ee46-4673-93a8-de2c2bdba33b
81
82  tasks:
83    - name: Create volume
84      packet_volume:
85        description: "{{ volname }}"
86        project_id: "{{ project_id }}"
87        facility: ewr1
88        plan: storage_1
89        state: present
90        size: 10
91        snapshot_policy:
92          snapshot_count: 10
93          snapshot_frequency: 1day
94
95    - name: Create a device
96      packet_device:
97        project_id: "{{ project_id }}"
98        hostnames: "{{ devname }}"
99        operating_system: ubuntu_16_04
100        plan: baremetal_0
101        facility: ewr1
102        state: present
103
104    - name: Attach testvol to testdev
105      community.general.packet_volume_attachment:
106        project_id: "{{ project_id }}"
107        volume: "{{ volname }}"
108        device: "{{ devname }}"
109
110    - name: Detach testvol from testdev
111      community.general.packet_volume_attachment:
112        project_id: "{{ project_id }}"
113        volume: "{{ volname }}"
114        device: "{{ devname }}"
115        state: absent
116'''
117
118RETURN = '''
119volume_id:
120    description: UUID of volume addressed by the module call.
121    type: str
122    returned: success
123
124device_id:
125    description: UUID of device addressed by the module call.
126    type: str
127    returned: success
128'''
129
130import uuid
131
132from ansible.module_utils.basic import AnsibleModule, env_fallback
133from ansible.module_utils.common.text.converters import to_native
134
135HAS_PACKET_SDK = True
136
137
138try:
139    import packet
140except ImportError:
141    HAS_PACKET_SDK = False
142
143
144PACKET_API_TOKEN_ENV_VAR = "PACKET_API_TOKEN"
145
146STATES = ["present", "absent"]
147
148
149def is_valid_uuid(myuuid):
150    try:
151        val = uuid.UUID(myuuid, version=4)
152    except ValueError:
153        return False
154    return str(val) == myuuid
155
156
157def get_volume_selector(spec):
158    if is_valid_uuid(spec):
159        return lambda v: v['id'] == spec
160    else:
161        return lambda v: v['name'] == spec or v['description'] == spec
162
163
164def get_device_selector(spec):
165    if is_valid_uuid(spec):
166        return lambda v: v['id'] == spec
167    else:
168        return lambda v: v['hostname'] == spec
169
170
171def do_attach(packet_conn, vol_id, dev_id):
172    api_method = "storage/{0}/attachments".format(vol_id)
173    packet_conn.call_api(
174        api_method,
175        params={"device_id": dev_id},
176        type="POST")
177
178
179def do_detach(packet_conn, vol, dev_id=None):
180    def dev_match(a):
181        return (dev_id is None) or (a['device']['id'] == dev_id)
182    for a in vol['attachments']:
183        if dev_match(a):
184            packet_conn.call_api(a['href'], type="DELETE")
185
186
187def validate_selected(l, resource_type, spec):
188    if len(l) > 1:
189        _msg = ("more than one {0} matches specification {1}: {2}".format(
190                resource_type, spec, l))
191        raise Exception(_msg)
192    if len(l) == 0:
193        _msg = "no {0} matches specification: {1}".format(resource_type, spec)
194        raise Exception(_msg)
195
196
197def get_attached_dev_ids(volume_dict):
198    if len(volume_dict['attachments']) == 0:
199        return []
200    else:
201        return [a['device']['id'] for a in volume_dict['attachments']]
202
203
204def act_on_volume_attachment(target_state, module, packet_conn):
205    return_dict = {'changed': False}
206    volspec = module.params.get("volume")
207    devspec = module.params.get("device")
208    if devspec is None and target_state == 'present':
209        raise Exception("If you want to attach a volume, you must specify a device.")
210    project_id = module.params.get("project_id")
211    volumes_api_method = "projects/{0}/storage".format(project_id)
212    volumes = packet_conn.call_api(volumes_api_method,
213                                   params={'include': 'facility,attachments.device'})['volumes']
214    v_match = get_volume_selector(volspec)
215    matching_volumes = [v for v in volumes if v_match(v)]
216    validate_selected(matching_volumes, "volume", volspec)
217    volume = matching_volumes[0]
218    return_dict['volume_id'] = volume['id']
219
220    device = None
221    if devspec is not None:
222        devices_api_method = "projects/{0}/devices".format(project_id)
223        devices = packet_conn.call_api(devices_api_method)['devices']
224        d_match = get_device_selector(devspec)
225        matching_devices = [d for d in devices if d_match(d)]
226        validate_selected(matching_devices, "device", devspec)
227        device = matching_devices[0]
228        return_dict['device_id'] = device['id']
229
230    attached_device_ids = get_attached_dev_ids(volume)
231
232    if target_state == "present":
233        if len(attached_device_ids) == 0:
234            do_attach(packet_conn, volume['id'], device['id'])
235            return_dict['changed'] = True
236        elif device['id'] not in attached_device_ids:
237            # Don't reattach volume which is attached to a different device.
238            # Rather fail than force remove a device on state == 'present'.
239            raise Exception("volume {0} is already attached to device {1}".format(
240                            volume, attached_device_ids))
241    else:
242        if device is None:
243            if len(attached_device_ids) > 0:
244                do_detach(packet_conn, volume)
245                return_dict['changed'] = True
246        elif device['id'] in attached_device_ids:
247            do_detach(packet_conn, volume, device['id'])
248            return_dict['changed'] = True
249
250    return return_dict
251
252
253def main():
254    module = AnsibleModule(
255        argument_spec=dict(
256            state=dict(choices=STATES, default="present"),
257            auth_token=dict(
258                type='str',
259                fallback=(env_fallback, [PACKET_API_TOKEN_ENV_VAR]),
260                no_log=True
261            ),
262            volume=dict(type="str", required=True),
263            project_id=dict(type="str", required=True),
264            device=dict(type="str"),
265        ),
266        supports_check_mode=True,
267    )
268
269    if not HAS_PACKET_SDK:
270        module.fail_json(msg='packet required for this module')
271
272    if not module.params.get('auth_token'):
273        _fail_msg = ("if Packet API token is not in environment variable {0}, "
274                     "the auth_token parameter is required".format(PACKET_API_TOKEN_ENV_VAR))
275        module.fail_json(msg=_fail_msg)
276
277    auth_token = module.params.get('auth_token')
278
279    packet_conn = packet.Manager(auth_token=auth_token)
280
281    state = module.params.get('state')
282
283    if state in STATES:
284        if module.check_mode:
285            module.exit_json(changed=False)
286
287        try:
288            module.exit_json(
289                **act_on_volume_attachment(state, module, packet_conn))
290        except Exception as e:
291            module.fail_json(
292                msg="failed to set volume_attachment state {0}: {1}".format(state, to_native(e)))
293    else:
294        module.fail_json(msg="{0} is not a valid state for this module".format(state))
295
296
297if __name__ == '__main__':
298    main()
299