From 354efc3fe14cce821fc958f799733498447680eb Mon Sep 17 00:00:00 2001 From: Ali Bhatti Date: Tue, 19 May 2026 14:15:54 +0200 Subject: [PATCH] fix(roles/exoscale_vm): rewrite on top of v2 HTTP API old cloudstack APIs have been deprecated --- CHANGELOG.md | 9 + plugins/module_utils/exoscale.py | 201 +++++++ plugins/modules/exoscale_api.py | 248 ++++++++ roles/exoscale_vm/README.md | 51 +- roles/exoscale_vm/meta/argument_specs.yml | 176 ++++++ roles/exoscale_vm/tasks/main.yml | 701 +++++++++++++++++----- 6 files changed, 1213 insertions(+), 173 deletions(-) create mode 100644 plugins/module_utils/exoscale.py create mode 100644 plugins/modules/exoscale_api.py create mode 100644 roles/exoscale_vm/meta/argument_specs.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a62b4f5..c8ccbfa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **plugins/modules/exoscale_api**: Emit a `diff` payload on every mutating call so `ansible-playbook --diff` shows the exact `METHOD /v2/` and the JSON body that would be (or was) sent. `POST`/`PUT` render as added lines, `DELETE` as removed lines. Combined with `--check` (which already short-circuits mutations without contacting the API), this gives a usable preview of top-level changes; nested cascades (rules after their SG is just-created, attaches after their instance is just-created, the `:start` after a `:stop` for scale) still silently skip in `--check` because the dependency hasn't actually been created, and this caveat is documented in `roles/exoscale_vm/README.md`. +* **role:exoscale_vm**: React to changes in `exoscale_vm__service_offering` and `exoscale_vm__disk_size` on existing VMs instead of only honoring them at create time. When the current `instance-type.family.size` (compared against the live instance object) differs from the inventory, the role stops the VM if it is running, then calls `PUT /v2/instance/{id}:scale`; when `disk-size` differs, it calls `PUT /v2/instance/{id}:resize-disk` (same stop-first dance). The final power state is then left to `exoscale_vm__state`, which by default starts the VM again, so a routine re-run after editing the inventory becomes "stop, scale and/or resize-disk, start". Two API-imposed limits are documented in the README and apply unchanged: `:scale` only accepts within-family changes (e.g. `standard.tiny` to `standard.large`, not `standard` to `memory`), and `:resize-disk` can only grow the disk. +* **role:exoscale_vm**: Reconcile `exoscale_vm__private_networks` against the live attachment state on every run instead of only attaching new entries. Networks present on the instance but no longer in the inventory list are detached (`PUT /v2/private-network/{id}:detach`); entries whose `fixed_ip` differs from the current lease for this VM trigger an in-place IP change (`PUT /v2/private-network/{id}:update-ip`) instead of needing a detach-then-reattach cycle. Existing private networks are never destroyed, since they may be shared with other VMs. +* **role:exoscale_vm**: Honor `exoscale_vm__state` post-create. `started` (default) and `present` now actually call `PUT /v2/instance/{id}:start` when the VM is currently `stopped`; `stopped` creates the VM with `auto-start: false` and calls `:stop` when it is `running`; `restarted` calls `:reboot` when the VM is already `running` (and just starts it if it was stopped, so a fresh create with `restarted` does not double-reboot). Previously every non-`absent` value collapsed to "create and keep" with no power-state management. `meta/argument_specs.yml` now constrains `exoscale_vm__state` to `absent` / `present` / `restarted` / `started` / `stopped`. +* **role:exoscale_vm**: Add `meta/argument_specs.yml` declaring all user-facing variables (mandatory `exoscale_vm__api_key`, `exoscale_vm__api_secret`, `exoscale_vm__service_offering`, `exoscale_vm__template`, `exoscale_vm__zone`; optional `exoscale_vm__disk_size`, `exoscale_vm__name`, `exoscale_vm__private_instance`, `exoscale_vm__private_networks`, `exoscale_vm__security_group_rules`, `exoscale_vm__ssh_key`, `exoscale_vm__state`, `exoscale_vm__template_visibility`) so role-entry validation catches type mismatches and missing required variables. The legacy `exoscale_vm__account` is declared as non-required to keep existing inventories valid after the v2-API rewrite stopped using it. * **role:graylog_datanode, role:graylog_server**: Add template for Graylog 7.1. * **role:sshd**: Add Debian 13 support. * **role:mirror**: Document the new per-repository `newest_only` subkey on `mirror__reposync_repos` entries. Defaults to `true` (only the newest version of each package is mirrored). Set to `false` for repositories that publish multiple versions in parallel, such as Icinga, where older versions must remain available. @@ -65,7 +70,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * **role:nodejs**: Fix `@nodejs:` install failing with `broken groups or modules: nodejs:`. Two issues compounded: DNF refuses to silently switch an already-enabled module stream, and some modules ship without a `[d]efault` profile, so `@nodejs:` (no profile specified) cannot be resolved. The role now runs `dnf -y module reset nodejs` first when `nodejs__dnf_module_stream` is set, and installs the explicit `/common` profile. + * **role:blocky**: The handler `blocky: validate config & restart blocky.service` is now notified if the blocky binary is changed on the host to ensure that the blocky service is restarted after an update (as it was already documented for the `blocky` tag) + +* **role:exoscale_vm, plugins/modules/exoscale_api, plugins/module_utils/exoscale**: Fix the role being broken end-to-end after Exoscale deprecated the CloudStack v1 API and upstream `exo` made breaking CLI changes. `ngine_io.cloudstack.*` calls returned `HTTP 403 'This API is deprecated.'` (see [Exoscale changelog](https://changelog.exoscale.com/en#feature54957866)), `exo compute instance create` rejected the old `--private-instance` flag with `error: unknown flag: --private-instance`, and `--check` runs crashed with `json.decoder.JSONDecodeError` because the list task was skipped while the create/delete `when:` still tried to `from_json` its empty output. The role is now driven by a new thin `linuxfabrik.lfops.exoscale_api` module that signs each request with `EXO2-HMAC-SHA256` against the Exoscale [v2 HTTP API](https://openapi-v2.exoscale.com/) and polls the returned `operation` until it leaves `pending`. Security groups, security-group rules, private networks, and the VM-to-private-network attach all go through this module with list-then-act idempotency. Neither the `exo` CLI nor the `python3-cs` Python library are required on the Ansible control node any more. The user-facing `exoscale_vm__private_instance` bool is preserved (mapped to `public-ip-assignment: none|inet4`); `exoscale_vm__account` is silently ignored and kept declared in `meta/argument_specs.yml` so existing inventories that still set it pass role-entry validation. + * **role:nextcloud**: The `nextcloud-update` script now owns the maintenance mode lifecycle itself instead of expecting callers to enable it beforehand. Previously, callers enabled maintenance mode before invoking the script (to protect the DB dump), which disables the LDAP user provider and causes the `before-update` export (`occ user:list`, `config:list`, `app:list`) to silently omit LDAP users. The script now assumes maintenance mode is **off** at start, runs the `before-update` export with apps loaded, lets `updater.phar` manage maintenance mode itself, and explicitly disables it again before `occ upgrade` and `occ app:update` (since `occ upgrade` does not turn it off on its own) — so all post-upgrade commands (`app:update`, `db:add-missing-*`, `db:convert-filecache-bigint`, the `after-update` export) also run with apps loaded. Callers must drop the manual `maintenance:mode --on` step from their pre-script workflow; the DB dump should rely on `--single-transaction` instead. * **roles**: Set `become: false` on tasks delegated to localhost across the collection. Previously these tasks inherited `become: true` from the playbook level and tried to call `sudo` on the Ansible controller, which fails on controllers without a passwordless sudo setup with `sudo: a password is required`. Affected are all `repo_*` roles, the `*_vm` cloud roles (`exoscale_vm`, `hetzner_vm`, `infomaniak_vm`), all `icingaweb2_module_*` roles that download artefacts, `monitoring_plugins`, `shared`, plus several others. Existing playbooks that were working without playbook-level `become: true` are unaffected ([#242](https://github.com/Linuxfabrik/lfops/issues/242)). diff --git a/plugins/module_utils/exoscale.py b/plugins/module_utils/exoscale.py new file mode 100644 index 00000000..e809aea6 --- /dev/null +++ b/plugins/module_utils/exoscale.py @@ -0,0 +1,201 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Linuxfabrik GmbH, Zurich, Switzerland, https://www.linuxfabrik.ch +# The Unlicense (see LICENSE or https://unlicense.org/) + +"""Shared client for the Exoscale v2 API used by the linuxfabrik.lfops.exoscale_* +modules. + +The Exoscale v2 API requires every request to carry an `Authorization` header +of the form + + EXO2-HMAC-SHA256 credential=[,signed-query-args=],expires=,signature= + +where `` is the base64-encoded HMAC-SHA256 of a multi-line message +composed of method+path, body, sorted query-string values, signed headers +(none today), and the expiration timestamp. Mutating endpoints +(POST/PUT/DELETE) return an `operation` object that has to be polled on +`/operation/{id}` until `state` flips from `pending` to `success` or +`failure`. Both pieces of behaviour live here so individual modules only deal +with the resource shape, not transport. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import base64 +import hashlib +import hmac +import json +import time +from urllib.parse import urlencode + +from ansible.module_utils.urls import fetch_url + + +API_BASE_TEMPLATE = 'https://api-{zone}.exoscale.com/v2' + +DEFAULT_REQUEST_TIMEOUT = 60 +DEFAULT_SIGNATURE_LIFETIME = 600 +DEFAULT_OPERATION_TIMEOUT = 300 +DEFAULT_OPERATION_INTERVAL = 2 + + +def build_auth_header(api_key, api_secret, method, sign_path, body=b'', + query_params=None, expires_in=DEFAULT_SIGNATURE_LIFETIME): + """Return the EXO2-HMAC-SHA256 Authorization header for one request. + + `sign_path` is the URL path including the `/v2` prefix (e.g. + `/v2/security-group`); `body` is the exact request body bytes that will be + sent on the wire (so the signature stays aligned with what the server + receives); `query_params` is the dict of query-string parameters that will + be appended to the URL. + """ + expires_ts = int(time.time() + expires_in) + + if body is None: + body_bytes = b'' + elif isinstance(body, (bytes, bytearray)): + body_bytes = bytes(body) + else: + body_bytes = body.encode('utf-8') + + msg_parts = [ + '{method} {path}'.format(method=method.upper(), path=sign_path).encode('utf-8'), + body_bytes, + b'', + b'', + str(expires_ts).encode('utf-8'), + ] + + signed_names = [] + if query_params: + signed_names = sorted(query_params) + msg_parts[2] = ''.join( + str(query_params[name]) for name in signed_names + ).encode('utf-8') + + msg = b'\n'.join(msg_parts) + signature = base64.standard_b64encode( + hmac.new(api_secret.encode('utf-8'), msg=msg, digestmod=hashlib.sha256).digest(), + ).decode('utf-8') + + header = 'EXO2-HMAC-SHA256 credential={key}'.format(key=api_key) + if signed_names: + header += ',signed-query-args=' + ';'.join(signed_names) + header += ',expires={ts},signature={sig}'.format(ts=expires_ts, sig=signature) + return header + + +def request(module, api_key, api_secret, zone, method, path, + body=None, query_params=None, timeout=DEFAULT_REQUEST_TIMEOUT): + """Send one signed request to the Exoscale v2 API. + + Returns a tuple `(success, status, payload)`. On success `payload` is the + decoded JSON response; on failure `payload` is a human-readable error + string and `status` carries the HTTP status (or -1 if the request did not + reach the server). + """ + method = method.upper() + base_url = API_BASE_TEMPLATE.format(zone=zone) + sign_path = '/v2' + path + + if body is None: + body_bytes = None + signed_body = b'' + else: + body_bytes = json.dumps(body, separators=(',', ':'), sort_keys=True).encode('utf-8') + signed_body = body_bytes + + url = base_url + path + if query_params: + url += '?' + urlencode(query_params) + + headers = { + 'Accept': 'application/json', + 'Authorization': build_auth_header( + api_key, api_secret, method, sign_path, signed_body, query_params, + ), + } + if body_bytes is not None: + headers['Content-Type'] = 'application/json' + + resp, info = fetch_url( + module, + url, + data=body_bytes, + headers=headers, + method=method, + timeout=timeout, + ) + status = info.get('status', -1) + + if status < 200 or status >= 300: + api_msg = info.get('msg') or '' + body_payload = info.get('body') + if body_payload: + try: + err = json.loads(body_payload) + api_msg = err.get('message') or err.get('error') or api_msg or body_payload + except (TypeError, ValueError): + api_msg = api_msg or body_payload + return False, status, 'HTTP {status} from {method} {url}: {msg}'.format( + status=status, method=method, url=url, msg=api_msg, + ) + + try: + raw = resp.read() if resp is not None else b'' + payload = json.loads(raw.decode('utf-8')) if raw else {} + except (AttributeError, UnicodeDecodeError, ValueError) as exc: + return False, status, 'cannot decode JSON response from {url}: {exc}'.format( + url=url, exc=exc, + ) + + return True, status, payload + + +def is_operation(payload): + """Heuristic: does this payload look like an Exoscale `operation` object?""" + return ( + isinstance(payload, dict) + and 'id' in payload + and 'state' in payload + and payload.get('state') in ('pending', 'success', 'failure') + ) + + +def wait_for_operation(module, api_key, api_secret, zone, operation_id, + timeout=DEFAULT_OPERATION_TIMEOUT, + interval=DEFAULT_OPERATION_INTERVAL): + """Poll `/operation/{id}` until `state` is no longer `pending`. + + Returns `(success, payload_or_error)`. `success=True` means the operation + finished with `state=success`; otherwise `payload_or_error` is a string + describing why (timeout, API error, or `state=failure`). + """ + deadline = time.time() + timeout + while True: + success, _status, payload = request( + module, api_key, api_secret, zone, 'GET', + '/operation/' + operation_id, + ) + if not success: + return False, payload + + state = payload.get('state') + if state == 'success': + return True, payload + if state == 'failure': + return False, 'operation {oid} failed: reason={reason} message={message}'.format( + oid=operation_id, + reason=payload.get('reason'), + message=payload.get('message'), + ) + if time.time() >= deadline: + return False, ( + 'operation {oid} did not finish within {timeout}s ' + '(last state={state})' + ).format(oid=operation_id, timeout=timeout, state=state) + time.sleep(interval) diff --git a/plugins/modules/exoscale_api.py b/plugins/modules/exoscale_api.py new file mode 100644 index 00000000..bf53e35a --- /dev/null +++ b/plugins/modules/exoscale_api.py @@ -0,0 +1,248 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Linuxfabrik GmbH, Zurich, Switzerland, https://www.linuxfabrik.ch +# The Unlicense (see LICENSE or https://unlicense.org/) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: exoscale_api +short_description: Send one signed request to the Exoscale v2 API +version_added: "3.0.0" +description: + - Sends a single HTTP request to the Exoscale v2 API (C(https://api-{zone}.exoscale.com/v2)) with the required C(EXO2-HMAC-SHA256) Authorization header computed on the fly from the supplied API key / secret. + - On a mutating request (C(POST), C(PUT), C(DELETE)) the response is normally an C(operation) object describing an asynchronous job. When I(wait) is C(true) (the default), the module then polls C(/operation/{id}) until C(state) leaves C(pending), so the caller can rely on the resource actually existing (or being gone) when the task returns. + - Idempotency is the caller's job. This is a thin transport wrapper, not a per-resource module. Use the role / playbook around it to GET the list first and skip the mutating call when the resource is already in the desired shape. +author: + - Linuxfabrik GmbH, Zurich, Switzerland +options: + api_key: + description: + - Exoscale API key (the C(EXO...) string). API keys can be managed at U(https://portal.exoscale.com/iam/api-keys). + type: str + required: true + api_secret: + description: + - Exoscale API secret matching I(api_key). + type: str + required: true + body: + description: + - Request body as a dictionary. The module serialises it with C(json.dumps(..., sort_keys=True, separators=(',', ':'))) and sends the resulting bytes both to the server and to the signature, so the on-wire and signed payloads match exactly. + - Omit for C(GET) and for mutating requests that have no body. + type: dict + required: false + method: + description: + - HTTP method to send. + type: str + choices: ['DELETE', 'GET', 'POST', 'PUT'] + required: true + operation_interval: + description: + - Seconds to wait between two operation polls when I(wait=true). + type: int + required: false + default: 2 + operation_timeout: + description: + - Maximum seconds to wait for an operation to finish when I(wait=true). The module fails if the operation is still C(pending) after this many seconds. + type: int + required: false + default: 300 + path: + description: + - API path without the C(/v2) prefix (the module adds it). Examples C(/security-group), C(/instance/{id}), C(/private-network/{id}:attach). + type: str + required: true + query_params: + description: + - Dictionary of query-string parameters. These are both appended to the URL and folded into the signature (alphabetically by name, values concatenated, names listed in C(signed-query-args=)) as the Exoscale signing scheme requires. + type: dict + required: false + wait: + description: + - Only meaningful for mutating requests. When C(true) (the default), if the response looks like an C(operation) object the module polls C(/operation/{id}) until C(state) is no longer C(pending), then returns the final operation payload alongside the original response. Set to C(false) to return immediately with the C(pending) operation. + type: bool + required: false + default: true + zone: + description: + - Exoscale zone. Determines the API endpoint host (C(https://api-{zone}.exoscale.com/v2)). Examples C(ch-dk-2), C(ch-gva-2), C(de-fra-1). + type: str + required: true +''' + +EXAMPLES = r''' +- name: 'GET /v2/security-group' + linuxfabrik.lfops.exoscale_api: + api_key: '{{ exoscale_vm__api_key }}' + api_secret: '{{ exoscale_vm__api_secret }}' + zone: 'ch-dk-2' + method: 'GET' + path: '/security-group' + register: 'sg_list' + delegate_to: 'localhost' + become: false + +- name: 'POST /v2/security-group' + linuxfabrik.lfops.exoscale_api: + api_key: '{{ exoscale_vm__api_key }}' + api_secret: '{{ exoscale_vm__api_secret }}' + zone: 'ch-dk-2' + method: 'POST' + path: '/security-group' + body: + name: 'my-vm' + delegate_to: 'localhost' + become: false + +- name: 'GET /v2/template?visibility=public' + linuxfabrik.lfops.exoscale_api: + api_key: '{{ exoscale_vm__api_key }}' + api_secret: '{{ exoscale_vm__api_secret }}' + zone: 'ch-dk-2' + method: 'GET' + path: '/template' + query_params: + visibility: 'public' + register: 'tpl_list' + delegate_to: 'localhost' + become: false +''' + +RETURN = r''' +changed: + description: C(true) for any mutating request (C(POST), C(PUT), C(DELETE)) that the server accepted; C(false) for C(GET). + returned: always + type: bool +check_mode_skipped: + description: C(true) when the request was a mutation and the play was run with C(--check), so the module returned without contacting the API. + returned: when running under C(--check) on a mutating request + type: bool +diff: + description: + - For mutating requests, a unified-diff payload describing the request that would be (or was) sent. The C(before) / C(after) text contains the method, path and JSON body. Rendered by Ansible when C(--diff) is set. + - Not emitted for C(GET). + returned: for mutating requests + type: dict +json: + description: Decoded JSON body returned by the initial API call (for mutating endpoints this is the C(operation) object as first returned by Exoscale, before any polling). + returned: success + type: dict +operation: + description: Final operation payload, only present for mutating requests when I(wait=true) and the initial response looked like an operation. C(operation.state) is C(success) at this point; C(operation.reference.id) typically holds the ID of the resource that was created / updated / deleted. + returned: when the initial response was an operation and I(wait=true) + type: dict +status: + description: HTTP status code returned by the initial API call. + returned: success + type: int +''' + + +import json +from urllib.parse import urlencode + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.linuxfabrik.lfops.plugins.module_utils.exoscale import ( + is_operation, + request, + wait_for_operation, +) + + +def _build_diff(method, path, body, query_params): + """Render the would-be-sent request as a diff payload. + + POST / PUT show as additions (`before` empty, `after` populated); + DELETE shows as a removal (`before` populated, `after` empty). + """ + summary = '{method} /v2{path}'.format(method=method, path=path) + if query_params: + summary += '?' + urlencode(query_params) + if body: + summary += '\n' + json.dumps(body, indent=2, sort_keys=True) + summary += '\n' + + if method == 'DELETE': + return {'before': summary, 'after': ''} + return {'before': '', 'after': summary} + + +def main(): + module = AnsibleModule( + argument_spec=dict( + api_key=dict(type='str', required=True, no_log=True), + api_secret=dict(type='str', required=True, no_log=True), + body=dict(type='dict', required=False, default=None), + method=dict(type='str', required=True, choices=['DELETE', 'GET', 'POST', 'PUT']), + operation_interval=dict(type='int', required=False, default=2), + operation_timeout=dict(type='int', required=False, default=300), + path=dict(type='str', required=True), + query_params=dict(type='dict', required=False, default=None), + wait=dict(type='bool', required=False, default=True), + zone=dict(type='str', required=True), + ), + supports_check_mode=True, + ) + + api_key = module.params['api_key'] + api_secret = module.params['api_secret'] + zone = module.params['zone'] + method = module.params['method'].upper() + path = module.params['path'] + body = module.params['body'] + query_params = module.params['query_params'] + wait = module.params['wait'] + operation_timeout = module.params['operation_timeout'] + operation_interval = module.params['operation_interval'] + + is_mutation = method != 'GET' + diff = _build_diff(method, path, body, query_params) if is_mutation else None + + if is_mutation and module.check_mode: + kwargs = dict( + changed=True, + json={}, + status=0, + check_mode_skipped=True, + ) + if diff is not None: + kwargs['diff'] = diff + module.exit_json(**kwargs) + + success, status, payload = request( + module, api_key, api_secret, zone, method, path, + body=body, query_params=query_params, + ) + if not success: + module.fail_json(msg=payload, status=status) + + result = { + 'changed': is_mutation, + 'status': status, + 'json': payload, + } + if diff is not None: + result['diff'] = diff + + if is_mutation and wait and is_operation(payload): + op_success, op_payload = wait_for_operation( + module, api_key, api_secret, zone, payload['id'], + timeout=operation_timeout, interval=operation_interval, + ) + if not op_success: + module.fail_json(msg=op_payload, status=status, json=payload) + result['operation'] = op_payload + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/roles/exoscale_vm/README.md b/roles/exoscale_vm/README.md index dfc5c312..2154a69f 100644 --- a/roles/exoscale_vm/README.md +++ b/roles/exoscale_vm/README.md @@ -8,14 +8,17 @@ This role creates and manages instances (virtual machines) on [Exoscale](https:/ ## Known Limitations -* Resizing / scaling of instances is currently not supported +* `exoscale_vm__service_offering` can only be changed within the same instance-type family (the Exoscale v2 API rejects cross-family scaling, e.g. `standard.tiny` to `memory.large`). To move across families, set `exoscale_vm__state: 'absent'`, re-run, then recreate. +* `exoscale_vm__disk_size` can only be grown, never shrunk (an Exoscale v2 API limit). Lowering the value will fail at `PUT /v2/instance/{id}:resize-disk`. +* Security-group rule idempotency relies on the v2 API returning the current `rules` array on `GET /v2/security-group/{id}`. If a future API version stops returning rules in that response, re-runs may attempt to create duplicate rules. +* `--check` only shows top-level changes accurately. Read-only GETs run normally (so `--check` reflects the live API state), but mutating calls are skipped without contacting the API, so downstream tasks that depend on a just-created resource (security-group rules after the SG is created, network attachments after the instance is created, the `:start` after a `:stop` for scale, etc.) silently skip in `--check`. Re-running `--check` after a successful real run gives a faithful diff because everything that needs to exist already does. +* `--diff` is supported on every mutating task: the diff shows the method, path, and JSON body that would be (or was) sent to the Exoscale API. `before` is empty for `POST`/`PUT` and contains the request summary for `DELETE`, so deletions render as removed lines. ## Mandatory Requirements -* Install the [exo command line tool](https://github.com/exoscale/cli/releases) and configure your Exoscale account using `exo config` on the Ansible control node. -* Install the `python3-cs` library on the Ansible control node. * Import your public SSH-key into Exoscale ([here](https://portal.exoscale.com/compute/keypairs)). Ideally, set the key name to your local username, then you can use the default value for `exoscale_vm__ssh_key`. +* No client binary or Python library is required on the Ansible control node. The role talks to the Exoscale [v2 HTTP API](https://openapi-v2.exoscale.com/) directly via the bundled `linuxfabrik.lfops.exoscale_api` module, which signs each request with `EXO2-HMAC-SHA256` using `exoscale_vm__api_key` / `exoscale_vm__api_secret`. ## Tags @@ -38,11 +41,6 @@ This role creates and manages instances (virtual machines) on [Exoscale](https:/ ## Mandatory Role Variables -`exoscale_vm__account` - -* The name of the Exoscale account name as configured during `exo config`. Can be found in `~/.config/exoscale/exoscale.toml` afterwards. -* Type: String. - `exoscale_vm__api_key` * Set the Exoscale API key. API keys can be managed [here](https://portal.exoscale.com/iam/api-keys). We recommend creating a unrestricted key, because else some operations fail. @@ -55,27 +53,26 @@ This role creates and manages instances (virtual machines) on [Exoscale](https:/ `exoscale_vm__service_offering` -* The Exoscale service offering. This defines the amount of CPU cores, RAM and disk space. The possible options can be obtained using `exo compute instance-type list --verbose`. Note that these changes will only be applied to stopped instances. +* The Exoscale service offering, in the form `.` (for example `standard.tiny`, `memory.extra-large`). This defines the amount of CPU cores and RAM. The available combinations can be obtained from the [v2 API](https://openapi-v2.exoscale.com/operation/operation-list-instance-types). Changing this value on an existing VM triggers `PUT /v2/instance/{id}:scale`; the role stops the VM first if needed, then leaves the post-state to `exoscale_vm__state` (which by default starts it again). Only within-family changes are accepted by the API (see Known Limitations). * Type: String. `exoscale_vm__template` -* The Exoscale template for the instance. The possible options can be obtained using `exo compute instance-template list`. Note that you have to use the ID instead of the name when referencing custom templates. +* The Exoscale template for the instance, either as a template name (looked up against `exoscale_vm__template_visibility`) or as a UUID (used directly without a lookup). The available templates can be obtained from the [v2 API](https://openapi-v2.exoscale.com/operation/operation-list-templates). Use the UUID when referencing custom (private) templates whose name is not unique. * Type: String. `exoscale_vm__zone` -* The Exoscale zone the instance should be in. The possible options can be obtained using `exo zone list`. +* The Exoscale zone the instance should be in. Determines both the placement of the VM and the API endpoint (`https://api-{zone}.exoscale.com/v2`). Possible values include `at-vie-1`, `at-vie-2`, `bg-sof-1`, `ch-dk-2`, `ch-gva-2`, `de-fra-1`, `de-muc-1`, `hr-zag-1`. * Type: String. Example: ```yaml # mandatory -exoscale_vm__account: 'example' exoscale_vm__api_key: 'EXOtn4Rg5ooosUALc1uNTqVTyTd' exoscale_vm__api_secret: '4Is7jmDfzCONfJtEfxqX1VePSK9p7iZLafJy9ItC' exoscale_vm__service_offering: 'standard.tiny' -exoscale_vm__template: 'Rocky Linux 8 (Green Obsidian) 64-bit' +exoscale_vm__template: 'Linux Rocky 9 (Blue Onyx) 64-bit' exoscale_vm__zone: 'ch-dk-2' ``` @@ -84,7 +81,7 @@ exoscale_vm__zone: 'ch-dk-2' `exoscale_vm__disk_size` -* The disk size in GBs. Must be greater than 10. Note that adjusting the disk size is not currently supported. +* The instance root disk size in GiB. Must be at least 10 and at most 51200 (Exoscale v2 limit). Increasing this value on an existing VM triggers `PUT /v2/instance/{id}:resize-disk`; the role stops the VM first if needed. The disk can only grow (see Known Limitations). * Type: Number. * Default: `10` @@ -102,7 +99,11 @@ exoscale_vm__zone: 'ch-dk-2' `exoscale_vm__private_networks` -* A list of dictionaries defining which networks should be attached to this instance. It also allows the creation of new internal networks, or setting a fixed IP for the instance. +* A list of dictionaries defining which private networks the instance should be attached to. The role reconciles the current attachment state against this list on every run: + * Networks listed here but not currently attached are attached (`PUT /v2/private-network/{id}:attach`). + * Networks currently attached but no longer in this list are detached (`PUT /v2/private-network/{id}:detach`). + * For networks listed here with a `fixed_ip` whose current lease for this VM does not match, the lease is updated (`PUT /v2/private-network/{id}:update-ip`). + * Networks listed with a `cidr` that do not yet exist are created (`POST /v2/private-network`). Existing networks are never destroyed by this role (a private network may be shared with other VMs). * Type: List of dictionaries. * Default: `[]` @@ -115,12 +116,12 @@ exoscale_vm__zone: 'ch-dk-2' * `cidr`: - * Optional. If this is given, a new network with this cidr is created. + * Optional. If this is given, a new network with this cidr is created if missing. Ignored when the network already exists. * Type: String. * `fixed_ip`: - * Optional. The fixed IP of this instance. This can be used for attach to an existing network, or when creating a new one. + * Optional. The IPv4 address this VM should hold on the network. If the current lease for this VM on the network differs (or the VM was holding a DHCP-assigned address), the role calls `:update-ip` to switch to this address. * Type: String. `exoscale_vm__security_group_rules` @@ -170,13 +171,17 @@ exoscale_vm__zone: 'ch-dk-2' `exoscale_vm__state` -* The state of the instance. Possible options: `deployed`, `started`, `stopped`, `restarted`, `restored`, `destroyed`, `expunged`, `present`, `absent`. +* The desired state of the instance. Possible values: + * `'started'` (default) or `'present'`: create the VM and ensure it is running. Calls `PUT /v2/instance/{id}:start` only if the current state is `stopped`. + * `'stopped'`: create the VM with `auto-start: false` so it is not running on first boot. On subsequent runs, calls `PUT /v2/instance/{id}:stop` only if the current state is `running`. + * `'restarted'`: same as `started`, but if the VM is already `running`, calls `PUT /v2/instance/{id}:reboot`. A fresh create with `'restarted'` behaves like `'started'` (no spurious reboot of a just-started VM). Leaving `'restarted'` in your inventory permanently means every Ansible run reboots the VM, which is usually not what you want. + * `'absent'`: delete the VM, and the per-VM security group when `exoscale_vm__security_group_rules` is set. * Type: String. * Default: `'started'` `exoscale_vm__template_visibility` -* Visibility of the Exoscale template for the instance. Usually `'private'` for custom templates. +* Visibility under which `exoscale_vm__template` is looked up when given as a name. Use `'private'` for custom templates uploaded to your own account. Ignored when `exoscale_vm__template` is already a UUID. * Type: String. * Default: `'public'` @@ -201,6 +206,14 @@ exoscale_vm__template_visibility: 'private' ``` +## Deprecated Role Variables + +`exoscale_vm__account` + +* No longer used. Previously identified which `exo` CLI / `~/.config/exoscale/exoscale.toml` profile to authenticate with. The role now signs every request itself with `exoscale_vm__api_key` / `exoscale_vm__api_secret`, so this variable is silently ignored. Kept in `meta/argument_specs.yml` so existing inventories that still set it do not fail role-entry validation. +* Type: String. + + ## License [The Unlicense](https://unlicense.org/) diff --git a/roles/exoscale_vm/meta/argument_specs.yml b/roles/exoscale_vm/meta/argument_specs.yml new file mode 100644 index 00000000..41c07bb1 --- /dev/null +++ b/roles/exoscale_vm/meta/argument_specs.yml @@ -0,0 +1,176 @@ +argument_specs: + main: + options: + + exoscale_vm__account: + type: 'str' + required: false + description: >- + Deprecated, no longer used. Previously identified the `exo` CLI / + `~/.config/exoscale/exoscale.toml` profile to authenticate with. + Kept here so existing inventories that still set it do not fail + role-entry validation. + + exoscale_vm__api_key: + type: 'str' + required: true + description: 'Exoscale API key. API keys can be managed at https://portal.exoscale.com/iam/api-keys. Use an unrestricted key, otherwise some operations fail.' + + exoscale_vm__api_secret: + type: 'str' + required: true + description: 'Exoscale API secret corresponding to exoscale_vm__api_key.' + + exoscale_vm__disk_size: + type: 'int' + required: false + default: 10 + description: 'Instance root disk size in GiB. Must be greater than or equal to 10. Adjusting the disk size is not currently supported (only honored on create).' + + exoscale_vm__name: + type: 'str' + required: false + description: >- + Name of the instance at Exoscale. Defaults to the inventory + hostname prefixed with `e` so it always starts with a letter. + + exoscale_vm__private_instance: + type: 'bool' + required: false + default: true + description: >- + Whether the instance should be created without a public IPv4 + (`public-ip-assignment: none`). When false, the instance gets a + public IPv4 (`public-ip-assignment: inet4`). + + exoscale_vm__private_networks: + type: 'list' + elements: 'dict' + required: false + default: [] + description: >- + Private networks to attach to the instance. Each entry attaches + to (and optionally creates) one network. Setting `cidr` creates + the network if missing; setting `fixed_ip` pins the instance's + IP on that network. + options: + + cidr: + type: 'str' + required: false + description: 'CIDR for the private network. If set, the network is created (if missing) with this range. Omit to attach to an existing network without creating it.' + + fixed_ip: + type: 'str' + required: false + description: 'Fixed IPv4 address the instance should get on this private network. Omit for DHCP.' + + name: + type: 'str' + required: true + description: 'Name of the private network to create or attach to.' + + exoscale_vm__security_group_rules: + type: 'list' + elements: 'dict' + required: false + description: >- + Rules for the per-VM security group. When set and non-empty, the + role creates a security group named `exoscale_vm__name`, attaches + it to the instance in addition to `default`, and reconciles its + rules against this list. Leave unset / empty to skip per-VM + security-group management entirely (the instance is then only + attached to the `default` security group). + options: + + cidr: + type: 'str' + required: false + default: '0.0.0.0/0' + description: 'Network in CIDR form that this rule applies to.' + + end_port: + type: 'int' + required: true + description: 'End port of the rule range (inclusive).' + + protocol: + type: 'str' + required: true + description: 'IP protocol the rule applies to. Accepted values match the v2 API: `tcp`, `udp`, `icmp`, `esp`, `gre`, `ah`, `ipip`, `icmpv6`.' + + start_port: + type: 'int' + required: true + description: 'Start port of the rule range (inclusive).' + + state: + type: 'str' + required: false + default: 'present' + choices: + - 'absent' + - 'present' + description: 'Whether to add (`present`) or remove (`absent`) this rule.' + + type: + type: 'str' + required: false + default: 'ingress' + choices: + - 'egress' + - 'ingress' + description: 'Direction the rule applies to.' + + exoscale_vm__service_offering: + type: 'str' + required: true + description: 'Exoscale instance type in the form `.` (for example `standard.tiny`, `memory.extra-large`). Resolved at create time via `GET /v2/instance-type` to the instance type UUID.' + + exoscale_vm__ssh_key: + type: 'str' + required: false + description: >- + Name of the SSH key registered with Exoscale + (https://portal.exoscale.com/compute/keypairs) to inject into the + instance. Defaults to the `USER` environment variable on the + Ansible control node. + + exoscale_vm__state: + type: 'str' + required: false + default: 'started' + choices: + - 'absent' + - 'present' + - 'restarted' + - 'started' + - 'stopped' + description: >- + Desired state of the instance. `absent` deletes the VM (and the + per-VM security group when `exoscale_vm__security_group_rules` + is set). `started` (default) and `present` (alias) create the + VM and start it if currently stopped. `stopped` creates the VM + with `auto-start: false` and stops it if currently running. + `restarted` ensures the VM is running and reboots it if it was + already running (no reboot if it had to be started, so a fresh + create with `restarted` behaves like `started`). + + exoscale_vm__template: + type: 'str' + required: true + description: 'Either a template name (resolved via `GET /v2/template?visibility=...`) or a template UUID (used directly without a lookup). For custom templates, prefer the UUID.' + + exoscale_vm__template_visibility: + type: 'str' + required: false + default: 'public' + choices: + - 'private' + - 'public' + description: 'Visibility used when resolving `exoscale_vm__template` by name. Ignored when the template is already a UUID.' + + exoscale_vm__zone: + type: 'str' + required: true + description: 'Exoscale zone the instance lives in. Also determines the API endpoint host (`https://api-{zone}.exoscale.com/v2`).' diff --git a/roles/exoscale_vm/tasks/main.yml b/roles/exoscale_vm/tasks/main.yml index 92fc5a9f..6a5b18d1 100644 --- a/roles/exoscale_vm/tasks/main.yml +++ b/roles/exoscale_vm/tasks/main.yml @@ -1,167 +1,560 @@ - block: - - name: 'Manage the security group for the VM' - ngine_io.cloudstack.cs_securitygroup: - api_key: '{{ exoscale_vm__api_key }}' - api_secret: '{{ exoscale_vm__api_secret }}' - api_url: 'https://api.exoscale.com/compute' - name: '{{ exoscale_vm__name }}' - state: 'present' - delegate_to: 'localhost' - become: false - when: - - 'exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0' - - 'exoscale_vm__state != "absent"' - - - name: 'Manage the required security group rules for the VM' - ngine_io.cloudstack.cs_securitygroup_rule: - api_key: '{{ exoscale_vm__api_key }}' - api_secret: '{{ exoscale_vm__api_secret }}' - api_url: 'https://api.exoscale.com/compute' - cidr: '{{ item["cidr"] | default(omit) }}' - protocol: '{{ item["protocol"] | default(omit) }}' - security_group: '{{ exoscale_vm__name }}' - start_port: '{{ item["start_port"] | default(omit) }}' - end_port: '{{ item["end_port"] | default(omit) }}' - state: '{{ item["state"] | d("present") }}' - type: '{{ item["type"] | default("ingress") }}' - loop: '{{ exoscale_vm__security_group_rules }}' - delegate_to: 'localhost' - become: false - when: - - 'exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0' - - 'exoscale_vm__state != "absent"' - - tags: - - 'exoscale_vm' - - 'exoscale_vm:firewalls' + - block: -- block: + - name: 'GET /v2/security-group' + linuxfabrik.lfops.exoscale_api: + method: 'GET' + path: '/security-group' + register: '__exoscale_vm__sg_list' + changed_when: false + check_mode: false + when: + - 'exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0' - # using the exo cli instead, as creating private instances is not supported by the v1 API which the cloudstack modules use - # - name: 'Manage the VM at Exoscale' - # ngine_io.cloudstack.cs_instance: - # api_key: '{{ exoscale_vm__api_key }}' - # api_secret: '{{ exoscale_vm__api_secret }}' - # api_url: 'https://api.exoscale.com/compute' - # name: '{{ exoscale_vm__name }}' - # service_offering: '{{ exoscale_vm__service_offering }}' - # security_groups: '{{ (exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0) | ternary(["default", exoscale_vm__name], ["default"]) }}' - # ssh_key: '{{ exoscale_vm__ssh_key }}' - # state: '{{ exoscale_vm__state }}' - # template: '{{ exoscale_vm__template }}' - # zone: '{{ exoscale_vm__zone }}' - # root_disk_size: '{{ exoscale_vm__disk_size }}' - # force: true - # delegate_to: 'localhost' - - - name: 'List the VMs at Exoscale' - ansible.builtin.command: > - exo compute instance list - --use-account '{{ exoscale_vm__account }}' - --output-format json - register: 'exoscale_vm__instance_list_result' - delegate_to: 'localhost' - become: false - changed_when: false # just gathering information, no actual change happening here - - - name: 'Create the VM at Exoscale' - ansible.builtin.command: > - exo compute instance create - --use-account '{{ exoscale_vm__account }}' - --output-format json - --instance-type '{{ exoscale_vm__service_offering }}' - {% for item in (exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0) | ternary(["default", exoscale_vm__name], ["default"]) %} - --security-group '{{ item }}' - {% endfor %} - --ssh-key '{{ exoscale_vm__ssh_key }}' - --template '{{ exoscale_vm__template }}' - --template-visibility '{{ exoscale_vm__template_visibility }}' - --zone '{{ exoscale_vm__zone }}' - --disk-size '{{ exoscale_vm__disk_size }}' - {% if exoscale_vm__private_instance %} - --private-instance - {% endif %} - '{{ exoscale_vm__name }}' - delegate_to: 'localhost' - become: false - when: - - 'exoscale_vm__state != "absent"' - - 'exoscale_vm__name not in exoscale_vm__instance_list_result["stdout"] | from_json | map(attribute="name")' - - - name: 'Delete the VM at Exoscale' - ansible.builtin.command: > - exo compute instance delete - --use-account '{{ exoscale_vm__account }}' - --output-format json - --zone '{{ exoscale_vm__zone }}' - --force - '{{ exoscale_vm__name }}' - delegate_to: 'localhost' - become: false - when: - - 'exoscale_vm__state == "absent"' - - 'exoscale_vm__name in exoscale_vm__instance_list_result["stdout"] | from_json | map(attribute="name")' - - tags: - - 'exoscale_vm' + - name: 'POST /v2/security-group' + linuxfabrik.lfops.exoscale_api: + method: 'POST' + path: '/security-group' + body: + name: '{{ exoscale_vm__name }}' + when: + - 'exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0' + - 'exoscale_vm__state != "absent"' + - 'exoscale_vm__name not in __exoscale_vm__sg_list["json"]["security-groups"] | map(attribute="name")' + - name: 'GET /v2/security-group (refresh after create)' + linuxfabrik.lfops.exoscale_api: + method: 'GET' + path: '/security-group' + register: '__exoscale_vm__sg_list' + changed_when: false + check_mode: false + when: + - 'exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0' + - 'exoscale_vm__state != "absent"' -- block: + - name: 'GET /v2/security-group/{id}' + linuxfabrik.lfops.exoscale_api: + method: 'GET' + path: '/security-group/{{ __exoscale_vm__sg_id }}' + register: '__exoscale_vm__sg_detail' + vars: + __exoscale_vm__sg_id: '{{ + (__exoscale_vm__sg_list["json"]["security-groups"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({}))["id"] | d("") + }}' + changed_when: false + check_mode: false + when: + - 'exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0' + - 'exoscale_vm__state != "absent"' + - '__exoscale_vm__sg_id | length > 0' - - name: 'Create the networks required by the VM' - ngine_io.cloudstack.cs_network: - api_key: '{{ exoscale_vm__api_key }}' - api_secret: '{{ exoscale_vm__api_secret }}' - api_url: 'https://api.exoscale.com/compute' - name: '{{ item["name"] }}' - zone: '{{ exoscale_vm__zone }}' - start_ip: '{{ item["cidr"] | ansible.utils.ipaddr("2") | ansible.utils.ipaddr("address") }}' - end_ip: '{{ item["cidr"] | ansible.utils.ipaddr("-3") | ansible.utils.ipaddr("address") }}' - netmask: '{{ item["cidr"] | ansible.utils.ipaddr("netmask") }}' - state: 'present' # never delete, as it could be used by other VMs as well - network_offering: 'Private Network' - loop: '{{ exoscale_vm__private_networks }}' - when: - - 'exoscale_vm__state != "absent"' - - 'item["cidr"] is defined' - delegate_to: 'localhost' - become: false - - - name: "Manage the VM's networks" - ngine_io.cloudstack.cs_instance_nic: - api_key: '{{ exoscale_vm__api_key }}' - api_secret: '{{ exoscale_vm__api_secret }}' - api_url: 'https://api.exoscale.com/compute' - name: '{{ exoscale_vm__name }}' - network: '{{ item["name"] }}' - ip_address: '{{ item["fixed_ip"] }}' - zone: '{{ exoscale_vm__zone }}' - loop: '{{ exoscale_vm__private_networks }}' - when: 'exoscale_vm__state != "absent"' - delegate_to: 'localhost' - become: false + - name: 'POST /v2/security-group/{id}/rules' + linuxfabrik.lfops.exoscale_api: + method: 'POST' + path: '/security-group/{{ __exoscale_vm__sg_id }}/rules' + body: >- + {{ { + "flow-direction": item["type"] | d("ingress"), + "protocol": item["protocol"], + "network": item["cidr"] | d("0.0.0.0/0"), + "start-port": item["start_port"] | int, + "end-port": item["end_port"] | int, + } }} + loop: '{{ exoscale_vm__security_group_rules | d([]) }}' + vars: + __exoscale_vm__sg_id: '{{ + (__exoscale_vm__sg_list["json"]["security-groups"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({}))["id"] | d("") + }}' + __exoscale_vm__matching_rules: '{{ + (__exoscale_vm__sg_detail["json"]["rules"] | d([])) + | selectattr("flow-direction", "eq", item["type"] | d("ingress")) + | selectattr("protocol", "eq", item["protocol"]) + | selectattr("start-port", "eq", item["start_port"] | int) + | selectattr("end-port", "eq", item["end_port"] | int) + | selectattr("network", "eq", item["cidr"] | d("0.0.0.0/0")) + | list + }}' + when: + - 'exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0' + - 'exoscale_vm__state != "absent"' + - 'item["state"] | d("present") != "absent"' + - '__exoscale_vm__sg_detail is defined and __exoscale_vm__sg_detail is not skipped' + - '__exoscale_vm__matching_rules | length == 0' - tags: - - 'exoscale_vm' - - 'exoscale_vm:networks' + - name: 'DELETE /v2/security-group/{id}/rules/{rule-id}' + linuxfabrik.lfops.exoscale_api: + method: 'DELETE' + path: '/security-group/{{ __exoscale_vm__sg_id }}/rules/{{ __exoscale_vm__matching_rules[0]["id"] }}' + loop: '{{ exoscale_vm__security_group_rules | d([]) }}' + vars: + __exoscale_vm__sg_id: '{{ + (__exoscale_vm__sg_list["json"]["security-groups"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({}))["id"] | d("") + }}' + __exoscale_vm__matching_rules: '{{ + (__exoscale_vm__sg_detail["json"]["rules"] | d([])) + | selectattr("flow-direction", "eq", item["type"] | d("ingress")) + | selectattr("protocol", "eq", item["protocol"]) + | selectattr("start-port", "eq", item["start_port"] | int) + | selectattr("end-port", "eq", item["end_port"] | int) + | selectattr("network", "eq", item["cidr"] | d("0.0.0.0/0")) + | list + }}' + when: + - 'exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0' + - 'exoscale_vm__state != "absent"' + - 'item["state"] | d("present") == "absent"' + - '__exoscale_vm__sg_detail is defined and __exoscale_vm__sg_detail is not skipped' + - '__exoscale_vm__matching_rules | length > 0' -- block: + tags: + - 'exoscale_vm' + - 'exoscale_vm:firewalls' + + + - block: + + - name: 'GET /v2/instance' + linuxfabrik.lfops.exoscale_api: + method: 'GET' + path: '/instance' + register: '__exoscale_vm__instance_list' + changed_when: false + check_mode: false + + - name: 'GET /v2/security-group (for instance create)' + linuxfabrik.lfops.exoscale_api: + method: 'GET' + path: '/security-group' + register: '__exoscale_vm__sg_list_for_instance' + changed_when: false + check_mode: false + when: + - 'exoscale_vm__state != "absent"' + - 'exoscale_vm__name not in __exoscale_vm__instance_list["json"]["instances"] | map(attribute="name")' + + - name: 'GET /v2/instance-type' + linuxfabrik.lfops.exoscale_api: + method: 'GET' + path: '/instance-type' + register: '__exoscale_vm__instance_type_list' + changed_when: false + check_mode: false + when: + - 'exoscale_vm__state != "absent"' + + - name: 'GET /v2/template (for instance create)' + linuxfabrik.lfops.exoscale_api: + method: 'GET' + path: '/template' + query_params: + visibility: '{{ exoscale_vm__template_visibility }}' + register: '__exoscale_vm__template_list' + changed_when: false + check_mode: false + when: + - 'exoscale_vm__state != "absent"' + - 'exoscale_vm__name not in __exoscale_vm__instance_list["json"]["instances"] | map(attribute="name")' + - 'exoscale_vm__template is not match("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")' + + - name: 'POST /v2/instance' + linuxfabrik.lfops.exoscale_api: + method: 'POST' + path: '/instance' + body: >- + {{ { + "name": exoscale_vm__name, + "disk-size": exoscale_vm__disk_size | int, + "instance-type": {"id": __exoscale_vm__instance_type_id}, + "template": {"id": __exoscale_vm__template_id}, + "ssh-keys": [{"name": exoscale_vm__ssh_key}], + "security-groups": __exoscale_vm__instance_security_groups, + "public-ip-assignment": (exoscale_vm__private_instance | bool) | ternary("none", "inet4"), + "auto-start": exoscale_vm__state != "stopped", + } }} + vars: + __exoscale_vm__instance_type_family: '{{ exoscale_vm__service_offering.split(".")[0] }}' + __exoscale_vm__instance_type_size: '{{ exoscale_vm__service_offering.split(".")[1] }}' + __exoscale_vm__instance_type_id: '{{ + (__exoscale_vm__instance_type_list["json"]["instance-types"] + | selectattr("family", "eq", __exoscale_vm__instance_type_family) + | selectattr("size", "eq", __exoscale_vm__instance_type_size) + | list | first | default({"id": ""}))["id"] + }}' + __exoscale_vm__template_id: '{{ + exoscale_vm__template + if (exoscale_vm__template is match("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")) + else (__exoscale_vm__template_list["json"]["templates"] + | selectattr("name", "eq", exoscale_vm__template) + | list | first | default({"id": ""}))["id"] + }}' + __exoscale_vm__default_sg_id: '{{ + (__exoscale_vm__sg_list_for_instance["json"]["security-groups"] + | selectattr("name", "eq", "default") + | list | first | default({"id": ""}))["id"] + }}' + __exoscale_vm__own_sg_id: '{{ + (__exoscale_vm__sg_list_for_instance["json"]["security-groups"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({"id": ""}))["id"] + }}' + __exoscale_vm__instance_security_groups: '{{ + [{"id": __exoscale_vm__default_sg_id}] + + ([{"id": __exoscale_vm__own_sg_id}] + if (exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0) + else []) + }}' + when: + - 'exoscale_vm__state != "absent"' + - 'exoscale_vm__name not in __exoscale_vm__instance_list["json"]["instances"] | map(attribute="name")' + + - name: 'DELETE /v2/instance/{id}' + linuxfabrik.lfops.exoscale_api: + method: 'DELETE' + path: '/instance/{{ __exoscale_vm__instance_id }}' + vars: + __exoscale_vm__instance_id: '{{ + (__exoscale_vm__instance_list["json"]["instances"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({}))["id"] | d("") + }}' + when: + - 'exoscale_vm__state == "absent"' + - '__exoscale_vm__instance_id | length > 0' + + - name: 'GET /v2/instance (refresh for power-state management)' + linuxfabrik.lfops.exoscale_api: + method: 'GET' + path: '/instance' + register: '__exoscale_vm__instance_list' + changed_when: false + check_mode: false + when: + - 'exoscale_vm__state != "absent"' + + - name: 'PUT /v2/instance/{id}:stop (before scale or resize-disk)' + linuxfabrik.lfops.exoscale_api: + method: 'PUT' + path: '/instance/{{ __exoscale_vm__instance_match["id"] }}:stop' + vars: + __exoscale_vm__instance_match: '{{ + (__exoscale_vm__instance_list["json"]["instances"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({"id": "", "state": "", "instance-type": {"family": "", "size": ""}, "disk-size": 0})) + }}' + __exoscale_vm__current_offering: '{{ + __exoscale_vm__instance_match["instance-type"]["family"] | d("") + ~ "." ~ + __exoscale_vm__instance_match["instance-type"]["size"] | d("") + }}' + when: + - 'exoscale_vm__state != "absent"' + - '__exoscale_vm__instance_match["id"] | length > 0' + - '__exoscale_vm__instance_match["state"] == "running"' + - > + __exoscale_vm__current_offering != exoscale_vm__service_offering + or (__exoscale_vm__instance_match["disk-size"] | int) != (exoscale_vm__disk_size | int) - # Cleanup - - name: 'Clean up the security group for the VM' - ngine_io.cloudstack.cs_securitygroup: + - name: 'PUT /v2/instance/{id}:scale' + linuxfabrik.lfops.exoscale_api: + method: 'PUT' + path: '/instance/{{ __exoscale_vm__instance_match["id"] }}:scale' + body: + instance-type: + id: '{{ __exoscale_vm__instance_type_id }}' + vars: + __exoscale_vm__instance_match: '{{ + (__exoscale_vm__instance_list["json"]["instances"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({"id": "", "state": "", "instance-type": {"family": "", "size": ""}, "disk-size": 0})) + }}' + __exoscale_vm__current_offering: '{{ + __exoscale_vm__instance_match["instance-type"]["family"] | d("") + ~ "." ~ + __exoscale_vm__instance_match["instance-type"]["size"] | d("") + }}' + __exoscale_vm__instance_type_family: '{{ exoscale_vm__service_offering.split(".")[0] }}' + __exoscale_vm__instance_type_size: '{{ exoscale_vm__service_offering.split(".")[1] }}' + __exoscale_vm__instance_type_id: '{{ + (__exoscale_vm__instance_type_list["json"]["instance-types"] + | selectattr("family", "eq", __exoscale_vm__instance_type_family) + | selectattr("size", "eq", __exoscale_vm__instance_type_size) + | list | first | default({"id": ""}))["id"] + }}' + when: + - 'exoscale_vm__state != "absent"' + - '__exoscale_vm__instance_match["id"] | length > 0' + - '__exoscale_vm__current_offering != exoscale_vm__service_offering' + + - name: 'PUT /v2/instance/{id}:resize-disk' + linuxfabrik.lfops.exoscale_api: + method: 'PUT' + path: '/instance/{{ __exoscale_vm__instance_match["id"] }}:resize-disk' + body: + disk-size: '{{ exoscale_vm__disk_size | int }}' + vars: + __exoscale_vm__instance_match: '{{ + (__exoscale_vm__instance_list["json"]["instances"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({"id": "", "state": "", "instance-type": {"family": "", "size": ""}, "disk-size": 0})) + }}' + when: + - 'exoscale_vm__state != "absent"' + - '__exoscale_vm__instance_match["id"] | length > 0' + - '(__exoscale_vm__instance_match["disk-size"] | int) != (exoscale_vm__disk_size | int)' + + - name: 'GET /v2/instance (refresh after scale / resize-disk)' + linuxfabrik.lfops.exoscale_api: + method: 'GET' + path: '/instance' + register: '__exoscale_vm__instance_list' + changed_when: false + check_mode: false + when: + - 'exoscale_vm__state != "absent"' + + - name: 'PUT /v2/instance/{id}:start' + linuxfabrik.lfops.exoscale_api: + method: 'PUT' + path: '/instance/{{ __exoscale_vm__instance_match["id"] }}:start' + body: {} + vars: + __exoscale_vm__instance_match: '{{ + (__exoscale_vm__instance_list["json"]["instances"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({"id": "", "state": ""})) + }}' + when: + - 'exoscale_vm__state in ["present", "restarted", "started"]' + - '__exoscale_vm__instance_match["id"] | length > 0' + - '__exoscale_vm__instance_match["state"] == "stopped"' + + - name: 'PUT /v2/instance/{id}:stop' + linuxfabrik.lfops.exoscale_api: + method: 'PUT' + path: '/instance/{{ __exoscale_vm__instance_match["id"] }}:stop' + vars: + __exoscale_vm__instance_match: '{{ + (__exoscale_vm__instance_list["json"]["instances"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({"id": "", "state": ""})) + }}' + when: + - 'exoscale_vm__state == "stopped"' + - '__exoscale_vm__instance_match["id"] | length > 0' + - '__exoscale_vm__instance_match["state"] == "running"' + + - name: 'PUT /v2/instance/{id}:reboot' + linuxfabrik.lfops.exoscale_api: + method: 'PUT' + path: '/instance/{{ __exoscale_vm__instance_match["id"] }}:reboot' + vars: + __exoscale_vm__instance_match: '{{ + (__exoscale_vm__instance_list["json"]["instances"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({"id": "", "state": ""})) + }}' + when: + - 'exoscale_vm__state == "restarted"' + - '__exoscale_vm__instance_match["id"] | length > 0' + - '__exoscale_vm__instance_match["state"] == "running"' + + tags: + - 'exoscale_vm' + + + - block: + + - name: 'GET /v2/private-network' + linuxfabrik.lfops.exoscale_api: + method: 'GET' + path: '/private-network' + register: '__exoscale_vm__private_network_list' + changed_when: false + check_mode: false + when: + - 'exoscale_vm__state != "absent"' + + - name: 'POST /v2/private-network' + linuxfabrik.lfops.exoscale_api: + method: 'POST' + path: '/private-network' + body: + name: '{{ item["name"] }}' + start-ip: '{{ item["cidr"] | ansible.utils.ipaddr("2") | ansible.utils.ipaddr("address") }}' + end-ip: '{{ item["cidr"] | ansible.utils.ipaddr("-3") | ansible.utils.ipaddr("address") }}' + netmask: '{{ item["cidr"] | ansible.utils.ipaddr("netmask") }}' + loop: '{{ exoscale_vm__private_networks | d([]) }}' + when: + - 'exoscale_vm__state != "absent"' + - 'item["cidr"] is defined' + - 'item["name"] not in __exoscale_vm__private_network_list["json"]["private-networks"] | map(attribute="name")' + + - name: 'GET /v2/private-network (refresh after create)' + linuxfabrik.lfops.exoscale_api: + method: 'GET' + path: '/private-network' + register: '__exoscale_vm__private_network_list' + changed_when: false + check_mode: false + when: + - 'exoscale_vm__state != "absent"' + + - name: 'GET /v2/instance/{id}' + linuxfabrik.lfops.exoscale_api: + method: 'GET' + path: '/instance/{{ __exoscale_vm__instance_id }}' + register: '__exoscale_vm__instance_detail' + vars: + __exoscale_vm__instance_id: '{{ + (__exoscale_vm__instance_list["json"]["instances"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({}))["id"] | d("") + }}' + changed_when: false + check_mode: false + when: + - 'exoscale_vm__state != "absent"' + - '__exoscale_vm__instance_id | length > 0' + + - name: 'PUT /v2/private-network/{id}:attach' + linuxfabrik.lfops.exoscale_api: + method: 'PUT' + path: '/private-network/{{ __exoscale_vm__pn_id }}:attach' + body: '{{ + {"instance": {"id": __exoscale_vm__instance_id}, "ip": item["fixed_ip"]} + if "fixed_ip" in item + else {"instance": {"id": __exoscale_vm__instance_id}} + }}' + loop: '{{ exoscale_vm__private_networks | d([]) }}' + vars: + __exoscale_vm__instance_id: '{{ + (__exoscale_vm__instance_list["json"]["instances"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({}))["id"] | d("") + }}' + __exoscale_vm__pn_id: '{{ + (__exoscale_vm__private_network_list["json"]["private-networks"] + | selectattr("name", "eq", item["name"]) + | list | first | default({}))["id"] | d("") + }}' + __exoscale_vm__attached_pn_ids: '{{ + (__exoscale_vm__instance_detail["json"]["private-networks"] | d([])) + | map(attribute="id") | list + }}' + when: + - 'exoscale_vm__state != "absent"' + - '__exoscale_vm__instance_detail is defined and __exoscale_vm__instance_detail is not skipped' + - '__exoscale_vm__pn_id | length > 0' + - '__exoscale_vm__pn_id not in __exoscale_vm__attached_pn_ids' + + - name: 'PUT /v2/private-network/{id}:update-ip' + linuxfabrik.lfops.exoscale_api: + method: 'PUT' + path: '/private-network/{{ __exoscale_vm__pn_id }}:update-ip' + body: + ip: '{{ item["fixed_ip"] }}' + instance: + id: '{{ __exoscale_vm__instance_id }}' + loop: '{{ exoscale_vm__private_networks | d([]) }}' + vars: + __exoscale_vm__instance_id: '{{ + (__exoscale_vm__instance_list["json"]["instances"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({}))["id"] | d("") + }}' + __exoscale_vm__pn_match: '{{ + (__exoscale_vm__private_network_list["json"]["private-networks"] + | selectattr("name", "eq", item["name"]) + | list | first | default({})) + }}' + __exoscale_vm__pn_id: '{{ __exoscale_vm__pn_match["id"] | d("") }}' + __exoscale_vm__current_lease_ip: '{{ + (__exoscale_vm__pn_match["leases"] | d([]) + | selectattr("instance-id", "eq", __exoscale_vm__instance_id) + | list | first | default({}))["ip"] | d("") + }}' + __exoscale_vm__attached_pn_ids: '{{ + (__exoscale_vm__instance_detail["json"]["private-networks"] | d([])) + | map(attribute="id") | list + }}' + when: + - 'exoscale_vm__state != "absent"' + - '__exoscale_vm__instance_detail is defined and __exoscale_vm__instance_detail is not skipped' + - '"fixed_ip" in item' + - '__exoscale_vm__pn_id | length > 0' + - '__exoscale_vm__pn_id in __exoscale_vm__attached_pn_ids' + - '__exoscale_vm__current_lease_ip != item["fixed_ip"]' + + - name: 'PUT /v2/private-network/{id}:detach' + linuxfabrik.lfops.exoscale_api: + method: 'PUT' + path: '/private-network/{{ item["id"] }}:detach' + body: + instance: + id: '{{ __exoscale_vm__instance_id }}' + loop: '{{ + (__exoscale_vm__instance_detail["json"]["private-networks"] | d([])) + if (__exoscale_vm__instance_detail is defined and __exoscale_vm__instance_detail is not skipped) + else [] + }}' + vars: + __exoscale_vm__instance_id: '{{ + (__exoscale_vm__instance_list["json"]["instances"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({}))["id"] | d("") + }}' + __exoscale_vm__pn_name: '{{ + (__exoscale_vm__private_network_list["json"]["private-networks"] + | selectattr("id", "eq", item["id"]) + | list | first | default({}))["name"] | d("") + }}' + __exoscale_vm__inventory_pn_names: '{{ + exoscale_vm__private_networks | d([]) | map(attribute="name") | list + }}' + when: + - 'exoscale_vm__state != "absent"' + - '__exoscale_vm__pn_name | length > 0' + - '__exoscale_vm__pn_name not in __exoscale_vm__inventory_pn_names' + + tags: + - 'exoscale_vm' + - 'exoscale_vm:networks' + + + - block: + + - name: 'DELETE /v2/security-group/{id}' + linuxfabrik.lfops.exoscale_api: + method: 'DELETE' + path: '/security-group/{{ __exoscale_vm__sg_id }}' + vars: + __exoscale_vm__sg_id: '{{ + (__exoscale_vm__sg_list["json"]["security-groups"] + | selectattr("name", "eq", exoscale_vm__name) + | list | first | default({}))["id"] | d("") + }}' + when: + - 'exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0' + - 'exoscale_vm__state == "absent"' + - '__exoscale_vm__sg_id | length > 0' + + tags: + - 'exoscale_vm' + + + delegate_to: 'localhost' + become: false + module_defaults: + linuxfabrik.lfops.exoscale_api: api_key: '{{ exoscale_vm__api_key }}' api_secret: '{{ exoscale_vm__api_secret }}' - api_url: 'https://api.exoscale.com/compute' - name: '{{ exoscale_vm__name }}' - state: 'absent' - delegate_to: 'localhost' - become: false - when: - - 'exoscale_vm__security_group_rules is defined and exoscale_vm__security_group_rules | length > 0' - - 'exoscale_vm__state == "absent"' - - tags: - - 'exoscale_vm' + zone: '{{ exoscale_vm__zone }}'