42032e576e
1.2.0 rewrote /1.0 to /v1 whenever a service account was in use, reasoning from a documentation example that showed OAuth2 against a /v1 path. It was wrong, and it was the cause of the 404s and the 500 that followed: OVH's own API console issues Bearer requests against /1.0, while /v1 — which exists, and answers 401 unauthenticated like any live route — answers 500 with an empty body for a token on the telephony routes. A failure with no message at all, produced by a helpful rewrite nobody asked for. The endpoint is now whatever was typed, for both authentication schemes. The config field says where to copy it from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
358 lines
16 KiB
Python
358 lines
16 KiB
Python
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, exactly as configured.
|
|
|
|
IT USED TO REWRITE /1.0 TO /v1 FOR A SERVICE ACCOUNT, on the strength of a
|
|
documentation example that showed OAuth2 against a /v1 path. That was wrong and
|
|
cost two rounds of debugging: OVH's own API console issues Bearer requests
|
|
against /1.0, and /v1 — which exists, and answers 401 unauthenticated like any
|
|
live route — answers 500 with an empty body for a token on the telephony
|
|
routes. A failure with no message, produced by a helpful rewrite nobody asked
|
|
for.
|
|
|
|
So the endpoint is now whatever was typed, for both schemes. Both spellings
|
|
work for the signed scheme; only the configured one is used, and OVH's console
|
|
is the place to copy it from.
|
|
"""
|
|
return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/")
|
|
|
|
|
|
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)
|
|
|
|
|
|
# ── the call queue ──────────────────────────────────────────────────────────
|
|
#
|
|
# THE SAME COORDINATES FOR EVERY ON-CALL COMMAND, hence this block: the billing
|
|
# account, the service (the number the queue hangs from) and the queue itself.
|
|
# Repeated in each script rather than imported, because the marketplace collects
|
|
# loose *.py files and every command has to stand on its own.
|
|
def _telephony(cfg):
|
|
ba = str(cfg.get("telephony_billing_account") or "").strip()
|
|
service = str(cfg.get("telephony_service") or "").strip()
|
|
if not ba or not service:
|
|
raise Exception(
|
|
"this instance has no telephony coordinates: set telephony_billing_account "
|
|
"(e.g. fr-xxxxx-1) and telephony_service (the call queue's number, 0033xxxxxxxxx)")
|
|
return ba, service
|
|
|
|
|
|
# The call queue lives under ONE OF TWO PRODUCTS, and which one is not something an
|
|
# operator can see: the control panel says "File d'appels" for both. OVH files them
|
|
# under easyHunting or under ovhPabx depending on the offer the number carries, and
|
|
# the hunting routes underneath are identical either way.
|
|
#
|
|
# Assuming easyHunting is what this did first, and the symptom was a 404 reading
|
|
# "This service does not exist" — which sounds like a wrong number and is in fact a
|
|
# wrong product. So it is DISCOVERED, once per process, and a failure says what does
|
|
# exist rather than what does not.
|
|
_PRODUCTS = ("easyHunting", "ovhPabx")
|
|
|
|
|
|
def _product(cfg):
|
|
if _CACHE.get("product"):
|
|
return _CACHE["product"]
|
|
ba, service = _telephony(cfg)
|
|
found = {}
|
|
for product in _PRODUCTS:
|
|
names = request("GET", "/telephony/" + urllib.parse.quote(ba, safe="") + "/" + product, cfg) or []
|
|
found[product] = [str(n) for n in names]
|
|
if service in found[product]:
|
|
_CACHE["product"] = product
|
|
return product
|
|
raise Exception(
|
|
"no call queue named %s on %s. easyHunting holds [%s]; ovhPabx holds [%s]. "
|
|
"Set telephony_service to one of those names, or configure the number as a "
|
|
"call queue in the control panel first."
|
|
% (service, ba, ", ".join(found.get("easyHunting") or ["-"]), ", ".join(found.get("ovhPabx") or ["-"])))
|
|
|
|
|
|
def _base(cfg):
|
|
ba, service = _telephony(cfg)
|
|
return ("/telephony/" + urllib.parse.quote(ba, safe="") + "/" + _product(cfg) +
|
|
"/" + urllib.parse.quote(service, safe=""))
|
|
|
|
|
|
def _queue_id(cfg):
|
|
"""The queue to act on.
|
|
|
|
Configured explicitly, or discovered when the service holds exactly one, which is
|
|
the normal shape. AMBIGUITY IS REFUSED RATHER THAN GUESSED: picking the first of
|
|
several would publish the on-call ladder into whichever queue OVH happened to
|
|
list first, and the symptom is a number that rings the wrong team at 03:00.
|
|
"""
|
|
configured = str(cfg.get("telephony_queue_id") or "").strip()
|
|
if configured:
|
|
return configured
|
|
ids = request("GET", _base(cfg) + "/hunting/queue", cfg)
|
|
if not isinstance(ids, list) or not ids:
|
|
raise Exception("this service holds no call queue; create one in the control panel first")
|
|
if len(ids) > 1:
|
|
raise Exception("this service holds %d queues (%s) - set telephony_queue_id to say which one" %
|
|
(len(ids), ", ".join(str(i) for i in ids)))
|
|
return str(ids[0])
|
|
|
|
|
|
|
|
# ── the probe ───────────────────────────────────────────────────────────────
|
|
#
|
|
# IT TESTS WHAT THIS INSTANCE IS FOR, and it walks there ONE STEP AT A TIME.
|
|
#
|
|
# Two rounds were lost to this endpoint answering a bare 404 — "This service does
|
|
# not exist" — which is true of a wrong billing account, a wrong service name, a
|
|
# queue filed under the other product, and a number nobody configured yet. Four
|
|
# different jobs, one message, and no way to tell them apart from Riposte.
|
|
#
|
|
# So each step is taken separately and REPORTS WHAT IT SAW: the accounts the
|
|
# credentials can reach, the services each product holds, the queues in the one we
|
|
# want. A failure names the step and lists the values that do exist, which is the
|
|
# thing somebody can act on.
|
|
#
|
|
# It also answers its own VERSION. When a test fails identically twice, the first
|
|
# question is whether the new code is the code that ran, and nothing on the screen
|
|
# could answer it.
|
|
VERSION = "1.2.5"
|
|
|
|
|
|
def _try(step, fn):
|
|
"""Run one step, turning any failure into a named one."""
|
|
try:
|
|
return fn(), None
|
|
except urllib.error.HTTPError as e:
|
|
return None, "%s: HTTP %d %s" % (step, e.code, e.read().decode("utf-8", "replace")[:200])
|
|
except Exception as e:
|
|
return None, "%s: %s" % (step, e)
|
|
|
|
|
|
def _walk(cfg, inputs):
|
|
scheme = "service account" if _oauth(cfg) else "application key"
|
|
out = {"version": VERSION, "auth": scheme, "endpoint": _endpoint(cfg)}
|
|
|
|
ba = str(cfg.get("telephony_billing_account") or "").strip()
|
|
service = str(cfg.get("telephony_service") or "").strip()
|
|
if not (ba and service):
|
|
resp = request("GET", "/me", cfg)
|
|
out.update({"ok": True, "nichandle": resp.get("nichandle")})
|
|
return out
|
|
|
|
# 1. What can these credentials see at all? Useful when it works, and NEVER
|
|
# FATAL when it does not.
|
|
#
|
|
# It is a diagnostic, not a dependency: nothing in this integration needs the
|
|
# list of billing accounts, and on a service account this route has been seen
|
|
# to answer 500 with an empty body while every account-scoped route underneath
|
|
# it works perfectly. A diagnostic step that fails the test it exists to
|
|
# explain is worse than no step at all — it hides the answer behind its own
|
|
# failure, which is exactly what it did here.
|
|
accounts, err = _try("listing telephony billing accounts", lambda: request("GET", "/telephony", cfg))
|
|
if err:
|
|
out["billing_accounts"] = "could not be listed (" + err + ") — not fatal, carrying on"
|
|
else:
|
|
accounts = [str(a) for a in (accounts or [])]
|
|
out["billing_accounts"] = accounts
|
|
if ba not in accounts:
|
|
out.update({"ok": False, "step": "billing account",
|
|
"detail": "%s is not one of the accounts these credentials can see: [%s]"
|
|
% (ba, ", ".join(accounts) or "none")})
|
|
return out
|
|
|
|
# 2. Which product holds the queue, and under what name. Both are listed even on
|
|
# success: seeing the OTHER product empty is what tells somebody their number
|
|
# was never configured as a queue at all.
|
|
services = {}
|
|
for product in _PRODUCTS:
|
|
names, err = _try("listing " + product,
|
|
lambda p=product: request("GET", "/telephony/" + urllib.parse.quote(ba, safe="") + "/" + p, cfg))
|
|
services[product] = [str(n) for n in (names or [])] if not err else err
|
|
out["services"] = services
|
|
product = None
|
|
for name in _PRODUCTS:
|
|
if isinstance(services[name], list) and service in services[name]:
|
|
product = name
|
|
break
|
|
if product is None:
|
|
listed = [n for p in _PRODUCTS for n in (services[p] if isinstance(services[p], list) else [])]
|
|
failures = [services[p] for p in _PRODUCTS if not isinstance(services[p], list)]
|
|
if not listed and failures:
|
|
# Neither product could even be LISTED. That is not a wrong service name,
|
|
# it is the account or the credentials — and the three shapes it comes in
|
|
# want three different answers, so they are named rather than merged.
|
|
detail = ("neither product could be listed on %s. A 403 means the IAM policy is missing the "
|
|
"telephony actions; a 404 means this billing account does not exist under these "
|
|
"credentials; a 500 on every telephony route means this service account cannot reach "
|
|
"the telephony API at all, and the way round is an application key from "
|
|
"https://api.ovh.com/createToken/ (fill in application_key, application_secret and "
|
|
"consumer_key instead). Details: %s") % (ba, " | ".join(failures))
|
|
else:
|
|
detail = ("%s is not a call queue on %s. Configure the number as a call queue in the control "
|
|
"panel, or set telephony_service to one of the names listed above." % (service, ba))
|
|
out.update({"ok": False, "step": "service", "detail": detail})
|
|
return out
|
|
out["product"] = product
|
|
_CACHE["product"] = product
|
|
|
|
# 3. The queue itself.
|
|
queues, err = _try("listing queues", lambda: request("GET", _base(cfg) + "/hunting/queue", cfg))
|
|
if err:
|
|
out.update({"ok": False, "step": "queues", "detail": err})
|
|
return out
|
|
queues = [str(q) for q in (queues or [])]
|
|
out["queue_ids"] = queues
|
|
if not queues:
|
|
# Reached the API and found the service, but it holds no queue. NOT a
|
|
# credential failure, and saying so plainly is the difference between "go and
|
|
# create a queue" and an evening spent on authentication.
|
|
out.update({"ok": True, "queues": 0,
|
|
"detail": "Authenticated, and the service was found, but it holds no call queue yet. "
|
|
"Open the number's configuration once and save it."})
|
|
return out
|
|
out.update({"ok": True, "queues": len(queues)})
|
|
return out
|
|
|
|
|
|
def main(cfg, inputs):
|
|
"""Print the walk, and FAIL when it did not get there.
|
|
|
|
A diagnosis that exits 0 is reported as a success, which is worse than the bare
|
|
404 it replaces: the screen would say the instance works while naming the step
|
|
where it does not. The "error" key is the marketplace's own convention and the
|
|
exit code is what the Test button reads — both are set, so no consumer has to
|
|
interpret the dictionary to know.
|
|
"""
|
|
out = _walk(cfg, inputs)
|
|
if not out.get("ok"):
|
|
out["error"] = out.get("detail") or ("failed at: " + str(out.get("step") or "an unnamed step"))
|
|
print(json.dumps(out))
|
|
sys.exit(1)
|
|
return out
|
|
|
|
|
|
_run(main)
|