import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error def _cfg(): return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) def _inputs(): return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) # ── authentication ────────────────────────────────────────────────────────── # # TWO SCHEMES, because OVHcloud now offers two and an operator does not always get # to choose. The historical one signs each request with an application key/secret # plus a consumer key. The newer SERVICE ACCOUNT is OAuth2 client-credentials, and # it is what the control panel offers when application keys are not available on # the account. # # Which one is in use is decided by what was filled in, not by a mode selector: # a client_id and a client_secret mean the service account, anything else falls # back to the signed scheme. One less field to get wrong, and an instance created # before service accounts existed keeps working untouched. _TOKEN_HOSTS = { "eu": "https://www.ovh.com", "ca": "https://ca.ovh.com", "us": "https://us.ovhcloud.com", } _CACHE = {} def _oauth(cfg): return bool(str(cfg.get("client_id") or "").strip() and str(cfg.get("client_secret") or "").strip()) def _endpoint(cfg): """The API base, corrected to match the credentials in use. The same routes answer under /1.0 and under /v1; the first is the signed scheme's historical spelling and the second is the one OAuth2 documents. An operator should not have to know that, so a service account gets /v1 whatever was typed — a Bearer token sent to the legacy spelling is the kind of failure that reads as "bad credentials" and sends somebody to regenerate a perfectly good secret. """ raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") if not _oauth(cfg): return raw if raw.endswith("/1.0"): return raw[:-4] + "/v1" if raw.endswith("/v1") or raw.endswith("/v2"): return raw return raw + "/v1" def _token_url(cfg): explicit = str(cfg.get("oauth_token_url") or "").strip() if explicit: return explicit host = urllib.parse.urlparse(_endpoint(cfg)).netloc region = host.split(".")[0] if host else "eu" return _TOKEN_HOSTS.get(region, _TOKEN_HOSTS["eu"]) + "/auth/oauth2/token" def _bearer(cfg): """The service account's access token. CACHED FOR THE LIFE OF THE PROCESS, which is not a micro-optimisation: publishing an on-call ladder makes a dozen or more API calls, and a token request in front of each one would triple the traffic and the time — on the path that runs every time the rota turns. """ if _CACHE.get("token"): return _CACHE["token"] data = urllib.parse.urlencode({ "grant_type": "client_credentials", "client_id": str(cfg.get("client_id", "")).strip(), "client_secret": str(cfg.get("client_secret", "")).strip(), # The scope the service account was granted in the control panel is what # actually bounds it; "all" here asks for everything that account may do. # Narrow the SERVICE ACCOUNT's own IAM policy rather than this value. "scope": "all", }).encode("utf-8") req = urllib.request.Request( _token_url(cfg), data=data, method="POST", headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}) with urllib.request.urlopen(req, timeout=30) as r: payload = json.loads(r.read() or b"{}") token = payload.get("access_token") if not token: raise Exception("the service account returned no access token: " + json.dumps(payload)[:200]) _CACHE["token"] = token return token def _time_offset(cfg): """Seconds between this clock and OVH's, read once. The signature carries a timestamp and OVH refuses one that has drifted, so the server's own time is the safe source. Read ONCE and applied as an offset rather than fetched per request: the ladder makes many calls, and an extra round trip in front of each was doubling them. """ if "offset" not in _CACHE: req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") with urllib.request.urlopen(req, timeout=30) as r: server = int(r.read().decode("utf-8", "replace").strip()) _CACHE["offset"] = server - int(time.time()) return _CACHE["offset"] def request(method, path, cfg, body=None): url = _endpoint(cfg) + path body_str = json.dumps(body) if body is not None else "" headers = {"Accept": "application/json"} if _oauth(cfg): headers["Authorization"] = "Bearer " + _bearer(cfg) else: app_key = str(cfg.get("application_key", "")).strip() app_secret = str(cfg.get("application_secret", "")).strip() consumer = str(cfg.get("consumer_key", "")).strip() if not (app_key and app_secret and consumer): raise Exception( "this instance has no credentials: fill in either a service account " "(client_id + client_secret) or the application key, secret and consumer key") ts = str(int(time.time()) + _time_offset(cfg)) to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts headers["X-Ovh-Application"] = app_key headers["X-Ovh-Consumer"] = consumer headers["X-Ovh-Timestamp"] = ts headers["X-Ovh-Signature"] = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() data = None if body is not None: data = body_str.encode("utf-8") headers["Content-Type"] = "application/json" req = urllib.request.Request(url, data=data, headers=headers, method=method) with urllib.request.urlopen(req, timeout=60) as r: raw = r.read() return json.loads(raw) if raw else {} def _run(fn): try: print(json.dumps(fn(_cfg(), _inputs()))) except urllib.error.HTTPError as e: print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) sys.exit(1) except Exception as e: print(json.dumps({"error": str(e)})) sys.exit(1) def main(cfg, inputs): resp = request("GET", "/me", cfg) return {"ok": True, "nichandle": resp.get("nichandle")} _run(main)