176 lines
7.8 KiB
Python
176 lines
7.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""The single place that talks to the OdooshCN platform.
|
|
|
|
Keeping every outbound request here means retry classification, timeouts and error
|
|
decoding have one implementation, and the access token is read in this file only.
|
|
|
|
Errors come in two kinds, which decide whether the queue retries or gives up:
|
|
* OdooshRetryable - network trouble, rate limiting, platform 5xx. Worth retrying.
|
|
* OdooshPermanent - bad parameters, name already taken, beyond the token limits.
|
|
Retrying cannot help; a human has to look at it.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
from odoo import _, models
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
TIMEOUT = 20 # seconds; syncing runs in a background queue, never in a user request
|
|
PARAM_URL = 'odoosh.base_url'
|
|
PARAM_TOKEN = 'odoosh.token'
|
|
|
|
|
|
class OdooshError(Exception):
|
|
"""A call to OdooshCN failed. `code` is the platform's stable error code."""
|
|
|
|
def __init__(self, message, code='unknown', status=None):
|
|
super().__init__(message)
|
|
self.message = message
|
|
self.code = code
|
|
self.status = status
|
|
|
|
def __str__(self):
|
|
return '[%s] %s' % (self.code, self.message)
|
|
|
|
|
|
class OdooshRetryable(OdooshError):
|
|
"""Temporary failure, worth retrying."""
|
|
|
|
|
|
class OdooshPermanent(OdooshError):
|
|
"""The caller's own problem; retrying will not help."""
|
|
|
|
|
|
class OdooshClient(models.AbstractModel):
|
|
_name = 'odoosh.client'
|
|
_description = 'OdooshCN API Client'
|
|
|
|
# ------------------------------------------------------------------ configuration
|
|
def _config(self):
|
|
"""Read the platform URL and the token. Read here only, and always as sudo:
|
|
regular users have no access to system parameters."""
|
|
icp = self.env['ir.config_parameter'].sudo()
|
|
base = (icp.get_param(PARAM_URL) or '').strip().rstrip('/')
|
|
token = (icp.get_param(PARAM_TOKEN) or '').strip()
|
|
if not base or not token:
|
|
raise OdooshPermanent(
|
|
_("The OdooshCN platform URL or access token is not configured yet. "
|
|
"Go to Settings > General Settings > OdooshCN Lab Environments."),
|
|
code='not_configured')
|
|
return self._prefer_ipv4(base), token
|
|
|
|
@staticmethod
|
|
def _prefer_ipv4(base):
|
|
"""Local development trap on Windows: `localhost` resolves to the IPv6 address
|
|
::1 first, while WSL2 and Docker only forward IPv4. urllib has no happy-eyeballs
|
|
fallback, so it waits out the full timeout (20s) on every single request.
|
|
Swapping localhost for 127.0.0.1 avoids it. Real deployments use a domain name."""
|
|
parts = urllib.parse.urlsplit(base)
|
|
if parts.hostname and parts.hostname.lower() == 'localhost':
|
|
netloc = '127.0.0.1' + (':%s' % parts.port if parts.port else '')
|
|
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
|
return base
|
|
|
|
def _enabled(self):
|
|
return self.env['ir.config_parameter'].sudo().get_param('odoosh.enabled') in ('True', 'true', '1', True)
|
|
|
|
# ------------------------------------------------------------------ request
|
|
def _request(self, method, path, payload=None):
|
|
"""Send one request and return the decoded body.
|
|
Raises OdooshRetryable / OdooshPermanent on failure."""
|
|
base, token = self._config()
|
|
url = '%s%s' % (base, path)
|
|
data = None
|
|
headers = {'Authorization': 'Bearer %s' % token, 'Accept': 'application/json'}
|
|
if payload is not None:
|
|
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
|
headers['Content-Type'] = 'application/json'
|
|
|
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
|
return json.loads(resp.read().decode('utf-8') or '{}')
|
|
except urllib.error.HTTPError as err:
|
|
raise self._http_error(err, method, path)
|
|
except urllib.error.URLError as err:
|
|
# DNS failure, refused connection, timeout: the platform may just be restarting
|
|
raise OdooshRetryable(_("Cannot reach OdooshCN (%(url)s): %(reason)s",
|
|
url=base, reason=err.reason), code='unreachable')
|
|
except json.JSONDecodeError:
|
|
raise OdooshRetryable(_("OdooshCN returned a body that is not valid JSON"), code='bad_response')
|
|
|
|
def _http_error(self, err, method, path):
|
|
"""Turn an HTTP error into a coded exception and decide whether it is worth retrying."""
|
|
raw = ''
|
|
try:
|
|
raw = err.read().decode('utf-8')
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
code, detail = 'http_%s' % err.code, raw or err.reason
|
|
try:
|
|
parsed = json.loads(raw or '{}')
|
|
code = parsed.get('code') or code
|
|
d = parsed.get('detail')
|
|
if isinstance(d, list): # 422 validation errors come as a list
|
|
d = '; '.join(str(x.get('msg') or x) for x in d)
|
|
detail = d or detail
|
|
except (ValueError, AttributeError):
|
|
pass
|
|
_logger.warning("OdooshCN %s %s -> %s %s", method, path, err.code, detail)
|
|
exc = OdooshRetryable if err.code in (408, 429) or err.code >= 500 else OdooshPermanent
|
|
return exc(detail, code=code, status=err.code)
|
|
|
|
# ------------------------------------------------------------------ endpoints
|
|
def ping(self):
|
|
"""Connectivity probe: validates URL and token only, no side effect.
|
|
Returns the token's source, organisation, scopes and limits."""
|
|
return self._request('GET', '/api/ext/ping')
|
|
|
|
def upsert_user(self, payload):
|
|
"""Create or update a user, keyed by external_id and idempotent.
|
|
`payload['provision']` additionally asks for a lab environment."""
|
|
return self._request('POST', '/api/ext/users', payload)
|
|
|
|
def deactivate_user(self, external_id, stop_envs=True):
|
|
"""Deactivate the platform account. Nothing is deleted."""
|
|
return self._request('POST', '/api/ext/users/deactivate',
|
|
{'external_id': str(external_id), 'stop_envs': stop_envs})
|
|
|
|
def delete_user(self, external_id, purge_envs=False):
|
|
"""Delete the platform account for good. Requires the token to allow deletion.
|
|
The user's environments go to the platform recycle bin unless purge_envs is set."""
|
|
return self._request('POST', '/api/ext/users/delete',
|
|
{'external_id': str(external_id), 'purge_envs': purge_envs})
|
|
|
|
def reconcile(self, external_ids, stop_envs=True, confirm=False):
|
|
"""Full reconciliation: accounts of this source that are not in the list get
|
|
deactivated. The platform refuses an obviously incomplete list unless confirmed."""
|
|
return self._request('POST', '/api/ext/users/reconcile',
|
|
{'external_ids': [str(x) for x in external_ids],
|
|
'stop_envs': stop_envs, 'confirm': confirm})
|
|
|
|
def list_envs(self, uid):
|
|
"""List the lab environments of one user."""
|
|
return self._request('GET', '/api/ext/envs?uid=%s' % urllib.parse.quote(str(uid)))
|
|
|
|
def create_env(self, uid, spec):
|
|
"""Provision one lab environment for a user."""
|
|
body = dict(spec or {})
|
|
body['uid'] = str(uid)
|
|
return self._request('POST', '/api/ext/envs', body)
|
|
|
|
def handoff(self, uid, target='console', env=None):
|
|
"""Exchange for a passwordless entry URL: {'url': ..., 'expires_in': ...}.
|
|
|
|
The URL is short lived and single use: redirect to it straight away, never
|
|
store it and never log it."""
|
|
body = {'uid': str(uid), 'target': target}
|
|
if env:
|
|
body['env'] = env
|
|
return self._request('POST', '/api/ext/handoff', body)
|