Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<path>` 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.
Expand Down Expand Up @@ -65,7 +70,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

* **role:nodejs**: Fix `@nodejs:<stream>` install failing with `broken groups or modules: nodejs:<stream>`. Two issues compounded: DNF refuses to silently switch an already-enabled module stream, and some modules ship without a `[d]efault` profile, so `@nodejs:<stream>` (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)).
Expand Down
201 changes: 201 additions & 0 deletions plugins/module_utils/exoscale.py
Original file line number Diff line number Diff line change
@@ -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=<key>[,signed-query-args=<p1;p2>],expires=<ts>,signature=<sig>

where `<sig>` 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)
Loading
Loading